diff --git a/aenv/src/aenv/client/scheduler_client.py b/aenv/src/aenv/client/scheduler_client.py index 307212b3..a7403725 100644 --- a/aenv/src/aenv/client/scheduler_client.py +++ b/aenv/src/aenv/client/scheduler_client.py @@ -109,6 +109,7 @@ async def create_env_instance( ttl: str = "", environment_variables: Optional[Dict[str, str]] = None, arguments: Optional[List[str]] = None, + owner: Optional[str] = None, ) -> EnvInstance: """ Create a new environment instance. @@ -119,6 +120,7 @@ async def create_env_instance( environment_variables: Optional environment variables arguments: Optional arguments ttl: Time to live for instance + owner: Optional owner of the instance Returns: Created EnvInstance @@ -130,7 +132,7 @@ async def create_env_instance( raise NetworkError("Client not connected") logger.info( - f"Creating environment instance: {name}, datasource: {datasource}, ttl: {ttl}, environment_variables: {environment_variables}, arguments: {arguments}, url: {self.base_url}" + f"Creating environment instance: {name}, datasource: {datasource}, ttl: {ttl}, environment_variables: {environment_variables}, arguments: {arguments}, owner: {owner}, url: {self.base_url}" ) request = EnvInstanceCreateRequest( envName=name, @@ -138,6 +140,7 @@ async def create_env_instance( environment_variables=environment_variables, arguments=arguments, ttl=ttl, + owner=owner, ) for attempt in range(self.max_retries + 1): diff --git a/aenv/src/aenv/core/environment.py b/aenv/src/aenv/core/environment.py index f0954b83..3268f5e6 100644 --- a/aenv/src/aenv/core/environment.py +++ b/aenv/src/aenv/core/environment.py @@ -22,7 +22,7 @@ import os import traceback from datetime import datetime, timezone -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from urllib.parse import urlparse, urlunparse import httpx @@ -58,6 +58,28 @@ def make_mcp_url(aenv_url: str, port: str, path: str = "") -> str: return urlunparse(new) +def split_env_name_version(env_name: str) -> Tuple[str, str]: + """ + Split environment name into name and version. + + Args: + env_name: Environment name in format "name@version" or just "name" + + Returns: + Tuple of (name, version). If no @ symbol, version is empty string. + """ + if not env_name: + return "", "" + + parts = env_name.split("@", 1) + if len(parts) == 1: + # No @ symbol, use entire string as name + return parts[0], "" + else: + # Has @ symbol, first part as name, second part as version + return parts[0], parts[1] + + class ToolResult: """Result of a tool execution.""" @@ -94,6 +116,7 @@ def __init__( max_retries: int = 10, api_key: Optional[str] = None, skip_for_healthy: bool = False, + owner: Optional[str] = None, ): """ Initialize environment. @@ -117,6 +140,7 @@ def __init__( self.arguments = arguments or [] self.dummy_instance_ip = os.getenv("DUMMY_INSTANCE_IP") self.skip_for_healthy = skip_for_healthy + self.owner = owner if not aenv_url: aenv_url = self.dummy_instance_ip or os.getenv( @@ -788,12 +812,23 @@ async def _create_env_instance(self): f"{self._log_prefix()} Creating environment instance: {self.env_name}" ) try: + # Parse env_name to extract name and version + env_name_parsed, env_version_parsed = split_env_name_version(self.env_name) + + # Inject system environment variables envNAME and envversion + env_vars = ( + dict(self.environment_variables) if self.environment_variables else {} + ) + env_vars["envNAME"] = env_name_parsed + env_vars["envversion"] = env_version_parsed + self._instance = await self._client.create_env_instance( name=self.env_name, datasource=self.datasource, - environment_variables=self.environment_variables, + environment_variables=env_vars, arguments=self.arguments, ttl=self.ttl, + owner=self.owner, ) logger.info( f"{self._log_prefix()} Environment instance created with ID: {self._instance.id}" diff --git a/aenv/src/aenv/core/models.py b/aenv/src/aenv/core/models.py index f609f9f0..3deb1a0a 100644 --- a/aenv/src/aenv/core/models.py +++ b/aenv/src/aenv/core/models.py @@ -82,6 +82,7 @@ class EnvInstanceCreateRequest(BaseModel): Field(None, description="Environment variables"), ) arguments: Optional[List[str]] = (Field(None, description="Startup arguments"),) + owner: Optional[str] = Field(None, description="Instance owner") class EnvInstanceListResponse(BaseModel): diff --git a/aenv/src/cli/cli.py b/aenv/src/cli/cli.py index 78f978d5..b14d807b 100644 --- a/aenv/src/cli/cli.py +++ b/aenv/src/cli/cli.py @@ -14,7 +14,18 @@ import click -from cli.cmds import build, config, get, init, instances, list, push, run, version +from cli.cmds import ( + build, + config, + get, + init, + instance, + instances, + list, + push, + run, + version, +) from cli.cmds.common import Config, global_error_handler, pass_config @@ -44,6 +55,7 @@ def cli(cfg: Config, debug: bool, verbose: bool): cli.add_command(build) cli.add_command(config) cli.add_command(instances) +cli.add_command(instance) if __name__ == "__main__": cli() diff --git a/aenv/src/cli/cmds/__init__.py b/aenv/src/cli/cmds/__init__.py index f55a4808..656cc505 100644 --- a/aenv/src/cli/cmds/__init__.py +++ b/aenv/src/cli/cmds/__init__.py @@ -21,6 +21,7 @@ from cli.cmds.config import config from cli.cmds.get import get from cli.cmds.init import init +from cli.cmds.instance import instance from cli.cmds.instances import instances from cli.cmds.list import list_env as list from cli.cmds.push import push @@ -38,4 +39,5 @@ "build", "config", "instances", + "instance", ] diff --git a/aenv/src/cli/cmds/instance.py b/aenv/src/cli/cmds/instance.py new file mode 100644 index 00000000..70f89ee0 --- /dev/null +++ b/aenv/src/cli/cmds/instance.py @@ -0,0 +1,1228 @@ +# Copyright 2025. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +instance command - Manage environment instances + +This command provides a unified interface for managing environment instances: +- instance create: Create new instances +- instance list: List running instances +- instance get: Get detailed instance information +- instance delete: Delete an instance + +Uses HTTP API for control plane operations (list, get, delete) +Uses Environment SDK for deployment operations (create) +""" +import asyncio +import json +import os +from typing import Any, Dict, Optional, Tuple +from urllib.parse import urlparse, urlunparse + +import click +import requests +from tabulate import tabulate + +from aenv.core.environment import Environment +from cli.cmds.common import Config, pass_config +from cli.utils.cli_config import get_config_manager + + +def _parse_env_vars(env_var_list: tuple) -> Dict[str, str]: + """Parse environment variables from command line arguments. + + Args: + env_var_list: Tuple of strings in format "KEY=VALUE" + + Returns: + Dictionary of environment variables + """ + env_vars = {} + for env_var in env_var_list: + if "=" not in env_var: + raise click.BadParameter( + f"Environment variable must be in format KEY=VALUE, got: {env_var}" + ) + key, value = env_var.split("=", 1) + env_vars[key.strip()] = value.strip() + return env_vars + + +def _parse_arguments(arg_list: tuple) -> list: + """Parse command line arguments. + + Args: + arg_list: Tuple of argument strings + + Returns: + List of arguments + """ + return list(arg_list) if arg_list else [] + + +def _split_env_name_version(env_name: str) -> Tuple[str, str]: + """Split environment name into name and version. + + Args: + env_name: Environment name in format "name@version" or just "name" + + Returns: + Tuple of (name, version). If no @ symbol, version is empty string. + """ + if not env_name: + return "", "" + + parts = env_name.split("@", 1) + if len(parts) == 1: + # No @ symbol, use entire string as name + return parts[0], "" + else: + # Has @ symbol, first part as name, second part as version + return parts[0], parts[1] + + +def _make_api_url(aenv_url: str, port: int = 8080) -> str: + """Make API URL with specified port. + + Args: + aenv_url: Base URL (with or without protocol) + port: Port number (default 8080) + + Returns: + URL with specified port + """ + if not aenv_url: + return f"http://localhost:{port}" + + if "://" not in aenv_url: + aenv_url = f"http://{aenv_url}" + + p = urlparse(aenv_url) + host = p.hostname or "127.0.0.1" + new = p._replace( + scheme="http", + netloc=f"{host}:{port}", + path="", + params="", + query="", + fragment="", + ) + return urlunparse(new).rstrip("/") + + +def _get_system_url_raw() -> Optional[str]: + """Get raw AEnv system URL from environment variable or config (without processing). + + Priority order: + 1. AENV_SYSTEM_URL environment variable (highest priority) + 2. system_url in config file + 3. None (no default) + + Returns: + Raw system URL string or None if not found + """ + # First check environment variable + system_url = os.getenv("AENV_SYSTEM_URL") + + # If not in env, check config + if not system_url: + config_manager = get_config_manager() + system_url = config_manager.get("system_url") + + return system_url + + +def _get_system_url() -> str: + """Get AEnv system URL from environment variable or config (processed for API). + + Priority order: + 1. AENV_SYSTEM_URL environment variable (highest priority) + 2. system_url in config file + 3. Default value (http://localhost:8080) + + Returns: + Processed API URL with port + """ + system_url = _get_system_url_raw() + + # Use default if still not found + if not system_url: + system_url = "http://localhost:8080" + + return _make_api_url(system_url, port=8080) + + +def _get_api_headers() -> Dict[str, str]: + """Get API headers with authentication if available.""" + config_manager = get_config_manager() + hub_config = config_manager.get_hub_config() + api_key = hub_config.get("api_key") or os.getenv("AENV_API_KEY") + + headers = {"Content-Type": "application/json", "Accept": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + return headers + + +def _list_instances_from_api( + system_url: str, + env_name: Optional[str] = None, + version: Optional[str] = None, + verbose: bool = False, + console=None, +) -> list: + """List running instances from API service. + + Args: + system_url: AEnv system URL + env_name: Optional environment name filter + version: Optional version filter + verbose: Enable debug logging + console: Console object for logging (if verbose) + + Returns: + List of running instances + """ + # Build the API endpoint + if env_name: + if version: + env_id = f"{env_name}@{version}" + else: + env_id = env_name + else: + env_id = "*" + + url = f"{system_url}/env-instance/{env_id}/list" + headers = _get_api_headers() + + # Add query parameters + params = {} + + # Debug logging + if verbose and console: + console.print(f"[dim]🔍 Debug: Request URL: {url}[/dim]") + console.print( + f"[dim]🔍 Debug: Query params: {params if params else 'none'}[/dim]" + ) + # Don't log full headers for security, but show if auth is present + has_auth = "Authorization" in headers + console.print( + f"[dim]🔍 Debug: Headers: Content-Type={headers.get('Content-Type')}, " + f"Authorization={'present' if has_auth else 'not set'}[/dim]" + ) + + try: + if verbose and console: + console.print("[dim]🔍 Debug: Sending GET request...[/dim]") + + response = requests.get(url, headers=headers, params=params, timeout=30) + + if verbose and console: + console.print( + f"[dim]🔍 Debug: Response status: {response.status_code}[/dim]" + ) + console.print( + f"[dim]🔍 Debug: Response headers: {dict(response.headers)}[/dim]" + ) + + # Check for HTTP errors + if response.status_code == 403: + error_detail = "" + if verbose and console: + try: + error_body = response.json() + error_detail = f"\nResponse: {error_body}" + except BaseException: + error_detail = f"\nResponse body: {response.text[:200]}" + raise click.ClickException( + "Authentication failed (403). Please check your API key configuration.\n" + "You can set it with: aenv config set hub_config.api_key \n" + "Or use AENV_API_KEY environment variable." + error_detail + ) + elif response.status_code == 401: + error_detail = "" + if verbose and console: + try: + error_body = response.json() + error_detail = f"\nResponse: {error_body}" + except BaseException: + error_detail = f"\nResponse body: {response.text[:200]}" + raise click.ClickException( + "Unauthorized (401). Invalid or missing API key.\n" + "Please check your API key configuration." + error_detail + ) + + response.raise_for_status() + + result = response.json() + + if verbose and console: + console.print( + f"[dim]🔍 Debug: Response body (success): {result.get('success')}[/dim]" + ) + console.print( + f"[dim]🔍 Debug: Response body (code): {result.get('code')}[/dim]" + ) + if result.get("data"): + data_len = ( + len(result.get("data", [])) + if isinstance(result.get("data"), list) + else 1 + ) + console.print( + f"[dim]🔍 Debug: Response data: {data_len} item(s) returned[/dim]" + ) + else: + console.print("[dim]🔍 Debug: Response data: empty or null[/dim]") + + if result.get("success") and result.get("data"): + instances = result["data"] + + if verbose and console: + console.print( + f"[dim]🔍 Debug: Found {len(instances)} instance(s)[/dim]" + ) + + return instances + elif not result.get("success"): + error_msg = result.get("message", "Unknown error") + if verbose and console: + console.print(f"[dim]🔍 Debug: API returned error: {error_msg}[/dim]") + raise click.ClickException(f"API returned error: {error_msg}") + + if verbose and console: + console.print( + "[dim]🔍 Debug: No data in response, returning empty list[/dim]" + ) + + return [] + except requests.exceptions.ConnectionError as e: + error_msg = ( + f"Failed to connect to API service at {system_url}.\n" + f"Please check:\n" + f" 1. Is the API service running?\n" + f" 2. Is the system_url correct? (current: {system_url})\n" + f" 3. You can set it with: aenv config set system_url \n" + f" 4. Or use AENV_SYSTEM_URL environment variable.\n" + f"Error: {str(e)}" + ) + if verbose and console: + console.print(f"[dim]🔍 Debug: Connection error details: {str(e)}[/dim]") + raise click.ClickException(error_msg) + except requests.exceptions.Timeout: + error_msg = ( + f"Request timeout while connecting to {system_url}.\n" + f"The API service may be slow or unreachable." + ) + if verbose and console: + console.print("[dim]🔍 Debug: Timeout after 30 seconds[/dim]") + raise click.ClickException(error_msg) + except requests.exceptions.RequestException as e: + error_msg = f"Failed to query instances: {str(e)}" + if verbose and console: + console.print(f"[dim]🔍 Debug: Request exception: {str(e)}[/dim]") + import traceback + + console.print(f"[dim]🔍 Debug: Traceback:\n{traceback.format_exc()}[/dim]") + raise click.ClickException(error_msg) + + +def _get_instance_from_api( + system_url: str, + instance_id: str, + verbose: bool = False, + console=None, +) -> Optional[dict]: + """Get detailed information for a single instance. + + Args: + system_url: AEnv system URL + instance_id: Instance ID + verbose: Enable debug logging + console: Console object for logging (if verbose) + + Returns: + Instance details dict or None if failed + """ + url = f"{system_url}/env-instance/{instance_id}" + headers = _get_api_headers() + + # Debug logging + if verbose and console: + console.print(f"[dim]🔍 Debug: Request URL: {url}[/dim]") + has_auth = "Authorization" in headers + console.print( + f"[dim]🔍 Debug: Headers: Content-Type={headers.get('Content-Type')}, " + f"Authorization={'present' if has_auth else 'not set'}[/dim]" + ) + + try: + if verbose and console: + console.print("[dim]🔍 Debug: Sending GET request...[/dim]") + + response = requests.get(url, headers=headers, timeout=10) + + if verbose and console: + console.print( + f"[dim]🔍 Debug: Response status: {response.status_code}[/dim]" + ) + console.print( + f"[dim]🔍 Debug: Response headers: {dict(response.headers)}[/dim]" + ) + + # Check for HTTP errors + if response.status_code == 403: + error_detail = "" + if verbose and console: + try: + error_body = response.json() + error_detail = f"\nResponse: {error_body}" + except BaseException: + error_detail = f"\nResponse body: {response.text[:200]}" + raise click.ClickException( + "Authentication failed (403). Please check your API key configuration.\n" + "You can set it with: aenv config set hub_config.api_key \n" + "Or use AENV_API_KEY environment variable." + error_detail + ) + elif response.status_code == 401: + error_detail = "" + if verbose and console: + try: + error_body = response.json() + error_detail = f"\nResponse: {error_body}" + except BaseException: + error_detail = f"\nResponse body: {response.text[:200]}" + raise click.ClickException( + "Unauthorized (401). Invalid or missing API key.\n" + "Please check your API key configuration." + error_detail + ) + + response.raise_for_status() + + result = response.json() + + if verbose and console: + console.print( + f"[dim]🔍 Debug: Response body (success): {result.get('success')}[/dim]" + ) + console.print( + f"[dim]🔍 Debug: Response body (code): {result.get('code')}[/dim]" + ) + if result.get("data"): + console.print("[dim]🔍 Debug: Response data: instance found[/dim]") + else: + console.print("[dim]🔍 Debug: Response data: empty or null[/dim]") + + if result.get("success") and result.get("data"): + return result["data"] + elif not result.get("success"): + error_msg = result.get("message", "Unknown error") + if verbose and console: + console.print(f"[dim]🔍 Debug: API returned error: {error_msg}[/dim]") + raise click.ClickException(f"API returned error: {error_msg}") + return None + except requests.exceptions.HTTPError as e: + # Extract error details from response body + error_detail = "" + if hasattr(e.response, "text"): + try: + error_body = e.response.json() + if isinstance(error_body, dict): + error_msg = ( + error_body.get("message") + or error_body.get("error") + or str(error_body) + ) + error_detail = f": {error_msg}" + else: + error_detail = f": {str(error_body)[:200]}" + except BaseException: + error_detail = f": {e.response.text[:200]}" + + error_msg = f"Failed to get instance info: {str(e)}{error_detail}" + if verbose and console: + console.print(f"[dim]🔍 Debug: HTTP error details: {error_detail}[/dim]") + raise click.ClickException(error_msg) + except requests.exceptions.ConnectionError as e: + error_msg = ( + f"Failed to connect to API service at {system_url}.\n" + f"Please check:\n" + f" 1. Is the API service running?\n" + f" 2. Is the system_url correct? (current: {system_url})\n" + f" 3. You can set it with: aenv config set system_url \n" + f" 4. Or use AENV_SYSTEM_URL environment variable.\n" + f"Error: {str(e)}" + ) + if verbose and console: + console.print(f"[dim]🔍 Debug: Connection error details: {str(e)}[/dim]") + raise click.ClickException(error_msg) + except requests.exceptions.Timeout: + error_msg = ( + f"Request timeout while connecting to {system_url}.\n" + f"The API service may be slow or unreachable." + ) + if verbose and console: + console.print("[dim]🔍 Debug: Timeout after 10 seconds[/dim]") + raise click.ClickException(error_msg) + except requests.exceptions.RequestException as e: + error_msg = f"Failed to get instance info: {str(e)}" + if verbose and console: + console.print(f"[dim]🔍 Debug: Request exception: {str(e)}[/dim]") + import traceback + + console.print(f"[dim]🔍 Debug: Traceback:\n{traceback.format_exc()}[/dim]") + raise click.ClickException(error_msg) + + +def _delete_instance_from_api(system_url: str, instance_id: str) -> bool: + """Delete an instance via API. + + Args: + system_url: AEnv system URL + instance_id: Instance ID + + Returns: + True if deletion successful + """ + url = f"{system_url}/env-instance/{instance_id}" + headers = _get_api_headers() + + try: + response = requests.delete(url, headers=headers, timeout=30) + response.raise_for_status() + + result = response.json() + return result.get("success", False) + except requests.exceptions.RequestException as e: + raise click.ClickException(f"Failed to delete instance: {str(e)}") + + +async def _deploy_instance( + env_name: str, + datasource: str, + ttl: str, + environment_variables: Dict[str, str], + arguments: list, + aenv_url: Optional[str], + timeout: float, + startup_timeout: float, + max_retries: int, + api_key: Optional[str], + skip_health: bool, + owner: Optional[str], +) -> Environment: + """Deploy a new environment instance. + + Returns: + Environment object + """ + env = Environment( + env_name=env_name, + datasource=datasource, + ttl=ttl, + environment_variables=environment_variables, + arguments=arguments, + aenv_url=aenv_url, + timeout=timeout, + startup_timeout=startup_timeout, + max_retries=max_retries, + api_key=api_key, + skip_for_healthy=skip_health, + owner=owner, + ) + + await env.initialize() + return env + + +async def _get_instance_info(env: Environment) -> Dict[str, Any]: + """Get environment instance information. + + Args: + env: Environment object + + Returns: + Dictionary with instance information + """ + return await env.get_env_info() + + +async def _stop_instance(env: Environment): + """Stop and release environment instance. + + Args: + env: Environment object + """ + await env.release() + + +@click.group("instance") +@pass_config +def instance(cfg: Config): + """Manage environment instances + + Manage the lifecycle of environment instances including creation, + querying, and deletion. + """ + pass + + +@instance.command("create") +@click.argument("env_name") +@click.option( + "--datasource", + "-d", + default="", + help="Data source for mounting on the MCP server", +) +@click.option( + "--ttl", + "-t", + default="30m", + help="Time to live for the instance (e.g., 30m, 1h, 2h)", +) +@click.option( + "--env", + "-e", + "environment_variables", + multiple=True, + help="Environment variables in format KEY=VALUE (can be used multiple times)", +) +@click.option( + "--arg", + "-a", + "arguments", + multiple=True, + help="Command line arguments for the instance entrypoint (can be used multiple times)", +) +@click.option( + "--system-url", + help="AEnv system URL (defaults to AENV_SYSTEM_URL env var or config)", +) +@click.option( + "--timeout", + type=float, + default=60.0, + help="Request timeout in seconds", +) +@click.option( + "--startup-timeout", + type=float, + default=500.0, + help="Startup timeout in seconds", +) +@click.option( + "--max-retries", + type=int, + default=10, + help="Maximum retry attempts for failed requests", +) +@click.option( + "--api-key", + help="API key for authentication (defaults to AENV_API_KEY env var)", +) +@click.option( + "--skip-health", + is_flag=True, + help="Skip health check during initialization", +) +@click.option( + "--output", + "-o", + type=click.Choice(["table", "json"]), + default="table", + help="Output format", +) +@click.option( + "--keep-alive", + is_flag=True, + help="Keep the instance running after deployment (doesn't auto-release)", +) +@click.option( + "--owner", + type=str, + help="Owner of the instance (defaults to owner in config if not specified)", +) +@pass_config +def create( + cfg: Config, + env_name: str, + datasource: str, + ttl: str, + environment_variables: tuple, + arguments: tuple, + system_url: Optional[str], + timeout: float, + startup_timeout: float, + max_retries: int, + api_key: Optional[str], + skip_health: bool, + output: str, + keep_alive: bool, + owner: Optional[str], +): + """Create a new environment instance + + Create and initialize a new environment instance with the specified configuration. + + Examples: + # Create a basic instance + aenv instance create flowise-xxx@1.0.2 + + # Create with custom TTL and environment variables + aenv instance create flowise-xxx@1.0.2 --ttl 1h -e DB_HOST=localhost -e DB_PORT=5432 + + # Create with arguments and skip health check + aenv instance create flowise-xxx@1.0.2 --arg --debug --arg --verbose --skip-health + + # Create and keep alive (doesn't auto-release) + aenv instance create flowise-xxx@1.0.2 --keep-alive + """ + console = cfg.console.console() + + # Parse environment variables and arguments + try: + env_vars = _parse_env_vars(environment_variables) + args = _parse_arguments(arguments) + except click.BadParameter as e: + console.print(f"[red]Error:[/red] {str(e)}") + raise click.Abort() + + # Parse env_name to extract name and version, and inject system environment variables + env_name_parsed, env_version_parsed = _split_env_name_version(env_name) + env_vars["envNAME"] = env_name_parsed + env_vars["envversion"] = env_version_parsed + + # Get API key from env if not provided + if not api_key: + api_key = os.getenv("AENV_API_KEY") + + # Get system URL from env, config, or use default + if not system_url: + system_url = _get_system_url_raw() + + # Get owner from command line, config, or None + if not owner: + config_manager = get_config_manager() + owner = config_manager.get("owner") + + console.print(f"[cyan]🚀 Deploying environment instance:[/cyan] {env_name}") + if datasource: + console.print(f" Datasource: {datasource}") + console.print(f" TTL: {ttl}") + if env_vars: + console.print(f" Environment Variables: {len(env_vars)} variables") + if args: + console.print(f" Arguments: {len(args)} arguments") + console.print() + + try: + # Deploy the instance + with console.status("[bold green]Deploying instance..."): + env = asyncio.run( + _deploy_instance( + env_name=env_name, + datasource=datasource, + ttl=ttl, + environment_variables=env_vars, + arguments=args, + aenv_url=system_url, + timeout=timeout, + startup_timeout=startup_timeout, + max_retries=max_retries, + api_key=api_key, + skip_health=skip_health, + owner=owner, + ) + ) + + # Get instance info + info = asyncio.run(_get_instance_info(env)) + + console.print("[green]✅ Instance deployed successfully![/green]\n") + + # Display instance information + if output == "json": + console.print(json.dumps(info, indent=2, ensure_ascii=False)) + else: + table_data = [ + {"Property": "Instance ID", "Value": info.get("instance_id", "-")}, + {"Property": "Environment", "Value": info.get("name", "-")}, + {"Property": "Status", "Value": info.get("status", "-")}, + {"Property": "IP Address", "Value": info.get("ip", "-")}, + {"Property": "Created At", "Value": info.get("created_at", "-")}, + ] + console.print(tabulate(table_data, headers="keys", tablefmt="grid")) + + # Store instance reference for potential cleanup + if not keep_alive: + console.print( + "\n[yellow]âš ī¸ Instance will be released when the command exits[/yellow]" + ) + console.print( + "[yellow] Use --keep-alive flag to keep the instance running[/yellow]" + ) + # Release the instance + asyncio.run(_stop_instance(env)) + console.print("[green]✅ Instance released[/green]") + else: + console.print("\n[green]✅ Instance is running and will stay alive[/green]") + console.print(f"[cyan]Instance ID:[/cyan] {info.get('instance_id')}") + console.print( + "[cyan]Use 'aenv instances' to view all running instances[/cyan]" + ) + + except Exception as e: + console.print(f"[red]❌ Deployment failed:[/red] {str(e)}") + if cfg.verbose: + import traceback + + console.print(traceback.format_exc()) + raise click.Abort() + + +@instance.command("info") +@click.argument("env_name") +@click.option( + "--system-url", + help="AEnv system URL (defaults to AENV_SYSTEM_URL env var or config)", +) +@click.option( + "--timeout", + type=float, + default=60.0, + help="Request timeout in seconds", +) +@click.option( + "--api-key", + help="API key for authentication (defaults to AENV_API_KEY env var)", +) +@click.option( + "--output", + "-o", + type=click.Choice(["table", "json"]), + default="table", + help="Output format", +) +@pass_config +def info( + cfg: Config, + env_name: str, + system_url: Optional[str], + timeout: float, + api_key: Optional[str], + output: str, +): + """Get information about a deployed instance + + Retrieve detailed information about a running environment instance. + Note: This command requires an active instance. Use with DUMMY_INSTANCE_IP + environment variable for testing. + + Examples: + # Get info for a test instance + DUMMY_INSTANCE_IP=localhost aenv instance info flowise-xxx@1.0.2 + + # Get info in JSON format + DUMMY_INSTANCE_IP=localhost aenv instance info flowise-xxx@1.0.2 --output json + """ + console = cfg.console.console() + + # Get API key from env if not provided + if not api_key: + api_key = os.getenv("AENV_API_KEY") + + # Get system URL from env, config, or use default + if not system_url: + system_url = _get_system_url_raw() + + console.print(f"[cyan]â„šī¸ Retrieving instance information:[/cyan] {env_name}\n") + + try: + # Create environment instance (will use DUMMY_INSTANCE_IP if set) + with console.status("[bold green]Connecting to instance..."): + env = Environment( + env_name=env_name, + aenv_url=system_url, + timeout=timeout, + api_key=api_key, + skip_for_healthy=True, + ) + asyncio.run(env.initialize()) + info = asyncio.run(_get_instance_info(env)) + + console.print("[green]✅ Instance information retrieved![/green]\n") + + # Display instance information + if output == "json": + console.print(json.dumps(info, indent=2, ensure_ascii=False)) + else: + table_data = [ + {"Property": "Instance ID", "Value": info.get("instance_id", "-")}, + {"Property": "Environment", "Value": info.get("name", "-")}, + {"Property": "Status", "Value": info.get("status", "-")}, + {"Property": "IP Address", "Value": info.get("ip", "-")}, + {"Property": "Created At", "Value": info.get("created_at", "-")}, + {"Property": "Updated At", "Value": info.get("updated_at", "-")}, + ] + console.print(tabulate(table_data, headers="keys", tablefmt="grid")) + + # Release the environment + asyncio.run(_stop_instance(env)) + + except Exception as e: + console.print(f"[red]❌ Failed to get instance information:[/red] {str(e)}") + if cfg.verbose: + import traceback + + console.print(traceback.format_exc()) + raise click.Abort() + + +@instance.command("list") +@click.option( + "--name", + "-n", + type=str, + help="Filter by environment name", +) +@click.option( + "--version", + type=str, + help="Filter by environment version (requires --name)", +) +@click.option( + "--output", + "-o", + type=click.Choice(["table", "json"]), + default="table", + help="Output format", +) +@click.option( + "--system-url", + type=str, + help="AEnv system URL (defaults to AENV_SYSTEM_URL env var or config)", +) +@click.option( + "--verbose", + is_flag=True, + help="Enable verbose/debug output", +) +@pass_config +def list_instances(cfg: Config, name, version, output, system_url, verbose): + """List running environment instances + + Query and display running environment instances. Can filter by environment + name and version. + + Examples: + # List all running instances + aenv instance list + + # List instances for a specific environment + aenv instance list --name my-env + + # List instances for a specific environment and version + aenv instance list --name my-env --version 1.0.0 + + # Output as JSON + aenv instance list --output json + + # Use custom system URL + aenv instance list --system-url http://api.example.com:8080 + """ + console = cfg.console.console() + + if version and not name: + raise click.BadOptionUsage( + "--version", "Version filter requires --name to be specified" + ) + + # Get system URL + if not system_url: + system_url = _get_system_url() + else: + system_url = _make_api_url(system_url, port=8080) + + # Use command-level verbose flag or config-level verbose + is_verbose = verbose or cfg.verbose + + # Debug: show configuration if verbose + if is_verbose: + console.print("[dim]🔍 Debug: Configuration[/dim]") + console.print(f"[dim] Using system URL: {system_url}[/dim]") + config_manager = get_config_manager() + config_url = config_manager.get("system_url") + env_url = os.getenv("AENV_SYSTEM_URL") + console.print(f"[dim] Config system_url: {config_url or 'not set'}[/dim]") + console.print(f"[dim] Env AENV_SYSTEM_URL: {env_url or 'not set'}[/dim]") + if name: + console.print(f"[dim] Filter by env_name: {name}[/dim]") + if version: + console.print(f"[dim] Filter by version: {version}[/dim]") + console.print() # Empty line for readability + + try: + instances_list = _list_instances_from_api( + system_url, + name, + version, + verbose=is_verbose, + console=console if is_verbose else None, + ) + except Exception as e: + console.print(f"[red]❌ Failed to list instances:[/red] {str(e)}") + if is_verbose: + import traceback + + console.print(traceback.format_exc()) + raise click.Abort() + + if not instances_list: + if name: + if version: + console.print(f"📭 No running instances found for {name}@{version}") + else: + console.print(f"📭 No running instances found for {name}") + else: + console.print("📭 No running instances found") + return + + if output == "json": + console.print(json.dumps(instances_list, indent=2, ensure_ascii=False)) + elif output == "table": + # Prepare table data + table_data = [] + for instance in instances_list: + instance_id = instance.get("id", "") + if not instance_id: + continue + + # Use list data directly + env_info = instance.get("env") or {} + env_name = env_info.get("name") if env_info else None + env_version = env_info.get("version") if env_info else None + + # If env is None, try to extract from instance ID + if not env_name and instance_id: + parts = instance_id.split("-") + if len(parts) >= 2: + env_name = parts[0] + + # Get IP from list data + ip = instance.get("ip") or "" + if not ip: + ip = "-" + + # Get status from list data + status = instance.get("status") or "-" + + # Get created_at from list data + created_at = instance.get("created_at") or "-" + + # Get owner from list data + owner = instance.get("owner") or "-" + + table_data.append( + { + "Instance ID": instance_id, + "Environment": env_name or "-", + "Version": env_version or "-", + "Owner": owner, + "Status": status, + "IP": ip, + "Created At": created_at, + } + ) + + if table_data: + console.print(tabulate(table_data, headers="keys", tablefmt="grid")) + else: + console.print("📭 No running instances found") + + +@instance.command("get") +@click.argument("instance_id") +@click.option( + "--output", + "-o", + type=click.Choice(["table", "json"]), + default="table", + help="Output format", +) +@click.option( + "--system-url", + type=str, + help="AEnv system URL (defaults to AENV_SYSTEM_URL env var)", +) +@click.option( + "--verbose", + is_flag=True, + help="Enable verbose/debug output", +) +@pass_config +def get_instance(cfg: Config, instance_id, output, system_url, verbose): + """Get detailed information for a specific instance + + Retrieve detailed information about a running environment instance by its ID. + + Examples: + # Get instance information + aenv instance get flowise-xxx-abc123 + + # Get instance information in JSON format + aenv instance get flowise-xxx-abc123 --output json + + # Get instance information with verbose output + aenv instance get flowise-xxx-abc123 --verbose + """ + console = cfg.console.console() + + # Get system URL + if not system_url: + system_url = _get_system_url() + else: + system_url = _make_api_url(system_url, port=8080) + + # Use command-level verbose flag or config-level verbose + is_verbose = verbose or cfg.verbose + + # Debug: show configuration if verbose + if is_verbose: + console.print("[dim]🔍 Debug: Configuration[/dim]") + console.print(f"[dim] Using system URL: {system_url}[/dim]") + config_manager = get_config_manager() + config_url = config_manager.get("system_url") + env_url = os.getenv("AENV_SYSTEM_URL") + console.print(f"[dim] Config system_url: {config_url or 'not set'}[/dim]") + console.print(f"[dim] Env AENV_SYSTEM_URL: {env_url or 'not set'}[/dim]") + console.print(f"[dim] Instance ID: {instance_id}[/dim]") + console.print() # Empty line for readability + + console.print(f"[cyan]â„šī¸ Retrieving instance information:[/cyan] {instance_id}\n") + + try: + instance_info = _get_instance_from_api( + system_url, + instance_id, + verbose=is_verbose, + console=console if is_verbose else None, + ) + + if not instance_info: + console.print(f"[red]❌ Instance not found:[/red] {instance_id}") + raise click.Abort() + + console.print("[green]✅ Instance information retrieved![/green]\n") + + if output == "json": + console.print(json.dumps(instance_info, indent=2, ensure_ascii=False)) + else: + # Extract environment info + env_info = instance_info.get("env") or {} + env_name = env_info.get("name") or "-" + env_version = env_info.get("version") or "-" + + table_data = [ + {"Property": "Instance ID", "Value": instance_info.get("id", "-")}, + {"Property": "Environment", "Value": env_name}, + {"Property": "Version", "Value": env_version}, + {"Property": "Status", "Value": instance_info.get("status", "-")}, + {"Property": "IP Address", "Value": instance_info.get("ip", "-")}, + { + "Property": "Created At", + "Value": instance_info.get("created_at", "-"), + }, + { + "Property": "Updated At", + "Value": instance_info.get("updated_at", "-"), + }, + ] + console.print(tabulate(table_data, headers="keys", tablefmt="grid")) + + except click.Abort: + raise + except Exception as e: + console.print(f"[red]❌ Failed to get instance information:[/red] {str(e)}") + if cfg.verbose: + import traceback + + console.print(traceback.format_exc()) + raise click.Abort() + + +@instance.command("delete") +@click.argument("instance_id") +@click.option( + "--yes", + "-y", + is_flag=True, + help="Skip confirmation prompt", +) +@click.option( + "--system-url", + type=str, + help="AEnv system URL (defaults to AENV_SYSTEM_URL env var)", +) +@pass_config +def delete_instance(cfg: Config, instance_id, yes, system_url): + """Delete a running instance + + Delete a running environment instance by its ID. + + Examples: + # Delete an instance (with confirmation) + aenv instance delete flowise-xxx-abc123 + + # Delete an instance (skip confirmation) + aenv instance delete flowise-xxx-abc123 --yes + """ + console = cfg.console.console() + + # Get system URL + if not system_url: + system_url = _get_system_url() + else: + system_url = _make_api_url(system_url, port=8080) + + # Confirm deletion unless --yes flag is provided + if not yes: + console.print( + f"[yellow]âš ī¸ You are about to delete instance:[/yellow] {instance_id}" + ) + if not click.confirm("Are you sure you want to continue?"): + console.print("[cyan]Deletion cancelled[/cyan]") + return + + console.print(f"[cyan]đŸ—‘ī¸ Deleting instance:[/cyan] {instance_id}\n") + + try: + with console.status("[bold green]Deleting instance..."): + success = _delete_instance_from_api(system_url, instance_id) + + if success: + console.print("[green]✅ Instance deleted successfully![/green]") + else: + console.print("[red]❌ Failed to delete instance[/red]") + raise click.Abort() + + except click.Abort: + raise + except Exception as e: + console.print(f"[red]❌ Failed to delete instance:[/red] {str(e)}") + if cfg.verbose: + import traceback + + console.print(traceback.format_exc()) + raise click.Abort() diff --git a/aenv/src/cli/cmds/instances.py b/aenv/src/cli/cmds/instances.py index 5481c52a..3c878efa 100644 --- a/aenv/src/cli/cmds/instances.py +++ b/aenv/src/cli/cmds/instances.py @@ -59,12 +59,25 @@ def _make_api_url(aenv_url: str, port: int = 8080) -> str: def _get_system_url() -> str: """Get AEnv system URL from environment variable or config. + Priority order: + 1. AENV_SYSTEM_URL environment variable (highest priority) + 2. system_url in config file + 3. Default value (http://localhost:8080) + Uses make_api_url logic to ensure port 8080 is specified. """ + # First check environment variable system_url = os.getenv("AENV_SYSTEM_URL") + + # If not in env, check config + if not system_url: + config_manager = get_config_manager() + system_url = config_manager.get("system_url") + + # Use default if still not found if not system_url: - # Try to get from config, but for now default to localhost system_url = "http://localhost:8080" + # Use make_api_url to ensure port 8080 is set return _make_api_url(system_url, port=8080) @@ -178,7 +191,7 @@ def _list_instances_from_api( @click.option( "--system-url", type=str, - help="AEnv system URL (defaults to AENV_SYSTEM_URL env var or http://localhost:8080)", + help="AEnv system URL (defaults to AENV_SYSTEM_URL env var, config, or http://localhost:8080)", ) @pass_config def instances(cfg: Config, name, version, format, system_url): diff --git a/aenv/src/cli/extends/artifacts/artifacts_builder.py b/aenv/src/cli/extends/artifacts/artifacts_builder.py index 7582483b..be08885c 100644 --- a/aenv/src/cli/extends/artifacts/artifacts_builder.py +++ b/aenv/src/cli/extends/artifacts/artifacts_builder.py @@ -13,7 +13,6 @@ # limitations under the License. import sys -import time from abc import ABC, abstractmethod from dataclasses import dataclass from pathlib import Path @@ -207,16 +206,16 @@ def _build_image( ) # Process streaming build logs current_step = 0 - last_output_time = time.time() - heartbeat_interval = 30 # Show heartbeat every 30 seconds if no output - last_heartbeat_time = time.time() + # last_output_time = time.time() + # heartbeat_interval = 30 # Show heartbeat every 30 seconds if no output + # last_heartbeat_time = time.time() for log_line in response: if not log_line: continue - current_time = time.time() - last_output_time = current_time + # current_time = time.time() + # last_output_time = current_time # Handle different types of log messages if "stream" in log_line: @@ -317,18 +316,24 @@ def _build_image( print(f" ❌ Error: {error_msg}") sys.stdout.flush() raise docker.errors.BuildError(error_msg, [log_line]) - + # Show heartbeat if no output for a while (handled in a separate check) # Note: This is a simple approach. For better UX, consider using threading # to show periodic heartbeats during long-running steps - + # Handle any other log line types that might be present else: # Log unknown log line types for debugging if log_line: # Only print if it's not empty and might be useful - if any(key in log_line for key in ["message", "log", "output"]): - message = log_line.get("message") or log_line.get("log") or log_line.get("output", "") + if any( + key in log_line for key in ["message", "log", "output"] + ): + message = ( + log_line.get("message") + or log_line.get("log") + or log_line.get("output", "") + ) if message: print(f" {message}") sys.stdout.flush() diff --git a/aenv/src/cli/templates/default/Dockerfile b/aenv/src/cli/templates/default/Dockerfile index 3c2789b3..2076d5b7 100644 --- a/aenv/src/cli/templates/default/Dockerfile +++ b/aenv/src/cli/templates/default/Dockerfile @@ -6,4 +6,4 @@ ENV PYTHONPATH=/app/src COPY . . RUN python -m pip install --no-cache-dir -r /app/requirements.txt ENTRYPOINT ["/bin/bash", "-c"] -CMD ["python3 -m aenv.main /app/src"] \ No newline at end of file +CMD ["python3 -m aenv.main /app/src"] diff --git a/aenv/src/cli/tests/test_instances.py b/aenv/src/cli/tests/test_instances.py new file mode 100644 index 00000000..5e926bc4 --- /dev/null +++ b/aenv/src/cli/tests/test_instances.py @@ -0,0 +1,469 @@ +# Copyright 2025. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Test script for instances command +""" +import json +import os +from unittest.mock import Mock, patch + +import pytest +from click.testing import CliRunner + +from cli.cmds.instances import instances + + +class TestInstances: + """Test cases for instances command""" + + @pytest.fixture + def runner(self): + """Create a CliRunner instance""" + return CliRunner() + + @pytest.fixture + def mock_instances_list_response(self): + """Mock response for list instances API""" + return { + "success": True, + "data": [ + { + "id": "test-env-abc123", + "ip": "192.168.1.100", + "status": "running", + "created_at": "2025-01-15T10:30:00Z", + "env": { + "name": "test-env", + "version": "1.0.0", + }, + }, + { + "id": "test-env-def456", + "ip": "192.168.1.101", + "status": "running", + "created_at": "2025-01-15T11:00:00Z", + "env": { + "name": "test-env", + "version": "1.0.0", + }, + }, + ], + } + + @pytest.fixture + def mock_instance_detail_response(self): + """Mock response for get instance detail API""" + return { + "success": True, + "data": { + "id": "test-env-abc123", + "ip": "192.168.1.100", + "status": "running", + "created_at": "2025-01-15T10:30:00Z", + "env": { + "name": "test-env", + "version": "1.0.0", + }, + }, + } + + def test_list_all_instances(self, runner, mock_instances_list_response): + """Test listing all instances""" + with patch("cli.cmds.instances.requests.get") as mock_get: + # Mock list API response + mock_response = Mock() + mock_response.json.return_value = mock_instances_list_response + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + # Mock detail API response (called for each instance in table format) + with patch("cli.cmds.instances._get_instance_info") as mock_detail: + mock_detail.return_value = mock_instances_list_response["data"][0] + + result = runner.invoke(instances, []) + + assert result.exit_code == 0 + assert "test-env-abc123" in result.output + assert "test-env-def456" in result.output + assert "test-env" in result.output + assert "1.0.0" in result.output + + def test_list_instances_with_name_filter( + self, runner, mock_instances_list_response + ): + """Test listing instances filtered by name""" + with patch("cli.cmds.instances.requests.get") as mock_get: + mock_response = Mock() + # Filter to only one instance + filtered_data = { + "success": True, + "data": [mock_instances_list_response["data"][0]], + } + mock_response.json.return_value = filtered_data + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + with patch("cli.cmds.instances._get_instance_info") as mock_detail: + mock_detail.return_value = mock_instances_list_response["data"][0] + + result = runner.invoke(instances, ["--name", "test-env"]) + + assert result.exit_code == 0 + # Verify the API was called with correct env_id + mock_get.assert_called_once() + call_args = mock_get.call_args + assert "test-env/list" in call_args[0][0] + + def test_list_instances_with_name_and_version_filter( + self, runner, mock_instances_list_response + ): + """Test listing instances filtered by name and version""" + with patch("cli.cmds.instances.requests.get") as mock_get: + mock_response = Mock() + filtered_data = { + "success": True, + "data": [mock_instances_list_response["data"][0]], + } + mock_response.json.return_value = filtered_data + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + with patch("cli.cmds.instances._get_instance_info") as mock_detail: + mock_detail.return_value = mock_instances_list_response["data"][0] + + result = runner.invoke( + instances, ["--name", "test-env", "--version", "1.0.0"] + ) + + assert result.exit_code == 0 + # Verify the API was called with correct env_id format + mock_get.assert_called_once() + call_args = mock_get.call_args + assert "test-env@1.0.0/list" in call_args[0][0] + + def test_list_instances_json_format(self, runner, mock_instances_list_response): + """Test listing instances with JSON output format""" + with patch("cli.cmds.instances.requests.get") as mock_get: + mock_response = Mock() + mock_response.json.return_value = mock_instances_list_response + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + result = runner.invoke(instances, ["--format", "json"]) + + assert result.exit_code == 0 + # Verify output is valid JSON + output_data = json.loads(result.output) + assert isinstance(output_data, list) + assert len(output_data) == 2 + assert output_data[0]["id"] == "test-env-abc123" + + def test_list_instances_table_format(self, runner, mock_instances_list_response): + """Test listing instances with table output format""" + with patch("cli.cmds.instances.requests.get") as mock_get: + mock_response = Mock() + mock_response.json.return_value = mock_instances_list_response + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + with patch("cli.cmds.instances._get_instance_info") as mock_detail: + mock_detail.return_value = mock_instances_list_response["data"][0] + + result = runner.invoke(instances, ["--format", "table"]) + + assert result.exit_code == 0 + # Verify table format contains headers + assert "Instance ID" in result.output + assert "Environment" in result.output + assert "Version" in result.output + assert "Status" in result.output + assert "IP" in result.output + assert "Created At" in result.output + + def test_list_instances_empty_result(self, runner): + """Test listing instances when no instances are running""" + with patch("cli.cmds.instances.requests.get") as mock_get: + mock_response = Mock() + mock_response.json.return_value = {"success": True, "data": []} + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + result = runner.invoke(instances, []) + + assert result.exit_code == 0 + assert "No running instances found" in result.output + + def test_list_instances_empty_result_with_name(self, runner): + """Test listing instances with name filter when no instances found""" + with patch("cli.cmds.instances.requests.get") as mock_get: + mock_response = Mock() + mock_response.json.return_value = {"success": True, "data": []} + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + result = runner.invoke(instances, ["--name", "nonexistent-env"]) + + assert result.exit_code == 0 + assert "No running instances found for nonexistent-env" in result.output + + def test_list_instances_empty_result_with_name_and_version(self, runner): + """Test listing instances with name and version filter when no instances found""" + with patch("cli.cmds.instances.requests.get") as mock_get: + mock_response = Mock() + mock_response.json.return_value = {"success": True, "data": []} + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + result = runner.invoke( + instances, ["--name", "nonexistent-env", "--version", "1.0.0"] + ) + + assert result.exit_code == 0 + assert ( + "No running instances found for nonexistent-env@1.0.0" in result.output + ) + + def test_list_instances_version_without_name_error(self, runner): + """Test that version option requires name option""" + result = runner.invoke(instances, ["--version", "1.0.0"]) + + assert result.exit_code != 0 + assert "Version filter requires --name" in result.output + + def test_list_instances_with_custom_system_url( + self, runner, mock_instances_list_response + ): + """Test listing instances with custom system URL""" + with patch("cli.cmds.instances.requests.get") as mock_get: + mock_response = Mock() + mock_response.json.return_value = mock_instances_list_response + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + with patch("cli.cmds.instances._get_instance_info") as mock_detail: + mock_detail.return_value = mock_instances_list_response["data"][0] + + result = runner.invoke( + instances, ["--system-url", "http://custom.example.com:8080"] + ) + + assert result.exit_code == 0 + # Verify the API was called with custom URL + mock_get.assert_called_once() + call_args = mock_get.call_args + assert "custom.example.com:8080" in call_args[0][0] + + def test_list_instances_api_error(self, runner): + """Test handling of API errors""" + with patch("cli.cmds.instances.requests.get") as mock_get: + import requests + + mock_get.side_effect = requests.exceptions.RequestException( + "Connection error" + ) + + result = runner.invoke(instances, []) + + assert result.exit_code != 0 + assert "Failed to list instances" in result.output + + def test_list_instances_api_non_success_response(self, runner): + """Test handling of API non-success response""" + with patch("cli.cmds.instances.requests.get") as mock_get: + mock_response = Mock() + mock_response.json.return_value = {"success": False, "data": None} + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + result = runner.invoke(instances, []) + + assert result.exit_code == 0 + assert "No running instances found" in result.output + + def test_list_instances_with_api_key(self, runner, mock_instances_list_response): + """Test that API key is included in headers when available""" + with patch("cli.cmds.instances.requests.get") as mock_get: + mock_response = Mock() + mock_response.json.return_value = mock_instances_list_response + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + with patch("cli.cmds.instances.get_config_manager") as mock_config: + mock_config_manager = Mock() + mock_hub_config = {"api_key": "test-api-key"} + mock_config_manager.get_hub_config.return_value = mock_hub_config + mock_config.return_value = mock_config_manager + + with patch("cli.cmds.instances._get_instance_info") as mock_detail: + mock_detail.return_value = mock_instances_list_response["data"][0] + + result = runner.invoke(instances, []) + + assert result.exit_code == 0 + # Verify Authorization header was set + mock_get.assert_called_once() + call_kwargs = mock_get.call_args[1] + assert "Authorization" in call_kwargs["headers"] + assert ( + call_kwargs["headers"]["Authorization"] == "Bearer test-api-key" + ) + + def test_list_instances_with_env_api_key( + self, runner, mock_instances_list_response + ): + """Test that API key from environment variable is used""" + with patch("cli.cmds.instances.requests.get") as mock_get: + mock_response = Mock() + mock_response.json.return_value = mock_instances_list_response + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + with patch("cli.cmds.instances.get_config_manager") as mock_config: + mock_config_manager = Mock() + mock_config_manager.get_hub_config.return_value = {} + mock_config.return_value = mock_config_manager + + with patch.dict(os.environ, {"AENV_API_KEY": "env-api-key"}): + with patch("cli.cmds.instances._get_instance_info") as mock_detail: + mock_detail.return_value = mock_instances_list_response["data"][ + 0 + ] + + result = runner.invoke(instances, []) + + assert result.exit_code == 0 + # Verify Authorization header was set from env var + mock_get.assert_called_once() + call_kwargs = mock_get.call_args[1] + assert "Authorization" in call_kwargs["headers"] + assert ( + call_kwargs["headers"]["Authorization"] + == "Bearer env-api-key" + ) + + def test_list_instances_with_env_system_url( + self, runner, mock_instances_list_response + ): + """Test that system URL from environment variable is used""" + with patch("cli.cmds.instances.requests.get") as mock_get: + mock_response = Mock() + mock_response.json.return_value = mock_instances_list_response + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + with patch("cli.cmds.instances._get_instance_info") as mock_detail: + mock_detail.return_value = mock_instances_list_response["data"][0] + + with patch.dict( + os.environ, {"AENV_SYSTEM_URL": "http://env.example.com"} + ): + result = runner.invoke(instances, []) + + assert result.exit_code == 0 + # Verify the API was called with env URL + mock_get.assert_called_once() + call_args = mock_get.call_args[0] + assert "env.example.com:8080" in call_args[0] + + def test_list_instances_detail_api_failure_fallback( + self, runner, mock_instances_list_response + ): + """Test that list API data is used when detail API fails""" + with patch("cli.cmds.instances.requests.get") as mock_get: + mock_response = Mock() + mock_response.json.return_value = mock_instances_list_response + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + # Mock detail API to return None (simulating failure) + with patch("cli.cmds.instances._get_instance_info") as mock_detail: + mock_detail.return_value = None + + result = runner.invoke(instances, []) + + assert result.exit_code == 0 + # Should still show instances using list data + assert "test-env-abc123" in result.output + + def test_list_instances_missing_env_info(self, runner): + """Test handling of instances with missing environment info""" + with patch("cli.cmds.instances.requests.get") as mock_get: + mock_response = Mock() + # Instance without env info + mock_response.json.return_value = { + "success": True, + "data": [ + { + "id": "test-env-xyz789", + "ip": "192.168.1.102", + "status": "running", + "created_at": "2025-01-15T12:00:00Z", + # Missing env field + }, + ], + } + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + with patch("cli.cmds.instances._get_instance_info") as mock_detail: + mock_detail.return_value = None + + result = runner.invoke(instances, []) + + assert result.exit_code == 0 + # Should extract name from instance ID + assert "test-env-xyz789" in result.output + # Environment name should be extracted from ID + assert "test-env" in result.output + + def test_make_api_url_with_protocol(self): + """Test _make_api_url with protocol in URL""" + from cli.cmds.instances import _make_api_url + + url = _make_api_url("http://example.com", port=8080) + assert url == "http://example.com:8080" + + def test_make_api_url_without_protocol(self): + """Test _make_api_url without protocol in URL""" + from cli.cmds.instances import _make_api_url + + url = _make_api_url("example.com", port=8080) + assert url == "http://example.com:8080" + + def test_make_api_url_empty(self): + """Test _make_api_url with empty URL""" + from cli.cmds.instances import _make_api_url + + url = _make_api_url("", port=8080) + assert url == "http://localhost:8080" + + def test_get_system_url_from_env(self): + """Test _get_system_url with environment variable""" + from cli.cmds.instances import _get_system_url + + with patch.dict(os.environ, {"AENV_SYSTEM_URL": "http://test.example.com"}): + url = _get_system_url() + assert url == "http://test.example.com:8080" + + def test_get_system_url_default(self): + """Test _get_system_url without environment variable""" + from cli.cmds.instances import _get_system_url + + with patch.dict(os.environ, {}, clear=True): + # Remove AENV_SYSTEM_URL if it exists + if "AENV_SYSTEM_URL" in os.environ: + del os.environ["AENV_SYSTEM_URL"] + url = _get_system_url() + assert url == "http://localhost:8080" diff --git a/aenv/src/cli/utils/cli_config.py b/aenv/src/cli/utils/cli_config.py index 27a54468..71c340f3 100644 --- a/aenv/src/cli/utils/cli_config.py +++ b/aenv/src/cli/utils/cli_config.py @@ -37,6 +37,12 @@ class CLIConfig: # AEnv hub backend configuration hub_config: Dict[str, Any] = None + # System URL for traffic plane (can be overridden by AENV_SYSTEM_URL env var) + system_url: Optional[str] = None + + # Owner information for instance queries + owner: Optional[str] = None + def __post_init__(self): """Initialize default configurations.""" if self.build_config is None: diff --git a/api-service/controller/env_instance.go b/api-service/controller/env_instance.go index 98f4d9df..b76a7850 100644 --- a/api-service/controller/env_instance.go +++ b/api-service/controller/env_instance.go @@ -54,6 +54,7 @@ type CreateEnvInstanceRequest struct { EnvironmentVariables map[string]string `json:"environment_variables"` Arguments []string `json:"arguments"` TTL string `json:"ttl"` + Owner string `json:"owner"` } // CreateEnvInstance creates a new EnvInstance @@ -102,6 +103,10 @@ func (ctrl *EnvInstanceController) CreateEnvInstance(c *gin.Context) { } // Set TTL for environment backendEnv.DeployConfig["ttl"] = req.TTL + // Set owner for controller to store in pod label + if req.Owner != "" { + backendEnv.DeployConfig["owner"] = req.Owner + } // Call ScheduleClient to create Pod envInstance, err := ctrl.envInstanceService.CreateEnvInstance(backendEnv) if err != nil { @@ -110,6 +115,15 @@ func (ctrl *EnvInstanceController) CreateEnvInstance(c *gin.Context) { } envInstance.Env = backendEnv + // Set owner from DeployConfig if available (controller stores it in pod labels but doesn't return it) + if backendEnv.DeployConfig != nil { + if ownerValue, ok := backendEnv.DeployConfig["owner"]; ok { + if ownerStr, ok := ownerValue.(string); ok && ownerStr != "" { + envInstance.Owner = ownerStr + } + } + } + token := util.GetCurrentToken(c) if token != nil && ctrl.redisClient != nil { if result, err := ctrl.redisClient.StoreEnvInstanceToRedis(token.Token, envInstance); !result || err != nil { @@ -167,22 +181,49 @@ func (ctrl *EnvInstanceController) ListEnvInstances(c *gin.Context) { backendmodels.JSONErrorWithMessage(c, 403, "token required") return } + id := c.Param("id") + + // Handle wildcard "*" as "list all instances" + if id == "*" { + id = "" + } + if ctrl.redisClient != nil { var query = models.EnvInstance{Env: &backendmodels.Env{}} - id := c.Param("id") if id != "" { name, version := util.SplitEnvNameVersion(id) query.Env.Name = name query.Env.Version = version } instances, err := ctrl.redisClient.ListEnvInstancesFromRedis(token.Token, &query) - if err == nil { - backendmodels.JSONSuccess(c, instances) - return + if err == nil && len(instances) > 0 { + // Check if any instance is missing version info, if so, fetch from service + missingVersion := false + for _, instance := range instances { + if instance.Env == nil || instance.Env.Version == "" { + missingVersion = true + break + } + } + if !missingVersion { + backendmodels.JSONSuccess(c, instances) + return + } + log.Warnf("some instances from redis are missing version info, falling back to service") + } else if err != nil { + log.Warnf("failed to list from redis: %v", err) } - log.Warnf("failed to list from redis: %v", err) } - envName := c.Query("envName") + + // Extract envName from id or query parameter + var envName string + if id != "" { + name, _ := util.SplitEnvNameVersion(id) + envName = name + } else { + envName = c.Query("envName") + } + instances, err := ctrl.envInstanceService.ListEnvInstances(envName) if err != nil { backendmodels.JSONErrorWithMessage(c, 500, err.Error()) diff --git a/api-service/controller/mcp_proxy.go b/api-service/controller/mcp_proxy.go index 6998b263..8d23bab6 100644 --- a/api-service/controller/mcp_proxy.go +++ b/api-service/controller/mcp_proxy.go @@ -203,7 +203,9 @@ func (g *MCPGateway) handleMCPSSEWithHeader(c *gin.Context) { return } defer func() { - resp.Body.Close() + if closeErr := resp.Body.Close(); closeErr != nil { + log.Printf("failed to close response body: %v", closeErr) + } }() // Check response status diff --git a/api-service/main.go b/api-service/main.go index 90bce780..912484a2 100644 --- a/api-service/main.go +++ b/api-service/main.go @@ -96,11 +96,12 @@ func main() { } var scheduleClient service.EnvInstanceService - if scheduleType == "k8s" { + switch scheduleType { + case "k8s": scheduleClient = service.NewScheduleClient(scheduleAddr) - } else if scheduleType == "standard" { + case "standard": scheduleClient = service.NewEnvInstanceClient(scheduleAddr) - } else { + default: log.Fatalf("unsupported schedule type: %v", scheduleType) } diff --git a/api-service/models/env_instance.go b/api-service/models/env_instance.go index 7bc69d3c..db364ffa 100644 --- a/api-service/models/env_instance.go +++ b/api-service/models/env_instance.go @@ -59,6 +59,7 @@ type EnvInstance struct { UpdatedAt string `json:"updated_at"` // Update time IP string `json:"ip"` // Instance IP TTL string `json:"ttl"` // time to live + Owner string `json:"owner"` // Instance owner (user who created it) } // NewEnvInstance creates a new environment instance object @@ -71,6 +72,21 @@ func NewEnvInstance(id string, env *backend.Env, ip string) *EnvInstance { CreatedAt: now, UpdatedAt: now, IP: ip, + Owner: "", + } +} + +// NewEnvInstanceWithOwner creates a new environment instance object with owner +func NewEnvInstanceWithOwner(id string, env *backend.Env, ip string, owner string) *EnvInstance { + now := time.Now().Format("2006-01-02 15:04:05") + return &EnvInstance{ + ID: id, + Env: env, + Status: EnvInstanceStatusPending.String(), + CreatedAt: now, + UpdatedAt: now, + IP: ip, + Owner: owner, } } @@ -84,6 +100,7 @@ func NewEnvInstanceWithStatus(id string, env *backend.Env, status EnvInstanceSta CreatedAt: now, UpdatedAt: now, IP: ip, + Owner: "", } } @@ -96,6 +113,7 @@ func NewEnvInstanceFull(id string, env *backend.Env, status EnvInstanceStatus, c CreatedAt: createdAt, UpdatedAt: updatedAt, IP: ip, + Owner: "", } } diff --git a/api-service/service/backend_client.go b/api-service/service/backend_client.go index 57f69c6f..c8abcac8 100644 --- a/api-service/service/backend_client.go +++ b/api-service/service/backend_client.go @@ -131,7 +131,11 @@ func (c *BackendClient) GetEnvByVersion(name, version string) (*backendmodel.Env if err != nil { return nil, fmt.Errorf("failed to send request: %v", err) } - defer resp.Body.Close() + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + log.Printf("failed to close response body: %v", closeErr) + } + }() body, err := io.ReadAll(resp.Body) if err != nil { @@ -177,7 +181,11 @@ func (c *BackendClient) ValidateToken(token string) (*backendmodel.Token, error) if err != nil { return nil, fmt.Errorf("http request failed: %w", err) } - defer resp.Body.Close() + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + log.Printf("failed to close response body: %v", closeErr) + } + }() // 3. Parse response var tokenResp models.ClientResponse[*backendmodel.Token] @@ -216,7 +224,11 @@ func (c *BackendClient) SearchDatasource(scenario, key string) (string, error) { if err != nil { return "", fmt.Errorf("failed to send request: %v", err) } - defer resp.Body.Close() + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + log.Printf("failed to close response body: %v", closeErr) + } + }() body, err := io.ReadAll(resp.Body) if err != nil { diff --git a/api-service/service/env_instance.go b/api-service/service/env_instance.go index 5ec3e4e2..61d79fe5 100644 --- a/api-service/service/env_instance.go +++ b/api-service/service/env_instance.go @@ -7,6 +7,7 @@ import ( backend "envhub/models" "fmt" "io" + "log" "net/http" "time" ) @@ -63,7 +64,11 @@ func (c *EnvInstanceClient) CreateEnvInstance(req *backend.Env) (*models.EnvInst if err != nil { return nil, fmt.Errorf("create env instance: failed to send request: %v", err) } - defer resp.Body.Close() + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + log.Printf("failed to close response body: %v", closeErr) + } + }() body, err := io.ReadAll(resp.Body) if err != nil { @@ -106,7 +111,11 @@ func (c *EnvInstanceClient) GetEnvInstance(id string) (*models.EnvInstance, erro if err != nil { return nil, fmt.Errorf("get env instance %s: failed to send request: %v", id, err) } - defer resp.Body.Close() + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + log.Printf("failed to close response body: %v", closeErr) + } + }() body, err := io.ReadAll(resp.Body) if err != nil { @@ -148,7 +157,11 @@ func (c *EnvInstanceClient) DeleteEnvInstance(id string) error { if err != nil { return fmt.Errorf("delete env instance %s: failed to send request: %v", id, err) } - defer resp.Body.Close() + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + log.Printf("failed to close response body: %v", closeErr) + } + }() body, err := io.ReadAll(resp.Body) if err != nil { @@ -191,7 +204,11 @@ func (c *EnvInstanceClient) ListEnvInstances(envName string) ([]*models.EnvInsta if err != nil { return nil, fmt.Errorf("list env instances: failed to send request: %v", err) } - defer resp.Body.Close() + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + log.Printf("failed to close response body: %v", closeErr) + } + }() body, err := io.ReadAll(resp.Body) if err != nil { @@ -233,7 +250,11 @@ func (c *EnvInstanceClient) Warmup(req *backend.Env) error { if err != nil { return fmt.Errorf("warmup env: failed to send request: %v", err) } - defer resp.Body.Close() + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + log.Printf("failed to close response body: %v", closeErr) + } + }() body, err := io.ReadAll(resp.Body) if err != nil { @@ -275,7 +296,11 @@ func (c *EnvInstanceClient) Cleanup() error { if err != nil { return fmt.Errorf("cleanup env: failed to send request: %v", err) } - defer resp.Body.Close() + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + log.Printf("failed to close response body: %v", closeErr) + } + }() body, err := io.ReadAll(resp.Body) if err != nil { diff --git a/api-service/service/schedule_client.go b/api-service/service/schedule_client.go index 88b96d93..d20a904b 100644 --- a/api-service/service/schedule_client.go +++ b/api-service/service/schedule_client.go @@ -64,7 +64,11 @@ func (c *ScheduleClient) CreatePod(req *backend.Env) (*models.EnvInstance, error if err != nil { return nil, fmt.Errorf("failed to send request: %v", err) } - defer resp.Body.Close() + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + log.Printf("failed to close response body: %v", closeErr) + } + }() body, err := io.ReadAll(resp.Body) if err != nil { @@ -100,7 +104,11 @@ func (c *ScheduleClient) GetPod(podName string) (*models.EnvInstance, error) { if err != nil { return nil, fmt.Errorf("failed to send request: %v", err) } - defer resp.Body.Close() + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + log.Printf("failed to close response body: %v", closeErr) + } + }() body, err := io.ReadAll(resp.Body) if err != nil { @@ -136,7 +144,11 @@ func (c *ScheduleClient) DeletePod(podName string) (bool, error) { if err != nil { return false, fmt.Errorf("failed to send request: %v", err) } - defer resp.Body.Close() + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + log.Printf("failed to close response body: %v", closeErr) + } + }() body, err := io.ReadAll(resp.Body) if err != nil { @@ -171,7 +183,11 @@ func (c *ScheduleClient) FilterPods() (*[]models.EnvInstance, error) { if err != nil { return nil, fmt.Errorf("failed to send request: %v", err) } - defer resp.Body.Close() + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + log.Printf("failed to close response body: %v", closeErr) + } + }() body, err := io.ReadAll(resp.Body) if err != nil { @@ -222,6 +238,25 @@ func (c *ScheduleClient) DeleteEnvInstance(id string) error { return nil } +// PodListResponseData represents the data structure returned by controller's list pod endpoint +type PodListResponseData struct { + ID string `json:"id"` + Status string `json:"status"` + TTL string `json:"ttl"` + CreatedAt time.Time `json:"created_at"` + EnvName string `json:"envname"` + Version string `json:"version"` + IP string `json:"ip"` + Owner string `json:"owner"` +} + +// PodListResponse represents the response structure from controller's list pod endpoint +type PodListResponse struct { + Success bool `json:"success"` + Code int `json:"code"` + Data []PodListResponseData `json:"data"` +} + // ListEnvInstances implements EnvInstanceService interface // Lists environment instances, optionally filtered by environment name func (c *ScheduleClient) ListEnvInstances(envName string) ([]*models.EnvInstance, error) { @@ -239,7 +274,11 @@ func (c *ScheduleClient) ListEnvInstances(envName string) ([]*models.EnvInstance if err != nil { return nil, fmt.Errorf("list env instances: failed to send request: %v", err) } - defer resp.Body.Close() + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + log.Printf("failed to close response body: %v", closeErr) + } + }() body, err := io.ReadAll(resp.Body) if err != nil { @@ -250,19 +289,38 @@ func (c *ScheduleClient) ListEnvInstances(envName string) ([]*models.EnvInstance return nil, fmt.Errorf("list env instances: request failed with status %d: %s", resp.StatusCode, string(body)) } - var getResp models.ClientResponse[[]models.EnvInstance] - if err := json.Unmarshal(body, &getResp); err != nil { + var podListResp PodListResponse + if err := json.Unmarshal(body, &podListResp); err != nil { return nil, fmt.Errorf("list env instances: failed to unmarshal response: %v", err) } - if !getResp.Success { - return nil, fmt.Errorf("list env instances: server returned error, code: %d", getResp.Code) + if !podListResp.Success { + return nil, fmt.Errorf("list env instances: server returned error, code: %d", podListResp.Code) } - // Convert []models.EnvInstance to []*models.EnvInstance - instances := make([]*models.EnvInstance, len(getResp.Data)) - for i := range getResp.Data { - instances[i] = &getResp.Data[i] + // Convert PodListResponseData to EnvInstance + instances := make([]*models.EnvInstance, len(podListResp.Data)) + for i, podData := range podListResp.Data { + // Create a minimal Env object with Name and Version + env := &backend.Env{ + Name: podData.EnvName, + Version: podData.Version, + } + + // Format CreatedAt time + createdAtStr := podData.CreatedAt.Format("2006-01-02 15:04:05") + nowStr := time.Now().Format("2006-01-02 15:04:05") + + instances[i] = &models.EnvInstance{ + ID: podData.ID, + Env: env, + Status: podData.Status, + CreatedAt: createdAtStr, + UpdatedAt: nowStr, + IP: podData.IP, + TTL: podData.TTL, + Owner: podData.Owner, + } } return instances, nil diff --git a/controller/pkg/aenvhub_http_server/aenv_pod_handler.go b/controller/pkg/aenvhub_http_server/aenv_pod_handler.go index 39bf4e11..dab3cee8 100644 --- a/controller/pkg/aenvhub_http_server/aenv_pod_handler.go +++ b/controller/pkg/aenvhub_http_server/aenv_pod_handler.go @@ -169,6 +169,7 @@ type HttpResponseData struct { Status string `json:"status"` IP string `json:"ip"` TTL string `json:"ttl"` + Owner string `json:"owner"` } type HttpResponse struct { Success bool `json:"success"` @@ -195,6 +196,10 @@ type HttpListResponseData struct { Status string `json:"status"` TTL string `json:"ttl"` CreatedAt time.Time `json:"created_at"` + EnvName string `json:"envname"` + Version string `json:"version"` + IP string `json:"ip"` + Owner string `json:"owner"` } type HttpListResponse struct { Success bool `json:"success"` @@ -208,7 +213,11 @@ func (h *AEnvPodHandler) createPod(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("Invalid request body: %v", err), http.StatusBadRequest) return } - defer r.Body.Close() + defer func() { + if closeErr := r.Body.Close(); closeErr != nil { + klog.Errorf("failed to close request body: %v", closeErr) + } + }() // Get podTemplate type, default to "singleContainer" templateType := SingleContainerTemplate @@ -228,17 +237,28 @@ func (h *AEnvPodHandler) createPod(w http.ResponseWriter, r *http.Request) { // Generate name pod.Name = fmt.Sprintf("%s-%s", aenvHubEnv.Name, RandString(6)) + // Initialize labels if nil + labels := pod.Labels + if labels == nil { + labels = make(map[string]string) + pod.Labels = labels + } + // Set pods envname and version by label + labels[constants.AENV_NAME] = aenvHubEnv.Name + labels[constants.AENV_VERSION] = aenvHubEnv.Version + klog.Infof("add aenv-name label with value:%v and aenv-version label with value:%v for pod:%s", aenvHubEnv.Name, aenvHubEnv.Version, pod.Name) // Set pods TTL by label if aenvHubEnv.DeployConfig["ttl"] != nil { - labels := pod.Labels - if labels == nil { - labels = make(map[string]string) - pod.Labels = labels - } ttlValue := aenvHubEnv.DeployConfig["ttl"].(string) labels[constants.AENV_TTL] = ttlValue klog.Infof("add aenv-ttl label with value:%v for pod:%s", ttlValue, pod.Name) } + // Set pods owner by label + if aenvHubEnv.DeployConfig["owner"] != nil { + ownerValue := aenvHubEnv.DeployConfig["owner"].(string) + labels[constants.AENV_OWNER] = ownerValue + klog.Infof("add aenv-owner label with value:%v for pod:%s", ownerValue, pod.Name) + } createdPod, err := h.clientset.CoreV1().Pods(h.namespace).Create(r.Context(), pod, metav1.CreateOptions{}) if err != nil { @@ -257,6 +277,7 @@ func (h *AEnvPodHandler) createPod(w http.ResponseWriter, r *http.Request) { ID: createdPod.Name, Status: string(createdPod.Status.Phase), IP: createdPod.Status.PodIP, + Owner: createdPod.Labels[constants.AENV_OWNER], }, } if err := json.NewEncoder(w).Encode(res); err != nil { @@ -304,6 +325,7 @@ func (h *AEnvPodHandler) getPod(podName string, w http.ResponseWriter, r *http.R TTL: pod.Labels[constants.AENV_TTL], Status: string(pod.Status.Phase), IP: pod.Status.PodIP, + Owner: pod.Labels[constants.AENV_OWNER], }, } @@ -335,6 +357,8 @@ func (h *AEnvPodHandler) getPod(podName string, w http.ResponseWriter, r *http.R func (h *AEnvPodHandler) listPod(w http.ResponseWriter, r *http.Request) { // query param:?filter=expired filterMark := r.URL.Query().Get("filter") + // query param:?envName=xxx + envNameFilter := r.URL.Query().Get("envName") var podList []*corev1.Pod var err error @@ -361,11 +385,22 @@ func (h *AEnvPodHandler) listPod(w http.ResponseWriter, r *http.Request) { Code: 0, } for _, pod := range podList { + // Filter by envName if specified + if envNameFilter != "" { + podEnvName := pod.Labels[constants.AENV_NAME] + if podEnvName != envNameFilter { + continue + } + } httpListResponse.ListResponseData = append(httpListResponse.ListResponseData, HttpListResponseData{ ID: pod.Name, Status: string(pod.Status.Phase), CreatedAt: pod.CreationTimestamp.Time, TTL: pod.Labels[constants.AENV_TTL], + EnvName: pod.Labels[constants.AENV_NAME], + Version: pod.Labels[constants.AENV_VERSION], + IP: pod.Status.PodIP, + Owner: pod.Labels[constants.AENV_OWNER], }) } @@ -494,12 +529,32 @@ func applyConfig(configs map[string]interface{}, container *corev1.Container) { klog.Infof("resource not autoscale for container %s", container.Name) return } - cfgCpu := configs["cpu"] - strCpu := cfgCpu.(string) - strMemory := configs["memory"].(string) - klog.Infof("resource config cpu: %s, memory: %s", strCpu, strMemory) - resources := container.Resources + // Validate and parse CPU + cfgCpu, ok := configs["cpu"] + if !ok { + klog.Errorf("cpu config not found") + return + } + strCpu, ok := cfgCpu.(string) + if !ok { + klog.Errorf("cpu config is not a string: %v", cfgCpu) + return + } + + // Validate and parse Memory + cfgMemory, ok := configs["memory"] + if !ok { + klog.Errorf("memory config not found") + return + } + strMemory, ok := cfgMemory.(string) + if !ok { + klog.Errorf("memory config is not a string: %v", cfgMemory) + return + } + + klog.Infof("resource config cpu: %s, memory: %s", strCpu, strMemory) var expectCpu resource.Quantity var err error @@ -513,22 +568,32 @@ func applyConfig(configs map[string]interface{}, container *corev1.Container) { return } - requestCpu := resources.Requests.Cpu() + // Initialize Requests if nil + if container.Resources.Requests == nil { + container.Resources.Requests = make(corev1.ResourceList) + } + + // Initialize Limits if nil + if container.Resources.Limits == nil { + container.Resources.Limits = make(corev1.ResourceList) + } + + requestCpu := container.Resources.Requests.Cpu() if requestCpu.Cmp(expectCpu) != 0 { klog.Infof("reset resource request cpu: %s, expect: %s", requestCpu.String(), expectCpu.String()) container.Resources.Requests[corev1.ResourceCPU] = expectCpu } - requestMemory := resources.Requests[corev1.ResourceMemory] + requestMemory := container.Resources.Requests[corev1.ResourceMemory] if requestMemory.Cmp(expectMemory) != 0 { container.Resources.Requests[corev1.ResourceMemory] = expectMemory } - limitCpu := resources.Limits[corev1.ResourceCPU] + limitCpu := container.Resources.Limits[corev1.ResourceCPU] if limitCpu.Cmp(expectCpu) != 0 { klog.Infof("reset limit request cpu: %s, expect: %s", limitCpu.String(), expectCpu.String()) container.Resources.Limits[corev1.ResourceCPU] = expectCpu } - limitMemory := resources.Limits[corev1.ResourceMemory] + limitMemory := container.Resources.Limits[corev1.ResourceMemory] if limitMemory.Cmp(expectMemory) != 0 { container.Resources.Limits[corev1.ResourceMemory] = expectMemory } diff --git a/controller/pkg/constants/common.go b/controller/pkg/constants/common.go index ffc56a5a..00d4b104 100644 --- a/controller/pkg/constants/common.go +++ b/controller/pkg/constants/common.go @@ -17,5 +17,8 @@ limitations under the License. package constants const ( - AENV_TTL = "business/aenv-ttl" + AENV_TTL = "business/aenv-ttl" + AENV_NAME = "business/aenv-name" + AENV_VERSION = "business/aenv-version" + AENV_OWNER = "business/aenv-owner" ) diff --git a/envhub/clients/aci_client.go b/envhub/clients/aci_client.go index 897683f1..a00a7195 100644 --- a/envhub/clients/aci_client.go +++ b/envhub/clients/aci_client.go @@ -85,7 +85,7 @@ func PKCS5Padding(data []byte, blockSize int) []byte { // Returns: hexadecimal encrypted result func AESECBEncrypt(src, key string) (string, error) { if len(key) != 16 { - return "", errors.New("Key must be 16 characters long") + return "", errors.New("key must be 16 characters long") } // Use UTF-8 encoding for key and plaintext diff --git a/envhub/controller/env.go b/envhub/controller/env.go index 6547fa85..cd36294c 100644 --- a/envhub/controller/env.go +++ b/envhub/controller/env.go @@ -403,7 +403,7 @@ func (ctrl *EnvController) AciCallback(c *gin.Context) { artifacts = make([]models.Artifact, 0) } exist := false - for idx, _ := range artifacts { + for idx := range artifacts { if artifacts[idx].Type == "image" { exist = true if artifacts[idx].Content != imageUrl { diff --git a/envhub/service/oss_helper.go b/envhub/service/oss_helper.go index a8a04128..4d959f38 100644 --- a/envhub/service/oss_helper.go +++ b/envhub/service/oss_helper.go @@ -20,6 +20,7 @@ import ( "bytes" "context" "io" + "log" "os" "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss" @@ -144,7 +145,11 @@ func readConfig(key string) string { if err != nil { return "" } - defer configFile.Close() + defer func() { + if closeErr := configFile.Close(); closeErr != nil { + log.Printf("failed to close config file: %v", closeErr) + } + }() var buffer bytes.Buffer _, err = io.Copy(&buffer, configFile)