diff --git a/desktop_env/providers/aliyun/config.py b/desktop_env/providers/aliyun/config.py new file mode 100644 index 0000000..1e4e750 --- /dev/null +++ b/desktop_env/providers/aliyun/config.py @@ -0,0 +1,31 @@ +import os + + +# Default TTL minutes for instance auto-release (Aliyun-side) +# Can be overridden via environment variable DEFAULT_TTL_MINUTES +# ATTENTION: ECS requires TTL to be at least 30 minutes (if TTL > 0) +MIN_TTL_MINUTES: int = 30 + +_ttl_env_str = os.getenv("DEFAULT_TTL_MINUTES", "60") +try: + _ttl_env_val = int(_ttl_env_str) +except Exception: + _ttl_env_val = 60 + +# If TTL is positive but less than Aliyun minimum, clamp to 30 minutes +if _ttl_env_val > 0 and _ttl_env_val < MIN_TTL_MINUTES: + DEFAULT_TTL_MINUTES: int = MIN_TTL_MINUTES +else: + DEFAULT_TTL_MINUTES: int = _ttl_env_val + +# Master switch for TTL feature +ENABLE_TTL: bool = os.getenv("ENABLE_TTL", "true").lower() == "true" + + +def compute_ttl_seconds(ttl_minutes: int) -> int: + try: + return max(0, int(ttl_minutes) * 60) + except Exception: + return 0 + + diff --git a/desktop_env/providers/aliyun/manager.py b/desktop_env/providers/aliyun/manager.py index 2a5e313..8586936 100644 --- a/desktop_env/providers/aliyun/manager.py +++ b/desktop_env/providers/aliyun/manager.py @@ -4,12 +4,14 @@ import dotenv import time import signal import requests +from datetime import datetime, timedelta, timezone 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 +from desktop_env.providers.aliyun.config import ENABLE_TTL, DEFAULT_TTL_MINUTES dotenv.load_dotenv() @@ -101,27 +103,56 @@ def _allocate_vm(screen_size=(1920, 1080)): 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, + # TTL configuration + ttl_enabled = ENABLE_TTL + ttl_minutes = DEFAULT_TTL_MINUTES + ttl_seconds = max(0, int(ttl_minutes) * 60) + + # Aliyun constraints: at least 30 minutes in the future, ISO8601 UTC, seconds must be 00 + now_utc = datetime.now(timezone.utc) + min_eta = now_utc + timedelta(minutes=30) + raw_eta = now_utc + timedelta(seconds=ttl_seconds) + effective_eta = raw_eta if raw_eta > min_eta else min_eta + # round up to the next full minute, zero seconds + effective_eta = (effective_eta + timedelta(seconds=59)).replace(second=0, microsecond=0) + auto_release_str = effective_eta.strftime('%Y-%m-%dT%H:%M:%SZ') + logger.info( + f"TTL config: enabled={ttl_enabled}, minutes={ttl_minutes}, seconds={ttl_seconds}, ETA(UTC)={auto_release_str}" ) - # Create the instance - response = client.run_instances(request) + # Create instance request (attempt with auto_release_time first when TTL enabled) + def _build_request(with_ttl: bool) -> ecs_models.RunInstancesRequest: + kwargs = dict( + 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, + ) + if with_ttl and ttl_enabled and ttl_seconds > 0: + kwargs["auto_release_time"] = auto_release_str + return ecs_models.RunInstancesRequest(**kwargs) + + try: + request = _build_request(with_ttl=True) + response = client.run_instances(request) + except Exception as create_err: + # Retry without auto_release_time if creation-time TTL is rejected + logger.warning( + f"RunInstances with auto_release_time failed: {create_err}. Retrying without TTL field..." + ) + request = _build_request(with_ttl=False) + response = client.run_instances(request) instance_ids = response.body.instance_id_sets.instance_id_set if not instance_ids: diff --git a/desktop_env/providers/aliyun/provider.py b/desktop_env/providers/aliyun/provider.py index 9e154f0..1528f22 100644 --- a/desktop_env/providers/aliyun/provider.py +++ b/desktop_env/providers/aliyun/provider.py @@ -24,6 +24,11 @@ class AliyunProvider(Provider): super().__init__(**kwargs) self.region = os.getenv("ALIYUN_REGION", "eu-central-1") self.client = self._create_client() + # Whether to use private IP instead of public IP. Default: enabled. + # Priority: explicit kwarg > env var ALIYUN_USE_PRIVATE_IP > default True + env_use_private = os.getenv("ALIYUN_USE_PRIVATE_IP", "1").lower() in {"1", "true", "yes", "on"} + kw_flag = kwargs.get("use_private_ip", None) + self.use_private_ip = env_use_private if kw_flag is None else bool(kw_flag) def _create_client(self) -> ECSClient: config = open_api_models.Config( @@ -107,24 +112,29 @@ class AliyunProvider(Provider): 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) + # Select which IP to use based on configuration + ip_to_use = private_ip if (self.use_private_ip and private_ip) else 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") + if not ip_to_use: + logger.warning("No usable IP address available (private/public both missing)") + return "" - return public_ip + _wait_until_server_ready(ip_to_use) + + vnc_url = f"http://{ip_to_use}: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(f"šŸ”§ Using IP: {'Private' if ip_to_use == private_ip else 'Public'} -> {ip_to_use}") + 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" + ) + + return ip_to_use except Exception as e: logger.error( diff --git a/requirements.txt b/requirements.txt index 355a725..6a9e84e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -65,3 +65,6 @@ loguru dotenv tldextract anthropic +alibabacloud_ecs20140526 +alibabacloud_tea_openapi +alibabacloud_tea_util diff --git a/setup.py b/setup.py index 6d5f871..ab7ee42 100644 --- a/setup.py +++ b/setup.py @@ -105,6 +105,10 @@ setup( "dotenv", "tldextract", "anthropic", + # Aliyun ECS SDK dependencies + "alibabacloud_ecs20140526", + "alibabacloud_tea_openapi", + "alibabacloud_tea_util", ], cmdclass={ 'install': InstallPlaywrightCommand, # Use the custom install command