From 893b059e55936379599f2bc4248ae9aaea46ad08 Mon Sep 17 00:00:00 2001 From: Quyu Kong Date: Tue, 12 Aug 2025 14:31:08 +0800 Subject: [PATCH] feat: Add Aliyun provider support for desktop environment (#304) * Adding support for aliyun as a provider * feat: enhance Aliyun provider support - Added Aliyun as a new provider in the desktop environment. - Updated the environment configuration guidelines for Aliyun, including prerequisites and environment variables. - Implemented instance allocation and management functions for Aliyun ECS, including signal handling for graceful termination. - Improved logging and error handling during instance creation and status checks. - Adjusted the provider's methods to utilize the new instance management functions. --- desktop_env/desktop_env.py | 2 +- desktop_env/providers/__init__.py | 4 + .../providers/aliyun/ALIYUN_GUIDELINE.md | 79 +++++ .../providers/aliyun/ALIYUN_GUIDELINE_CN.md | 81 +++++ desktop_env/providers/aliyun/__init__.py | 0 desktop_env/providers/aliyun/manager.py | 289 ++++++++++++++++++ desktop_env/providers/aliyun/provider.py | 214 +++++++++++++ 7 files changed, 668 insertions(+), 1 deletion(-) create mode 100644 desktop_env/providers/aliyun/ALIYUN_GUIDELINE.md create mode 100644 desktop_env/providers/aliyun/ALIYUN_GUIDELINE_CN.md create mode 100644 desktop_env/providers/aliyun/__init__.py create mode 100644 desktop_env/providers/aliyun/manager.py create mode 100644 desktop_env/providers/aliyun/provider.py diff --git a/desktop_env/desktop_env.py b/desktop_env/desktop_env.py index 2641a46..8c9ebc1 100644 --- a/desktop_env/desktop_env.py +++ b/desktop_env/desktop_env.py @@ -158,7 +158,7 @@ class DesktopEnv(gym.Env): # Track whether environment has been used (step/setup) to optimize snapshot revert # docker, aws, gcp, azure are always unused as the emulator starts from a clean state # vmware, virtualbox are always used as the emulator starts from a dirty state - if self.provider_name in {"docker", "aws", "gcp", "azure"}: + if self.provider_name in {"docker", "aws", "gcp", "azure", "aliyun"}: self.is_environment_used = False elif self.provider_name in {"vmware", "virtualbox"}: self.is_environment_used = True diff --git a/desktop_env/providers/__init__.py b/desktop_env/providers/__init__.py index 63a07b0..2c9282e 100644 --- a/desktop_env/providers/__init__.py +++ b/desktop_env/providers/__init__.py @@ -31,5 +31,9 @@ def create_vm_manager_and_provider(provider_name: str, region: str, use_proxy: b from desktop_env.providers.docker.manager import DockerVMManager from desktop_env.providers.docker.provider import DockerProvider return DockerVMManager(), DockerProvider(region) + elif provider_name == "aliyun": + from desktop_env.providers.aliyun.manager import AliyunVMManager + from desktop_env.providers.aliyun.provider import AliyunProvider + return AliyunVMManager(), AliyunProvider() else: raise NotImplementedError(f"{provider_name} not implemented!") diff --git a/desktop_env/providers/aliyun/ALIYUN_GUIDELINE.md b/desktop_env/providers/aliyun/ALIYUN_GUIDELINE.md new file mode 100644 index 0000000..4b4000a --- /dev/null +++ b/desktop_env/providers/aliyun/ALIYUN_GUIDELINE.md @@ -0,0 +1,79 @@ +# Aliyun ECS Provider Configuration Guide + +This guide explains how to configure and use the Aliyun ECS provider for OSWorld desktop environments. + +## Configuration Process + +1. **Aliyun Account**: You need an active Aliyun Cloud account. This script uses pay-as-you-go billing by default, so ensure your account balance is above 100. +2. **Access Keys**: Create AccessKey ID and AccessKey Secret in Aliyun RAM Access Control Console and grant ECS control permissions +3. **VPC Setup**: Create a VPC, VSwitch, and Security Group in your target region +4. **Custom Images**: Create OSWorld custom images +5. It is recommended to manually complete the ECS creation process once to record all required environment variable information. + +## Environment Variables + +Set the following environment variables in your `.env` file: + +```bash +# Aliyun Access Credentials +ALIYUN_ACCESS_KEY_ID=your_access_key_id +ALIYUN_ACCESS_KEY_SECRET=your_access_key_secret + +# ECS Configuration Information +ALIYUN_REGION=eu-central-1 +ALIYUN_IMAGE_ID=your_image_id +ALIYUN_INSTANCE_TYPE=ecs.e-c1m2.large +ALIYUN_VSWITCH_ID=vsw-xxxxxxxxx +ALIYUN_SECURITY_GROUP_ID=sg-xxxxxxxxx +``` + +## Required Aliyun Resources + +### 1. VPC and VSwitch +- Create a VPC in your target region +- Create a VSwitch within the VPC +- Ensure the VSwitch has internet access for VNC connectivity + +### 2. Security Group +**⚠️ Important**: Please strictly follow the port settings below to prevent OSWorld tasks from failing due to connection issues: + +#### Inbound Rules (8 rules required) + +| Type | Protocol | Port Range | Source | Description | +|------|----------|------------|--------|-------------| +| SSH | TCP | 22 | 0.0.0.0/0 | SSH access | +| HTTP | TCP | 80 | 172.31.0.0/16 | HTTP traffic | +| Custom TCP | TCP | 5000 | 172.31.0.0/16 | OSWorld backend service | +| Custom TCP | TCP | 5910 | 0.0.0.0/0 | NoVNC visualization port | +| Custom TCP | TCP | 8006 | 172.31.0.0/16 | VNC service port | +| Custom TCP | TCP | 8080 | 172.31.0.0/16 | VLC service port | +| Custom TCP | TCP | 8081 | 172.31.0.0/16 | Additional service port | +| Custom TCP | TCP | 9222 | 172.31.0.0/16 | Chrome control port | + +#### Outbound Rules (1 rule required) + +| Type | Protocol | Port Range | Destination | Description | +|------|----------|------------|-------------|-------------| +| All traffic | All | All | 0.0.0.0/0 | Allow all outbound traffic | + +### 3. Custom Images +You need to create a custom OSWorld image for Aliyun ECS. Please follow the instructions in the "Creating Custom ECS Images for OSWorld" section. + +## Creating Custom ECS Images for OSWorld + +This section provides guidance on how to create the custom ECS images required for OSWorld desktop environments. The process involves setting up a base instance with desktop environment and VNC server, then creating a custom image from it. + +### Step-by-Step Image Creation Process +#### Step 1: Upload existing qcow2 image to Aliyun +- Download the provided qcow2 image from the link in `desktop_env/providers/docker/manager.py`: https://huggingface.co/datasets/xlangai/ubuntu_osworld/resolve/main/Ubuntu.qcow2.zip +- Unzip the downloaded file and upload it to Aliyun Object Storage Service (OSS). Make sure the OSS is in the same region as your target region to launch ECS instance. +- In your ECS dashboard, go to "Images" and You will see the "Import Image" button. Click it and follow the instructions to import the qcow2 image from OSS. +- After the import is complete, you will see the imported image in the "Images" list. +#### Step 2: Create a new image +Note that the image you created in Step 1 will have a different resolution than the one you want to use for OSWorld (1920x1080). We need to customize the image to have the correct resolution and setup noVNC. +- Go to `Instances` tab and create a new instance with the imported image. +- Connect to the running instance via VNC. +- After connecting to the instance, please open the terminal and download this configuration script: `https://gist.githubusercontent.com/qykong/bea58ff98f20057d3a69921276dd4553/raw/cd1a91a0840c4192d793f43cfb90553370343b08/config.sh`. +- Run the script and reboot your instance. +- After rebooting, the instance will have the correct resolution and noVNC setup. You can connect to the instance via "http://:5910/vnc.html" (make sure your security group allows port 5910). +- Save the running instance as a new image. The new image will be used as the OSWorld image. \ No newline at end of file diff --git a/desktop_env/providers/aliyun/ALIYUN_GUIDELINE_CN.md b/desktop_env/providers/aliyun/ALIYUN_GUIDELINE_CN.md new file mode 100644 index 0000000..556ada6 --- /dev/null +++ b/desktop_env/providers/aliyun/ALIYUN_GUIDELINE_CN.md @@ -0,0 +1,81 @@ +# 阿里云ECS提供商配置指南 + +本指南介绍如何为OSWorld桌面环境配置和使用阿里云ECS。 + +## 配置流程 + +1. **阿里云账户**:您需要一个有效的阿里云账户,本脚本默认ECS通过按量付费方式拉起,需保证账户余额在100以上。 +2. **访问密钥**:在阿里云RAM访问控制控制台中创建AccessKey ID和AccessKey Secret,并授权ECS控制权限 +3. **VPC设置**:在目标地域创建VPC、交换机和安全组 +4. **自定义镜像**:创建OSWorld自定义镜像。 +5. 建议手动完成一次ECS创建流程后,记录所有需要的环境变量信息。 + +## 环境变量 + +在您的`.env`文件中设置以下环境变量: + +```bash +# 阿里云访问凭证 +ALIYUN_ACCESS_KEY_ID=your_access_key_id +ALIYUN_ACCESS_KEY_SECRET=your_access_key_secret + +# ECS配置信息 +ALIYUN_REGION=eu-central-1 +ALIYUN_IMAGE_ID=your_image_id +ALIYUN_INSTANCE_TYPE=ecs.e-c1m2.large +ALIYUN_VSWITCH_ID=vsw-xxxxxxxxx +ALIYUN_SECURITY_GROUP_ID=sg-xxxxxxxxx +``` + +## 所需阿里云资源 + +### 1. VPC和交换机 +- 在目标地域创建VPC +- 在VPC内创建交换机 +- 确保交换机具有互联网访问能力以支持VNC连接 + +### 2. 安全组 +**⚠️ 重要提示**:请严格按照以下端口设置,以防止OSWorld任务因连接问题而失败: + +#### 入方向规则(需要8条规则) + +| 类型 | 协议 | 端口范围 | 源地址 | 描述 | +|------|------|----------|--------|------| +| SSH | TCP | 22 | 0.0.0.0/0 | SSH访问 | +| HTTP | TCP | 80 | 172.31.0.0/16 | HTTP流量 | +| 自定义TCP | TCP | 5000 | 172.31.0.0/16 | OSWorld后端服务 | +| 自定义TCP | TCP | 5910 | 0.0.0.0/0 | NoVNC可视化端口 | +| 自定义TCP | TCP | 8006 | 172.31.0.0/16 | VNC服务端口 | +| 自定义TCP | TCP | 8080 | 172.31.0.0/16 | VLC服务端口 | +| 自定义TCP | TCP | 8081 | 172.31.0.0/16 | 附加服务端口 | +| 自定义TCP | TCP | 9222 | 172.31.0.0/16 | Chrome控制端口 | + +#### 出方向规则(需要1条规则) + +| 类型 | 协议 | 端口范围 | 目标地址 | 描述 | +|------|------|----------|----------|------| +| 全部流量 | 全部 | 全部 | 0.0.0.0/0 | 允许所有出站流量 | + +### 3. 自定义镜像 +您需要为阿里云ECS创建自定义OSWorld镜像。请按照"为OSWorld创建自定义ECS镜像"部分的说明进行操作。 + + +## 为OSWorld创建自定义ECS镜像 + +本部分提供如何创建OSWorld桌面环境所需的自定义ECS镜像的指导。该过程包括设置带有桌面环境和VNC服务器的基础实例,然后从中创建自定义镜像。 + +### 分步镜像创建过程 +#### 步骤1:上传现有qcow2镜像到阿里云 +- 从`desktop_env/providers/docker/manager.py`中的链接下载提供的qcow2镜像:https://huggingface.co/datasets/xlangai/ubuntu_osworld/resolve/main/Ubuntu.qcow2.zip +- 解压下载的文件并上传到阿里云对象存储服务(OSS)。确保OSS与您要启动ECS实例的目标地域在同一地域。 +- 在您的ECS控制台中,转到"镜像"页面,您将看到"导入镜像"按钮。点击它并按照说明从OSS导入qcow2镜像。 +- 导入完成后,您将在"镜像"列表中看到导入的镜像。 + +#### 步骤2:创建新镜像 +请注意,您在步骤1中创建的镜像分辨率与您想要用于OSWorld的分辨率(1920x1080)不同。我们需要自定义镜像以具有正确的分辨率并设置noVNC。 +- 转到"实例"选项卡,使用导入的镜像创建新实例。 +- 通过VNC连接到正在运行的实例。 +- 连接到实例后,请打开终端并下载此配置脚本:`https://gist.githubusercontent.com/qykong/bea58ff98f20057d3a69921276dd4553/raw/cd1a91a0840c4192d793f43cfb90553370343b08/config.sh`。 +- 运行脚本并重启您的实例。 +- 重启后,实例将具有正确的分辨率和noVNC设置。您可以通过"http://:5910/vnc.html"连接到实例(确保您的安全组允许端口5910)。 +- 将正在运行的实例保存为新镜像。新镜像将用作OSWorld镜像。 diff --git a/desktop_env/providers/aliyun/__init__.py b/desktop_env/providers/aliyun/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/desktop_env/providers/aliyun/manager.py b/desktop_env/providers/aliyun/manager.py new file mode 100644 index 0000000..2a5e313 --- /dev/null +++ b/desktop_env/providers/aliyun/manager.py @@ -0,0 +1,289 @@ +import os +import logging +import dotenv +import time +import signal +import requests + +from alibabacloud_ecs20140526.client import Client as ECSClient +from alibabacloud_tea_openapi import models as open_api_models +from alibabacloud_ecs20140526 import models as ecs_models +from alibabacloud_tea_util.client import Client as UtilClient +from desktop_env.providers.base import VMManager + + +dotenv.load_dotenv() + +for env_name in [ + "ALIYUN_REGION", + "ALIYUN_VSWITCH_ID", + "ALIYUN_SECURITY_GROUP_ID", + "ALIYUN_IMAGE_ID", + "ALIYUN_ACCESS_KEY_ID", + "ALIYUN_ACCESS_KEY_SECRET", + "ALIYUN_INSTANCE_TYPE", +]: + if not os.getenv(env_name): + raise EnvironmentError(f"{env_name} must be set in the environment variables.") + + +logger = logging.getLogger("desktopenv.providers.aliyun.AliyunVMManager") +logger.setLevel(logging.INFO) + +ALIYUN_INSTANCE_TYPE = os.getenv("ALIYUN_INSTANCE_TYPE") +ALIYUN_ACCESS_KEY_ID = os.getenv("ALIYUN_ACCESS_KEY_ID") +ALIYUN_ACCESS_KEY_SECRET = os.getenv("ALIYUN_ACCESS_KEY_SECRET") +ALIYUN_REGION = os.getenv("ALIYUN_REGION") +ALIYUN_IMAGE_ID = os.getenv("ALIYUN_IMAGE_ID") +ALIYUN_SECURITY_GROUP_ID = os.getenv("ALIYUN_SECURITY_GROUP_ID") +ALIYUN_VSWITCH_ID = os.getenv("ALIYUN_VSWITCH_ID") + +WAIT_DELAY = 20 +MAX_ATTEMPTS = 15 + + +def _allocate_vm(screen_size=(1920, 1080)): + """ + Allocate a new Aliyun ECS instance + """ + assert screen_size == (1920, 1080), "Only 1920x1080 screen size is supported" + + config = open_api_models.Config( + access_key_id=ALIYUN_ACCESS_KEY_ID, + access_key_secret=ALIYUN_ACCESS_KEY_SECRET, + region_id=ALIYUN_REGION, + ) + client = ECSClient(config) + instance_id = None + original_sigint_handler = signal.getsignal(signal.SIGINT) + original_sigterm_handler = signal.getsignal(signal.SIGTERM) + + def signal_handler(sig, frame): + if instance_id: + signal_name = "SIGINT" if sig == signal.SIGINT else "SIGTERM" + logger.warning( + f"Received {signal_name} signal, terminating instance {instance_id}..." + ) + try: + delete_request = ecs_models.DeleteInstancesRequest( + region_id=ALIYUN_REGION, + instance_ids=UtilClient.to_jsonstring([instance_id]), + force=True, + ) + client.delete_instances(delete_request) + logger.info( + f"Successfully terminated instance {instance_id} after {signal_name}." + ) + except Exception as cleanup_error: + logger.error( + f"Failed to terminate instance {instance_id} after {signal_name}: {str(cleanup_error)}" + ) + + # Restore original signal handlers + signal.signal(signal.SIGINT, original_sigint_handler) + signal.signal(signal.SIGTERM, original_sigterm_handler) + + # Raise appropriate exception based on signal type + if sig == signal.SIGINT: + raise KeyboardInterrupt + else: + # For SIGTERM, exit gracefully + import sys + + sys.exit(0) + + try: + # Set up signal handlers for both SIGINT and SIGTERM + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + logger.info( + f"Creating new ECS instance in region {ALIYUN_REGION} with image {ALIYUN_IMAGE_ID}" + ) + + # Create instance request + request = ecs_models.RunInstancesRequest( + region_id=ALIYUN_REGION, + image_id=ALIYUN_IMAGE_ID, + instance_type=ALIYUN_INSTANCE_TYPE, + security_group_id=ALIYUN_SECURITY_GROUP_ID, + v_switch_id=ALIYUN_VSWITCH_ID, + instance_name=f"OSWorld-Desktop-{int(time.time())}", + description="OSWorld Desktop Environment Instance", + internet_max_bandwidth_out=10, + internet_charge_type="PayByTraffic", + instance_charge_type="PostPaid", + system_disk=ecs_models.RunInstancesRequestSystemDisk( + size="50", + category="cloud_essd", + ), + deletion_protection=False, + ) + + # Create the instance + response = client.run_instances(request) + instance_ids = response.body.instance_id_sets.instance_id_set + + if not instance_ids: + raise RuntimeError( + "Failed to create ECS instance - no instance ID returned" + ) + + instance_id = instance_ids[0] + logger.info(f"ECS instance {instance_id} created successfully") + + # Wait for the instance to be running + logger.info(f"Waiting for instance {instance_id} to be running...") + _wait_for_instance_running(client, instance_id) + + logger.info(f"Instance {instance_id} is now running and ready") + + except KeyboardInterrupt: + logger.warning("VM allocation interrupted by user (SIGINT).") + if instance_id: + logger.info(f"Terminating instance {instance_id} due to interruption.") + try: + delete_request = ecs_models.DeleteInstancesRequest( + region_id=ALIYUN_REGION, + instance_ids=UtilClient.to_jsonstring([instance_id]), + force=True, + ) + client.delete_instances(delete_request) + except Exception as cleanup_error: + logger.error( + f"Failed to cleanup instance {instance_id}: {str(cleanup_error)}" + ) + raise + except Exception as e: + logger.error(f"Failed to allocate ECS instance: {str(e)}") + if instance_id: + logger.info(f"Terminating instance {instance_id} due to an error.") + try: + delete_request = ecs_models.DeleteInstancesRequest( + region_id=ALIYUN_REGION, + instance_ids=UtilClient.to_jsonstring([instance_id]), + force=True, + ) + client.delete_instances(delete_request) + except Exception as cleanup_error: + logger.error( + f"Failed to cleanup instance {instance_id}: {str(cleanup_error)}" + ) + raise + finally: + # Restore original signal handlers + signal.signal(signal.SIGINT, original_sigint_handler) + signal.signal(signal.SIGTERM, original_sigterm_handler) + + return instance_id + + +def _wait_for_instance_running( + client: ECSClient, instance_id: str, max_attempts: int = MAX_ATTEMPTS +): + """Wait for instance to reach Running state""" + for _ in range(max_attempts): + try: + req = ecs_models.DescribeInstancesRequest( + region_id=ALIYUN_REGION, + instance_ids=UtilClient.to_jsonstring([instance_id]), + ) + response = client.describe_instances(req) + + if response.body.instances.instance: + instance = response.body.instances.instance[0] + status = instance.status + logger.info(f"Instance {instance_id} status: {status}") + + if status == "Running": + return + elif status in ["Stopped", "Stopping"]: + start_req = ecs_models.StartInstanceRequest(instance_id=instance_id) + client.start_instance(start_req) + logger.info(f"Started instance {instance_id}") + + time.sleep(WAIT_DELAY) + + except Exception as e: + logger.warning(f"Error checking instance status: {e}") + time.sleep(WAIT_DELAY) + + raise TimeoutError( + f"Instance {instance_id} did not reach Running state within {max_attempts * WAIT_DELAY} seconds" + ) + + +def _wait_until_server_ready(public_ip: str): + """Wait until the server is ready""" + for _ in range(MAX_ATTEMPTS): + try: + logger.info(f"Checking server status on {public_ip}...") + response = requests.get(f"http://{public_ip}:5000/", timeout=2) + if response.status_code == 404: + logger.info(f"Server {public_ip} is ready") + return + except Exception: + time.sleep(WAIT_DELAY) + + raise TimeoutError( + f"Server {public_ip} did not respond within {MAX_ATTEMPTS * WAIT_DELAY} seconds" + ) + + +class AliyunVMManager(VMManager): + """ + Aliyun ECS VM Manager for managing virtual machines on Aliyun Cloud. + + Aliyun ECS does not need to maintain a registry of VMs, as it can dynamically allocate and deallocate VMs. + """ + + def __init__(self, **kwargs): + self.initialize_registry() + + def initialize_registry(self, **kwargs): + pass + + def add_vm(self, vm_path, lock_needed=True, **kwargs): + pass + + def _add_vm(self, vm_path): + pass + + def delete_vm(self, vm_path, lock_needed=True, **kwargs): + pass + + def _delete_vm(self, vm_path): + pass + + def occupy_vm(self, vm_path, pid, lock_needed=True, **kwargs): + pass + + def _occupy_vm(self, vm_path, pid): + pass + + def check_and_clean(self, lock_needed=True, **kwargs): + pass + + def _check_and_clean(self): + pass + + def list_free_vms(self, lock_needed=True, **kwargs): + pass + + def _list_free_vms(self): + pass + + def get_vm_path(self, screen_size=(1920, 1080), **kwargs): + """Get a VM path (instance ID) for use""" + logger.info( + f"Allocating new ECS instance in region {ALIYUN_REGION} with screen size {screen_size}" + ) + + try: + instance_id = _allocate_vm(screen_size) + logger.info(f"Successfully allocated instance {instance_id}") + return instance_id + + except Exception as e: + logger.error(f"Failed to allocate instance: {str(e)}") + raise diff --git a/desktop_env/providers/aliyun/provider.py b/desktop_env/providers/aliyun/provider.py new file mode 100644 index 0000000..9e154f0 --- /dev/null +++ b/desktop_env/providers/aliyun/provider.py @@ -0,0 +1,214 @@ +import os +import logging +from datetime import datetime + +from alibabacloud_ecs20140526.client import Client as ECSClient +from alibabacloud_tea_openapi import models as open_api_models +from alibabacloud_ecs20140526 import models as ecs_models +from alibabacloud_tea_util.client import Client as UtilClient + +from desktop_env.providers.base import Provider +from desktop_env.providers.aliyun.manager import ( + _allocate_vm, + _wait_for_instance_running, + _wait_until_server_ready, +) + + +logger = logging.getLogger("desktopenv.providers.aliyun.AliyunProvider") +logger.setLevel(logging.INFO) + + +class AliyunProvider(Provider): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.region = os.getenv("ALIYUN_REGION", "eu-central-1") + self.client = self._create_client() + + def _create_client(self) -> ECSClient: + config = open_api_models.Config( + access_key_id=os.getenv("ALIYUN_ACCESS_KEY_ID"), + access_key_secret=os.getenv("ALIYUN_ACCESS_KEY_SECRET"), + region_id=self.region, + ) + return ECSClient(config) + + def start_emulator(self, path_to_vm: str, headless: bool, *args, **kwargs): + logger.info("Starting Aliyun ECS instance...") + + try: + # Check the current state of the instance + response = self._describe_instance(path_to_vm) + if not response.body.instances.instance: + logger.error(f"Instance {path_to_vm} not found") + return + + instance = response.body.instances.instance[0] + state = instance.status + logger.info(f"Instance {path_to_vm} current state: {state}") + + if state == "Running": + # If the instance is already running, skip starting it + logger.info( + f"Instance {path_to_vm} is already running. Skipping start." + ) + return + + if state == "Stopped": + # Start the instance if it's currently stopped + req = ecs_models.StartInstanceRequest(instance_id=path_to_vm) + self.client.start_instance(req) + logger.info(f"Instance {path_to_vm} is starting...") + + # Wait until the instance reaches 'Running' state + _wait_for_instance_running(self.client, path_to_vm) + logger.info(f"Instance {path_to_vm} is now running.") + else: + # For all other states (Pending, Starting, etc.), log a warning + logger.warning( + f"Instance {path_to_vm} is in state '{state}' and cannot be started." + ) + + except Exception as e: + logger.error( + f"Failed to start the Aliyun ECS instance {path_to_vm}: {str(e)}" + ) + raise + + def get_ip_address(self, path_to_vm: str) -> str: + logger.info("Getting Aliyun ECS instance IP address...") + + try: + response = self._describe_instance(path_to_vm) + if not response.body.instances.instance: + logger.error(f"Instance {path_to_vm} not found") + return "" + + instance = response.body.instances.instance[0] + + # Get private and public IP addresses + private_ip = "" + public_ip = "" + + if hasattr(instance, "vpc_attributes") and instance.vpc_attributes: + private_ip = ( + instance.vpc_attributes.private_ip_address.ip_address[0] + if instance.vpc_attributes.private_ip_address.ip_address + else "" + ) + + if hasattr(instance, "public_ip_address") and instance.public_ip_address: + public_ip = ( + instance.public_ip_address.ip_address[0] + if instance.public_ip_address.ip_address + else "" + ) + + if hasattr(instance, "eip_address") and instance.eip_address: + public_ip = instance.eip_address.ip_address or public_ip + + _wait_until_server_ready(public_ip) + + if public_ip: + vnc_url = f"http://{public_ip}:5910/vnc.html" + logger.info("=" * 80) + logger.info(f"🖥️ VNC Web Access URL: {vnc_url}") + logger.info(f"📡 Public IP: {public_ip}") + logger.info(f"🏠 Private IP: {private_ip}") + logger.info("=" * 80) + print(f"\n🌐 VNC Web Access URL: {vnc_url}") + print( + "📍 Please open the above address in the browser " + "for remote desktop access\n" + ) + else: + logger.warning("No public IP address available for VNC access") + + return public_ip + + except Exception as e: + logger.error( + f"Failed to retrieve IP address for the instance {path_to_vm}: {str(e)}" + ) + raise + + def save_state(self, path_to_vm: str, snapshot_name: str): + logger.info("Saving Aliyun ECS instance state...") + + try: + req = ecs_models.CreateImageRequest( + region_id=self.region, + instance_id=path_to_vm, + image_name=snapshot_name, + description=f"Snapshot created at {datetime.now().isoformat()}", + ) + response = self.client.create_image(req) + image_id = response.body.image_id + logger.info( + f"Image {image_id} created successfully from instance {path_to_vm}." + ) + return image_id + + except Exception as e: + logger.error( + f"Failed to create image from the instance {path_to_vm}: {str(e)}" + ) + raise + + def revert_to_snapshot(self, path_to_vm: str, snapshot_name: str): + logger.info( + f"Reverting Aliyun ECS instance to snapshot image: {snapshot_name}..." + ) + + try: + # Step 1: Retrieve the original instance details + response = self._describe_instance(path_to_vm) + if not response.body.instances.instance: + logger.error(f"Instance {path_to_vm} not found") + return + # Step 2: Delete the old instance + req = ecs_models.DeleteInstancesRequest( + region_id=self.region, instance_id=[path_to_vm], force=True + ) + self.client.delete_instances(req) + logger.info(f"Old instance {path_to_vm} has been deleted.") + + # Step 3: Launch a new instance from the snapshot image + new_instance_id = _allocate_vm() + logger.info(f"Instance {new_instance_id} is ready.") + + # Get VNC access information + self.get_ip_address(new_instance_id) + + return new_instance_id + + except Exception as e: + logger.error( + f"Failed to revert to snapshot {snapshot_name} for the instance {path_to_vm}: {str(e)}" + ) + raise + + def stop_emulator(self, path_to_vm: str, region: str = None): + logger.info(f"Stopping Aliyun ECS instance {path_to_vm}...") + + try: + req = ecs_models.DeleteInstancesRequest( + region_id=self.region, instance_id=[path_to_vm], force=True + ) + self.client.delete_instances(req) + logger.info(f"Instance {path_to_vm} has been deleted.") + + except Exception as e: + logger.error( + f"Failed to stop the Aliyun ECS instance {path_to_vm}: {str(e)}" + ) + raise + + def _describe_instance( + self, instance_id: str + ) -> ecs_models.DescribeInstancesResponse: + """Get instance details""" + req = ecs_models.DescribeInstancesRequest( + region_id=self.region, instance_ids=UtilClient.to_jsonstring([instance_id]) + ) + return self.client.describe_instances(req)