From 80c826910952febb5464f7c54079c58bfea97d21 Mon Sep 17 00:00:00 2001 From: meijun Date: Fri, 16 Jan 2026 15:32:24 +0800 Subject: [PATCH 1/6] support deploy service with pvc --- aenv/src/aenv/client/scheduler_client.py | 314 +++++++++ aenv/src/aenv/core/models.py | 88 +++ aenv/src/cli/cli.py | 4 +- aenv/src/cli/cmds/__init__.py | 4 +- aenv/src/cli/cmds/init.py | 55 ++ aenv/src/cli/cmds/instance.py | 150 ++-- aenv/src/cli/cmds/instances.py | 309 -------- aenv/src/cli/cmds/service.py | 666 ++++++++++++++++++ aenv/src/cli/templates/default/config.json | 14 +- aenv/src/cli/utils/api_helpers.py | 135 ++++ api-service/Dockerfile | 2 +- api-service/controller/env_service.go | 271 +++++++ api-service/main.go | 12 + api-service/models/env_service.go | 123 ++++ api-service/service/schedule_client.go | 238 +++++++ controller/Dockerfile | 2 +- controller/cmd/main.go | 8 + .../pkg/aenvhub_http_server/aenv_pod_cache.go | 2 +- .../aenv_service_handler.go | 664 +++++++++++++++++ controller/pkg/aenvhub_http_server/util.go | 293 +++++++- deploy/controller/values.yaml | 93 +++ 21 files changed, 3034 insertions(+), 413 deletions(-) delete mode 100644 aenv/src/cli/cmds/instances.py create mode 100644 aenv/src/cli/cmds/service.py create mode 100644 aenv/src/cli/utils/api_helpers.py create mode 100644 api-service/controller/env_service.go create mode 100644 api-service/models/env_service.go create mode 100644 controller/pkg/aenvhub_http_server/aenv_service_handler.go diff --git a/aenv/src/aenv/client/scheduler_client.py b/aenv/src/aenv/client/scheduler_client.py index a7403725..ae738832 100644 --- a/aenv/src/aenv/client/scheduler_client.py +++ b/aenv/src/aenv/client/scheduler_client.py @@ -354,3 +354,317 @@ async def wait_for_status( ) await asyncio.sleep(check_interval) + + # ========== Service Management Methods ========== + + async def create_env_service( + self, + name: str, + replicas: int = 1, + environment_variables: Optional[Dict[str, str]] = None, + owner: Optional[str] = None, + # Storage configuration + pvc_name: Optional[str] = None, + mount_path: Optional[str] = None, + storage_size: Optional[str] = None, # If specified, PVC will be created + # Service configuration + port: Optional[int] = None, + # Resource limits + cpu_request: Optional[str] = None, + cpu_limit: Optional[str] = None, + memory_request: Optional[str] = None, + memory_limit: Optional[str] = None, + ephemeral_storage_request: Optional[str] = None, + ephemeral_storage_limit: Optional[str] = None, + ) -> "EnvService": + """ + Create a new environment service (Deployment + Service + optionally PVC). + + Args: + name: Service name (envName format: name@version) + replicas: Number of replicas (default: 1, must be 1 if storage_size is specified) + environment_variables: Optional environment variables + owner: Optional owner of the service + pvc_name: Optional PVC name (default: envName) + mount_path: Optional mount path (default: /home/admin/data) + storage_size: Optional storage size (e.g., "10Gi"). If specified, PVC will be created and replicas must be 1. + storageClass is configured in helm values.yaml deployment, not via API. + port: Optional service port (default: 8080) + cpu_request: Optional CPU request (default: 1) + cpu_limit: Optional CPU limit (default: 2) + memory_request: Optional memory request (default: 2Gi) + memory_limit: Optional memory limit (default: 4Gi) + ephemeral_storage_request: Optional ephemeral storage request (default: 5Gi) + ephemeral_storage_limit: Optional ephemeral storage limit (default: 10Gi) + + Returns: + Created EnvService + + Raises: + EnvironmentError: If creation fails + NetworkError: If network request fails + """ + if not self._client: + raise NetworkError("Client not connected") + + from aenv.core.models import EnvServiceCreateRequest + + logger.info( + f"Creating environment service: {name}, replicas: {replicas}, owner: {owner}" + ) + request = EnvServiceCreateRequest( + envName=name, + replicas=replicas, + environment_variables=environment_variables, + owner=owner, + pvc_name=pvc_name, + mount_path=mount_path, + storage_size=storage_size, + port=port, + cpu_request=cpu_request, + cpu_limit=cpu_limit, + memory_request=memory_request, + memory_limit=memory_limit, + ephemeral_storage_request=ephemeral_storage_request, + ephemeral_storage_limit=ephemeral_storage_limit, + ) + + for attempt in range(self.max_retries + 1): + try: + response = await self._client.post( + "/env-service", + json=request.model_dump(exclude_none=True), + ) + + try: + api_response = APIResponse(**response.json()) + if api_response.success and api_response.data: + from aenv.core.models import EnvService + + service = EnvService(**api_response.data) + logger.info(f"Environment service created: {service.id}") + return service + else: + error_msg = getattr( + api_response, "error_message", "Unknown error" + ) + raise EnvironmentError( + f"Failed to create service: {error_msg}, rsp: {api_response}" + ) + except ValueError as e: + raise EnvironmentError( + f"Invalid server response: {response.status_code} - {response.text[:200]}" + ) from e + + except httpx.RequestError as e: + import random + + if attempt < self.max_retries: + wait_time = 2**attempt + random.uniform(0, 1) + logger.warning( + f"Network error, retrying in {wait_time:.2f}s: {str(e)}" + ) + await asyncio.sleep(wait_time) + continue + raise NetworkError(f"Network error: {str(e)}") from e + + async def get_env_service(self, service_id: str) -> "EnvService": + """ + Get environment service by ID. + + Args: + service_id: Environment service ID + + Returns: + EnvService details + + Raises: + EnvironmentError: If service not found + NetworkError: If network request fails + """ + if not self._client: + raise NetworkError("Client not connected") + + logger.debug(f"Querying environment service: {service_id}") + for attempt in range(self.max_retries + 1): + try: + response = await self._client.get(f"/env-service/{service_id}") + + try: + api_response = APIResponse(**response.json()) + if api_response.success and api_response.data: + from aenv.core.models import EnvService + + service = EnvService(**api_response.data) + logger.debug( + f"Service status: {service.id} -> {service.status}" + ) + return service + else: + error_msg = getattr( + api_response, "error_message", "Unknown error" + ) + raise EnvironmentError(f"Failed to get service: {error_msg}") + except ValueError as e: + raise EnvironmentError( + f"Invalid server response: {response.status_code} - {response.text[:200]}" + ) from e + + except httpx.RequestError as e: + if attempt < self.max_retries: + wait_time = 2**attempt + logger.warning(f"Network error, retrying in {wait_time}s: {str(e)}") + await asyncio.sleep(wait_time) + continue + raise NetworkError(f"Network error: {str(e)}") from e + + async def list_env_services( + self, + env_name: Optional[str] = None, + ) -> List["EnvService"]: + """ + List environment services. + + Args: + env_name: Optional environment name filter + + Returns: + List of EnvService + + Raises: + EnvironmentError: If listing fails + NetworkError: If network request fails + """ + if not self._client: + raise NetworkError("Client not connected") + + url = "/env-service/*/list" + if env_name: + url = f"/env-service/{env_name}/list" + + for attempt in range(self.max_retries + 1): + try: + response = await self._client.get(url) + + try: + api_response = APIResponse(**response.json()) + if api_response.success and api_response.data: + if isinstance(api_response.data, list): + from aenv.core.models import EnvService + + return [EnvService(**item) for item in api_response.data] + return [] + else: + return [] + except ValueError as e: + raise EnvironmentError( + f"Invalid server response: {response.status_code} - {response.text[:200]}" + ) from e + + except httpx.RequestError as e: + if attempt < self.max_retries: + await asyncio.sleep(2**attempt) + continue + raise NetworkError(f"Network error: {str(e)}") from e + + async def delete_env_service(self, service_id: str) -> bool: + """ + Delete environment service. + + Args: + service_id: Environment service ID + + Returns: + True if deletion successful + + Raises: + EnvironmentError: If deletion fails + NetworkError: If network request fails + """ + if not self._client: + raise NetworkError("Client not connected") + + for attempt in range(self.max_retries + 1): + try: + response = await self._client.delete(f"/env-service/{service_id}") + + try: + api_response = APIResponse(**response.json()) + return api_response.success + except ValueError as e: + raise EnvironmentError( + f"Invalid server response: {response.status_code} - {response.text[:200]}" + ) from e + + except httpx.RequestError as e: + if attempt < self.max_retries: + await asyncio.sleep(2**attempt) + continue + raise NetworkError(f"Network error: {str(e)}") from e + + async def update_env_service( + self, + service_id: str, + replicas: Optional[int] = None, + image: Optional[str] = None, + environment_variables: Optional[Dict[str, str]] = None, + ) -> "EnvService": + """ + Update environment service. + + Args: + service_id: Environment service ID + replicas: Optional number of replicas + image: Optional container image + environment_variables: Optional environment variables + + Returns: + Updated EnvService + + Raises: + EnvironmentError: If update fails + NetworkError: If network request fails + """ + if not self._client: + raise NetworkError("Client not connected") + + from aenv.core.models import EnvServiceUpdateRequest + + request = EnvServiceUpdateRequest( + replicas=replicas, + image=image, + environment_variables=environment_variables, + ) + + for attempt in range(self.max_retries + 1): + try: + response = await self._client.put( + f"/env-service/{service_id}", + json=request.model_dump(exclude_none=True), + ) + + try: + api_response = APIResponse(**response.json()) + if api_response.success and api_response.data: + from aenv.core.models import EnvService + + service = EnvService(**api_response.data) + logger.info(f"Environment service updated: {service.id}") + return service + else: + error_msg = getattr( + api_response, "error_message", "Unknown error" + ) + raise EnvironmentError(f"Failed to update service: {error_msg}") + except ValueError as e: + raise EnvironmentError( + f"Invalid server response: {response.status_code} - {response.text[:200]}" + ) from e + + except httpx.RequestError as e: + if attempt < self.max_retries: + wait_time = 2**attempt + logger.warning(f"Network error, retrying in {wait_time}s: {str(e)}") + await asyncio.sleep(wait_time) + continue + raise NetworkError(f"Network error: {str(e)}") from e diff --git a/aenv/src/aenv/core/models.py b/aenv/src/aenv/core/models.py index 3deb1a0a..fd477dee 100644 --- a/aenv/src/aenv/core/models.py +++ b/aenv/src/aenv/core/models.py @@ -34,6 +34,17 @@ class EnvStatus(str, Enum): TERMINATED = "Terminated" +class ServiceStatus(str, Enum): + """Environment service status.""" + + PENDING = "Pending" + CREATING = "Creating" + RUNNING = "Running" + UPDATING = "Updating" + FAILED = "Failed" + TERMINATED = "Terminated" + + class Address(BaseModel): """Network address information.""" @@ -91,6 +102,83 @@ class EnvInstanceListResponse(BaseModel): items: List[EnvInstance] +class EnvService(BaseModel): + """Environment service model (Deployment + Service + PVC).""" + + id: str = Field(description="Service id, corresponds to deployment name") + env: Optional[Env] = Field(None, description="Environment object") + status: str = Field(description="Service status") + created_at: str = Field(description="Creation time") + updated_at: str = Field(description="Update time") + replicas: int = Field(description="Number of replicas") + available_replicas: int = Field(description="Number of available replicas") + service_url: Optional[str] = Field(None, description="Service URL") + owner: Optional[str] = Field(None, description="Service owner") + environment_variables: Optional[Dict[str, str]] = Field( + None, description="Environment variables" + ) + pvc_name: Optional[str] = Field(None, description="PVC name") + + +class EnvServiceCreateRequest(BaseModel): + """Request to create an environment service.""" + + envName: str = Field(description="Environment name") + replicas: int = Field(default=1, description="Number of replicas") + environment_variables: Optional[Dict[str, str]] = Field( + None, description="Environment variables" + ) + owner: Optional[str] = Field(None, description="Service owner") + + # Storage configuration + pvc_name: Optional[str] = Field(None, description="PVC name (default: envName)") + mount_path: Optional[str] = Field( + None, description="Mount path (default: /home/admin/data)" + ) + storage_size: Optional[str] = Field( + None, description="Storage size (e.g., 10Gi). If specified, PVC will be created and replicas must be 1. storageClass is configured in helm deployment." + ) + + # Service configuration + port: Optional[int] = Field(None, description="Service port (default: 8080)") + + # Resource limits + cpu_request: Optional[str] = Field( + None, description="CPU request (default: 1)" + ) + cpu_limit: Optional[str] = Field( + None, description="CPU limit (default: 2)" + ) + memory_request: Optional[str] = Field( + None, description="Memory request (default: 2Gi)" + ) + memory_limit: Optional[str] = Field( + None, description="Memory limit (default: 4Gi)" + ) + ephemeral_storage_request: Optional[str] = Field( + None, description="Ephemeral storage request (default: 5Gi)" + ) + ephemeral_storage_limit: Optional[str] = Field( + None, description="Ephemeral storage limit (default: 10Gi)" + ) + + +class EnvServiceUpdateRequest(BaseModel): + """Request to update an environment service.""" + + replicas: Optional[int] = Field(None, description="Number of replicas") + image: Optional[str] = Field(None, description="Container image") + environment_variables: Optional[Dict[str, str]] = Field( + None, description="Environment variables" + ) + + +class EnvServiceListResponse(BaseModel): + """Response for listing environment services.""" + + items: List[EnvService] + + class APIResponse(BaseModel): """Standard API response format.""" diff --git a/aenv/src/cli/cli.py b/aenv/src/cli/cli.py index b14d807b..2a8c287b 100644 --- a/aenv/src/cli/cli.py +++ b/aenv/src/cli/cli.py @@ -20,10 +20,10 @@ get, init, instance, - instances, list, push, run, + service, version, ) from cli.cmds.common import Config, global_error_handler, pass_config @@ -54,8 +54,8 @@ def cli(cfg: Config, debug: bool, verbose: bool): cli.add_command(version) cli.add_command(build) cli.add_command(config) -cli.add_command(instances) cli.add_command(instance) +cli.add_command(service) if __name__ == "__main__": cli() diff --git a/aenv/src/cli/cmds/__init__.py b/aenv/src/cli/cmds/__init__.py index 656cc505..27c73552 100644 --- a/aenv/src/cli/cmds/__init__.py +++ b/aenv/src/cli/cmds/__init__.py @@ -22,10 +22,10 @@ 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 from cli.cmds.run import run +from cli.cmds.service import service from cli.cmds.version import version # Optional: define all available commands @@ -38,6 +38,6 @@ "version", "build", "config", - "instances", "instance", + "service", ] diff --git a/aenv/src/cli/cmds/init.py b/aenv/src/cli/cmds/init.py index d6301d47..ef80d5ff 100644 --- a/aenv/src/cli/cmds/init.py +++ b/aenv/src/cli/cmds/init.py @@ -18,6 +18,7 @@ import json import os +import re from pathlib import Path import click @@ -31,6 +32,40 @@ from cli.utils.scaffold import ScaffoldParams, load_aenv_scaffold +def validate_env_name(name: str) -> tuple[bool, str]: + """ + Validate environment name according to Kubernetes pod naming rules. + + Pod names must: + - Contain only lowercase letters, numbers, and hyphens (-) + - Start with a letter or number + - End with a letter or number + - Be at most 253 characters long + + Returns: + tuple: (is_valid, error_message) + """ + if not name: + return False, "Environment name cannot be empty" + + if len(name) > 253: + return False, f"Environment name is too long (max 253 characters, got {len(name)})" + + # Check if name contains only lowercase letters, numbers, and hyphens + if not re.match(r'^[a-z0-9-]+$', name): + return False, "Environment name must contain only lowercase letters, numbers, and hyphens (-)" + + # Check if name starts with a letter or number + if not re.match(r'^[a-z0-9]', name): + return False, "Environment name must start with a lowercase letter or number" + + # Check if name ends with a letter or number + if not re.match(r'[a-z0-9]$', name): + return False, "Environment name must end with a lowercase letter or number" + + return True, "" + + @click.command() @click.argument("name") @click.option("--version", "-v", help="Specify aenv version number", default="1.0.0") @@ -61,6 +96,26 @@ def init(cfg: Config, name, version, template, work_dir, force, config_only): aenv init myproject --version 1.0.0 """ console = cfg.console.console() + + # Validate environment name (used as pod name prefix) + is_valid, error_msg = validate_env_name(name) + if not is_valid: + console.print( + Panel( + f"❌ Invalid environment name: {error_msg}\n\n" + "[yellow]Valid names must:[/yellow]\n" + " • Contain only lowercase letters, numbers, and hyphens (-)\n" + " • Start with a lowercase letter or number\n" + " • End with a lowercase letter or number\n" + " • Be at most 253 characters long\n\n" + "[cyan]Examples:[/cyan] my-env, prod-env-01, test123", + title="Validation Error", + style="bold red", + box=box.ROUNDED, + ) + ) + raise click.Abort() + # Display initialization header console.print( Panel( diff --git a/aenv/src/cli/cmds/instance.py b/aenv/src/cli/cmds/instance.py index 70f89ee0..724192d7 100644 --- a/aenv/src/cli/cmds/instance.py +++ b/aenv/src/cli/cmds/instance.py @@ -27,8 +27,8 @@ import asyncio import json import os +from pathlib import Path from typing import Any, Dict, Optional, Tuple -from urllib.parse import urlparse, urlunparse import click import requests @@ -36,29 +36,15 @@ from aenv.core.environment import Environment from cli.cmds.common import Config, pass_config +from cli.utils.api_helpers import ( + get_api_headers, + get_system_url_raw, + make_api_url, + parse_env_vars as _parse_env_vars, +) 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. @@ -71,6 +57,24 @@ def _parse_arguments(arg_list: tuple) -> list: return list(arg_list) if arg_list else [] +def _load_env_config() -> Optional[Dict[str, Any]]: + """Load build configuration from config.json in current directory. + + Returns: + Dictionary containing build configuration, or None if not found. + """ + config_path = Path(".").resolve() / "config.json" + if not config_path.exists(): + return None + + try: + with open(config_path, "r") as f: + config = json.load(f) + return config + except Exception: + return None + + def _split_env_name_version(env_name: str) -> Tuple[str, str]: """Split environment name into name and version. @@ -92,57 +96,6 @@ def _split_env_name_version(env_name: str) -> Tuple[str, str]: 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). @@ -154,25 +107,13 @@ def _get_system_url() -> str: Returns: Processed API URL with port """ - system_url = _get_system_url_raw() + 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 + return make_api_url(system_url, port=8080) def _list_instances_from_api( @@ -204,7 +145,7 @@ def _list_instances_from_api( env_id = "*" url = f"{system_url}/env-instance/{env_id}/list" - headers = _get_api_headers() + headers = get_api_headers() # Add query parameters params = {} @@ -356,7 +297,7 @@ def _get_instance_from_api( Instance details dict or None if failed """ url = f"{system_url}/env-instance/{instance_id}" - headers = _get_api_headers() + headers = get_api_headers() # Debug logging if verbose and console: @@ -496,7 +437,7 @@ def _delete_instance_from_api(system_url: str, instance_id: str) -> bool: True if deletion successful """ url = f"{system_url}/env-instance/{instance_id}" - headers = _get_api_headers() + headers = get_api_headers() try: response = requests.delete(url, headers=headers, timeout=30) @@ -579,7 +520,7 @@ def instance(cfg: Config): @instance.command("create") -@click.argument("env_name") +@click.argument("env_name", required=False) @click.option( "--datasource", "-d", @@ -657,7 +598,7 @@ def instance(cfg: Config): @pass_config def create( cfg: Config, - env_name: str, + env_name: Optional[str], datasource: str, ttl: str, environment_variables: tuple, @@ -676,8 +617,14 @@ def create( Create and initialize a new environment instance with the specified configuration. + The env_name argument is optional. If not provided, it will be read from config.json + in the current directory. + Examples: - # Create a basic instance + # Create using config.json in current directory + aenv instance create + + # Create with explicit environment name aenv instance create flowise-xxx@1.0.2 # Create with custom TTL and environment variables @@ -691,6 +638,21 @@ def create( """ console = cfg.console.console() + # If env_name not provided, try to load from config.json + if not env_name: + config = _load_env_config() + if config and "name" in config and "version" in config: + env_name = f"{config['name']}@{config['version']}" + console.print( + f"[dim]📄 Reading from config.json: {env_name}[/dim]\n" + ) + else: + console.print( + "[red]Error:[/red] env_name not provided and config.json not found or invalid.\n" + "Either provide env_name as argument or ensure config.json exists in current directory." + ) + raise click.Abort() + # Parse environment variables and arguments try: env_vars = _parse_env_vars(environment_variables) @@ -710,7 +672,7 @@ def create( # Get system URL from env, config, or use default if not system_url: - system_url = _get_system_url_raw() + system_url = get_system_url_raw() # Get owner from command line, config, or None if not owner: @@ -845,7 +807,7 @@ def info( # Get system URL from env, config, or use default if not system_url: - system_url = _get_system_url_raw() + system_url = get_system_url_raw() console.print(f"[cyan]ℹ️ Retrieving instance information:[/cyan] {env_name}\n") diff --git a/aenv/src/cli/cmds/instances.py b/aenv/src/cli/cmds/instances.py deleted file mode 100644 index 3c878efa..00000000 --- a/aenv/src/cli/cmds/instances.py +++ /dev/null @@ -1,309 +0,0 @@ -# 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. - -""" -instances command - List running environment instances -""" -import json -import os -from typing import Optional -from urllib.parse import urlparse, urlunparse - -import click -from tabulate import tabulate - -from cli.cmds.common import Config, pass_config -from cli.utils.cli_config import get_config_manager - - -def _make_api_url(aenv_url: str, port: int = 8080) -> str: - """Make API URL with specified port, similar to make_mcp_url logic. - - 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=p.path, - params="", - query="", - fragment="", - ) - return urlunparse(new).rstrip("/") - - -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: - system_url = "http://localhost:8080" - - # Use make_api_url to ensure port 8080 is set - return _make_api_url(system_url, port=8080) - - -def _get_instance_info(system_url: str, instance_id: str) -> Optional[dict]: - """Get detailed information for a single instance. - - Args: - system_url: AEnv system URL - instance_id: Instance ID - - Returns: - Instance details dict or None if failed - """ - import requests # noqa: I001 - - url = f"{system_url}/env-instance/{instance_id}" - - # Get API key from config 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}" - - try: - response = requests.get(url, headers=headers, timeout=10) - response.raise_for_status() - - result = response.json() - if result.get("success") and result.get("data"): - return result["data"] - return None - except requests.exceptions.RequestException: - # Silently fail and return None - we'll use list data as fallback - return None - - -def _list_instances_from_api( - system_url: str, env_name: Optional[str] = None, version: Optional[str] = None -) -> list: - """List running instances from API service. - - Args: - system_url: AEnv system URL - env_name: Optional environment name filter - version: Optional version filter - - Returns: - List of running instances - """ - import requests # noqa: I001 - - # Build the API endpoint - # Route is /env-instance/:id/list where :id is required - # Use "*" to list all instances when no filter is specified - if env_name: - if version: - # Format: name@version - env_id = f"{env_name}@{version}" - else: - env_id = env_name - else: - # Use "*" to list all instances - env_id = "*" - url = f"{system_url}/env-instance/{env_id}/list" - - # Get API key from config 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}" - - try: - response = requests.get(url, headers=headers, timeout=30) - response.raise_for_status() - - result = response.json() - if result.get("success") and result.get("data"): - return result["data"] - return [] - except requests.exceptions.RequestException as e: - raise click.ClickException(f"Failed to query instances: {str(e)}") - - -@click.command("instances") -@click.option( - "--name", - "-n", - type=str, - help="Filter by environment name", -) -@click.option( - "--version", - "-v", - type=str, - help="Filter by environment version (requires --name)", -) -@click.option( - "--format", - "-f", - 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, config, or http://localhost:8080)", -) -@pass_config -def instances(cfg: Config, name, version, format, system_url): - """List running environment instances - - Query and display running environment instances. Can filter by environment - name and optionally by version. - - Examples: - # List all running instances - aenv instances - - # List instances for a specific environment - aenv instances --name my-env - - # List instances for a specific environment and version - aenv instances --name my-env --version 1.0.0 - - # Output as JSON - aenv instances --format json - - # Use custom system URL - aenv instances --system-url http://api.example.com:8080 - """ - if version and not name: - raise click.BadOptionUsage( - "--version", "Version filter requires --name to be specified" - ) - - # Get system URL and ensure port 8080 is set - if not system_url: - system_url = _get_system_url() - else: - # Apply make_api_url logic to ensure port 8080 - system_url = _make_api_url(system_url, port=8080) - - try: - instances_list = _list_instances_from_api(system_url, name, version) - except Exception as e: - raise click.ClickException(f"Failed to list instances: {str(e)}") - - if not instances_list: - if name: - if version: - click.echo(f"📭 No running instances found for {name}@{version}") - else: - click.echo(f"📭 No running instances found for {name}") - else: - click.echo("📭 No running instances found") - return - - if format == "json": - click.echo(json.dumps(instances_list, indent=2, ensure_ascii=False)) - elif format == "table": - # Prepare table data - table_data = [] - for instance in instances_list: - instance_id = instance.get("id", "") - if not instance_id: - continue - - # Try to get detailed info for each instance - detailed_info = _get_instance_info(system_url, instance_id) - - # Use detailed info if available, otherwise use list data - if detailed_info: - env_info = detailed_info.get("env") or {} - else: - 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 (format: envname-randomid) - if not env_name and instance_id: - # Try to extract environment name from instance ID - parts = instance_id.split("-") - if len(parts) >= 2: - # Assume format: envname-randomid or envname-version-randomid - env_name = parts[0] - - # Get IP from detailed info or list data - if detailed_info: - ip = detailed_info.get("ip") or "" - else: - ip = instance.get("ip") or "" - - if not ip: - ip = "-" - - # Get status from detailed info or list data - status = ( - detailed_info.get("status") - if detailed_info - else instance.get("status") or "-" - ) - - # Get created_at from list data (list API already includes this) - created_at = instance.get("created_at") or "-" - - table_data.append( - { - "Instance ID": instance_id, - "Environment": env_name or "-", - "Version": env_version or "-", - "Status": status, - "IP": ip, - "Created At": created_at, - } - ) - - if table_data: - click.echo(tabulate(table_data, headers="keys", tablefmt="grid")) - else: - click.echo("📭 No running instances found") diff --git a/aenv/src/cli/cmds/service.py b/aenv/src/cli/cmds/service.py new file mode 100644 index 00000000..3641e8fb --- /dev/null +++ b/aenv/src/cli/cmds/service.py @@ -0,0 +1,666 @@ +# 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. + +""" +service command - Manage environment services (Deployment + Service + PVC) + +This command provides interface for managing long-running services: +- service create: Create new services +- service list: List running services +- service get: Get detailed service information +- service delete: Delete a service +- service update: Update service (replicas, image, env vars) +""" +import asyncio +import json +import os +from pathlib import Path +from typing import Any, Dict, Optional + +import click +from tabulate import tabulate + +from aenv.client.scheduler_client import AEnvSchedulerClient +from cli.cmds.common import Config, pass_config +from cli.utils.api_helpers import ( + get_api_headers, + get_system_url_raw, + make_api_url, + parse_env_vars, +) +from cli.utils.cli_config import get_config_manager + + +def _load_env_config() -> Optional[Dict[str, Any]]: + """Load build configuration from config.json in current directory. + + Returns: + Dictionary containing build configuration, or None if not found. + """ + config_path = Path(".").resolve() / "config.json" + if not config_path.exists(): + return None + + try: + with open(config_path, "r") as f: + config = json.load(f) + return config + except Exception: + return None + + +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" + + # Ensure port is set for API communication + return make_api_url(system_url, port=8080) + + +@click.group("service") +@pass_config +def service(cfg: Config): + """Manage environment services (long-running deployments) + + Services are persistent deployments with: + - Multiple replicas + - Persistent storage (PVC) + - Cluster DNS service URL + - No TTL (always running) + """ + pass + + +@service.command("create") +@click.argument("env_name", required=False) +@click.option( + "--replicas", + "-r", + type=int, + help="Number of replicas (default: 1 or from config.json)", +) +@click.option( + "--port", + "-p", + type=int, + help="Service port (default: 8080 or from config.json)", +) +@click.option( + "--env", + "-e", + "environment_variables", + multiple=True, + help="Environment variables in format KEY=VALUE (can be used multiple times)", +) +@click.option( + "--enable-storage", + is_flag=True, + default=False, + help="Enable PVC storage. Storage configuration (storageSize, pvcName, mountPath) will be read from config.json's deployConfig.", +) +@click.option( + "--output", + "-o", + type=click.Choice(["table", "json"]), + default="table", + help="Output format", +) +@pass_config +def create( + cfg: Config, + env_name: Optional[str], + replicas: Optional[int], + port: Optional[int], + environment_variables: tuple, + enable_storage: bool, + output: str, +): + """Create a new environment service + + Creates a long-running service with Deployment, Service, and optionally PVC. + + The env_name argument is optional. If not provided, it will be read from config.json + in the current directory. + + Configuration priority (high to low): + 1. CLI parameters (--replicas, --port, --enable-storage) + 2. config.json's deployConfig + 3. System defaults + + PVC creation behavior: + - Use --enable-storage flag to enable PVC + - Storage configuration (storageSize, pvcName, mountPath) is read from config.json's deployConfig + - When PVC is created, replicas must be 1 (enforced by backend) + - storageClass is configured in helm values.yaml deployment, not in config.json + + config.json deployConfig fields: + - replicas: Number of replicas (default: 1) + - port: Service port (default: 8080) + - storageSize: Storage size like "10Gi", "20Gi" (required when --enable-storage is used) + - pvcName: PVC name (default: environment name) + - mountPath: Mount path (default: /home/admin/data) + - cpuRequest, cpuLimit: CPU resources (default: 1, 2) + - memoryRequest, memoryLimit: Memory resources (default: 2Gi, 4Gi) + - ephemeralStorageRequest, ephemeralStorageLimit: Storage (default: 5Gi, 10Gi) + - environmentVariables: Environment variables dict + + Examples: + # Create using config.json in current directory + aenv service create + + # Create with explicit environment name + aenv service create myapp@1.0.0 + + # Create with 3 replicas and custom port (no PVC) + aenv service create myapp@1.0.0 --replicas 3 --port 8000 + + # Create with PVC enabled (storageSize must be in config.json) + aenv service create myapp@1.0.0 --enable-storage + + # Create with environment variables + aenv service create myapp@1.0.0 -e DB_HOST=postgres -e CACHE_SIZE=1024 + """ + console = cfg.console.console() + + # Load config.json if exists + config = _load_env_config() + deploy_config = config.get("deployConfig", {}) if config else {} + + # If env_name not provided, try to load from config.json + if not env_name: + if config and "name" in config and "version" in config: + env_name = f"{config['name']}@{config['version']}" + console.print( + f"[dim]📄 Reading from config.json: {env_name}[/dim]\n" + ) + else: + console.print( + "[red]Error:[/red] env_name not provided and config.json not found or invalid.\n" + "Either provide env_name as argument or ensure config.json exists in current directory." + ) + raise click.Abort() + + # Merge parameters: CLI > config.json > defaults + final_replicas = replicas if replicas is not None else deploy_config.get("replicas", 1) + final_port = port if port is not None else deploy_config.get("port") + + # Storage configuration - only use if --enable-storage is set + final_storage_size = None + final_pvc_name = None + final_mount_path = None + if enable_storage: + final_storage_size = deploy_config.get("storageSize") + if not final_storage_size: + console.print( + "[red]Error:[/red] --enable-storage flag is set but 'storageSize' is not found in config.json's deployConfig.\n" + "Please add 'storageSize' (e.g., '10Gi', '20Gi') to deployConfig in config.json." + ) + raise click.Abort() + final_pvc_name = deploy_config.get("pvcName") + final_mount_path = deploy_config.get("mountPath") + + # Resource configurations from deployConfig + cpu_request = deploy_config.get("cpuRequest") + cpu_limit = deploy_config.get("cpuLimit") + memory_request = deploy_config.get("memoryRequest") + memory_limit = deploy_config.get("memoryLimit") + ephemeral_storage_request = deploy_config.get("ephemeralStorageRequest") + ephemeral_storage_limit = deploy_config.get("ephemeralStorageLimit") + + # Parse environment variables from CLI + try: + env_vars = parse_env_vars(environment_variables) if environment_variables else None + except click.BadParameter as e: + console.print(f"[red]Error:[/red] {str(e)}") + raise click.Abort() + + # Merge with environment variables from config + if deploy_config.get("environmentVariables"): + if env_vars is None: + env_vars = {} + # CLI env vars override config env vars + for k, v in deploy_config["environmentVariables"].items(): + if k not in env_vars: + env_vars[k] = v + + # Get config + system_url = _get_system_url() + 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") + + # Get owner from config (unified management) + owner = config_manager.get("owner") + + # Display configuration summary + console.print(f"[cyan]🚀 Creating environment service:[/cyan] {env_name}") + console.print(f" Replicas: {final_replicas}") + if final_port: + console.print(f" Port: {final_port}") + if env_vars: + console.print(f" Environment Variables: {len(env_vars)} variables") + if owner: + console.print(f" Owner: {owner}") + + if enable_storage: + console.print(f"[cyan] Storage Configuration:[/cyan]") + console.print(f" - Size: {final_storage_size}") + if final_pvc_name: + console.print(f" - PVC Name: {final_pvc_name}") + else: + console.print(f" - PVC Name: {env_name.split('@')[0]} (default)") + if final_mount_path: + console.print(f" - Mount Path: {final_mount_path}") + else: + console.print(f" - Mount Path: /home/admin/data (default)") + console.print(f" [yellow]⚠️ With PVC enabled, replicas must be 1[/yellow]") + else: + console.print(f"[dim] Storage: Disabled (use --enable-storage to enable PVC)[/dim]") + console.print() + + async def _create(): + async with AEnvSchedulerClient( + base_url=system_url, + api_key=api_key, + ) as client: + return await client.create_env_service( + name=env_name, + replicas=final_replicas, + environment_variables=env_vars, + owner=owner, + port=final_port, + pvc_name=final_pvc_name, + storage_size=final_storage_size, + mount_path=final_mount_path, + cpu_request=cpu_request, + cpu_limit=cpu_limit, + memory_request=memory_request, + memory_limit=memory_limit, + ephemeral_storage_request=ephemeral_storage_request, + ephemeral_storage_limit=ephemeral_storage_limit, + ) + + try: + with console.status("[bold green]Creating service..."): + svc = asyncio.run(_create()) + + console.print("[green]✅ Service created successfully![/green]\n") + + if output == "json": + console.print(json.dumps(svc.model_dump(), indent=2, default=str)) + else: + table_data = [ + {"Property": "Service ID", "Value": svc.id}, + {"Property": "Status", "Value": svc.status}, + {"Property": "Service URL", "Value": svc.service_url or "-"}, + {"Property": "Replicas", "Value": f"{svc.available_replicas}/{svc.replicas}"}, + {"Property": "PVC Name", "Value": svc.pvc_name or "-"}, + {"Property": "Created At", "Value": svc.created_at}, + ] + console.print(tabulate(table_data, headers="keys", tablefmt="grid")) + + except Exception as e: + console.print(f"[red]❌ Creation failed:[/red] {str(e)}") + if cfg.verbose: + import traceback + console.print(traceback.format_exc()) + raise click.Abort() + + +@service.command("list") +@click.option( + "--name", + "-n", + type=str, + help="Filter by environment name", +) +@click.option( + "--output", + "-o", + type=click.Choice(["table", "json"]), + default="table", + help="Output format", +) +@pass_config +def list_services(cfg: Config, name, output): + """List running environment services + + Examples: + # List all services + aenv service list + + # List services for specific environment + aenv service list --name myapp + + # Output as JSON + aenv service list --output json + """ + console = cfg.console.console() + + system_url = _get_system_url() + 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") + + async def _list(): + async with AEnvSchedulerClient( + base_url=system_url, + api_key=api_key, + ) as client: + return await client.list_env_services(env_name=name) + + try: + services_list = asyncio.run(_list()) + except Exception as e: + console.print(f"[red]❌ Failed to list services:[/red] {str(e)}") + if cfg.verbose: + import traceback + console.print(traceback.format_exc()) + raise click.Abort() + + if not services_list: + if name: + console.print(f"📭 No running services found for {name}") + else: + console.print("📭 No running services found") + return + + if output == "json": + console.print(json.dumps([s.model_dump() for s in services_list], indent=2, default=str)) + else: + table_data = [] + for svc in services_list: + env_name = svc.env.name if svc.env else "-" + env_version = svc.env.version if svc.env else "-" + + table_data.append({ + "Service ID": svc.id, + "Environment": env_name, + "Version": env_version, + "Owner": svc.owner or "-", + "Status": svc.status, + "Replicas": f"{svc.available_replicas}/{svc.replicas}", + "Service URL": svc.service_url or "-", + "Created At": svc.created_at, + }) + + if table_data: + console.print(tabulate(table_data, headers="keys", tablefmt="grid")) + else: + console.print("📭 No running services found") + + +@service.command("get") +@click.argument("service_id") +@click.option( + "--output", + "-o", + type=click.Choice(["table", "json"]), + default="table", + help="Output format", +) +@pass_config +def get_service(cfg: Config, service_id, output): + """Get detailed information for a specific service + + Examples: + # Get service information + aenv service get myapp-svc-abc123 + + # Get in JSON format + aenv service get myapp-svc-abc123 --output json + """ + console = cfg.console.console() + + system_url = _get_system_url() + 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") + + console.print(f"[cyan]ℹ️ Retrieving service information:[/cyan] {service_id}\n") + + async def _get(): + async with AEnvSchedulerClient( + base_url=system_url, + api_key=api_key, + ) as client: + return await client.get_env_service(service_id) + + try: + svc = asyncio.run(_get()) + + console.print("[green]✅ Service information retrieved![/green]\n") + + if output == "json": + console.print(json.dumps(svc.model_dump(), indent=2, default=str)) + else: + env_name = svc.env.name if svc.env else "-" + env_version = svc.env.version if svc.env else "-" + + table_data = [ + {"Property": "Service ID", "Value": svc.id}, + {"Property": "Environment", "Value": env_name}, + {"Property": "Version", "Value": env_version}, + {"Property": "Owner", "Value": svc.owner or "-"}, + {"Property": "Status", "Value": svc.status}, + {"Property": "Replicas", "Value": f"{svc.available_replicas}/{svc.replicas}"}, + {"Property": "Service URL", "Value": svc.service_url or "-"}, + {"Property": "PVC Name", "Value": svc.pvc_name or "-"}, + {"Property": "Created At", "Value": svc.created_at}, + {"Property": "Updated At", "Value": svc.updated_at}, + ] + console.print(tabulate(table_data, headers="keys", tablefmt="grid")) + + except Exception as e: + console.print(f"[red]❌ Failed to get service information:[/red] {str(e)}") + if cfg.verbose: + import traceback + console.print(traceback.format_exc()) + raise click.Abort() + + +@service.command("delete") +@click.argument("service_id") +@click.option( + "--yes", + "-y", + is_flag=True, + help="Skip confirmation prompt", +) +@pass_config +def delete_service(cfg: Config, service_id, yes): + """Delete a running service + + Note: This deletes the Deployment and Service, but keeps the PVC for reuse. + + Examples: + # Delete a service (with confirmation) + aenv service delete myapp-svc-abc123 + + # Delete without confirmation + aenv service delete myapp-svc-abc123 --yes + """ + console = cfg.console.console() + + if not yes: + console.print(f"[yellow]⚠️ You are about to delete service:[/yellow] {service_id}") + console.print("[yellow]Note: PVC will be kept for reuse[/yellow]") + if not click.confirm("Are you sure you want to continue?"): + console.print("[cyan]Deletion cancelled[/cyan]") + return + + system_url = _get_system_url() + 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") + + console.print(f"[cyan]🗑️ Deleting service:[/cyan] {service_id}\n") + + async def _delete(): + async with AEnvSchedulerClient( + base_url=system_url, + api_key=api_key, + ) as client: + return await client.delete_env_service(service_id) + + try: + with console.status("[bold green]Deleting service..."): + success = asyncio.run(_delete()) + + if success: + console.print("[green]✅ Service deleted successfully![/green]") + console.print("[cyan]Note: PVC was kept for reuse[/cyan]") + else: + console.print("[red]❌ Failed to delete service[/red]") + raise click.Abort() + + except Exception as e: + console.print(f"[red]❌ Failed to delete service:[/red] {str(e)}") + if cfg.verbose: + import traceback + console.print(traceback.format_exc()) + raise click.Abort() + + +@service.command("update") +@click.argument("service_id") +@click.option( + "--replicas", + "-r", + type=int, + help="Update number of replicas", +) +@click.option( + "--image", + type=str, + help="Update container image", +) +@click.option( + "--env", + "-e", + "environment_variables", + multiple=True, + help="Environment variables in format KEY=VALUE (can be used multiple times)", +) +@click.option( + "--output", + "-o", + type=click.Choice(["table", "json"]), + default="table", + help="Output format", +) +@pass_config +def update_service( + cfg: Config, + service_id: str, + replicas: Optional[int], + image: Optional[str], + environment_variables: tuple, + output: str, +): + """Update a running service + + Can update replicas, image, and environment variables. + + Examples: + # Scale to 5 replicas + aenv service update myapp-svc-abc123 --replicas 5 + + # Update image + aenv service update myapp-svc-abc123 --image myapp:2.0.0 + + # Update environment variables + aenv service update myapp-svc-abc123 -e DB_HOST=newhost -e DB_PORT=3306 + + # Update multiple things at once + aenv service update myapp-svc-abc123 --replicas 3 --image myapp:2.0.0 + """ + console = cfg.console.console() + + if not replicas and not image and not environment_variables: + console.print("[red]Error:[/red] At least one of --replicas, --image, or --env must be provided") + raise click.Abort() + + # Parse environment variables + env_vars = None + if environment_variables: + try: + env_vars = parse_env_vars(environment_variables) + except click.BadParameter as e: + console.print(f"[red]Error:[/red] {str(e)}") + raise click.Abort() + + system_url = _get_system_url() + 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") + + console.print(f"[cyan]🔄 Updating service:[/cyan] {service_id}") + if replicas is not None: + console.print(f" Replicas: {replicas}") + if image: + console.print(f" Image: {image}") + if env_vars: + console.print(f" Environment Variables: {len(env_vars)} variables") + console.print() + + async def _update(): + async with AEnvSchedulerClient( + base_url=system_url, + api_key=api_key, + ) as client: + return await client.update_env_service( + service_id=service_id, + replicas=replicas, + image=image, + environment_variables=env_vars, + ) + + try: + with console.status("[bold green]Updating service..."): + svc = asyncio.run(_update()) + + console.print("[green]✅ Service updated successfully![/green]\n") + + if output == "json": + console.print(json.dumps(svc.model_dump(), indent=2, default=str)) + else: + table_data = [ + {"Property": "Service ID", "Value": svc.id}, + {"Property": "Status", "Value": svc.status}, + {"Property": "Replicas", "Value": f"{svc.available_replicas}/{svc.replicas}"}, + {"Property": "Service URL", "Value": svc.service_url or "-"}, + {"Property": "Updated At", "Value": svc.updated_at}, + ] + console.print(tabulate(table_data, headers="keys", tablefmt="grid")) + + except Exception as e: + console.print(f"[red]❌ Update failed:[/red] {str(e)}") + if cfg.verbose: + import traceback + console.print(traceback.format_exc()) + raise click.Abort() diff --git a/aenv/src/cli/templates/default/config.json b/aenv/src/cli/templates/default/config.json index 96fb5721..fca4b668 100644 --- a/aenv/src/cli/templates/default/config.json +++ b/aenv/src/cli/templates/default/config.json @@ -19,6 +19,18 @@ "deployConfig": { "cpu": "1", "memory": "2G", - "os": "linux" + "os": "linux", + "replicas": 1, + "port": 8080, + "cpuRequest": "1", + "cpuLimit": "2", + "memoryRequest": "2Gi", + "memoryLimit": "4Gi", + "ephemeralStorageRequest": "5Gi", + "ephemeralStorageLimit": "10Gi", + "environmentVariables": {}, + "storageSize": "", + "pvcName": "", + "mountPath": "/home/admin/data" } } diff --git a/aenv/src/cli/utils/api_helpers.py b/aenv/src/cli/utils/api_helpers.py new file mode 100644 index 00000000..8815575a --- /dev/null +++ b/aenv/src/cli/utils/api_helpers.py @@ -0,0 +1,135 @@ +# 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. + +""" +Shared API helper functions for CLI commands. + +This module provides common utility functions for API interactions, configuration +management, and environment variable parsing used across multiple CLI commands. +""" + +import os +from typing import Dict, Optional +from urllib.parse import urlparse, urlunparse + +import click + +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 + + Raises: + click.BadParameter: If any variable is not in KEY=VALUE format + """ + 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 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 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() -> str: + """Get AEnv system URL from environment variable or config (with default fallback). + + Priority order: + 1. AENV_SYSTEM_URL environment variable (highest priority) + 2. system_url in config file + 3. Default value (http://localhost:8080) + + Returns: + System URL string + """ + system_url = get_system_url_raw() + if not system_url: + system_url = "http://localhost:8080" + return system_url.rstrip("/") + + +def get_api_headers() -> Dict[str, str]: + """Get API headers with authentication if available. + + Returns: + Dictionary of HTTP headers including authentication if API key is configured + """ + 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 diff --git a/api-service/Dockerfile b/api-service/Dockerfile index ec0d1cad..2a42c199 100644 --- a/api-service/Dockerfile +++ b/api-service/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM golang:1.21-alpine AS builder +FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/golang:1.21-alpine AS builder RUN apk add --no-cache git diff --git a/api-service/controller/env_service.go b/api-service/controller/env_service.go new file mode 100644 index 00000000..bef306dc --- /dev/null +++ b/api-service/controller/env_service.go @@ -0,0 +1,271 @@ +/* +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. +*/ + +package controller + +import ( + "api-service/service" + "api-service/util" + backendmodels "envhub/models" + + "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" +) + +// EnvServiceController handles EnvService operations +type EnvServiceController struct { + scheduleClient *service.ScheduleClient + backendClient *service.BackendClient + redisClient *service.RedisClient +} + +// NewEnvServiceController creates a new EnvService controller instance +func NewEnvServiceController( + scheduleClient service.EnvInstanceService, + backendClient *service.BackendClient, + redisClient *service.RedisClient, +) *EnvServiceController { + // Type assert to *ScheduleClient to access service methods + sc, ok := scheduleClient.(*service.ScheduleClient) + if !ok { + log.Fatal("EnvServiceController requires *ScheduleClient implementation") + } + return &EnvServiceController{ + scheduleClient: sc, + backendClient: backendClient, + redisClient: redisClient, + } +} + +// CreateEnvServiceRequest represents the request body for creating an EnvService +type CreateEnvServiceRequest struct { + EnvName string `json:"envName" binding:"required"` + Replicas int32 `json:"replicas"` + EnvironmentVariables map[string]string `json:"environment_variables"` + Owner string `json:"owner"` + + // Storage configuration + PVCName string `json:"pvc_name"` + MountPath string `json:"mount_path"` + // Note: storageClass is now configured in helm values.yaml, not via API + StorageSize string `json:"storage_size"` // If specified, PVC will be created and replicas must be 1 + + // Service configuration + Port int32 `json:"port"` + + // Resource limits + CPURequest string `json:"cpu_request"` + CPULimit string `json:"cpu_limit"` + MemoryRequest string `json:"memory_request"` + MemoryLimit string `json:"memory_limit"` + EphemeralStorageRequest string `json:"ephemeral_storage_request"` + EphemeralStorageLimit string `json:"ephemeral_storage_limit"` +} + +// CreateEnvService creates a new EnvService (Deployment + Service + PVC) +// POST /env-service/ +func (ctrl *EnvServiceController) CreateEnvService(c *gin.Context) { + var req CreateEnvServiceRequest + if err := c.ShouldBindJSON(&req); err != nil { + backendmodels.JSONErrorWithMessage(c, 400, "Invalid request parameters: "+err.Error()) + return + } + + // Default replicas to 1 if not specified + if req.Replicas == 0 { + req.Replicas = 1 + } + + // Split name and version using SplitEnvNameVersionStrict function + name, version, err := util.SplitEnvNameVersionStrict(req.EnvName) + if err != nil { + backendmodels.JSONErrorWithMessage(c, 400, "Invalid EnvName format: "+err.Error()) + return + } + backendEnv, err := ctrl.backendClient.GetEnvByVersion(name, version) + if err != nil { + backendmodels.JSONErrorWithMessage(c, 500, "Failed to find environment: "+err.Error()) + return + } + if backendEnv == nil { + backendmodels.JSONErrorWithMessage(c, 404, "Environment not found: "+req.EnvName) + return + } + + // Configure DeployConfig + if backendEnv.DeployConfig == nil { + backendEnv.DeployConfig = make(map[string]interface{}) + } + if req.EnvironmentVariables != nil { + backendEnv.DeployConfig["environmentVariables"] = req.EnvironmentVariables + } + backendEnv.DeployConfig["replicas"] = req.Replicas + if req.Owner != "" { + backendEnv.DeployConfig["owner"] = req.Owner + } + + // Storage configuration + if req.PVCName != "" { + backendEnv.DeployConfig["pvcName"] = req.PVCName + } + if req.MountPath != "" { + backendEnv.DeployConfig["mountPath"] = req.MountPath + } + // storageClass is now configured in helm values.yaml, not passed via API + if req.StorageSize != "" { + backendEnv.DeployConfig["storageSize"] = req.StorageSize + } + + // Service configuration + if req.Port > 0 { + backendEnv.DeployConfig["port"] = req.Port + } + + // Resource configuration + if req.CPURequest != "" { + backendEnv.DeployConfig["cpuRequest"] = req.CPURequest + } + if req.CPULimit != "" { + backendEnv.DeployConfig["cpuLimit"] = req.CPULimit + } + if req.MemoryRequest != "" { + backendEnv.DeployConfig["memoryRequest"] = req.MemoryRequest + } + if req.MemoryLimit != "" { + backendEnv.DeployConfig["memoryLimit"] = req.MemoryLimit + } + if req.EphemeralStorageRequest != "" { + backendEnv.DeployConfig["ephemeralStorageRequest"] = req.EphemeralStorageRequest + } + if req.EphemeralStorageLimit != "" { + backendEnv.DeployConfig["ephemeralStorageLimit"] = req.EphemeralStorageLimit + } + + // Call ScheduleClient to create Service + envService, err := ctrl.scheduleClient.CreateService(backendEnv) + if err != nil { + backendmodels.JSONErrorWithMessage(c, 500, "Failed to create service: "+err.Error()) + return + } + envService.Env = backendEnv + + // Construct response data + backendmodels.JSONSuccess(c, envService) +} + +// GetEnvService retrieves a single EnvService +// GET /env-service/:id +func (ctrl *EnvServiceController) GetEnvService(c *gin.Context) { + id := c.Param("id") + if id == "" { + backendmodels.JSONErrorWithMessage(c, 400, "Missing id parameter") + return + } + // Call ScheduleClient to query Service + envService, err := ctrl.scheduleClient.GetService(id) + if err != nil { + backendmodels.JSONErrorWithMessage(c, 500, "Failed to query service: "+err.Error()) + return + } + backendmodels.JSONSuccess(c, envService) +} + +// DeleteEnvService deletes an EnvService +// DELETE /env-service/:id +func (ctrl *EnvServiceController) DeleteEnvService(c *gin.Context) { + id := c.Param("id") + if id == "" { + backendmodels.JSONErrorWithMessage(c, 400, "Missing id parameter") + return + } + + // Call ScheduleClient to delete Service + success, err := ctrl.scheduleClient.DeleteService(id) + if err != nil { + backendmodels.JSONErrorWithMessage(c, 500, "Failed to delete service: "+err.Error()) + return + } + if !success { + backendmodels.JSONErrorWithMessage(c, 500, "Service deletion returned false") + return + } + backendmodels.JSONSuccess(c, "Deleted successfully") +} + +// UpdateEnvServiceRequest represents the request body for updating an EnvService +type UpdateEnvServiceRequest struct { + Replicas *int32 `json:"replicas,omitempty"` + Image *string `json:"image,omitempty"` + EnvironmentVariables *map[string]string `json:"environment_variables,omitempty"` +} + +// UpdateEnvService updates an EnvService (replicas, image, env vars) +// PUT /env-service/:id +func (ctrl *EnvServiceController) UpdateEnvService(c *gin.Context) { + id := c.Param("id") + if id == "" { + backendmodels.JSONErrorWithMessage(c, 400, "Missing id parameter") + return + } + + var req UpdateEnvServiceRequest + if err := c.ShouldBindJSON(&req); err != nil { + backendmodels.JSONErrorWithMessage(c, 400, "Invalid request parameters: "+err.Error()) + return + } + + // Build update request + updateReq := &service.UpdateServiceRequest{ + Replicas: req.Replicas, + Image: req.Image, + EnvironmentVariables: req.EnvironmentVariables, + } + + // Call ScheduleClient to update Service + envService, err := ctrl.scheduleClient.UpdateService(id, updateReq) + if err != nil { + backendmodels.JSONErrorWithMessage(c, 500, "Failed to update service: "+err.Error()) + return + } + backendmodels.JSONSuccess(c, envService) +} + +// ListEnvServices lists EnvServices +// GET /env-service/:id/list +func (ctrl *EnvServiceController) ListEnvServices(c *gin.Context) { + id := c.Param("id") + + // Handle wildcard "*" as "list all services" + if id == "*" { + id = "" + } + + // Extract envName from id or query parameter + var envName string + if id != "" { + name, _ := util.SplitEnvNameVersion(id) + envName = name + } else { + envName = c.Query("envName") + } + + services, err := ctrl.scheduleClient.ListServices(envName) + if err != nil { + backendmodels.JSONErrorWithMessage(c, 500, "Failed to list services: "+err.Error()) + return + } + backendmodels.JSONSuccess(c, services) +} diff --git a/api-service/main.go b/api-service/main.go index 912484a2..4ee75530 100644 --- a/api-service/main.go +++ b/api-service/main.go @@ -106,6 +106,7 @@ func main() { } envInstanceController := controller.NewEnvInstanceController(scheduleClient, backendClient, redisClient) + envServiceController := controller.NewEnvServiceController(scheduleClient, backendClient, redisClient) // Main route configuration mainRouter.POST("/env-instance", middleware.AuthTokenMiddleware(tokenEnabled, backendClient), @@ -115,6 +116,17 @@ func main() { mainRouter.GET("/env-instance/:id/list", middleware.AuthTokenMiddleware(tokenEnabled, backendClient), envInstanceController.ListEnvInstances) mainRouter.GET("/env-instance/:id", middleware.AuthTokenMiddleware(tokenEnabled, backendClient), envInstanceController.GetEnvInstance) mainRouter.DELETE("/env-instance/:id", middleware.AuthTokenMiddleware(tokenEnabled, backendClient), envInstanceController.DeleteEnvInstance) + + // Service routes + mainRouter.POST("/env-service", + middleware.AuthTokenMiddleware(tokenEnabled, backendClient), + middleware.RateLimit(qps), + envServiceController.CreateEnvService) + mainRouter.GET("/env-service/:id/list", middleware.AuthTokenMiddleware(tokenEnabled, backendClient), envServiceController.ListEnvServices) + mainRouter.GET("/env-service/:id", middleware.AuthTokenMiddleware(tokenEnabled, backendClient), envServiceController.GetEnvService) + mainRouter.DELETE("/env-service/:id", middleware.AuthTokenMiddleware(tokenEnabled, backendClient), envServiceController.DeleteEnvService) + mainRouter.PUT("/env-service/:id", middleware.AuthTokenMiddleware(tokenEnabled, backendClient), envServiceController.UpdateEnvService) + mainRouter.GET("/health", healthChecker) mainRouter.GET("/metrics", gin.WrapH(promhttp.Handler())) diff --git a/api-service/models/env_service.go b/api-service/models/env_service.go new file mode 100644 index 00000000..79592367 --- /dev/null +++ b/api-service/models/env_service.go @@ -0,0 +1,123 @@ +/* +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. +*/ + +package models + +import ( + backend "envhub/models" + "time" +) + +// EnvServiceStatus environment service status enumeration +type EnvServiceStatus int + +const ( + EnvServiceStatusPending EnvServiceStatus = iota + EnvServiceStatusCreating + EnvServiceStatusRunning + EnvServiceStatusUpdating + EnvServiceStatusFailed + EnvServiceStatusTerminated +) + +// String returns string representation of status +func (s EnvServiceStatus) String() string { + switch s { + case EnvServiceStatusPending: + return "Pending" + case EnvServiceStatusCreating: + return "Creating" + case EnvServiceStatusRunning: + return "Running" + case EnvServiceStatusUpdating: + return "Updating" + case EnvServiceStatusFailed: + return "Failed" + case EnvServiceStatusTerminated: + return "Terminated" + default: + return "Unknown" + } +} + +// EnvService environment service object (Deployment + Service + PVC) +type EnvService struct { + ID string `json:"id"` // Service id, corresponds to deployment name + Env *backend.Env `json:"env"` // Env object + Status string `json:"status"` // Service status + CreatedAt string `json:"created_at"` // Creation time + UpdatedAt string `json:"updated_at"` // Update time + Replicas int32 `json:"replicas"` // Number of replicas + AvailableReplicas int32 `json:"available_replicas"` // Number of available replicas + ServiceURL string `json:"service_url"` // Service URL (internal cluster DNS) + Owner string `json:"owner"` // Service owner (user who created it) + EnvironmentVariables map[string]string `json:"environment_variables"` // Environment variables + PVCName string `json:"pvc_name"` // PVC name (shared by same envName) +} + +// NewEnvService creates a new environment service object +func NewEnvService(id string, env *backend.Env, replicas int32, owner string, envVars map[string]string, pvcName string) *EnvService { + now := time.Now().Format("2006-01-02 15:04:05") + return &EnvService{ + ID: id, + Env: env, + Status: EnvServiceStatusPending.String(), + CreatedAt: now, + UpdatedAt: now, + Replicas: replicas, + AvailableReplicas: 0, + Owner: owner, + EnvironmentVariables: envVars, + PVCName: pvcName, + } +} + +// NewEnvServiceWithStatus creates an environment service object with specified status +func NewEnvServiceWithStatus(id string, env *backend.Env, status EnvServiceStatus, replicas int32, availableReplicas int32, serviceURL string, owner string, envVars map[string]string, pvcName string) *EnvService { + now := time.Now().Format("2006-01-02 15:04:05") + return &EnvService{ + ID: id, + Env: env, + Status: status.String(), + CreatedAt: now, + UpdatedAt: now, + Replicas: replicas, + AvailableReplicas: availableReplicas, + ServiceURL: serviceURL, + Owner: owner, + EnvironmentVariables: envVars, + PVCName: pvcName, + } +} + +// UpdateStatus updates service status +func (s *EnvService) UpdateStatus(status EnvServiceStatus) { + s.Status = status.String() + s.UpdatedAt = time.Now().Format("2006-01-02 15:04:05") +} + +// UpdateReplicas updates service replicas +func (s *EnvService) UpdateReplicas(replicas int32, availableReplicas int32) { + s.Replicas = replicas + s.AvailableReplicas = availableReplicas + s.UpdatedAt = time.Now().Format("2006-01-02 15:04:05") +} + +// UpdateServiceURL updates service URL +func (s *EnvService) UpdateServiceURL(url string) { + s.ServiceURL = url + s.UpdatedAt = time.Now().Format("2006-01-02 15:04:05") +} diff --git a/api-service/service/schedule_client.go b/api-service/service/schedule_client.go index d20a904b..929e979f 100644 --- a/api-service/service/schedule_client.go +++ b/api-service/service/schedule_client.go @@ -210,6 +210,244 @@ func (c *ScheduleClient) FilterPods() (*[]models.EnvInstance, error) { return &getResp.Data, nil } +/* +==================================== +==== Service Management Methods ==== +==================================== +*/ + +// CreateService creates a Service (Deployment + Service + PVC) +func (c *ScheduleClient) CreateService(req *backend.Env) (*models.EnvService, error) { + url := fmt.Sprintf("%s/services", c.baseURL) + + jsonData, err := req.ToJSON() + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %v", err) + } + httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %v", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to send request: %v", err) + } + 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 { + return nil, fmt.Errorf("failed to read response body: %v", err) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("request failed with status: %d, body: %s", resp.StatusCode, string(body)) + } + + var createResp models.ClientResponse[models.EnvService] + if err := json.Unmarshal(body, &createResp); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %v", err) + } + + if !createResp.Success { + return nil, fmt.Errorf("server returned error, code: %d", createResp.Code) + } + + return &createResp.Data, nil +} + +// GetService queries a Service +func (c *ScheduleClient) GetService(serviceName string) (*models.EnvService, error) { + url := fmt.Sprintf("%s/services/%s", c.baseURL, serviceName) + + httpReq, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %v", err) + } + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to send request: %v", err) + } + 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 { + return nil, fmt.Errorf("failed to read response body: %v", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("request failed with status: %d, body: %s", resp.StatusCode, string(body)) + } + + var getResp models.ClientResponse[models.EnvService] + if err := json.Unmarshal(body, &getResp); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %v", err) + } + + if !getResp.Success { + return nil, fmt.Errorf("server returned error, code: %d", getResp.Code) + } + + return &getResp.Data, nil +} + +// DeleteService deletes a Service +func (c *ScheduleClient) DeleteService(serviceName string) (bool, error) { + url := fmt.Sprintf("%s/services/%s", c.baseURL, serviceName) + + httpReq, err := http.NewRequest("DELETE", url, nil) + if err != nil { + return false, fmt.Errorf("failed to create request: %v", err) + } + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return false, fmt.Errorf("failed to send request: %v", err) + } + 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 { + return false, fmt.Errorf("failed to read response body: %v", err) + } + + if resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("request failed with status: %d, body: %s", resp.StatusCode, string(body)) + } + var deleteResp models.ClientResponse[bool] + if err := json.Unmarshal(body, &deleteResp); err != nil { + return false, fmt.Errorf("failed to unmarshal response: %v", err) + } + + if !deleteResp.Success { + return false, fmt.Errorf("server returned error, code: %d", deleteResp.Code) + } + + return deleteResp.Data, nil +} + +// UpdateServiceRequest represents the request body for updating a service +type UpdateServiceRequest struct { + Replicas *int32 `json:"replicas,omitempty"` + Image *string `json:"image,omitempty"` + EnvironmentVariables *map[string]string `json:"environment_variables,omitempty"` +} + +// UpdateService updates a Service (replicas, image, env vars) +func (c *ScheduleClient) UpdateService(serviceName string, updateReq *UpdateServiceRequest) (*models.EnvService, error) { + url := fmt.Sprintf("%s/services/%s", c.baseURL, serviceName) + + jsonData, err := json.Marshal(updateReq) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %v", err) + } + httpReq, err := http.NewRequest("PUT", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %v", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to send request: %v", err) + } + 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 { + return nil, fmt.Errorf("failed to read response body: %v", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("request failed with status: %d, body: %s", resp.StatusCode, string(body)) + } + + var updateResp models.ClientResponse[models.EnvService] + if err := json.Unmarshal(body, &updateResp); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %v", err) + } + + if !updateResp.Success { + return nil, fmt.Errorf("server returned error, code: %d", updateResp.Code) + } + + return &updateResp.Data, nil +} + +// ServiceListResponse represents the response structure from controller's list service endpoint +type ServiceListResponse struct { + Success bool `json:"success"` + Code int `json:"code"` + Data []models.EnvService `json:"data"` +} + +// ListServices lists services, optionally filtered by environment name +func (c *ScheduleClient) ListServices(envName string) ([]*models.EnvService, error) { + url := fmt.Sprintf("%s/services", c.baseURL) + if envName != "" { + url = fmt.Sprintf("%s/services?envName=%s", c.baseURL, envName) + } + + httpReq, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("list services: failed to create request: %v", err) + } + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("list services: failed to send request: %v", err) + } + 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 { + return nil, fmt.Errorf("list services: failed to read response body: %v", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("list services: request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var serviceListResp ServiceListResponse + if err := json.Unmarshal(body, &serviceListResp); err != nil { + return nil, fmt.Errorf("list services: failed to unmarshal response: %v", err) + } + + if !serviceListResp.Success { + return nil, fmt.Errorf("list services: server returned error, code: %d", serviceListResp.Code) + } + + // Convert to pointer slice + services := make([]*models.EnvService, len(serviceListResp.Data)) + for i := range serviceListResp.Data { + services[i] = &serviceListResp.Data[i] + } + + return services, nil +} + /* ==================================== ==== EnvInstanceService adapter ==== diff --git a/controller/Dockerfile b/controller/Dockerfile index b6cd5de9..196bc987 100644 --- a/controller/Dockerfile +++ b/controller/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM golang:1.21-alpine AS builder +FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/golang:1.21-alpine AS builder RUN apk add --no-cache git diff --git a/controller/cmd/main.go b/controller/cmd/main.go index 95933e10..9b4d825d 100644 --- a/controller/cmd/main.go +++ b/controller/cmd/main.go @@ -68,11 +68,19 @@ func StartHttpServer() { klog.Fatalf("failed to create AENV Pod manager, err is %v", err) } + // AENV Service Manager + aenvServiceManager, err := aenvhubserver.NewAEnvServiceHandler() + if err != nil { + klog.Fatalf("failed to create AENV Service manager, err is %v", err) + } + // Set up routes mux := http.NewServeMux() mux.Handle("/pods", aenvPodManager) mux.Handle("/pods/", aenvPodManager) + mux.Handle("/services", aenvServiceManager) + mux.Handle("/services/", aenvServiceManager) // Start server poolserver := &http.Server{ diff --git a/controller/pkg/aenvhub_http_server/aenv_pod_cache.go b/controller/pkg/aenvhub_http_server/aenv_pod_cache.go index 827e2704..5ba64973 100644 --- a/controller/pkg/aenvhub_http_server/aenv_pod_cache.go +++ b/controller/pkg/aenvhub_http_server/aenv_pod_cache.go @@ -134,8 +134,8 @@ func (c *AEnvPodCache) ListExpiredPods(namespace string) ([]*corev1.Pod, error) currentTime := time.Now() if currentTime.Sub(createdAt.Time) > limited { klog.Infof("Instance %s has expired (created: %s, ttl: %v), deleting...", pod.Name, createdAt, limited) + expired = append(expired, pod) } - expired = append(expired, pod) } return expired, nil } diff --git a/controller/pkg/aenvhub_http_server/aenv_service_handler.go b/controller/pkg/aenvhub_http_server/aenv_service_handler.go new file mode 100644 index 00000000..c470d65b --- /dev/null +++ b/controller/pkg/aenvhub_http_server/aenv_service_handler.go @@ -0,0 +1,664 @@ +/* +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. +*/ + +package aenvhub_http_server + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "time" + + "controller/pkg/constants" + "controller/pkg/model" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/klog" +) + +// AEnvServiceHandler handles Kubernetes Deployment + Service + PVC operations +type AEnvServiceHandler struct { + clientset kubernetes.Interface + namespace string +} + +// NewAEnvServiceHandler creates new ServiceHandler +func NewAEnvServiceHandler() (*AEnvServiceHandler, error) { + config, err := rest.InClusterConfig() + if err != nil { + kubeconfig := os.Getenv("KUBECONFIG") + if kubeconfig == "" { + kubeconfig = "" + } + config, err = clientcmd.BuildConfigFromFlags("", kubeconfig) + if err != nil { + return nil, fmt.Errorf("failed to create Kubernetes config: %v", err) + } + } + + config.UserAgent = "aenv-controller" + config.QPS = 1000 + config.Burst = 1000 + + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("failed to create k8s clientset, err is %v", err) + } + + serviceHandler := &AEnvServiceHandler{ + clientset: clientset, + } + + // Get namespace from pod template + namespace := LoadNsFromPodTemplate(SingleContainerTemplate) + serviceHandler.namespace = namespace + + klog.Infof("AEnv service handler is created, namespace is %s", serviceHandler.namespace) + + return serviceHandler, nil +} + +// HttpServiceResponseData represents service response data +type HttpServiceResponseData struct { + ID string `json:"id"` + Status string `json:"status"` + ServiceURL string `json:"service_url"` + Replicas int32 `json:"replicas"` + AvailableReplicas int32 `json:"available_replicas"` + Owner string `json:"owner"` + EnvName string `json:"envname"` + Version string `json:"version"` + PVCName string `json:"pvc_name"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Env map[string]string `json:"environment_variables,omitempty"` +} + +type HttpServiceResponse struct { + Success bool `json:"success"` + Code int `json:"code"` + ResponseData HttpServiceResponseData `json:"data"` +} + +type HttpServiceListResponse struct { + Success bool `json:"success"` + Code int `json:"code"` + ListResponseData []HttpServiceResponseData `json:"data"` +} + +type HttpServiceDeleteResponse struct { + Success bool `json:"success"` + Code int `json:"code"` + ResponseData bool `json:"data"` +} + +// ServeHTTP main routing method +func (h *AEnvServiceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(r.URL.Path, "/") + if len(parts) < 2 || parts[1] != "services" { + http.Error(w, "Invalid URL path", http.StatusBadRequest) + return + } + klog.Infof("access URL path %s, method %s, host %s", r.URL.Path, r.Method, r.Host) + + // Route handling + switch { + case r.Method == http.MethodPost && len(parts) == 2: // /services + h.createService(w, r) + case r.Method == http.MethodGet && len(parts) == 2: // /services + h.listServices(w, r) + case r.Method == http.MethodGet && len(parts) == 3: // /services/{serviceName} + serviceName := parts[2] + h.getService(serviceName, w, r) + case r.Method == http.MethodPut && len(parts) == 3: // /services/{serviceName} + serviceName := parts[2] + h.updateService(serviceName, w, r) + case r.Method == http.MethodDelete && len(parts) == 3: // /services/{serviceName} + serviceName := parts[2] + h.deleteService(serviceName, w, r) + default: + http.Error(w, "http method not allowed", http.StatusMethodNotAllowed) + } +} + +// createService creates a new service (Deployment + Service + PVC) +func (h *AEnvServiceHandler) createService(w http.ResponseWriter, r *http.Request) { + var aenvHubEnv model.AEnvHubEnv + if err := json.NewDecoder(r.Body).Decode(&aenvHubEnv); err != nil { + http.Error(w, fmt.Sprintf("Invalid request body: %v", err), http.StatusBadRequest) + return + } + defer func() { + if closeErr := r.Body.Close(); closeErr != nil { + klog.Errorf("failed to close request body: %v", closeErr) + } + }() + + ctx := r.Context() + + // Generate service name + serviceName := fmt.Sprintf("%s-svc-%s", aenvHubEnv.Name, RandString(6)) + + // Get PVC name from deploy config, default to envName + pvcName := aenvHubEnv.Name // Default PVC name equals envName + if pvcNameValue, ok := aenvHubEnv.DeployConfig["pvcName"]; ok { + if pvcNameStr, ok := pvcNameValue.(string); ok && pvcNameStr != "" { + pvcName = pvcNameStr + } + } + + // Get mount path from deploy config, default to /home/admin/data + mountPath := "/home/admin/data" // Default mount path + if mountPathValue, ok := aenvHubEnv.DeployConfig["mountPath"]; ok { + if mountPathStr, ok := mountPathValue.(string); ok && mountPathStr != "" { + mountPath = mountPathStr + } + } + + // Get replicas from deploy config, default to 1 + replicas := int32(1) + if replicasValue, ok := aenvHubEnv.DeployConfig["replicas"]; ok { + if replicasInt, ok := replicasValue.(float64); ok { + replicas = int32(replicasInt) + } else if replicasInt32, ok := replicasValue.(int32); ok { + replicas = replicasInt32 + } + } + + // Check if PVC creation is enabled (default: false, only create when storageSize is specified) + createPVC := false + storageSize := "" + if storageSizeValue, ok := aenvHubEnv.DeployConfig["storageSize"]; ok { + if storageSizeStr, ok := storageSizeValue.(string); ok && storageSizeStr != "" { + storageSize = storageSizeStr + createPVC = true + } + } + + // If PVC creation is enabled, validate replicas must be 1 + if createPVC && replicas != 1 { + http.Error(w, "When creating PVC (storageSize specified), replicas must be 1", http.StatusBadRequest) + return + } + + // Create or get existing PVC only if enabled + var pvc *corev1.PersistentVolumeClaim + if createPVC { + var err error + pvc, err = h.ensurePVC(ctx, pvcName, storageSize, &aenvHubEnv) + if err != nil { + handleK8sAPiError(w, err, "failed to ensure PVC") + return + } + klog.Infof("PVC %s ensured", pvc.Name) + } else { + klog.Infof("PVC creation skipped (no storageSize specified)") + pvcName = "" // Clear pvcName so deployment won't mount it + } + + // Create Deployment + deployment, err := h.createDeployment(ctx, serviceName, &aenvHubEnv, replicas, pvcName, mountPath) + if err != nil { + handleK8sAPiError(w, err, "failed to create deployment") + return + } + klog.Infof("created deployment %s/%s successfully", h.namespace, deployment.Name) + + // Create Service + service, err := h.createK8sService(ctx, serviceName, &aenvHubEnv) + if err != nil { + // Cleanup deployment if service creation fails + _ = h.clientset.AppsV1().Deployments(h.namespace).Delete(ctx, deployment.Name, metav1.DeleteOptions{}) + handleK8sAPiError(w, err, "failed to create k8s service") + return + } + klog.Infof("created k8s service %s/%s successfully", h.namespace, service.Name) + + // Build service URL + serviceURL := fmt.Sprintf("%s.%s.svc.cluster.local", service.Name, h.namespace) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + + owner := "" + if aenvHubEnv.DeployConfig["owner"] != nil { + owner = aenvHubEnv.DeployConfig["owner"].(string) + } + + res := &HttpServiceResponse{ + Success: true, + Code: 0, + ResponseData: HttpServiceResponseData{ + ID: serviceName, + Status: "Creating", + ServiceURL: serviceURL, + Replicas: replicas, + AvailableReplicas: 0, + Owner: owner, + EnvName: aenvHubEnv.Name, + Version: aenvHubEnv.Version, + PVCName: pvcName, + CreatedAt: time.Now().Format("2006-01-02 15:04:05"), + UpdatedAt: time.Now().Format("2006-01-02 15:04:05"), + }, + } + if err := json.NewEncoder(w).Encode(res); err != nil { + klog.Errorf("failed to encode response: %v", err) + } +} + +// ensurePVC creates PVC if not exists, or returns existing one +func (h *AEnvServiceHandler) ensurePVC(ctx context.Context, pvcName string, storageSize string, env *model.AEnvHubEnv) (*corev1.PersistentVolumeClaim, error) { + // Try to get existing PVC + existingPVC, err := h.clientset.CoreV1().PersistentVolumeClaims(h.namespace).Get(ctx, pvcName, metav1.GetOptions{}) + if err == nil { + klog.Infof("PVC %s already exists, reusing it", pvcName) + return existingPVC, nil + } + + if !errors.IsNotFound(err) { + return nil, err + } + + // Load PVC template and merge with environment config + // storageClassName is now configured in values.yaml template, not passed as parameter + pvc := LoadPVCTemplateFromYaml() + pvc.Namespace = h.namespace + MergePVC(pvc, pvcName, storageSize, "") + + // Add labels + if pvc.Labels == nil { + pvc.Labels = make(map[string]string) + } + pvc.Labels[constants.AENV_NAME] = env.Name + pvc.Labels[constants.AENV_VERSION] = env.Version + + createdPVC, err := h.clientset.CoreV1().PersistentVolumeClaims(h.namespace).Create(ctx, pvc, metav1.CreateOptions{}) + if err != nil { + return nil, err + } + + klog.Infof("created PVC %s with size %s (storageClass from template)", pvcName, storageSize) + return createdPVC, nil +} + +// createDeployment creates a Deployment +func (h *AEnvServiceHandler) createDeployment(ctx context.Context, name string, env *model.AEnvHubEnv, replicas int32, pvcName string, mountPath string) (*appsv1.Deployment, error) { + // Extract image from env artifacts + image := "" + for _, artifact := range env.Artifacts { + if artifact.Type == "image" { + image = artifact.Content + break + } + } + + // Prepare labels + labels := map[string]string{ + constants.AENV_NAME: env.Name, + constants.AENV_VERSION: env.Version, + } + if env.DeployConfig["owner"] != nil { + labels[constants.AENV_OWNER] = env.DeployConfig["owner"].(string) + } + + // Extract environment variables + environs := make(map[string]string) + if envVarsValue, ok := env.DeployConfig["environment_variables"]; ok { + if envVarsMap, ok := envVarsValue.(map[string]interface{}); ok { + for k, v := range envVarsMap { + if vStr, ok := v.(string); ok { + environs[k] = vStr + } + } + } + } + + // Extract resource configuration + resources := &ResourceConfig{ + CPURequest: getStringFromConfig(env.DeployConfig, "cpuRequest", "1"), + CPULimit: getStringFromConfig(env.DeployConfig, "cpuLimit", "1"), + MemoryRequest: getStringFromConfig(env.DeployConfig, "memoryRequest", "2Gi"), + MemoryLimit: getStringFromConfig(env.DeployConfig, "memoryLimit", "2Gi"), + EphemeralStorageRequest: getStringFromConfig(env.DeployConfig, "ephemeralStorageRequest", "10Gi"), + EphemeralStorageLimit: getStringFromConfig(env.DeployConfig, "ephemeralStorageLimit", "10Gi"), + } + + // Load Deployment template and merge with environment config + deployment := LoadDeploymentTemplateFromYaml() + deployment.Namespace = h.namespace + MergeDeployment(deployment, name, replicas, labels, environs, image, pvcName, mountPath, resources) + + return h.clientset.AppsV1().Deployments(h.namespace).Create(ctx, deployment, metav1.CreateOptions{}) +} + +// getStringFromConfig extracts a string value from DeployConfig with default fallback +func getStringFromConfig(config map[string]interface{}, key string, defaultValue string) string { + if value, ok := config[key]; ok { + if strValue, ok := value.(string); ok && strValue != "" { + return strValue + } + } + return defaultValue +} + +// createK8sService creates a Kubernetes Service +func (h *AEnvServiceHandler) createK8sService(ctx context.Context, name string, env *model.AEnvHubEnv) (*corev1.Service, error) { + // Default port 8080 + port := int32(8080) + if portValue, ok := env.DeployConfig["port"]; ok { + if portFloat, ok := portValue.(float64); ok { + port = int32(portFloat) + } else if portInt32, ok := portValue.(int32); ok { + port = portInt32 + } + } + + // Load Service template and merge with environment config + service := LoadServiceTemplateFromYaml() + service.Namespace = h.namespace + MergeService(service, name, port) + + // Add labels + if service.Labels == nil { + service.Labels = make(map[string]string) + } + service.Labels[constants.AENV_NAME] = env.Name + service.Labels[constants.AENV_VERSION] = env.Version + + return h.clientset.CoreV1().Services(h.namespace).Create(ctx, service, metav1.CreateOptions{}) +} + +// getService gets a service +func (h *AEnvServiceHandler) getService(serviceName string, w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + deployment, err := h.clientset.AppsV1().Deployments(h.namespace).Get(ctx, serviceName, metav1.GetOptions{}) + if err != nil { + handleK8sAPiError(w, err, "failed to get deployment") + return + } + + service, err := h.clientset.CoreV1().Services(h.namespace).Get(ctx, serviceName, metav1.GetOptions{}) + if err != nil { + handleK8sAPiError(w, err, "failed to get service") + return + } + + serviceURL := fmt.Sprintf("%s.%s.svc.cluster.local", service.Name, h.namespace) + + status := "Running" + if deployment.Status.AvailableReplicas == 0 { + status = "Creating" + } else if deployment.Status.AvailableReplicas < *deployment.Spec.Replicas { + status = "Updating" + } + + // Extract PVC name from deployment spec + pvcName := "" + for _, volume := range deployment.Spec.Template.Spec.Volumes { + if volume.PersistentVolumeClaim != nil { + pvcName = volume.PersistentVolumeClaim.ClaimName + break + } + } + + // Extract environment variables from first container + envVars := make(map[string]string) + if len(deployment.Spec.Template.Spec.Containers) > 0 { + for _, env := range deployment.Spec.Template.Spec.Containers[0].Env { + envVars[env.Name] = env.Value + } + } + + w.Header().Set("Content-Type", "application/json") + res := &HttpServiceResponse{ + Success: true, + Code: 0, + ResponseData: HttpServiceResponseData{ + ID: serviceName, + Status: status, + ServiceURL: serviceURL, + Replicas: *deployment.Spec.Replicas, + AvailableReplicas: deployment.Status.AvailableReplicas, + Owner: deployment.Labels[constants.AENV_OWNER], + EnvName: deployment.Labels[constants.AENV_NAME], + Version: deployment.Labels[constants.AENV_VERSION], + PVCName: pvcName, + CreatedAt: deployment.CreationTimestamp.Format("2006-01-02 15:04:05"), + UpdatedAt: time.Now().Format("2006-01-02 15:04:05"), + Env: envVars, + }, + } + if err := json.NewEncoder(w).Encode(res); err != nil { + klog.Errorf("failed to encode response: %v", err) + } +} + +// deleteService deletes a service (Deployment + Service, keep PVC) +func (h *AEnvServiceHandler) deleteService(serviceName string, w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Delete Deployment + err := h.clientset.AppsV1().Deployments(h.namespace).Delete(ctx, serviceName, metav1.DeleteOptions{}) + if err != nil && !errors.IsNotFound(err) { + handleK8sAPiError(w, err, "failed to delete deployment") + return + } + + // Delete Service + err = h.clientset.CoreV1().Services(h.namespace).Delete(ctx, serviceName, metav1.DeleteOptions{}) + if err != nil && !errors.IsNotFound(err) { + handleK8sAPiError(w, err, "failed to delete service") + return + } + + // Note: We keep PVC for reuse by same envName + + klog.Infof("deleted service %s/%s successfully", h.namespace, serviceName) + + w.Header().Set("Content-Type", "application/json") + res := &HttpServiceDeleteResponse{ + Success: true, + Code: 0, + ResponseData: true, + } + if err := json.NewEncoder(w).Encode(res); err != nil { + klog.Errorf("failed to encode response: %v", err) + } +} + +// updateService updates a service (replicas, image, env vars) +func (h *AEnvServiceHandler) updateService(serviceName string, w http.ResponseWriter, r *http.Request) { + var updateReq struct { + Replicas *int32 `json:"replicas,omitempty"` + Image *string `json:"image,omitempty"` + EnvironmentVariables *map[string]string `json:"environment_variables,omitempty"` + } + + if err := json.NewDecoder(r.Body).Decode(&updateReq); err != nil { + http.Error(w, fmt.Sprintf("Invalid request body: %v", err), http.StatusBadRequest) + return + } + defer func() { + if closeErr := r.Body.Close(); closeErr != nil { + klog.Errorf("failed to close request body: %v", closeErr) + } + }() + + ctx := r.Context() + + deployment, err := h.clientset.AppsV1().Deployments(h.namespace).Get(ctx, serviceName, metav1.GetOptions{}) + if err != nil { + handleK8sAPiError(w, err, "failed to get deployment") + return + } + + // Update replicas + if updateReq.Replicas != nil { + deployment.Spec.Replicas = updateReq.Replicas + } + + // Update image + if updateReq.Image != nil && *updateReq.Image != "" { + for i := range deployment.Spec.Template.Spec.Containers { + deployment.Spec.Template.Spec.Containers[i].Image = *updateReq.Image + } + } + + // Update environment variables + if updateReq.EnvironmentVariables != nil { + for i := range deployment.Spec.Template.Spec.Containers { + container := &deployment.Spec.Template.Spec.Containers[i] + for key, value := range *updateReq.EnvironmentVariables { + found := false + for j := range container.Env { + if container.Env[j].Name == key { + container.Env[j].Value = value + found = true + break + } + } + if !found { + container.Env = append(container.Env, corev1.EnvVar{ + Name: key, + Value: value, + }) + } + } + } + } + + updatedDeployment, err := h.clientset.AppsV1().Deployments(h.namespace).Update(ctx, deployment, metav1.UpdateOptions{}) + if err != nil { + handleK8sAPiError(w, err, "failed to update deployment") + return + } + + klog.Infof("updated deployment %s/%s successfully", h.namespace, serviceName) + + service, _ := h.clientset.CoreV1().Services(h.namespace).Get(ctx, serviceName, metav1.GetOptions{}) + serviceURL := "" + if service != nil { + serviceURL = fmt.Sprintf("%s.%s.svc.cluster.local", service.Name, h.namespace) + } + + w.Header().Set("Content-Type", "application/json") + res := &HttpServiceResponse{ + Success: true, + Code: 0, + ResponseData: HttpServiceResponseData{ + ID: serviceName, + Status: "Updating", + ServiceURL: serviceURL, + Replicas: *updatedDeployment.Spec.Replicas, + AvailableReplicas: updatedDeployment.Status.AvailableReplicas, + Owner: updatedDeployment.Labels[constants.AENV_OWNER], + EnvName: updatedDeployment.Labels[constants.AENV_NAME], + Version: updatedDeployment.Labels[constants.AENV_VERSION], + UpdatedAt: time.Now().Format("2006-01-02 15:04:05"), + }, + } + if err := json.NewEncoder(w).Encode(res); err != nil { + klog.Errorf("failed to encode response: %v", err) + } +} + +// listServices lists all services +func (h *AEnvServiceHandler) listServices(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + envName := r.URL.Query().Get("envName") + + // List deployments + listOptions := metav1.ListOptions{} + if envName != "" { + listOptions.LabelSelector = fmt.Sprintf("%s=%s", constants.AENV_NAME, envName) + } + + deployments, err := h.clientset.AppsV1().Deployments(h.namespace).List(ctx, listOptions) + if err != nil { + handleK8sAPiError(w, err, "failed to list deployments") + return + } + + responseData := make([]HttpServiceResponseData, 0, len(deployments.Items)) + for _, deployment := range deployments.Items { + status := "Running" + if deployment.Status.AvailableReplicas == 0 { + status = "Creating" + } else if deployment.Status.AvailableReplicas < *deployment.Spec.Replicas { + status = "Updating" + } + + // Try to get service + service, _ := h.clientset.CoreV1().Services(h.namespace).Get(ctx, deployment.Name, metav1.GetOptions{}) + serviceURL := "" + if service != nil { + serviceURL = fmt.Sprintf("%s.%s.svc.cluster.local", service.Name, h.namespace) + } + + // Extract PVC name from deployment spec + pvcName := "" + for _, volume := range deployment.Spec.Template.Spec.Volumes { + if volume.PersistentVolumeClaim != nil { + pvcName = volume.PersistentVolumeClaim.ClaimName + break + } + } + + // Extract environment variables from first container + envVars := make(map[string]string) + if len(deployment.Spec.Template.Spec.Containers) > 0 { + for _, env := range deployment.Spec.Template.Spec.Containers[0].Env { + envVars[env.Name] = env.Value + } + } + + responseData = append(responseData, HttpServiceResponseData{ + ID: deployment.Name, + Status: status, + ServiceURL: serviceURL, + Replicas: *deployment.Spec.Replicas, + AvailableReplicas: deployment.Status.AvailableReplicas, + Owner: deployment.Labels[constants.AENV_OWNER], + EnvName: deployment.Labels[constants.AENV_NAME], + Version: deployment.Labels[constants.AENV_VERSION], + PVCName: pvcName, + CreatedAt: deployment.CreationTimestamp.Format("2006-01-02 15:04:05"), + UpdatedAt: time.Now().Format("2006-01-02 15:04:05"), + Env: envVars, + }) + } + + w.Header().Set("Content-Type", "application/json") + res := &HttpServiceListResponse{ + Success: true, + Code: 0, + ListResponseData: responseData, + } + if err := json.NewEncoder(w).Encode(res); err != nil { + klog.Errorf("failed to encode response: %v", err) + } +} diff --git a/controller/pkg/aenvhub_http_server/util.go b/controller/pkg/aenvhub_http_server/util.go index 0b60a18a..6b99d2ae 100644 --- a/controller/pkg/aenvhub_http_server/util.go +++ b/controller/pkg/aenvhub_http_server/util.go @@ -22,6 +22,7 @@ import ( "os" "time" + appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" "k8s.io/klog" @@ -34,8 +35,22 @@ const ( AMD64 = "amd64" WIN64 = "win64" SingleContainerTemplate = "singleContainer" + DeploymentTemplate = "deployment" + ServiceTemplate = "service" + PVCTemplate = "pvc" + podTemplateBaseDir = "/etc/aenv/pod-templates" ) +// ResourceConfig holds resource configuration for containers +type ResourceConfig struct { + CPURequest string + CPULimit string + MemoryRequest string + MemoryLimit string + EphemeralStorageRequest string + EphemeralStorageLimit string +} + func RandString(n int) string { r := rand.New(rand.NewSource(time.Now().UnixNano())) b := make([]byte, n) @@ -164,8 +179,6 @@ func MergePod(pod *corev1.Pod, labels map[string]string, environs map[string]str // LoadPodTemplateFromYaml loads Pod template from mounted ConfigMap directory // machineType: template type, such as "amd64", "win64", "singleContainer", etc. func LoadPodTemplateFromYaml(machineType string) *corev1.Pod { - const podTemplateBaseDir = "/etc/aenv/pod-templates" - // Construct template file path templateFilePath := fmt.Sprintf("%s/%s.yaml", podTemplateBaseDir, machineType) @@ -263,3 +276,279 @@ func (ct *CustomTime) UnmarshalJSON(data []byte) error { ct.Time = t return nil } + +// LoadDeploymentTemplateFromYaml loads Deployment template from mounted ConfigMap directory +func LoadDeploymentTemplateFromYaml() *appsv1.Deployment { + templateFilePath := fmt.Sprintf("%s/%s.yaml", podTemplateBaseDir, DeploymentTemplate) + + yamlFile, err := os.ReadFile(templateFilePath) + if err != nil { + panic(fmt.Errorf("failed to read deployment template from %s: %v", templateFilePath, err)) + } + + klog.Infof("loaded deployment template from %s", templateFilePath) + + var deployment *appsv1.Deployment + if err := yaml.Unmarshal(yamlFile, &deployment); err != nil { + panic(fmt.Errorf("failed to unmarshal deployment YAML from %s: %v", templateFilePath, err)) + } + + // Clear auto-generated fields + deployment.ResourceVersion = "" + deployment.UID = "" + + return deployment +} + +// LoadServiceTemplateFromYaml loads Service template from mounted ConfigMap directory +func LoadServiceTemplateFromYaml() *corev1.Service { + templateFilePath := fmt.Sprintf("%s/%s.yaml", podTemplateBaseDir, ServiceTemplate) + + yamlFile, err := os.ReadFile(templateFilePath) + if err != nil { + panic(fmt.Errorf("failed to read service template from %s: %v", templateFilePath, err)) + } + + klog.Infof("loaded service template from %s", templateFilePath) + + var service *corev1.Service + if err := yaml.Unmarshal(yamlFile, &service); err != nil { + panic(fmt.Errorf("failed to unmarshal service YAML from %s: %v", templateFilePath, err)) + } + + // Clear auto-generated fields + service.ResourceVersion = "" + service.UID = "" + + return service +} + +// LoadPVCTemplateFromYaml loads PVC template from mounted ConfigMap directory +func LoadPVCTemplateFromYaml() *corev1.PersistentVolumeClaim { + templateFilePath := fmt.Sprintf("%s/%s.yaml", podTemplateBaseDir, PVCTemplate) + + yamlFile, err := os.ReadFile(templateFilePath) + if err != nil { + panic(fmt.Errorf("failed to read PVC template from %s: %v", templateFilePath, err)) + } + + klog.Infof("loaded PVC template from %s", templateFilePath) + + var pvc *corev1.PersistentVolumeClaim + if err := yaml.Unmarshal(yamlFile, &pvc); err != nil { + panic(fmt.Errorf("failed to unmarshal PVC YAML from %s: %v", templateFilePath, err)) + } + + // Clear auto-generated fields + pvc.ResourceVersion = "" + pvc.UID = "" + + return pvc +} + +// MergeDeployment merges environment-specific configuration into a Deployment template +func MergeDeployment(deployment *appsv1.Deployment, name string, replicas int32, labels map[string]string, environs map[string]string, image string, pvcName string, mountPath string, resources *ResourceConfig) { + // Set deployment name + deployment.Name = name + deployment.Spec.Replicas = &replicas + + // Update selector labels and template labels based on what's defined in the template + // Preserve the selector keys from template, only update their values + if deployment.Spec.Selector.MatchLabels == nil { + deployment.Spec.Selector.MatchLabels = make(map[string]string) + } + if deployment.Spec.Template.Labels == nil { + deployment.Spec.Template.Labels = make(map[string]string) + } + + for key := range deployment.Spec.Selector.MatchLabels { + deployment.Spec.Selector.MatchLabels[key] = name + deployment.Spec.Template.Labels[key] = name + } + + // Merge additional labels (preserve existing labels from template) + // These additional labels are only added to deployment metadata and template labels + // NOT to selector, to avoid selector mismatch + if deployment.Labels == nil { + deployment.Labels = make(map[string]string) + } + if labels != nil { + for k, v := range labels { + deployment.Labels[k] = v + deployment.Spec.Template.Labels[k] = v + } + } + + // Update container image, env vars, and resources + for i := range deployment.Spec.Template.Spec.Containers { + container := &deployment.Spec.Template.Spec.Containers[i] + + // Set image + if image != "" { + container.Image = image + } + + // Merge environment variables + if environs != nil { + for k, v := range environs { + found := false + for j := range container.Env { + if container.Env[j].Name == k { + container.Env[j].Value = v + found = true + break + } + } + if !found { + container.Env = append(container.Env, corev1.EnvVar{ + Name: k, + Value: v, + }) + } + } + } + + // Update resource limits and requests + if resources != nil { + if container.Resources.Requests == nil { + container.Resources.Requests = make(corev1.ResourceList) + } + if container.Resources.Limits == nil { + container.Resources.Limits = make(corev1.ResourceList) + } + + // CPU + if cpuReq, err := resource.ParseQuantity(resources.CPURequest); err == nil { + container.Resources.Requests[corev1.ResourceCPU] = cpuReq + } else { + klog.Warningf("failed to parse CPU request %s: %v", resources.CPURequest, err) + } + if cpuLimit, err := resource.ParseQuantity(resources.CPULimit); err == nil { + container.Resources.Limits[corev1.ResourceCPU] = cpuLimit + } else { + klog.Warningf("failed to parse CPU limit %s: %v", resources.CPULimit, err) + } + + // Memory + if memReq, err := resource.ParseQuantity(resources.MemoryRequest); err == nil { + container.Resources.Requests[corev1.ResourceMemory] = memReq + } else { + klog.Warningf("failed to parse memory request %s: %v", resources.MemoryRequest, err) + } + if memLimit, err := resource.ParseQuantity(resources.MemoryLimit); err == nil { + container.Resources.Limits[corev1.ResourceMemory] = memLimit + } else { + klog.Warningf("failed to parse memory limit %s: %v", resources.MemoryLimit, err) + } + + // Ephemeral Storage + if storageReq, err := resource.ParseQuantity(resources.EphemeralStorageRequest); err == nil { + container.Resources.Requests[corev1.ResourceEphemeralStorage] = storageReq + } else { + klog.Warningf("failed to parse ephemeral storage request %s: %v", resources.EphemeralStorageRequest, err) + } + if storageLimit, err := resource.ParseQuantity(resources.EphemeralStorageLimit); err == nil { + container.Resources.Limits[corev1.ResourceEphemeralStorage] = storageLimit + } else { + klog.Warningf("failed to parse ephemeral storage limit %s: %v", resources.EphemeralStorageLimit, err) + } + } + + // Update mount path in volumeMounts + if mountPath != "" { + for j := range container.VolumeMounts { + volumeMount := &container.VolumeMounts[j] + // Update the data volume mount path + if volumeMount.Name == "data" { + volumeMount.MountPath = mountPath + } + } + } + } + + // Update PVC name in volumes, or remove PVC volumes if pvcName is empty + if pvcName == "" { + // Remove PVC volumes and their corresponding volumeMounts when no storage is requested + var filteredVolumes []corev1.Volume + for i := range deployment.Spec.Template.Spec.Volumes { + volume := &deployment.Spec.Template.Spec.Volumes[i] + if volume.PersistentVolumeClaim == nil { + // Keep non-PVC volumes + filteredVolumes = append(filteredVolumes, *volume) + } + } + deployment.Spec.Template.Spec.Volumes = filteredVolumes + + // Remove volumeMounts that reference removed PVC volumes (e.g., "data" volume) + for i := range deployment.Spec.Template.Spec.Containers { + container := &deployment.Spec.Template.Spec.Containers[i] + var filteredMounts []corev1.VolumeMount + for j := range container.VolumeMounts { + volumeMount := &container.VolumeMounts[j] + // Keep volumeMounts that have a corresponding volume in the deployment + hasVolume := false + for k := range deployment.Spec.Template.Spec.Volumes { + if deployment.Spec.Template.Spec.Volumes[k].Name == volumeMount.Name { + hasVolume = true + break + } + } + if hasVolume { + filteredMounts = append(filteredMounts, *volumeMount) + } + } + container.VolumeMounts = filteredMounts + } + } else { + // Update PVC ClaimName when pvcName is provided + for i := range deployment.Spec.Template.Spec.Volumes { + volume := &deployment.Spec.Template.Spec.Volumes[i] + if volume.PersistentVolumeClaim != nil { + volume.PersistentVolumeClaim.ClaimName = pvcName + } + } + } +} + +// MergeService merges environment-specific configuration into a Service template +func MergeService(service *corev1.Service, name string, port int32) { + // Set service name + service.Name = name + + // Update selector + if service.Spec.Selector == nil { + service.Spec.Selector = make(map[string]string) + } + service.Spec.Selector["app"] = name + + // Update port if specified + if port > 0 && len(service.Spec.Ports) > 0 { + service.Spec.Ports[0].Port = port + service.Spec.Ports[0].TargetPort.IntVal = port + } +} + +// MergePVC merges environment-specific configuration into a PVC template +func MergePVC(pvc *corev1.PersistentVolumeClaim, name string, storageSize string, storageClass string) { + // Set PVC name + pvc.Name = name + + // Update storage size if specified + if storageSize != "" { + if pvc.Spec.Resources.Requests == nil { + pvc.Spec.Resources.Requests = make(corev1.ResourceList) + } + quantity, err := resource.ParseQuantity(storageSize) + if err == nil { + pvc.Spec.Resources.Requests[corev1.ResourceStorage] = quantity + } else { + klog.Warningf("failed to parse storage size %s: %v", storageSize, err) + } + } + + // Update storage class only if specified (otherwise use template default) + if storageClass != "" { + pvc.Spec.StorageClassName = &storageClass + } + // Note: If storageClass is empty, the StorageClassName from template (values.yaml) is preserved +} diff --git a/deploy/controller/values.yaml b/deploy/controller/values.yaml index 71f0a4da..d64da7fe 100644 --- a/deploy/controller/values.yaml +++ b/deploy/controller/values.yaml @@ -193,3 +193,96 @@ podTemplates: - name: shared-data emptyDir: {} restartPolicy: Never + + # Deployment 模板(用于 service 功能) + deployment: | + apiVersion: apps/v1 + kind: Deployment + metadata: + name: placeholder + namespace: {{ .Values.sandboxNamespace }} + labels: + app.kubernetes.io/name: aenv-service + template-type: deployment + spec: + replicas: 1 + selector: + matchLabels: + app: placeholder + template: + metadata: + labels: + app: placeholder + spec: + automountServiceAccountToken: false + containers: + - name: main + image: weather:v0.1.0 + imagePullPolicy: IfNotPresent + resources: + limits: + cpu: "2" + memory: "4Gi" + ephemeral-storage: "10Gi" + requests: + cpu: "1" + memory: "2Gi" + ephemeral-storage: "5Gi" + livenessProbe: + failureThreshold: 30 + initialDelaySeconds: 3 + periodSeconds: 10 + successThreshold: 1 + httpGet: + path: /health + port: 8081 + scheme: HTTP + timeoutSeconds: 60 + env: + - name: AENV_TYPE + value: "service" + volumeMounts: + - name: data + mountPath: /home/admin/data + volumes: + - name: data + persistentVolumeClaim: + claimName: placeholder-pvc + + # Service 模板(用于 service 功能) + service: | + apiVersion: v1 + kind: Service + metadata: + name: placeholder + namespace: {{ .Values.sandboxNamespace }} + labels: + app.kubernetes.io/name: aenv-service + template-type: service + spec: + type: ClusterIP + selector: + app: placeholder + ports: + - name: http + protocol: TCP + port: 8080 + targetPort: 8080 + + # PVC 模板(用于 service 功能) + pvc: | + apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + name: placeholder + namespace: {{ .Values.sandboxNamespace }} + labels: + app.kubernetes.io/name: aenv-service + template-type: pvc + spec: + accessModes: + - ReadWriteOnce + storageClassName: alilocal-ssd + resources: + requests: + storage: 10Gi From fcca19667524a18278d656120596b4c6e8dd0f7d Mon Sep 17 00:00:00 2001 From: meijun Date: Fri, 16 Jan 2026 18:01:41 +0800 Subject: [PATCH 2/6] support service deploy --- aenv/src/aenv/client/scheduler_client.py | 24 ++++---------- aenv/src/aenv/core/models.py | 12 +++++-- aenv/src/cli/cmds/init.py | 2 +- api-service/Dockerfile | 3 +- api-service/service/env_instance.go | 42 ++++++++++++++++++++---- controller/Dockerfile | 2 +- 6 files changed, 57 insertions(+), 28 deletions(-) diff --git a/aenv/src/aenv/client/scheduler_client.py b/aenv/src/aenv/client/scheduler_client.py index ae738832..7925a301 100644 --- a/aenv/src/aenv/client/scheduler_client.py +++ b/aenv/src/aenv/client/scheduler_client.py @@ -157,11 +157,9 @@ async def create_env_instance( logger.info(f"Environment instance created: {instance.id}") return instance else: - error_msg = getattr( - api_response, "error_message", "Unknown error" - ) + error_msg = api_response.get_error_message() raise EnvironmentError( - f"Failed to create instance: {error_msg}, rsp: {api_response}" + f"Failed to create instance: {error_msg}" ) except ValueError as e: raise EnvironmentError( @@ -211,9 +209,7 @@ async def get_env_instance(self, instance_id: str) -> EnvInstance: ) return instance else: - error_msg = getattr( - api_response, "error_message", "Unknown error" - ) + error_msg = api_response.get_error_message() raise EnvironmentError(f"Failed to get instance: {error_msg}") except ValueError as e: raise EnvironmentError( @@ -445,11 +441,9 @@ async def create_env_service( logger.info(f"Environment service created: {service.id}") return service else: - error_msg = getattr( - api_response, "error_message", "Unknown error" - ) + error_msg = api_response.get_error_message() raise EnvironmentError( - f"Failed to create service: {error_msg}, rsp: {api_response}" + f"Failed to create service: {error_msg}" ) except ValueError as e: raise EnvironmentError( @@ -501,9 +495,7 @@ async def get_env_service(self, service_id: str) -> "EnvService": ) return service else: - error_msg = getattr( - api_response, "error_message", "Unknown error" - ) + error_msg = api_response.get_error_message() raise EnvironmentError(f"Failed to get service: {error_msg}") except ValueError as e: raise EnvironmentError( @@ -652,9 +644,7 @@ async def update_env_service( logger.info(f"Environment service updated: {service.id}") return service else: - error_msg = getattr( - api_response, "error_message", "Unknown error" - ) + error_msg = api_response.get_error_message() raise EnvironmentError(f"Failed to update service: {error_msg}") except ValueError as e: raise EnvironmentError( diff --git a/aenv/src/aenv/core/models.py b/aenv/src/aenv/core/models.py index fd477dee..de984bad 100644 --- a/aenv/src/aenv/core/models.py +++ b/aenv/src/aenv/core/models.py @@ -182,11 +182,19 @@ class EnvServiceListResponse(BaseModel): class APIResponse(BaseModel): """Standard API response format.""" - error_code: int = Field(0, alias="errorCode") - error_message: str = Field("", alias="errorMessage") success: bool = True + code: Optional[int] = Field(None, description="Response code") + message: Optional[str] = Field(None, description="Response message") data: Optional[Any] = None + # Legacy fields for backwards compatibility + error_code: Optional[int] = Field(None, alias="errorCode") + error_message: Optional[str] = Field(None, alias="errorMessage") + + def get_error_message(self) -> str: + """Get error message from either message or error_message field.""" + return self.message or self.error_message or "Unknown error" + class APIError(BaseModel): """API error response.""" diff --git a/aenv/src/cli/cmds/init.py b/aenv/src/cli/cmds/init.py index ef80d5ff..89c1f8bb 100644 --- a/aenv/src/cli/cmds/init.py +++ b/aenv/src/cli/cmds/init.py @@ -60,7 +60,7 @@ def validate_env_name(name: str) -> tuple[bool, str]: return False, "Environment name must start with a lowercase letter or number" # Check if name ends with a letter or number - if not re.match(r'[a-z0-9]$', name): + if not re.search(r'[a-z0-9]$', name): return False, "Environment name must end with a lowercase letter or number" return True, "" diff --git a/api-service/Dockerfile b/api-service/Dockerfile index 2a42c199..1e55f277 100644 --- a/api-service/Dockerfile +++ b/api-service/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/golang:1.21-alpine AS builder +FROM golang:1.21-alpine AS builder RUN apk add --no-cache git @@ -51,3 +51,4 @@ COPY --from=builder /workspace/api-service/api-service /usr/bin/api-service EXPOSE 8080 ENTRYPOINT ["/usr/bin/api-service"] + diff --git a/api-service/service/env_instance.go b/api-service/service/env_instance.go index 61d79fe5..18be8a49 100644 --- a/api-service/service/env_instance.go +++ b/api-service/service/env_instance.go @@ -85,7 +85,12 @@ func (c *EnvInstanceClient) CreateEnvInstance(req *backend.Env) (*models.EnvInst } if !createResp.Success { - return nil, fmt.Errorf("create env instance: server returned error, code: %d", createResp.Code) + // Include both code and message in error + errMsg := fmt.Sprintf("create env instance: server returned error, code: %d", createResp.Code) + if createResp.Message != "" { + errMsg = fmt.Sprintf("create env instance: server returned error (code %d): %s", createResp.Code, createResp.Message) + } + return nil, fmt.Errorf("%s", errMsg) } return &createResp.Data, nil @@ -132,7 +137,12 @@ func (c *EnvInstanceClient) GetEnvInstance(id string) (*models.EnvInstance, erro } if !getResp.Success { - return nil, fmt.Errorf("get env instance %s: server returned error, code: %d", id, getResp.Code) + // Include both code and message in error + errMsg := fmt.Sprintf("get env instance %s: server returned error, code: %d", id, getResp.Code) + if getResp.Message != "" { + errMsg = fmt.Sprintf("get env instance %s: server returned error (code %d): %s", id, getResp.Code, getResp.Message) + } + return nil, fmt.Errorf("%s", errMsg) } return &getResp.Data, nil @@ -178,7 +188,12 @@ func (c *EnvInstanceClient) DeleteEnvInstance(id string) error { } if !deleteResp.Success { - return fmt.Errorf("delete env instance %s: server returned error, code: %d", id, deleteResp.Code) + // Include both code and message in error + errMsg := fmt.Sprintf("delete env instance %s: server returned error, code: %d", id, deleteResp.Code) + if deleteResp.Message != "" { + errMsg = fmt.Sprintf("delete env instance %s: server returned error (code %d): %s", id, deleteResp.Code, deleteResp.Message) + } + return fmt.Errorf("%s", errMsg) } return nil @@ -225,7 +240,12 @@ func (c *EnvInstanceClient) ListEnvInstances(envName string) ([]*models.EnvInsta } if !getResp.Success { - return nil, fmt.Errorf("list env instances: server returned error, code: %d", getResp.Code) + // Include both code and message in error + errMsg := fmt.Sprintf("list env instances: server returned error, code: %d", getResp.Code) + if getResp.Message != "" { + errMsg = fmt.Sprintf("list env instances: server returned error (code %d): %s", getResp.Code, getResp.Message) + } + return nil, fmt.Errorf("%s", errMsg) } return getResp.Data, nil @@ -271,7 +291,12 @@ func (c *EnvInstanceClient) Warmup(req *backend.Env) error { } if !getResp.Success { - return fmt.Errorf("warmup env: server returned error, code: %d", getResp.Code) + // Include both code and message in error + errMsg := fmt.Sprintf("warmup env: server returned error, code: %d", getResp.Code) + if getResp.Message != "" { + errMsg = fmt.Sprintf("warmup env: server returned error (code %d): %s", getResp.Code, getResp.Message) + } + return fmt.Errorf("%s", errMsg) } return nil @@ -317,7 +342,12 @@ func (c *EnvInstanceClient) Cleanup() error { } if !getResp.Success { - return fmt.Errorf("cleanup env: server returned error, code: %d", getResp.Code) + // Include both code and message in error + errMsg := fmt.Sprintf("cleanup env: server returned error, code: %d", getResp.Code) + if getResp.Message != "" { + errMsg = fmt.Sprintf("cleanup env: server returned error (code %d): %s", getResp.Code, getResp.Message) + } + return fmt.Errorf("%s", errMsg) } return nil diff --git a/controller/Dockerfile b/controller/Dockerfile index 196bc987..b6cd5de9 100644 --- a/controller/Dockerfile +++ b/controller/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/golang:1.21-alpine AS builder +FROM golang:1.21-alpine AS builder RUN apk add --no-cache git From 46fb56cff9a40ea1e5e2c90a6a8f5e2a94f7d887 Mon Sep 17 00:00:00 2001 From: meijun Date: Sun, 18 Jan 2026 21:01:57 +0800 Subject: [PATCH 3/6] fix service deploy issue --- aenv/src/aenv/client/scheduler_client.py | 10 +- aenv/src/aenv/core/models.py | 4 +- aenv/src/cli/cli.py | 3 + aenv/src/cli/cmds/build.py | 8 +- aenv/src/cli/cmds/common.py | 4 + aenv/src/cli/cmds/init.py | 7 +- aenv/src/cli/cmds/instance.py | 147 ++++----- aenv/src/cli/cmds/list.py | 20 +- aenv/src/cli/cmds/service.py | 286 +++++++++++------ aenv/src/cli/templates/default/config.json | 26 +- aenv/src/cli/utils/api_helpers.py | 49 ++- aenv/src/cli/utils/common/aenv_logger.py | 22 +- aenv/src/cli/utils/table_formatter.py | 292 ++++++++++++++++++ api-service/controller/env_service.go | 7 +- api-service/service/schedule_client.go | 54 +++- controller/cmd/main.go | 2 +- .../aenv_service_handler.go | 85 ++++- controller/pkg/aenvhub_http_server/util.go | 7 +- 18 files changed, 797 insertions(+), 236 deletions(-) create mode 100644 aenv/src/cli/utils/table_formatter.py diff --git a/aenv/src/aenv/client/scheduler_client.py b/aenv/src/aenv/client/scheduler_client.py index 7925a301..4075d657 100644 --- a/aenv/src/aenv/client/scheduler_client.py +++ b/aenv/src/aenv/client/scheduler_client.py @@ -559,12 +559,13 @@ async def list_env_services( continue raise NetworkError(f"Network error: {str(e)}") from e - async def delete_env_service(self, service_id: str) -> bool: + async def delete_env_service(self, service_id: str, delete_storage: bool = False) -> bool: """ Delete environment service. Args: service_id: Environment service ID + delete_storage: If True, also delete associated storage (PVC). Default False. Returns: True if deletion successful @@ -576,9 +577,14 @@ async def delete_env_service(self, service_id: str) -> bool: if not self._client: raise NetworkError("Client not connected") + # Build URL with query parameter if delete_storage is True + url = f"/env-service/{service_id}" + if delete_storage: + url += "?deleteStorage=true" + for attempt in range(self.max_retries + 1): try: - response = await self._client.delete(f"/env-service/{service_id}") + response = await self._client.delete(url) try: api_response = APIResponse(**response.json()) diff --git a/aenv/src/aenv/core/models.py b/aenv/src/aenv/core/models.py index de984bad..7864aed1 100644 --- a/aenv/src/aenv/core/models.py +++ b/aenv/src/aenv/core/models.py @@ -61,10 +61,10 @@ class Env(BaseModel): name: str description: str version: str - tags: List[str] + tags: Optional[List[str]] = None code_url: str status: int - artifacts: List[Dict[str, str]] + artifacts: Optional[List[Dict[str, str]]] = None build_config: Optional[Dict] = None test_config: Optional[Dict] = None deploy_config: Optional[Dict] = None diff --git a/aenv/src/cli/cli.py b/aenv/src/cli/cli.py index 2a8c287b..cf66e159 100644 --- a/aenv/src/cli/cli.py +++ b/aenv/src/cli/cli.py @@ -27,6 +27,7 @@ version, ) from cli.cmds.common import Config, global_error_handler, pass_config +from cli.utils.common.aenv_logger import configure_logging class CLI(click.Group): @@ -43,6 +44,8 @@ def cli(cfg: Config, debug: bool, verbose: bool): """Aenv cli helps build your custom aenv""" cfg.debug = debug cfg.verbose = verbose + # Configure logging based on verbose flag + configure_logging(verbose) # add subcommand diff --git a/aenv/src/cli/cmds/build.py b/aenv/src/cli/cmds/build.py index 130c464e..37d05e9f 100644 --- a/aenv/src/cli/cmds/build.py +++ b/aenv/src/cli/cmds/build.py @@ -162,9 +162,9 @@ def build( docker_sock_idx = Path(docker_sock_path) if not docker_sock_idx.exists(): console.print( - f"[red]Error: Docker sock:{docker_sock_idx} your provided in config:{config_path} is not exist[/red]" + f"[red]Error: Docker socket {docker_sock_idx} specified in config {config_path} does not exist[/red]" ) - return + raise click.Abort() # Initialize build context work_path = Path(work_dir).resolve() @@ -182,8 +182,8 @@ def build( if not image_tag: image_tag = env_build_config.get("version") if not image_name or not image_tag: - console.print("[red]Error: image name/tag is not config[/red]") - return + console.print("[red]Error: Image name or tag is not configured[/red]") + raise click.Abort() registry_settings = build_config.get("registry", {}) if registry is None: diff --git a/aenv/src/cli/cmds/common.py b/aenv/src/cli/cmds/common.py index 53ca6770..a3f2a984 100644 --- a/aenv/src/cli/cmds/common.py +++ b/aenv/src/cli/cmds/common.py @@ -81,6 +81,10 @@ def wrapper(*args, **kwargs): logger.info("Operation interrupted by user") click.secho("\n⚠️ Operation cancelled", fg="yellow", err=True) sys.exit(130) + except click.Abort: + # User explicitly aborted, clean exit without error message + logger.info("Operation aborted by user") + sys.exit(1) except click.ClickException as e: _handle_click_error(e) sys.exit(e.exit_code) diff --git a/aenv/src/cli/cmds/init.py b/aenv/src/cli/cmds/init.py index 89c1f8bb..83a9c575 100644 --- a/aenv/src/cli/cmds/init.py +++ b/aenv/src/cli/cmds/init.py @@ -189,9 +189,14 @@ def init(cfg: Config, name, version, template, work_dir, force, config_only): scaffold = load_aenv_scaffold() # Get config.json content from template template_config = scaffold.get_template_config(template) - # Update only name and version + # Update name and version template_config["name"] = name template_config["version"] = version + # Update pvcName in service config to match environment name + if "deployConfig" in template_config: + deploy_config = template_config["deployConfig"] + if "service" in deploy_config and isinstance(deploy_config["service"], dict): + deploy_config["service"]["pvcName"] = name with console.status("[bold green]Creating config.json..."): with open(config_path, "w") as f: diff --git a/aenv/src/cli/cmds/instance.py b/aenv/src/cli/cmds/instance.py index 724192d7..6f4539b2 100644 --- a/aenv/src/cli/cmds/instance.py +++ b/aenv/src/cli/cmds/instance.py @@ -32,17 +32,18 @@ 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.api_helpers import ( + format_time_to_local, get_api_headers, get_system_url_raw, make_api_url, parse_env_vars as _parse_env_vars, ) from cli.utils.cli_config import get_config_manager +from cli.utils.table_formatter import print_detail_table, print_instance_list def _parse_arguments(arg_list: tuple) -> list: @@ -723,9 +724,9 @@ def create( {"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": "Created At", "Value": format_time_to_local(info.get("created_at"))}, ] - console.print(tabulate(table_data, headers="keys", tablefmt="grid")) + print_detail_table(table_data, console, title="Instance Deployed") # Store instance reference for potential cleanup if not keep_alive: @@ -835,10 +836,10 @@ def info( {"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", "-")}, + {"Property": "Created At", "Value": format_time_to_local(info.get("created_at"))}, + {"Property": "Updated At", "Value": format_time_to_local(info.get("updated_at"))}, ] - console.print(tabulate(table_data, headers="keys", tablefmt="grid")) + print_detail_table(table_data, console, title="Instance Information") # Release the environment asyncio.run(_stop_instance(env)) @@ -876,13 +877,8 @@ def info( 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): +def list_instances(cfg: Config, name, version, output, system_url): """List running environment instances Query and display running environment instances. Can filter by environment @@ -917,8 +913,8 @@ def list_instances(cfg: Config, name, version, output, system_url, verbose): 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 + # Use config-level verbose + is_verbose = cfg.verbose # Debug: show configuration if verbose if is_verbose: @@ -944,11 +940,26 @@ def list_instances(cfg: Config, name, version, output, system_url, verbose): console=console if is_verbose else None, ) except Exception as e: - console.print(f"[red]❌ Failed to list instances:[/red] {str(e)}") + error_msg = str(e) + + # Parse and simplify error messages + if "403" in error_msg or "401" in error_msg: + console.print(f"[red]❌ Authentication failed[/red]") + console.print("\n[dim]Please check your API key configuration.[/dim]") + console.print("[dim]You can set it with: [cyan]aenv config set hub_config.api_key [/cyan][/dim]") + elif "connection" in error_msg.lower() or "timeout" in error_msg.lower(): + console.print(f"[red]❌ Connection failed[/red]") + console.print(f"\n[dim]Cannot connect to the API service at: [cyan]{system_url}[/cyan][/dim]") + console.print("[dim]Please check your network connection and system_url configuration.[/dim]") + else: + console.print(f"[red]❌ Failed to list instances[/red]") + console.print(f"\n[yellow]Error:[/yellow] {error_msg}") + if is_verbose: + console.print("\n[dim]--- Full error trace ---[/dim]") import traceback - console.print(traceback.format_exc()) + raise click.Abort() if not instances_list: @@ -964,52 +975,25 @@ def list_instances(cfg: Config, name, version, output, system_url, verbose): if output == "json": console.print(json.dumps(instances_list, indent=2, ensure_ascii=False)) elif output == "table": - # Prepare table data - table_data = [] + # Prepare data for the rich table formatter + instances_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")) + # Format the data for display + instances_data.append({ + "id": instance_id, + "env": instance.get("env"), + "owner": instance.get("owner"), + "status": instance.get("status"), + "ip": instance.get("ip"), + "created_at": format_time_to_local(instance.get("created_at")), + }) + + if instances_data: + print_instance_list(instances_data, console) else: console.print("📭 No running instances found") @@ -1028,13 +1012,8 @@ def list_instances(cfg: Config, name, version, output, system_url, verbose): 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): +def get_instance(cfg: Config, instance_id, output, system_url): """Get detailed information for a specific instance Retrieve detailed information about a running environment instance by its ID. @@ -1057,8 +1036,8 @@ def get_instance(cfg: Config, instance_id, output, system_url, verbose): 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 + # Use config-level verbose + is_verbose = cfg.verbose # Debug: show configuration if verbose if is_verbose: @@ -1083,7 +1062,9 @@ def get_instance(cfg: Config, instance_id, output, system_url, verbose): ) if not instance_info: - console.print(f"[red]❌ Instance not found:[/red] {instance_id}") + console.print(f"[red]❌ Instance not found:[/red] [yellow]{instance_id}[/yellow]") + console.print("\n[dim]The instance does not exist or has been deleted.[/dim]") + console.print("[dim]Use [cyan]aenv instance list[/cyan] to see available instances.[/dim]") raise click.Abort() console.print("[green]✅ Instance information retrieved![/green]\n") @@ -1104,23 +1085,47 @@ def get_instance(cfg: Config, instance_id, output, system_url, verbose): {"Property": "IP Address", "Value": instance_info.get("ip", "-")}, { "Property": "Created At", - "Value": instance_info.get("created_at", "-"), + "Value": format_time_to_local(instance_info.get("created_at")), }, { "Property": "Updated At", - "Value": instance_info.get("updated_at", "-"), + "Value": format_time_to_local(instance_info.get("updated_at")), }, ] - console.print(tabulate(table_data, headers="keys", tablefmt="grid")) + print_detail_table(table_data, console, title="Instance Details") except click.Abort: raise except Exception as e: - console.print(f"[red]❌ Failed to get instance information:[/red] {str(e)}") + error_msg = str(e).lower() + + # Parse and simplify error messages - focus on user-friendly messages + if ("404" in error_msg and "not found" in error_msg) or "pods" in error_msg or ("500" in error_msg and "not found" in error_msg): + console.print(f"[red]❌ Instance not found:[/red] [yellow]{instance_id}[/yellow]") + console.print("\n[dim]The instance does not exist or has been deleted.[/dim]") + console.print("[dim]Use [cyan]aenv instance list[/cyan] to see available instances.[/dim]") + elif "403" in error_msg or "401" in error_msg: + console.print(f"[red]❌ Authentication failed[/red]") + console.print("\n[dim]Please check your API key configuration.[/dim]") + console.print("[dim]You can set it with: [cyan]aenv config set hub_config.api_key [/cyan][/dim]") + elif "500" in error_msg and "internal server error" in error_msg: + console.print(f"[red]❌ Server error occurred[/red]") + console.print("\n[dim]The API service encountered an internal error.[/dim]") + console.print("[dim]Please try again or contact support if the issue persists.[/dim]") + elif "connection" in error_msg or "timeout" in error_msg: + console.print(f"[red]❌ Connection failed[/red]") + console.print(f"\n[dim]Cannot connect to the API service at: [cyan]{system_url}[/cyan][/dim]") + console.print("[dim]Please check your network connection and system_url configuration.[/dim]") + else: + console.print(f"[red]❌ Failed to get instance information[/red]") + console.print(f"\n[dim]The instance [yellow]{instance_id}[/yellow] could not be retrieved.[/dim]") + console.print("[dim]It may have been deleted or never existed.[/dim]") + if cfg.verbose: + console.print(f"\n[dim]Technical details: {str(e)}[/dim]") import traceback + console.print(f"[dim]{traceback.format_exc()}[/dim]") - console.print(traceback.format_exc()) raise click.Abort() diff --git a/aenv/src/cli/cmds/list.py b/aenv/src/cli/cmds/list.py index 3575a717..e30ef551 100644 --- a/aenv/src/cli/cmds/list.py +++ b/aenv/src/cli/cmds/list.py @@ -18,9 +18,9 @@ import json import click -from tabulate import tabulate from cli.client.aenv_hub_client import AEnvHubClient +from cli.utils.table_formatter import print_environment_list @click.command("list") @@ -69,17 +69,7 @@ def list_env(limit, offset, format): if format == "json": click.echo(json.dumps(environments, indent=2, ensure_ascii=False)) elif format == "table": - # Assume environments is a list with name, version, description fields - # Adjust keys based on actual response structure - table_data = [] - for env in environments: - table_data.append( - { - "Name": env.get("name", "-"), - "Version": env.get("version", "-"), - "Description": env.get("description", "-"), - "Created At": env.get("created_at", "-"), - } - ) - # Use grid format for clarity - click.echo(tabulate(table_data, headers="keys", tablefmt="grid")) + # Use rich console for better display + from rich.console import Console + console = Console() + print_environment_list(environments, console) diff --git a/aenv/src/cli/cmds/service.py b/aenv/src/cli/cmds/service.py index 3641e8fb..f08a1fd8 100644 --- a/aenv/src/cli/cmds/service.py +++ b/aenv/src/cli/cmds/service.py @@ -13,7 +13,7 @@ # limitations under the License. """ -service command - Manage environment services (Deployment + Service + PVC) +service command - Manage environment services (Deployment + Service + Storage) This command provides interface for managing long-running services: - service create: Create new services @@ -29,17 +29,18 @@ from typing import Any, Dict, Optional import click -from tabulate import tabulate from aenv.client.scheduler_client import AEnvSchedulerClient from cli.cmds.common import Config, pass_config from cli.utils.api_helpers import ( + format_time_to_local, get_api_headers, get_system_url_raw, make_api_url, parse_env_vars, ) from cli.utils.cli_config import get_config_manager +from cli.utils.table_formatter import print_detail_table, print_service_list def _load_env_config() -> Optional[Dict[str, Any]]: @@ -85,10 +86,10 @@ def _get_system_url() -> str: @pass_config def service(cfg: Config): """Manage environment services (long-running deployments) - + Services are persistent deployments with: - Multiple replicas - - Persistent storage (PVC) + - Persistent storage - Cluster DNS service URL - No TTL (always running) """ @@ -120,7 +121,7 @@ def service(cfg: Config): "--enable-storage", is_flag=True, default=False, - help="Enable PVC storage. Storage configuration (storageSize, pvcName, mountPath) will be read from config.json's deployConfig.", + help="Enable storage. Storage configuration (storageSize, storageName, mountPath) will be read from config.json's deployConfig.service.", ) @click.option( "--output", @@ -141,33 +142,42 @@ def create( ): """Create a new environment service - Creates a long-running service with Deployment, Service, and optionally PVC. + Creates a long-running service with Deployment, Service, and optionally persistent storage. The env_name argument is optional. If not provided, it will be read from config.json in the current directory. Configuration priority (high to low): 1. CLI parameters (--replicas, --port, --enable-storage) - 2. config.json's deployConfig - 3. System defaults - - PVC creation behavior: - - Use --enable-storage flag to enable PVC - - Storage configuration (storageSize, pvcName, mountPath) is read from config.json's deployConfig - - When PVC is created, replicas must be 1 (enforced by backend) + 2. config.json's deployConfig.service (new structure) + 3. config.json's deployConfig (legacy flat structure, for backward compatibility) + 4. System defaults + + Storage creation behavior: + - Use --enable-storage flag to enable persistent storage + - Storage configuration (storageSize, storageName, mountPath) is read from config.json's deployConfig.service + - When storage is created, replicas must be 1 (enforced by backend) - storageClass is configured in helm values.yaml deployment, not in config.json - config.json deployConfig fields: + config.json deployConfig.service fields (new structure): - replicas: Number of replicas (default: 1) - port: Service port (default: 8080) + - enableStorage: Enable storage by default (default: false, CLI --enable-storage overrides) - storageSize: Storage size like "10Gi", "20Gi" (required when --enable-storage is used) - - pvcName: PVC name (default: environment name) + - storageName: Storage name (default: environment name) - mountPath: Mount path (default: /home/admin/data) - - cpuRequest, cpuLimit: CPU resources (default: 1, 2) - - memoryRequest, memoryLimit: Memory resources (default: 2Gi, 4Gi) - - ephemeralStorageRequest, ephemeralStorageLimit: Storage (default: 5Gi, 10Gi) + + config.json deployConfig fields (shared by both Pod and Service): + - cpu: CPU resource (used as both request and limit, default: "1") + - memory: Memory resource (used as both request and limit, default: "2Gi") + - ephemeralStorage: Ephemeral storage (used as both request and limit, default: "5Gi") - environmentVariables: Environment variables dict + Legacy config.json deployConfig fields (deprecated, kept for backward compatibility): + - cpuRequest, cpuLimit: CPU resources (if not set, both use cpu value) + - memoryRequest, memoryLimit: Memory resources (if not set, both use memory value) + - ephemeralStorageRequest, ephemeralStorageLimit: Storage (if not set, both use ephemeralStorage value) + Examples: # Create using config.json in current directory aenv service create @@ -175,10 +185,10 @@ def create( # Create with explicit environment name aenv service create myapp@1.0.0 - # Create with 3 replicas and custom port (no PVC) + # Create with 3 replicas and custom port (no storage) aenv service create myapp@1.0.0 --replicas 3 --port 8000 - # Create with PVC enabled (storageSize must be in config.json) + # Create with storage enabled (storageSize must be in config.json) aenv service create myapp@1.0.0 --enable-storage # Create with environment variables @@ -190,6 +200,12 @@ def create( config = _load_env_config() deploy_config = config.get("deployConfig", {}) if config else {} + # Get service config (support both new nested structure and legacy flat structure) + service_config = deploy_config.get("service", {}) + # For backward compatibility, fall back to root deployConfig if service config is empty + if not service_config: + service_config = deploy_config + # If env_name not provided, try to load from config.json if not env_name: if config and "name" in config and "version" in config: @@ -205,31 +221,49 @@ def create( raise click.Abort() # Merge parameters: CLI > config.json > defaults - final_replicas = replicas if replicas is not None else deploy_config.get("replicas", 1) - final_port = port if port is not None else deploy_config.get("port") + final_replicas = replicas if replicas is not None else service_config.get("replicas", 1) + final_port = port if port is not None else service_config.get("port") + + # Storage configuration - enabled by --enable-storage flag OR config.json enableStorage + enable_storage_from_config = service_config.get("enableStorage", False) + should_enable_storage = enable_storage or enable_storage_from_config - # Storage configuration - only use if --enable-storage is set final_storage_size = None - final_pvc_name = None + final_storage_name = None final_mount_path = None - if enable_storage: - final_storage_size = deploy_config.get("storageSize") + if should_enable_storage: + final_storage_size = service_config.get("storageSize") if not final_storage_size: console.print( - "[red]Error:[/red] --enable-storage flag is set but 'storageSize' is not found in config.json's deployConfig.\n" - "Please add 'storageSize' (e.g., '10Gi', '20Gi') to deployConfig in config.json." + "[red]Error:[/red] Storage is enabled but 'storageSize' is not found in config.json's deployConfig.service.\n" + "Please add 'storageSize' (e.g., '10Gi', '20Gi') to deployConfig.service in config.json." + ) + raise click.Abort() + final_storage_name = service_config.get("storageName") + final_mount_path = service_config.get("mountPath") + + # Validate replicas must be 1 when storage is enabled + if final_replicas != 1: + console.print( + "[red]Error:[/red] When storage is enabled (enableStorage=true or --enable-storage), replicas must be 1.\n" + f"Current replicas: {final_replicas}. Please set replicas to 1 in config.json or use --replicas 1." ) raise click.Abort() - final_pvc_name = deploy_config.get("pvcName") - final_mount_path = deploy_config.get("mountPath") - # Resource configurations from deployConfig - cpu_request = deploy_config.get("cpuRequest") - cpu_limit = deploy_config.get("cpuLimit") - memory_request = deploy_config.get("memoryRequest") - memory_limit = deploy_config.get("memoryLimit") - ephemeral_storage_request = deploy_config.get("ephemeralStorageRequest") - ephemeral_storage_limit = deploy_config.get("ephemeralStorageLimit") + # Resource configurations from deployConfig (kept at root level for backward compatibility) + # These can be derived from simplified parameters (cpu, memory, ephemeralStorage) + # Priority: explicit resource params > derived from simplified params > defaults + cpu = deploy_config.get("cpu", "1") + memory = deploy_config.get("memory", "2Gi") + ephemeral_storage = deploy_config.get("ephemeralStorage", "5Gi") + + # Try explicit resource configs first (for backward compatibility with old configs) + cpu_request = deploy_config.get("cpuRequest") or cpu + cpu_limit = deploy_config.get("cpuLimit") or cpu + memory_request = deploy_config.get("memoryRequest") or memory + memory_limit = deploy_config.get("memoryLimit") or memory + ephemeral_storage_request = deploy_config.get("ephemeralStorageRequest") or ephemeral_storage + ephemeral_storage_limit = deploy_config.get("ephemeralStorageLimit") or ephemeral_storage # Parse environment variables from CLI try: @@ -266,20 +300,21 @@ def create( if owner: console.print(f" Owner: {owner}") - if enable_storage: - console.print(f"[cyan] Storage Configuration:[/cyan]") + if should_enable_storage: + storage_source = "CLI flag" if enable_storage else "config.json" + console.print(f"[cyan] Storage Configuration (from {storage_source}):[/cyan]") console.print(f" - Size: {final_storage_size}") - if final_pvc_name: - console.print(f" - PVC Name: {final_pvc_name}") + if final_storage_name: + console.print(f" - Storage Name: {final_storage_name}") else: - console.print(f" - PVC Name: {env_name.split('@')[0]} (default)") + console.print(f" - Storage Name: {env_name.split('@')[0]} (default)") if final_mount_path: console.print(f" - Mount Path: {final_mount_path}") else: console.print(f" - Mount Path: /home/admin/data (default)") - console.print(f" [yellow]⚠️ With PVC enabled, replicas must be 1[/yellow]") + console.print(f" [yellow]⚠️ With storage enabled, replicas must be 1[/yellow]") else: - console.print(f"[dim] Storage: Disabled (use --enable-storage to enable PVC)[/dim]") + console.print(f"[dim] Storage: Disabled (use --enable-storage to enable storage)[/dim]") console.print() async def _create(): @@ -293,7 +328,7 @@ async def _create(): environment_variables=env_vars, owner=owner, port=final_port, - pvc_name=final_pvc_name, + pvc_name=final_storage_name, storage_size=final_storage_size, mount_path=final_mount_path, cpu_request=cpu_request, @@ -318,10 +353,10 @@ async def _create(): {"Property": "Status", "Value": svc.status}, {"Property": "Service URL", "Value": svc.service_url or "-"}, {"Property": "Replicas", "Value": f"{svc.available_replicas}/{svc.replicas}"}, - {"Property": "PVC Name", "Value": svc.pvc_name or "-"}, - {"Property": "Created At", "Value": svc.created_at}, + {"Property": "Storage Name", "Value": svc.pvc_name or "-"}, + {"Property": "Created At", "Value": format_time_to_local(svc.created_at)}, ] - console.print(tabulate(table_data, headers="keys", tablefmt="grid")) + print_detail_table(table_data, console, title="Service Created") except Exception as e: console.print(f"[red]❌ Creation failed:[/red] {str(e)}") @@ -376,10 +411,26 @@ async def _list(): try: services_list = asyncio.run(_list()) except Exception as e: - console.print(f"[red]❌ Failed to list services:[/red] {str(e)}") + error_msg = str(e) + + # Parse and simplify error messages + if "403" in error_msg or "401" in error_msg: + console.print(f"[red]❌ Authentication failed[/red]") + console.print("\n[dim]Please check your API key configuration.[/dim]") + console.print("[dim]You can set it with: [cyan]aenv config set hub_config.api_key [/cyan][/dim]") + elif "connection" in error_msg.lower() or "timeout" in error_msg.lower(): + console.print(f"[red]❌ Connection failed[/red]") + console.print(f"\n[dim]Cannot connect to the API service.[/dim]") + console.print("[dim]Please check your network connection and system_url configuration.[/dim]") + else: + console.print(f"[red]❌ Failed to list services[/red]") + console.print(f"\n[yellow]Error:[/yellow] {error_msg}") + if cfg.verbose: + console.print("\n[dim]--- Full error trace ---[/dim]") import traceback console.print(traceback.format_exc()) + raise click.Abort() if not services_list: @@ -388,30 +439,30 @@ async def _list(): else: console.print("📭 No running services found") return - + if output == "json": console.print(json.dumps([s.model_dump() for s in services_list], indent=2, default=str)) else: - table_data = [] + # Convert service objects to dictionaries for the formatter + services_data = [] for svc in services_list: - env_name = svc.env.name if svc.env else "-" - env_version = svc.env.version if svc.env else "-" - - table_data.append({ - "Service ID": svc.id, - "Environment": env_name, - "Version": env_version, - "Owner": svc.owner or "-", - "Status": svc.status, - "Replicas": f"{svc.available_replicas}/{svc.replicas}", - "Service URL": svc.service_url or "-", - "Created At": svc.created_at, + env_info = {} + if svc.env: + env_info["name"] = svc.env.name + env_info["version"] = svc.env.version + + services_data.append({ + "id": svc.id, + "env": env_info if env_info else None, + "owner": svc.owner, + "status": svc.status, + "available_replicas": svc.available_replicas, + "replicas": svc.replicas, + "storage_name": svc.pvc_name, + "created_at": format_time_to_local(svc.created_at), }) - - if table_data: - console.print(tabulate(table_data, headers="keys", tablefmt="grid")) - else: - console.print("📭 No running services found") + + print_service_list(services_data, console) @service.command("get") @@ -452,15 +503,15 @@ async def _get(): try: svc = asyncio.run(_get()) - + console.print("[green]✅ Service information retrieved![/green]\n") - + if output == "json": console.print(json.dumps(svc.model_dump(), indent=2, default=str)) else: env_name = svc.env.name if svc.env else "-" env_version = svc.env.version if svc.env else "-" - + table_data = [ {"Property": "Service ID", "Value": svc.id}, {"Property": "Environment", "Value": env_name}, @@ -469,17 +520,38 @@ async def _get(): {"Property": "Status", "Value": svc.status}, {"Property": "Replicas", "Value": f"{svc.available_replicas}/{svc.replicas}"}, {"Property": "Service URL", "Value": svc.service_url or "-"}, - {"Property": "PVC Name", "Value": svc.pvc_name or "-"}, - {"Property": "Created At", "Value": svc.created_at}, - {"Property": "Updated At", "Value": svc.updated_at}, + {"Property": "Storage Name", "Value": svc.pvc_name or "-"}, + {"Property": "Created At", "Value": format_time_to_local(svc.created_at)}, + {"Property": "Updated At", "Value": format_time_to_local(svc.updated_at)}, ] - console.print(tabulate(table_data, headers="keys", tablefmt="grid")) - + print_detail_table(table_data, console, title="Service Details") + except Exception as e: - console.print(f"[red]❌ Failed to get service information:[/red] {str(e)}") + error_msg = str(e).lower() + + # Parse and simplify error messages + if ("404" in error_msg and "not found" in error_msg) or "deployment" in error_msg: + console.print(f"[red]❌ Service not found:[/red] [yellow]{service_id}[/yellow]") + console.print("\n[dim]The service does not exist or has been deleted.[/dim]") + console.print("[dim]Use [cyan]aenv service list[/cyan] to see available services.[/dim]") + elif "403" in error_msg or "401" in error_msg: + console.print(f"[red]❌ Authentication failed[/red]") + console.print("\n[dim]Please check your API key configuration.[/dim]") + console.print("[dim]You can set it with: [cyan]aenv config set hub_config.api_key [/cyan][/dim]") + elif "connection" in error_msg or "timeout" in error_msg: + console.print(f"[red]❌ Connection failed[/red]") + console.print(f"\n[dim]Cannot connect to the API service at: [cyan]{system_url}[/cyan][/dim]") + console.print("[dim]Please check your network connection and system_url configuration.[/dim]") + else: + console.print(f"[red]❌ Failed to get service information[/red]") + console.print(f"\n[dim]The service [yellow]{service_id}[/yellow] could not be retrieved.[/dim]") + console.print("[dim]It may have been deleted or never existed.[/dim]") + if cfg.verbose: + console.print(f"\n[dim]Technical details: {str(e)}[/dim]") import traceback - console.print(traceback.format_exc()) + console.print(f"[dim]{traceback.format_exc()}[/dim]") + raise click.Abort() @@ -491,53 +563,71 @@ async def _get(): is_flag=True, help="Skip confirmation prompt", ) +@click.option( + "--delete-storage", + is_flag=True, + help="Also delete the associated storage. Warning: This will permanently delete all data.", +) @pass_config -def delete_service(cfg: Config, service_id, yes): +def delete_service(cfg: Config, service_id, yes, delete_storage): """Delete a running service - - Note: This deletes the Deployment and Service, but keeps the PVC for reuse. - + + By default, this deletes the Deployment and Service, but keeps the storage for reuse. + Use --delete-storage to also delete the storage and all associated data. + Examples: - # Delete a service (with confirmation) + # Delete a service (with confirmation), keep storage aenv service delete myapp-svc-abc123 - + # Delete without confirmation aenv service delete myapp-svc-abc123 --yes + + # Delete service and storage + aenv service delete myapp-svc-abc123 --delete-storage """ console = cfg.console.console() - + if not yes: console.print(f"[yellow]⚠️ You are about to delete service:[/yellow] {service_id}") - console.print("[yellow]Note: PVC will be kept for reuse[/yellow]") + if delete_storage: + console.print("[red]⚠️ WARNING: Storage will be PERMANENTLY deleted (all data will be lost)[/red]") + else: + console.print("[yellow]Note: Storage will be kept for reuse[/yellow]") if not click.confirm("Are you sure you want to continue?"): console.print("[cyan]Deletion cancelled[/cyan]") return - + system_url = _get_system_url() 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") - - console.print(f"[cyan]🗑️ Deleting service:[/cyan] {service_id}\n") - + + console.print(f"[cyan]🗑️ Deleting service:[/cyan] {service_id}") + if delete_storage: + console.print("[yellow] Also deleting storage...[/yellow]") + console.print() + async def _delete(): async with AEnvSchedulerClient( base_url=system_url, api_key=api_key, ) as client: - return await client.delete_env_service(service_id) - + return await client.delete_env_service(service_id, delete_storage=delete_storage) + try: with console.status("[bold green]Deleting service..."): success = asyncio.run(_delete()) - + if success: console.print("[green]✅ Service deleted successfully![/green]") - console.print("[cyan]Note: PVC was kept for reuse[/cyan]") + if delete_storage: + console.print("[cyan]Note: Storage was also deleted[/cyan]") + else: + console.print("[cyan]Note: Storage was kept for reuse[/cyan]") else: console.print("[red]❌ Failed to delete service[/red]") raise click.Abort() - + except Exception as e: console.print(f"[red]❌ Failed to delete service:[/red] {str(e)}") if cfg.verbose: @@ -645,7 +735,7 @@ async def _update(): svc = asyncio.run(_update()) console.print("[green]✅ Service updated successfully![/green]\n") - + if output == "json": console.print(json.dumps(svc.model_dump(), indent=2, default=str)) else: @@ -654,9 +744,9 @@ async def _update(): {"Property": "Status", "Value": svc.status}, {"Property": "Replicas", "Value": f"{svc.available_replicas}/{svc.replicas}"}, {"Property": "Service URL", "Value": svc.service_url or "-"}, - {"Property": "Updated At", "Value": svc.updated_at}, + {"Property": "Updated At", "Value": format_time_to_local(svc.updated_at)}, ] - console.print(tabulate(table_data, headers="keys", tablefmt="grid")) + print_detail_table(table_data, console, title="Service Updated") except Exception as e: console.print(f"[red]❌ Update failed:[/red] {str(e)}") diff --git a/aenv/src/cli/templates/default/config.json b/aenv/src/cli/templates/default/config.json index fca4b668..4b7e4690 100644 --- a/aenv/src/cli/templates/default/config.json +++ b/aenv/src/cli/templates/default/config.json @@ -2,8 +2,6 @@ "name": "aenv", "version": "1.0.0", "tags": [ - "swe", - "python", "linux" ], "status": "Ready", @@ -14,23 +12,21 @@ "dockerfile": "./Dockerfile" }, "testConfig": { - "script": "pytest xxx" + "script": "" }, "deployConfig": { "cpu": "1", - "memory": "2G", + "memory": "2Gi", "os": "linux", - "replicas": 1, - "port": 8080, - "cpuRequest": "1", - "cpuLimit": "2", - "memoryRequest": "2Gi", - "memoryLimit": "4Gi", - "ephemeralStorageRequest": "5Gi", - "ephemeralStorageLimit": "10Gi", + "ephemeralStorage": "5Gi", "environmentVariables": {}, - "storageSize": "", - "pvcName": "", - "mountPath": "/home/admin/data" + "service": { + "replicas": 1, + "port": 8081, + "enableStorage": false, + "storageName": "aenv", + "storageSize": "10Gi", + "mountPath": "/home/admin/data" + } } } diff --git a/aenv/src/cli/utils/api_helpers.py b/aenv/src/cli/utils/api_helpers.py index 8815575a..9e93bcaf 100644 --- a/aenv/src/cli/utils/api_helpers.py +++ b/aenv/src/cli/utils/api_helpers.py @@ -20,7 +20,8 @@ """ import os -from typing import Dict, Optional +from datetime import datetime +from typing import Dict, Optional, Union from urllib.parse import urlparse, urlunparse import click @@ -133,3 +134,49 @@ def get_api_headers() -> Dict[str, str]: if api_key: headers["Authorization"] = f"Bearer {api_key}" return headers + + +def format_time_to_local(time_value: Optional[Union[str, datetime]]) -> str: + """Convert UTC time to local timezone and format as string. + + Args: + time_value: UTC time as string (ISO format) or datetime object + + Returns: + Formatted time string in local timezone (YYYY-MM-DD HH:MM:SS) + Returns "-" if time_value is None or invalid + """ + if not time_value: + return "-" + + try: + # Parse string to datetime if needed + if isinstance(time_value, str): + # Try parsing ISO format with or without timezone info + if time_value.endswith('Z'): + dt = datetime.fromisoformat(time_value.replace('Z', '+00:00')) + elif '+' in time_value or time_value.count('-') > 2: + dt = datetime.fromisoformat(time_value) + else: + # Assume UTC if no timezone info + dt = datetime.fromisoformat(time_value).replace(tzinfo=None) + # Convert from UTC to local + from datetime import timezone + dt = dt.replace(tzinfo=timezone.utc).astimezone() + elif isinstance(time_value, datetime): + dt = time_value + # Convert to local timezone if it has timezone info + if dt.tzinfo is not None: + dt = dt.astimezone() + else: + return "-" + + # Convert to local timezone if it has timezone info + if dt.tzinfo is not None: + dt = dt.astimezone() + + # Format as local time string + return dt.strftime("%Y-%m-%d %H:%M:%S") + except (ValueError, AttributeError, TypeError): + # If parsing fails, return the original value as string + return str(time_value) if time_value else "-" diff --git a/aenv/src/cli/utils/common/aenv_logger.py b/aenv/src/cli/utils/common/aenv_logger.py index 3f89a589..0ab572a2 100644 --- a/aenv/src/cli/utils/common/aenv_logger.py +++ b/aenv/src/cli/utils/common/aenv_logger.py @@ -15,11 +15,31 @@ import logging logging.basicConfig( - level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" + level=logging.WARNING, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) +# Suppress httpx logs unless verbose mode is enabled +logging.getLogger("httpx").setLevel(logging.WARNING) +logging.getLogger("httpcore").setLevel(logging.WARNING) + def get_logger(name: str = __name__): """get structlog""" # return structlog.get_logger(name) return logging.getLogger(name) + + +def configure_logging(verbose: bool = False): + """Configure logging based on verbose flag + + Args: + verbose: If True, enable DEBUG logging for all loggers including httpx + """ + if verbose: + logging.getLogger().setLevel(logging.DEBUG) + logging.getLogger("httpx").setLevel(logging.DEBUG) + logging.getLogger("httpcore").setLevel(logging.DEBUG) + else: + logging.getLogger().setLevel(logging.WARNING) + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("httpcore").setLevel(logging.WARNING) diff --git a/aenv/src/cli/utils/table_formatter.py b/aenv/src/cli/utils/table_formatter.py new file mode 100644 index 00000000..69d6e3ba --- /dev/null +++ b/aenv/src/cli/utils/table_formatter.py @@ -0,0 +1,292 @@ +# 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. + +""" +Rich table formatting utilities for CLI output + +Provides beautiful, colorful table displays inspired by modern CLI tools. +""" +from typing import Any, Dict, List, Optional + +from rich.console import Console +from rich.table import Table +from rich.text import Text + + +def create_rich_table( + title: Optional[str] = None, + show_header: bool = True, + border_style: str = "cyan", + title_style: str = "bold cyan", +) -> Table: + """Create a rich table with consistent styling. + + Args: + title: Optional title for the table + show_header: Whether to show table headers + border_style: Style for table borders + title_style: Style for table title + + Returns: + Configured Rich Table instance + """ + table = Table( + show_header=show_header, + header_style="bold magenta", + border_style=border_style, + title=title, + title_style=title_style, + padding=(0, 1), + show_lines=False, + ) + return table + + +def format_status(status: str) -> Text: + """Format status with appropriate color. + + Args: + status: Status string + + Returns: + Colored Text object + """ + status_lower = status.lower() + + if status_lower in ["running", "active", "ready", "healthy"]: + return Text(status, style="bold green") + elif status_lower in ["pending", "creating", "starting"]: + return Text(status, style="bold yellow") + elif status_lower in ["failed", "error", "terminated", "stopped"]: + return Text(status, style="bold red") + elif status_lower in ["deleting", "stopping", "terminating"]: + return Text(status, style="bold orange3") + else: + return Text(status, style="dim") + + +def format_replicas(available: int, desired: int) -> Text: + """Format replica counts with color based on availability. + + Args: + available: Number of available replicas + desired: Number of desired replicas + + Returns: + Colored Text object + """ + text = f"{available}/{desired}" + + if available == 0: + return Text(text, style="bold red") + elif available < desired: + return Text(text, style="bold yellow") + else: + return Text(text, style="bold green") + + +def truncate_text(text: str, max_length: int = 50) -> str: + """Truncate text with ellipsis if too long. + + Args: + text: Text to truncate + max_length: Maximum length before truncation + + Returns: + Truncated text + """ + if len(text) <= max_length: + return text + return text[:max_length - 3] + "..." + + +def print_instance_list(instances: List[Dict[str, Any]], console: Console) -> None: + """Print a formatted list of instances. + + Args: + instances: List of instance dictionaries + console: Rich console instance + """ + table = create_rich_table(title="Environment Instances") + + # Add columns + table.add_column("Instance ID", style="cyan", no_wrap=False) + table.add_column("Environment", style="bright_blue") + table.add_column("Version", style="bright_magenta") + table.add_column("Owner", style="dim") + table.add_column("Status", justify="center") + table.add_column("IP Address", style="green") + table.add_column("Created", style="dim") + + # Add rows + for instance in instances: + instance_id = instance.get("id", "-") + + # Environment info + 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 and instance_id != "-": + parts = instance_id.split("-") + if len(parts) >= 2: + env_name = parts[0] + + # Get other fields + owner = instance.get("owner") or "-" + status_str = instance.get("status", "-") + status = format_status(status_str) + ip = instance.get("ip") or "-" + created_at = instance.get("created_at", "-") + + table.add_row( + truncate_text(instance_id, 40), + env_name or "-", + env_version or "-", + owner, + status, # Pass Text object directly + ip, + created_at, + ) + + console.print(table) + + +def print_service_list(services: List[Dict[str, Any]], console: Console) -> None: + """Print a formatted list of services. + + Args: + services: List of service dictionaries + console: Rich console instance + """ + table = create_rich_table(title="Environment Services") + + # Add columns + table.add_column("Service ID", style="cyan", no_wrap=False) + table.add_column("Env@Version", style="bright_blue") + table.add_column("Owner", style="dim") + table.add_column("Status", justify="center") + table.add_column("Replicas", justify="center") + table.add_column("Storage Name", style="bright_magenta") + table.add_column("Created", style="dim") + + # Add rows + for svc in services: + service_id = svc.get("id", "-") + + # Environment info - combine name@version + env_info = svc.get("env") or {} + env_name = env_info.get("name") or "-" + env_version = env_info.get("version") or "-" + env_display = f"{env_name}@{env_version}" if env_name != "-" and env_version != "-" else "-" + + # Get other fields + owner = svc.get("owner") or "-" + status_str = svc.get("status", "-") + status = format_status(status_str) + + # Replicas + available = svc.get("available_replicas", 0) + desired = svc.get("replicas", 0) + replicas = format_replicas(available, desired) + + # Storage name + storage_name = svc.get("storage_name") or "-" + + created_at = svc.get("created_at", "-") + + table.add_row( + truncate_text(service_id, 40), + env_display, + owner, + status, # Pass Text object directly + replicas, # Pass Text object directly + storage_name, + created_at, + ) + + console.print(table) + + +def print_environment_list(environments: List[Dict[str, Any]], console: Console) -> None: + """Print a formatted list of environments. + + Args: + environments: List of environment dictionaries + console: Rich console instance + """ + table = create_rich_table(title="Available Environments") + + # Add columns + table.add_column("Name", style="bright_blue", no_wrap=False) + table.add_column("Version", style="bright_magenta") + table.add_column("Description", style="dim") + table.add_column("Created", style="dim") + + # Add rows + for env in environments: + name = env.get("name", "-") + version = env.get("version", "-") + description = env.get("description", "-") + created_at = env.get("created_at", "-") + + table.add_row( + name, + version, + truncate_text(description, 60), + created_at, + ) + + console.print(table) + + +def print_detail_table( + data: List[Dict[str, str]], + console: Console, + title: Optional[str] = None, +) -> None: + """Print a property-value detail table. + + Args: + data: List of dicts with 'Property' and 'Value' keys + console: Rich console instance + title: Optional title for the table + """ + table = create_rich_table(title=title, border_style="blue") + + table.add_column("Property", style="cyan", no_wrap=True) + table.add_column("Value", style="white") + + for row in data: + prop = row.get("Property", "") + value = row.get("Value", "") + + # Special formatting for status + if prop == "Status" and isinstance(value, str): + value = format_status(value) + # Special formatting for replicas + elif prop == "Replicas" and "/" in str(value): + parts = str(value).split("/") + if len(parts) == 2: + try: + available = int(parts[0]) + desired = int(parts[1]) + value = format_replicas(available, desired) + except ValueError: + pass + + table.add_row(prop, value) + + console.print(table) diff --git a/api-service/controller/env_service.go b/api-service/controller/env_service.go index bef306dc..517f214e 100644 --- a/api-service/controller/env_service.go +++ b/api-service/controller/env_service.go @@ -184,7 +184,7 @@ func (ctrl *EnvServiceController) GetEnvService(c *gin.Context) { } // DeleteEnvService deletes an EnvService -// DELETE /env-service/:id +// DELETE /env-service/:id?deleteStorage=true func (ctrl *EnvServiceController) DeleteEnvService(c *gin.Context) { id := c.Param("id") if id == "" { @@ -192,8 +192,11 @@ func (ctrl *EnvServiceController) DeleteEnvService(c *gin.Context) { return } + // Check if deleteStorage query parameter is set + deleteStorage := c.Query("deleteStorage") == "true" + // Call ScheduleClient to delete Service - success, err := ctrl.scheduleClient.DeleteService(id) + success, err := ctrl.scheduleClient.DeleteService(id, deleteStorage) if err != nil { backendmodels.JSONErrorWithMessage(c, 500, "Failed to delete service: "+err.Error()) return diff --git a/api-service/service/schedule_client.go b/api-service/service/schedule_client.go index 929e979f..1541e3e1 100644 --- a/api-service/service/schedule_client.go +++ b/api-service/service/schedule_client.go @@ -302,8 +302,11 @@ func (c *ScheduleClient) GetService(serviceName string) (*models.EnvService, err } // DeleteService deletes a Service -func (c *ScheduleClient) DeleteService(serviceName string) (bool, error) { +func (c *ScheduleClient) DeleteService(serviceName string, deleteStorage bool) (bool, error) { url := fmt.Sprintf("%s/services/%s", c.baseURL, serviceName) + if deleteStorage { + url += "?deleteStorage=true" + } httpReq, err := http.NewRequest("DELETE", url, nil) if err != nil { @@ -392,11 +395,27 @@ func (c *ScheduleClient) UpdateService(serviceName string, updateReq *UpdateServ return &updateResp.Data, nil } +// ServiceListResponseData represents a single service item from controller's list endpoint +type ServiceListResponseData struct { + ID string `json:"id"` + Status string `json:"status"` + ServiceURL string `json:"service_url"` + Replicas int32 `json:"replicas"` + AvailableReplicas int32 `json:"available_replicas"` + Owner string `json:"owner"` + EnvName string `json:"envname"` + Version string `json:"version"` + PVCName string `json:"pvc_name"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + EnvironmentVars map[string]string `json:"environment_variables,omitempty"` +} + // ServiceListResponse represents the response structure from controller's list service endpoint type ServiceListResponse struct { - Success bool `json:"success"` - Code int `json:"code"` - Data []models.EnvService `json:"data"` + Success bool `json:"success"` + Code int `json:"code"` + Data []ServiceListResponseData `json:"data"` } // ListServices lists services, optionally filtered by environment name @@ -439,10 +458,31 @@ func (c *ScheduleClient) ListServices(envName string) ([]*models.EnvService, err return nil, fmt.Errorf("list services: server returned error, code: %d", serviceListResp.Code) } - // Convert to pointer slice + // Convert controller response to EnvService models services := make([]*models.EnvService, len(serviceListResp.Data)) - for i := range serviceListResp.Data { - services[i] = &serviceListResp.Data[i] + for i, svcData := range serviceListResp.Data { + // Build Env object from EnvName and Version + var env *backend.Env + if svcData.EnvName != "" || svcData.Version != "" { + env = &backend.Env{ + Name: svcData.EnvName, + Version: svcData.Version, + } + } + + services[i] = &models.EnvService{ + ID: svcData.ID, + Env: env, + Status: svcData.Status, + CreatedAt: svcData.CreatedAt, + UpdatedAt: svcData.UpdatedAt, + Replicas: svcData.Replicas, + AvailableReplicas: svcData.AvailableReplicas, + ServiceURL: svcData.ServiceURL, + Owner: svcData.Owner, + EnvironmentVariables: svcData.EnvironmentVars, + PVCName: svcData.PVCName, + } } return services, nil diff --git a/controller/cmd/main.go b/controller/cmd/main.go index 9b4d825d..8f9de336 100644 --- a/controller/cmd/main.go +++ b/controller/cmd/main.go @@ -49,7 +49,7 @@ var ( func main() { klog.Infof("entering main for AEnv server") - flag.StringVar(&defaultNamespace, "namespace", "aenvsandbox", "The namespace that pods are using.") + flag.StringVar(&defaultNamespace, "namespace", "aenv-sandbox", "The namespace that pods are using.") flag.StringVar(&logDir, "logdir", "/home/admin/logs", "The dir of log output.") flag.IntVar(&serverPort, "server-port", 8080, "The value for server port.") klog.InitFlags(nil) diff --git a/controller/pkg/aenvhub_http_server/aenv_service_handler.go b/controller/pkg/aenvhub_http_server/aenv_service_handler.go index c470d65b..ea22d722 100644 --- a/controller/pkg/aenvhub_http_server/aenv_service_handler.go +++ b/controller/pkg/aenvhub_http_server/aenv_service_handler.go @@ -40,8 +40,9 @@ import ( // AEnvServiceHandler handles Kubernetes Deployment + Service + PVC operations type AEnvServiceHandler struct { - clientset kubernetes.Interface - namespace string + clientset kubernetes.Interface + namespace string + serviceDomainSuffix string } // NewAEnvServiceHandler creates new ServiceHandler @@ -75,7 +76,14 @@ func NewAEnvServiceHandler() (*AEnvServiceHandler, error) { namespace := LoadNsFromPodTemplate(SingleContainerTemplate) serviceHandler.namespace = namespace - klog.Infof("AEnv service handler is created, namespace is %s", serviceHandler.namespace) + // Get service domain suffix from environment variable, default to "svc.cluster.local" + serviceDomainSuffix := os.Getenv("SERVICE_DOMAIN_SUFFIX") + if serviceDomainSuffix == "" { + serviceDomainSuffix = "svc.cluster.local" + } + serviceHandler.serviceDomainSuffix = serviceDomainSuffix + + klog.Infof("AEnv service handler is created, namespace is %s, serviceDomainSuffix is %s", serviceHandler.namespace, serviceHandler.serviceDomainSuffix) return serviceHandler, nil } @@ -236,8 +244,8 @@ func (h *AEnvServiceHandler) createService(w http.ResponseWriter, r *http.Reques } klog.Infof("created k8s service %s/%s successfully", h.namespace, service.Name) - // Build service URL - serviceURL := fmt.Sprintf("%s.%s.svc.cluster.local", service.Name, h.namespace) + // Build service URL with port + serviceURL := fmt.Sprintf("%s.%s.%s:%d", service.Name, h.namespace, h.serviceDomainSuffix, service.Spec.Ports[0].Port) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) @@ -407,7 +415,11 @@ func (h *AEnvServiceHandler) getService(serviceName string, w http.ResponseWrite return } - serviceURL := fmt.Sprintf("%s.%s.svc.cluster.local", service.Name, h.namespace) + // Build service URL with port + serviceURL := "" + if len(service.Spec.Ports) > 0 { + serviceURL = fmt.Sprintf("%s.%s.%s:%d", service.Name, h.namespace, h.serviceDomainSuffix, service.Spec.Ports[0].Port) + } status := "Running" if deployment.Status.AvailableReplicas == 0 { @@ -457,10 +469,32 @@ func (h *AEnvServiceHandler) getService(serviceName string, w http.ResponseWrite } } -// deleteService deletes a service (Deployment + Service, keep PVC) +// deleteService deletes a service (Deployment + Service, optionally delete PVC/storage) func (h *AEnvServiceHandler) deleteService(serviceName string, w http.ResponseWriter, r *http.Request) { ctx := r.Context() + // Check if deleteStorage query parameter is set + deleteStorage := r.URL.Query().Get("deleteStorage") == "true" + + // Get deployment first to extract PVC name before deletion + var pvcName string + if deleteStorage { + deployment, err := h.clientset.AppsV1().Deployments(h.namespace).Get(ctx, serviceName, metav1.GetOptions{}) + if err != nil && !errors.IsNotFound(err) { + handleK8sAPiError(w, err, "failed to get deployment") + return + } + if deployment != nil { + // Extract PVC name from deployment spec + for _, volume := range deployment.Spec.Template.Spec.Volumes { + if volume.PersistentVolumeClaim != nil { + pvcName = volume.PersistentVolumeClaim.ClaimName + break + } + } + } + } + // Delete Deployment err := h.clientset.AppsV1().Deployments(h.namespace).Delete(ctx, serviceName, metav1.DeleteOptions{}) if err != nil && !errors.IsNotFound(err) { @@ -475,7 +509,15 @@ func (h *AEnvServiceHandler) deleteService(serviceName string, w http.ResponseWr return } - // Note: We keep PVC for reuse by same envName + // Delete PVC if requested and PVC name was found + if deleteStorage && pvcName != "" { + err = h.clientset.CoreV1().PersistentVolumeClaims(h.namespace).Delete(ctx, pvcName, metav1.DeleteOptions{}) + if err != nil && !errors.IsNotFound(err) { + handleK8sAPiError(w, err, "failed to delete PVC") + return + } + klog.Infof("deleted PVC %s/%s successfully", h.namespace, pvcName) + } klog.Infof("deleted service %s/%s successfully", h.namespace, serviceName) @@ -561,8 +603,8 @@ func (h *AEnvServiceHandler) updateService(serviceName string, w http.ResponseWr service, _ := h.clientset.CoreV1().Services(h.namespace).Get(ctx, serviceName, metav1.GetOptions{}) serviceURL := "" - if service != nil { - serviceURL = fmt.Sprintf("%s.%s.svc.cluster.local", service.Name, h.namespace) + if service != nil && len(service.Spec.Ports) > 0 { + serviceURL = fmt.Sprintf("%s.%s.%s:%d", service.Name, h.namespace, h.serviceDomainSuffix, service.Spec.Ports[0].Port) } w.Header().Set("Content-Type", "application/json") @@ -615,8 +657,8 @@ func (h *AEnvServiceHandler) listServices(w http.ResponseWriter, r *http.Request // Try to get service service, _ := h.clientset.CoreV1().Services(h.namespace).Get(ctx, deployment.Name, metav1.GetOptions{}) serviceURL := "" - if service != nil { - serviceURL = fmt.Sprintf("%s.%s.svc.cluster.local", service.Name, h.namespace) + if service != nil && len(service.Spec.Ports) > 0 { + serviceURL = fmt.Sprintf("%s.%s.%s:%d", service.Name, h.namespace, h.serviceDomainSuffix, service.Spec.Ports[0].Port) } // Extract PVC name from deployment spec @@ -636,6 +678,21 @@ func (h *AEnvServiceHandler) listServices(w http.ResponseWriter, r *http.Request } } + // Get EnvName and Version from labels + // If labels don't exist (for old deployments), try to extract from deployment name + envNameFromLabel := deployment.Labels[constants.AENV_NAME] + versionFromLabel := deployment.Labels[constants.AENV_VERSION] + + // Fallback: extract from deployment name for backward compatibility + // Deployment name format: {envName}-svc-{random} + if envNameFromLabel == "" { + // Try to extract from deployment name + // Remove "-svc-{random}" suffix to get envName + if idx := strings.Index(deployment.Name, "-svc-"); idx > 0 { + envNameFromLabel = deployment.Name[:idx] + } + } + responseData = append(responseData, HttpServiceResponseData{ ID: deployment.Name, Status: status, @@ -643,8 +700,8 @@ func (h *AEnvServiceHandler) listServices(w http.ResponseWriter, r *http.Request Replicas: *deployment.Spec.Replicas, AvailableReplicas: deployment.Status.AvailableReplicas, Owner: deployment.Labels[constants.AENV_OWNER], - EnvName: deployment.Labels[constants.AENV_NAME], - Version: deployment.Labels[constants.AENV_VERSION], + EnvName: envNameFromLabel, + Version: versionFromLabel, PVCName: pvcName, CreatedAt: deployment.CreationTimestamp.Format("2006-01-02 15:04:05"), UpdatedAt: time.Now().Format("2006-01-02 15:04:05"), diff --git a/controller/pkg/aenvhub_http_server/util.go b/controller/pkg/aenvhub_http_server/util.go index 6b99d2ae..b1535a77 100644 --- a/controller/pkg/aenvhub_http_server/util.go +++ b/controller/pkg/aenvhub_http_server/util.go @@ -515,11 +515,14 @@ func MergeService(service *corev1.Service, name string, port int32) { // Set service name service.Name = name - // Update selector + // Update selector to match the deployment selector keys from template + // Preserve the selector keys from template, only update their values if service.Spec.Selector == nil { service.Spec.Selector = make(map[string]string) } - service.Spec.Selector["app"] = name + for key := range service.Spec.Selector { + service.Spec.Selector[key] = name + } // Update port if specified if port > 0 && len(service.Spec.Ports) > 0 { From 091c47abe915af34e30780bcbd88989ec43cc487 Mon Sep 17 00:00:00 2001 From: meijun Date: Sun, 18 Jan 2026 22:26:04 +0800 Subject: [PATCH 4/6] support base image prefix --- aenv/src/cli/templates/default/Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/aenv/src/cli/templates/default/Dockerfile b/aenv/src/cli/templates/default/Dockerfile index 2076d5b7..f6bc794c 100644 --- a/aenv/src/cli/templates/default/Dockerfile +++ b/aenv/src/cli/templates/default/Dockerfile @@ -1,4 +1,5 @@ -FROM python:3.12-slim +ARG REGISTRY_PREFIX= +FROM ${REGISTRY_PREFIX}python:3.12-slim WORKDIR /app ENV PYTHONPATH=/app/src From 449188461b28e0626dd08da925f9d6e3b3230163 Mon Sep 17 00:00:00 2001 From: meijun Date: Mon, 19 Jan 2026 10:25:59 +0800 Subject: [PATCH 5/6] fix lint issue --- aenv/src/aenv/client/scheduler_client.py | 12 +- aenv/src/aenv/core/models.py | 15 +- aenv/src/cli/cmds/init.py | 20 ++- aenv/src/cli/cmds/instance.py | 123 ++++++++----- aenv/src/cli/cmds/list.py | 1 + aenv/src/cli/cmds/service.py | 217 +++++++++++++++-------- aenv/src/cli/utils/api_helpers.py | 7 +- aenv/src/cli/utils/table_formatter.py | 12 +- api-service/Dockerfile | 1 - api-service/models/env_service.go | 22 +-- deploy/controller/values.yaml | 93 ---------- 11 files changed, 275 insertions(+), 248 deletions(-) diff --git a/aenv/src/aenv/client/scheduler_client.py b/aenv/src/aenv/client/scheduler_client.py index 4075d657..72ed288c 100644 --- a/aenv/src/aenv/client/scheduler_client.py +++ b/aenv/src/aenv/client/scheduler_client.py @@ -29,6 +29,7 @@ EnvInstance, EnvInstanceCreateRequest, EnvInstanceListResponse, + EnvService, EnvStatus, ) @@ -442,9 +443,7 @@ async def create_env_service( return service else: error_msg = api_response.get_error_message() - raise EnvironmentError( - f"Failed to create service: {error_msg}" - ) + raise EnvironmentError(f"Failed to create service: {error_msg}") except ValueError as e: raise EnvironmentError( f"Invalid server response: {response.status_code} - {response.text[:200]}" @@ -547,7 +546,8 @@ async def list_env_services( return [EnvService(**item) for item in api_response.data] return [] else: - return [] + error_msg = api_response.get_error_message() + raise EnvironmentError(f"Failed to list services: {error_msg}") except ValueError as e: raise EnvironmentError( f"Invalid server response: {response.status_code} - {response.text[:200]}" @@ -559,7 +559,9 @@ async def list_env_services( continue raise NetworkError(f"Network error: {str(e)}") from e - async def delete_env_service(self, service_id: str, delete_storage: bool = False) -> bool: + async def delete_env_service( + self, service_id: str, delete_storage: bool = False + ) -> bool: """ Delete environment service. diff --git a/aenv/src/aenv/core/models.py b/aenv/src/aenv/core/models.py index 7864aed1..af645ac5 100644 --- a/aenv/src/aenv/core/models.py +++ b/aenv/src/aenv/core/models.py @@ -136,25 +136,20 @@ class EnvServiceCreateRequest(BaseModel): None, description="Mount path (default: /home/admin/data)" ) storage_size: Optional[str] = Field( - None, description="Storage size (e.g., 10Gi). If specified, PVC will be created and replicas must be 1. storageClass is configured in helm deployment." + None, + description="Storage size (e.g., 10Gi). If specified, PVC will be created and replicas must be 1. storageClass is configured in helm deployment.", ) # Service configuration port: Optional[int] = Field(None, description="Service port (default: 8080)") # Resource limits - cpu_request: Optional[str] = Field( - None, description="CPU request (default: 1)" - ) - cpu_limit: Optional[str] = Field( - None, description="CPU limit (default: 2)" - ) + cpu_request: Optional[str] = Field(None, description="CPU request (default: 1)") + cpu_limit: Optional[str] = Field(None, description="CPU limit (default: 2)") memory_request: Optional[str] = Field( None, description="Memory request (default: 2Gi)" ) - memory_limit: Optional[str] = Field( - None, description="Memory limit (default: 4Gi)" - ) + memory_limit: Optional[str] = Field(None, description="Memory limit (default: 4Gi)") ephemeral_storage_request: Optional[str] = Field( None, description="Ephemeral storage request (default: 5Gi)" ) diff --git a/aenv/src/cli/cmds/init.py b/aenv/src/cli/cmds/init.py index 83a9c575..9cf81688 100644 --- a/aenv/src/cli/cmds/init.py +++ b/aenv/src/cli/cmds/init.py @@ -49,18 +49,24 @@ def validate_env_name(name: str) -> tuple[bool, str]: return False, "Environment name cannot be empty" if len(name) > 253: - return False, f"Environment name is too long (max 253 characters, got {len(name)})" + return ( + False, + f"Environment name is too long (max 253 characters, got {len(name)})", + ) # Check if name contains only lowercase letters, numbers, and hyphens - if not re.match(r'^[a-z0-9-]+$', name): - return False, "Environment name must contain only lowercase letters, numbers, and hyphens (-)" + if not re.match(r"^[a-z0-9-]+$", name): + return ( + False, + "Environment name must contain only lowercase letters, numbers, and hyphens (-)", + ) # Check if name starts with a letter or number - if not re.match(r'^[a-z0-9]', name): + if not re.match(r"^[a-z0-9]", name): return False, "Environment name must start with a lowercase letter or number" # Check if name ends with a letter or number - if not re.search(r'[a-z0-9]$', name): + if not re.search(r"[a-z0-9]$", name): return False, "Environment name must end with a lowercase letter or number" return True, "" @@ -195,7 +201,9 @@ def init(cfg: Config, name, version, template, work_dir, force, config_only): # Update pvcName in service config to match environment name if "deployConfig" in template_config: deploy_config = template_config["deployConfig"] - if "service" in deploy_config and isinstance(deploy_config["service"], dict): + if "service" in deploy_config and isinstance( + deploy_config["service"], dict + ): deploy_config["service"]["pvcName"] = name with console.status("[bold green]Creating config.json..."): diff --git a/aenv/src/cli/cmds/instance.py b/aenv/src/cli/cmds/instance.py index 6f4539b2..2f0efe9b 100644 --- a/aenv/src/cli/cmds/instance.py +++ b/aenv/src/cli/cmds/instance.py @@ -40,8 +40,8 @@ get_api_headers, get_system_url_raw, make_api_url, - parse_env_vars as _parse_env_vars, ) +from cli.utils.api_helpers import parse_env_vars as _parse_env_vars from cli.utils.cli_config import get_config_manager from cli.utils.table_formatter import print_detail_table, print_instance_list @@ -644,9 +644,7 @@ def create( config = _load_env_config() if config and "name" in config and "version" in config: env_name = f"{config['name']}@{config['version']}" - console.print( - f"[dim]📄 Reading from config.json: {env_name}[/dim]\n" - ) + console.print(f"[dim]📄 Reading from config.json: {env_name}[/dim]\n") else: console.print( "[red]Error:[/red] env_name not provided and config.json not found or invalid.\n" @@ -724,7 +722,10 @@ def create( {"Property": "Environment", "Value": info.get("name", "-")}, {"Property": "Status", "Value": info.get("status", "-")}, {"Property": "IP Address", "Value": info.get("ip", "-")}, - {"Property": "Created At", "Value": format_time_to_local(info.get("created_at"))}, + { + "Property": "Created At", + "Value": format_time_to_local(info.get("created_at")), + }, ] print_detail_table(table_data, console, title="Instance Deployed") @@ -836,8 +837,14 @@ def info( {"Property": "Environment", "Value": info.get("name", "-")}, {"Property": "Status", "Value": info.get("status", "-")}, {"Property": "IP Address", "Value": info.get("ip", "-")}, - {"Property": "Created At", "Value": format_time_to_local(info.get("created_at"))}, - {"Property": "Updated At", "Value": format_time_to_local(info.get("updated_at"))}, + { + "Property": "Created At", + "Value": format_time_to_local(info.get("created_at")), + }, + { + "Property": "Updated At", + "Value": format_time_to_local(info.get("updated_at")), + }, ] print_detail_table(table_data, console, title="Instance Information") @@ -911,7 +918,7 @@ def list_instances(cfg: Config, name, version, output, system_url): if not system_url: system_url = _get_system_url() else: - system_url = _make_api_url(system_url, port=8080) + system_url = make_api_url(system_url, port=8080) # Use config-level verbose is_verbose = cfg.verbose @@ -944,20 +951,27 @@ def list_instances(cfg: Config, name, version, output, system_url): # Parse and simplify error messages if "403" in error_msg or "401" in error_msg: - console.print(f"[red]❌ Authentication failed[/red]") + console.print("[red]❌ Authentication failed[/red]") console.print("\n[dim]Please check your API key configuration.[/dim]") - console.print("[dim]You can set it with: [cyan]aenv config set hub_config.api_key [/cyan][/dim]") + console.print( + "[dim]You can set it with: [cyan]aenv config set hub_config.api_key [/cyan][/dim]" + ) elif "connection" in error_msg.lower() or "timeout" in error_msg.lower(): - console.print(f"[red]❌ Connection failed[/red]") - console.print(f"\n[dim]Cannot connect to the API service at: [cyan]{system_url}[/cyan][/dim]") - console.print("[dim]Please check your network connection and system_url configuration.[/dim]") + console.print("[red]❌ Connection failed[/red]") + console.print( + f"\n[dim]Cannot connect to the API service at: [cyan]{system_url}[/cyan][/dim]" + ) + console.print( + "[dim]Please check your network connection and system_url configuration.[/dim]" + ) else: - console.print(f"[red]❌ Failed to list instances[/red]") + console.print("[red]❌ Failed to list instances[/red]") console.print(f"\n[yellow]Error:[/yellow] {error_msg}") if is_verbose: console.print("\n[dim]--- Full error trace ---[/dim]") import traceback + console.print(traceback.format_exc()) raise click.Abort() @@ -983,14 +997,16 @@ def list_instances(cfg: Config, name, version, output, system_url): continue # Format the data for display - instances_data.append({ - "id": instance_id, - "env": instance.get("env"), - "owner": instance.get("owner"), - "status": instance.get("status"), - "ip": instance.get("ip"), - "created_at": format_time_to_local(instance.get("created_at")), - }) + instances_data.append( + { + "id": instance_id, + "env": instance.get("env"), + "owner": instance.get("owner"), + "status": instance.get("status"), + "ip": instance.get("ip"), + "created_at": format_time_to_local(instance.get("created_at")), + } + ) if instances_data: print_instance_list(instances_data, console) @@ -1034,7 +1050,7 @@ def get_instance(cfg: Config, instance_id, output, system_url): if not system_url: system_url = _get_system_url() else: - system_url = _make_api_url(system_url, port=8080) + system_url = make_api_url(system_url, port=8080) # Use config-level verbose is_verbose = cfg.verbose @@ -1062,9 +1078,15 @@ def get_instance(cfg: Config, instance_id, output, system_url): ) if not instance_info: - console.print(f"[red]❌ Instance not found:[/red] [yellow]{instance_id}[/yellow]") - console.print("\n[dim]The instance does not exist or has been deleted.[/dim]") - console.print("[dim]Use [cyan]aenv instance list[/cyan] to see available instances.[/dim]") + console.print( + f"[red]❌ Instance not found:[/red] [yellow]{instance_id}[/yellow]" + ) + console.print( + "\n[dim]The instance does not exist or has been deleted.[/dim]" + ) + console.print( + "[dim]Use [cyan]aenv instance list[/cyan] to see available instances.[/dim]" + ) raise click.Abort() console.print("[green]✅ Instance information retrieved![/green]\n") @@ -1100,30 +1122,51 @@ def get_instance(cfg: Config, instance_id, output, system_url): error_msg = str(e).lower() # Parse and simplify error messages - focus on user-friendly messages - if ("404" in error_msg and "not found" in error_msg) or "pods" in error_msg or ("500" in error_msg and "not found" in error_msg): - console.print(f"[red]❌ Instance not found:[/red] [yellow]{instance_id}[/yellow]") - console.print("\n[dim]The instance does not exist or has been deleted.[/dim]") - console.print("[dim]Use [cyan]aenv instance list[/cyan] to see available instances.[/dim]") + if ( + ("404" in error_msg and "not found" in error_msg) + or "pods" in error_msg + or ("500" in error_msg and "not found" in error_msg) + ): + console.print( + f"[red]❌ Instance not found:[/red] [yellow]{instance_id}[/yellow]" + ) + console.print( + "\n[dim]The instance does not exist or has been deleted.[/dim]" + ) + console.print( + "[dim]Use [cyan]aenv instance list[/cyan] to see available instances.[/dim]" + ) elif "403" in error_msg or "401" in error_msg: - console.print(f"[red]❌ Authentication failed[/red]") + console.print("[red]❌ Authentication failed[/red]") console.print("\n[dim]Please check your API key configuration.[/dim]") - console.print("[dim]You can set it with: [cyan]aenv config set hub_config.api_key [/cyan][/dim]") + console.print( + "[dim]You can set it with: [cyan]aenv config set hub_config.api_key [/cyan][/dim]" + ) elif "500" in error_msg and "internal server error" in error_msg: - console.print(f"[red]❌ Server error occurred[/red]") + console.print("[red]❌ Server error occurred[/red]") console.print("\n[dim]The API service encountered an internal error.[/dim]") - console.print("[dim]Please try again or contact support if the issue persists.[/dim]") + console.print( + "[dim]Please try again or contact support if the issue persists.[/dim]" + ) elif "connection" in error_msg or "timeout" in error_msg: - console.print(f"[red]❌ Connection failed[/red]") - console.print(f"\n[dim]Cannot connect to the API service at: [cyan]{system_url}[/cyan][/dim]") - console.print("[dim]Please check your network connection and system_url configuration.[/dim]") + console.print("[red]❌ Connection failed[/red]") + console.print( + f"\n[dim]Cannot connect to the API service at: [cyan]{system_url}[/cyan][/dim]" + ) + console.print( + "[dim]Please check your network connection and system_url configuration.[/dim]" + ) else: - console.print(f"[red]❌ Failed to get instance information[/red]") - console.print(f"\n[dim]The instance [yellow]{instance_id}[/yellow] could not be retrieved.[/dim]") + console.print("[red]❌ Failed to get instance information[/red]") + console.print( + f"\n[dim]The instance [yellow]{instance_id}[/yellow] could not be retrieved.[/dim]" + ) console.print("[dim]It may have been deleted or never existed.[/dim]") if cfg.verbose: console.print(f"\n[dim]Technical details: {str(e)}[/dim]") import traceback + console.print(f"[dim]{traceback.format_exc()}[/dim]") raise click.Abort() @@ -1161,7 +1204,7 @@ def delete_instance(cfg: Config, instance_id, yes, system_url): if not system_url: system_url = _get_system_url() else: - system_url = _make_api_url(system_url, port=8080) + system_url = make_api_url(system_url, port=8080) # Confirm deletion unless --yes flag is provided if not yes: diff --git a/aenv/src/cli/cmds/list.py b/aenv/src/cli/cmds/list.py index e30ef551..da1960e9 100644 --- a/aenv/src/cli/cmds/list.py +++ b/aenv/src/cli/cmds/list.py @@ -71,5 +71,6 @@ def list_env(limit, offset, format): elif format == "table": # Use rich console for better display from rich.console import Console + console = Console() print_environment_list(environments, console) diff --git a/aenv/src/cli/cmds/service.py b/aenv/src/cli/cmds/service.py index f08a1fd8..58d69e79 100644 --- a/aenv/src/cli/cmds/service.py +++ b/aenv/src/cli/cmds/service.py @@ -34,7 +34,6 @@ from cli.cmds.common import Config, pass_config from cli.utils.api_helpers import ( format_time_to_local, - get_api_headers, get_system_url_raw, make_api_url, parse_env_vars, @@ -210,9 +209,7 @@ def create( if not env_name: if config and "name" in config and "version" in config: env_name = f"{config['name']}@{config['version']}" - console.print( - f"[dim]📄 Reading from config.json: {env_name}[/dim]\n" - ) + console.print(f"[dim]📄 Reading from config.json: {env_name}[/dim]\n") else: console.print( "[red]Error:[/red] env_name not provided and config.json not found or invalid.\n" @@ -221,7 +218,9 @@ def create( raise click.Abort() # Merge parameters: CLI > config.json > defaults - final_replicas = replicas if replicas is not None else service_config.get("replicas", 1) + final_replicas = ( + replicas if replicas is not None else service_config.get("replicas", 1) + ) final_port = port if port is not None else service_config.get("port") # Storage configuration - enabled by --enable-storage flag OR config.json enableStorage @@ -262,12 +261,18 @@ def create( cpu_limit = deploy_config.get("cpuLimit") or cpu memory_request = deploy_config.get("memoryRequest") or memory memory_limit = deploy_config.get("memoryLimit") or memory - ephemeral_storage_request = deploy_config.get("ephemeralStorageRequest") or ephemeral_storage - ephemeral_storage_limit = deploy_config.get("ephemeralStorageLimit") or ephemeral_storage + ephemeral_storage_request = ( + deploy_config.get("ephemeralStorageRequest") or ephemeral_storage + ) + ephemeral_storage_limit = ( + deploy_config.get("ephemeralStorageLimit") or ephemeral_storage + ) # Parse environment variables from CLI try: - env_vars = parse_env_vars(environment_variables) if environment_variables else None + env_vars = ( + parse_env_vars(environment_variables) if environment_variables else None + ) except click.BadParameter as e: console.print(f"[red]Error:[/red] {str(e)}") raise click.Abort() @@ -311,10 +316,12 @@ def create( if final_mount_path: console.print(f" - Mount Path: {final_mount_path}") else: - console.print(f" - Mount Path: /home/admin/data (default)") - console.print(f" [yellow]⚠️ With storage enabled, replicas must be 1[/yellow]") + console.print(" - Mount Path: /home/admin/data (default)") + console.print(" [yellow]⚠️ With storage enabled, replicas must be 1[/yellow]") else: - console.print(f"[dim] Storage: Disabled (use --enable-storage to enable storage)[/dim]") + console.print( + "[dim] Storage: Disabled (use --enable-storage to enable storage)[/dim]" + ) console.print() async def _create(): @@ -352,9 +359,15 @@ async def _create(): {"Property": "Service ID", "Value": svc.id}, {"Property": "Status", "Value": svc.status}, {"Property": "Service URL", "Value": svc.service_url or "-"}, - {"Property": "Replicas", "Value": f"{svc.available_replicas}/{svc.replicas}"}, + { + "Property": "Replicas", + "Value": f"{svc.available_replicas}/{svc.replicas}", + }, {"Property": "Storage Name", "Value": svc.pvc_name or "-"}, - {"Property": "Created At", "Value": format_time_to_local(svc.created_at)}, + { + "Property": "Created At", + "Value": format_time_to_local(svc.created_at), + }, ] print_detail_table(table_data, console, title="Service Created") @@ -362,6 +375,7 @@ async def _create(): console.print(f"[red]❌ Creation failed:[/red] {str(e)}") if cfg.verbose: import traceback + console.print(traceback.format_exc()) raise click.Abort() @@ -383,31 +397,31 @@ async def _create(): @pass_config def list_services(cfg: Config, name, output): """List running environment services - + Examples: # List all services aenv service list - + # List services for specific environment aenv service list --name myapp - + # Output as JSON aenv service list --output json """ console = cfg.console.console() - + system_url = _get_system_url() 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") - + async def _list(): async with AEnvSchedulerClient( base_url=system_url, api_key=api_key, ) as client: return await client.list_env_services(env_name=name) - + try: services_list = asyncio.run(_list()) except Exception as e: @@ -415,24 +429,29 @@ async def _list(): # Parse and simplify error messages if "403" in error_msg or "401" in error_msg: - console.print(f"[red]❌ Authentication failed[/red]") + console.print("[red]❌ Authentication failed[/red]") console.print("\n[dim]Please check your API key configuration.[/dim]") - console.print("[dim]You can set it with: [cyan]aenv config set hub_config.api_key [/cyan][/dim]") + console.print( + "[dim]You can set it with: [cyan]aenv config set hub_config.api_key [/cyan][/dim]" + ) elif "connection" in error_msg.lower() or "timeout" in error_msg.lower(): - console.print(f"[red]❌ Connection failed[/red]") - console.print(f"\n[dim]Cannot connect to the API service.[/dim]") - console.print("[dim]Please check your network connection and system_url configuration.[/dim]") + console.print("[red]❌ Connection failed[/red]") + console.print("\n[dim]Cannot connect to the API service.[/dim]") + console.print( + "[dim]Please check your network connection and system_url configuration.[/dim]" + ) else: - console.print(f"[red]❌ Failed to list services[/red]") + console.print("[red]❌ Failed to list services[/red]") console.print(f"\n[yellow]Error:[/yellow] {error_msg}") if cfg.verbose: console.print("\n[dim]--- Full error trace ---[/dim]") import traceback + console.print(traceback.format_exc()) raise click.Abort() - + if not services_list: if name: console.print(f"📭 No running services found for {name}") @@ -441,7 +460,9 @@ async def _list(): return if output == "json": - console.print(json.dumps([s.model_dump() for s in services_list], indent=2, default=str)) + console.print( + json.dumps([s.model_dump() for s in services_list], indent=2, default=str) + ) else: # Convert service objects to dictionaries for the formatter services_data = [] @@ -451,16 +472,18 @@ async def _list(): env_info["name"] = svc.env.name env_info["version"] = svc.env.version - services_data.append({ - "id": svc.id, - "env": env_info if env_info else None, - "owner": svc.owner, - "status": svc.status, - "available_replicas": svc.available_replicas, - "replicas": svc.replicas, - "storage_name": svc.pvc_name, - "created_at": format_time_to_local(svc.created_at), - }) + services_data.append( + { + "id": svc.id, + "env": env_info if env_info else None, + "owner": svc.owner, + "status": svc.status, + "available_replicas": svc.available_replicas, + "replicas": svc.replicas, + "storage_name": svc.pvc_name, + "created_at": format_time_to_local(svc.created_at), + } + ) print_service_list(services_data, console) @@ -477,30 +500,30 @@ async def _list(): @pass_config def get_service(cfg: Config, service_id, output): """Get detailed information for a specific service - + Examples: # Get service information aenv service get myapp-svc-abc123 - + # Get in JSON format aenv service get myapp-svc-abc123 --output json """ console = cfg.console.console() - + system_url = _get_system_url() 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") - + console.print(f"[cyan]ℹ️ Retrieving service information:[/cyan] {service_id}\n") - + async def _get(): async with AEnvSchedulerClient( base_url=system_url, api_key=api_key, ) as client: return await client.get_env_service(service_id) - + try: svc = asyncio.run(_get()) @@ -518,11 +541,20 @@ async def _get(): {"Property": "Version", "Value": env_version}, {"Property": "Owner", "Value": svc.owner or "-"}, {"Property": "Status", "Value": svc.status}, - {"Property": "Replicas", "Value": f"{svc.available_replicas}/{svc.replicas}"}, + { + "Property": "Replicas", + "Value": f"{svc.available_replicas}/{svc.replicas}", + }, {"Property": "Service URL", "Value": svc.service_url or "-"}, {"Property": "Storage Name", "Value": svc.pvc_name or "-"}, - {"Property": "Created At", "Value": format_time_to_local(svc.created_at)}, - {"Property": "Updated At", "Value": format_time_to_local(svc.updated_at)}, + { + "Property": "Created At", + "Value": format_time_to_local(svc.created_at), + }, + { + "Property": "Updated At", + "Value": format_time_to_local(svc.updated_at), + }, ] print_detail_table(table_data, console, title="Service Details") @@ -530,26 +562,43 @@ async def _get(): error_msg = str(e).lower() # Parse and simplify error messages - if ("404" in error_msg and "not found" in error_msg) or "deployment" in error_msg: - console.print(f"[red]❌ Service not found:[/red] [yellow]{service_id}[/yellow]") - console.print("\n[dim]The service does not exist or has been deleted.[/dim]") - console.print("[dim]Use [cyan]aenv service list[/cyan] to see available services.[/dim]") + if ( + "404" in error_msg and "not found" in error_msg + ) or "deployment" in error_msg: + console.print( + f"[red]❌ Service not found:[/red] [yellow]{service_id}[/yellow]" + ) + console.print( + "\n[dim]The service does not exist or has been deleted.[/dim]" + ) + console.print( + "[dim]Use [cyan]aenv service list[/cyan] to see available services.[/dim]" + ) elif "403" in error_msg or "401" in error_msg: - console.print(f"[red]❌ Authentication failed[/red]") + console.print("[red]❌ Authentication failed[/red]") console.print("\n[dim]Please check your API key configuration.[/dim]") - console.print("[dim]You can set it with: [cyan]aenv config set hub_config.api_key [/cyan][/dim]") + console.print( + "[dim]You can set it with: [cyan]aenv config set hub_config.api_key [/cyan][/dim]" + ) elif "connection" in error_msg or "timeout" in error_msg: - console.print(f"[red]❌ Connection failed[/red]") - console.print(f"\n[dim]Cannot connect to the API service at: [cyan]{system_url}[/cyan][/dim]") - console.print("[dim]Please check your network connection and system_url configuration.[/dim]") + console.print("[red]❌ Connection failed[/red]") + console.print( + f"\n[dim]Cannot connect to the API service at: [cyan]{system_url}[/cyan][/dim]" + ) + console.print( + "[dim]Please check your network connection and system_url configuration.[/dim]" + ) else: - console.print(f"[red]❌ Failed to get service information[/red]") - console.print(f"\n[dim]The service [yellow]{service_id}[/yellow] could not be retrieved.[/dim]") + console.print("[red]❌ Failed to get service information[/red]") + console.print( + f"\n[dim]The service [yellow]{service_id}[/yellow] could not be retrieved.[/dim]" + ) console.print("[dim]It may have been deleted or never existed.[/dim]") if cfg.verbose: console.print(f"\n[dim]Technical details: {str(e)}[/dim]") import traceback + console.print(f"[dim]{traceback.format_exc()}[/dim]") raise click.Abort() @@ -588,9 +637,13 @@ def delete_service(cfg: Config, service_id, yes, delete_storage): console = cfg.console.console() if not yes: - console.print(f"[yellow]⚠️ You are about to delete service:[/yellow] {service_id}") + console.print( + f"[yellow]⚠️ You are about to delete service:[/yellow] {service_id}" + ) if delete_storage: - console.print("[red]⚠️ WARNING: Storage will be PERMANENTLY deleted (all data will be lost)[/red]") + console.print( + "[red]⚠️ WARNING: Storage will be PERMANENTLY deleted (all data will be lost)[/red]" + ) else: console.print("[yellow]Note: Storage will be kept for reuse[/yellow]") if not click.confirm("Are you sure you want to continue?"): @@ -612,7 +665,9 @@ async def _delete(): base_url=system_url, api_key=api_key, ) as client: - return await client.delete_env_service(service_id, delete_storage=delete_storage) + return await client.delete_env_service( + service_id, delete_storage=delete_storage + ) try: with console.status("[bold green]Deleting service..."): @@ -632,6 +687,7 @@ async def _delete(): console.print(f"[red]❌ Failed to delete service:[/red] {str(e)}") if cfg.verbose: import traceback + console.print(traceback.format_exc()) raise click.Abort() @@ -673,28 +729,30 @@ def update_service( output: str, ): """Update a running service - + Can update replicas, image, and environment variables. - + Examples: # Scale to 5 replicas aenv service update myapp-svc-abc123 --replicas 5 - + # Update image aenv service update myapp-svc-abc123 --image myapp:2.0.0 - + # Update environment variables aenv service update myapp-svc-abc123 -e DB_HOST=newhost -e DB_PORT=3306 - + # Update multiple things at once aenv service update myapp-svc-abc123 --replicas 3 --image myapp:2.0.0 """ console = cfg.console.console() - + if not replicas and not image and not environment_variables: - console.print("[red]Error:[/red] At least one of --replicas, --image, or --env must be provided") + console.print( + "[red]Error:[/red] At least one of --replicas, --image, or --env must be provided" + ) raise click.Abort() - + # Parse environment variables env_vars = None if environment_variables: @@ -703,12 +761,12 @@ def update_service( except click.BadParameter as e: console.print(f"[red]Error:[/red] {str(e)}") raise click.Abort() - + system_url = _get_system_url() 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") - + console.print(f"[cyan]🔄 Updating service:[/cyan] {service_id}") if replicas is not None: console.print(f" Replicas: {replicas}") @@ -717,7 +775,7 @@ def update_service( if env_vars: console.print(f" Environment Variables: {len(env_vars)} variables") console.print() - + async def _update(): async with AEnvSchedulerClient( base_url=system_url, @@ -729,11 +787,11 @@ async def _update(): image=image, environment_variables=env_vars, ) - + try: with console.status("[bold green]Updating service..."): svc = asyncio.run(_update()) - + console.print("[green]✅ Service updated successfully![/green]\n") if output == "json": @@ -742,15 +800,22 @@ async def _update(): table_data = [ {"Property": "Service ID", "Value": svc.id}, {"Property": "Status", "Value": svc.status}, - {"Property": "Replicas", "Value": f"{svc.available_replicas}/{svc.replicas}"}, + { + "Property": "Replicas", + "Value": f"{svc.available_replicas}/{svc.replicas}", + }, {"Property": "Service URL", "Value": svc.service_url or "-"}, - {"Property": "Updated At", "Value": format_time_to_local(svc.updated_at)}, + { + "Property": "Updated At", + "Value": format_time_to_local(svc.updated_at), + }, ] print_detail_table(table_data, console, title="Service Updated") - + except Exception as e: console.print(f"[red]❌ Update failed:[/red] {str(e)}") if cfg.verbose: import traceback + console.print(traceback.format_exc()) raise click.Abort() diff --git a/aenv/src/cli/utils/api_helpers.py b/aenv/src/cli/utils/api_helpers.py index 9e93bcaf..2478d7d5 100644 --- a/aenv/src/cli/utils/api_helpers.py +++ b/aenv/src/cli/utils/api_helpers.py @@ -153,15 +153,16 @@ def format_time_to_local(time_value: Optional[Union[str, datetime]]) -> str: # Parse string to datetime if needed if isinstance(time_value, str): # Try parsing ISO format with or without timezone info - if time_value.endswith('Z'): - dt = datetime.fromisoformat(time_value.replace('Z', '+00:00')) - elif '+' in time_value or time_value.count('-') > 2: + if time_value.endswith("Z"): + dt = datetime.fromisoformat(time_value.replace("Z", "+00:00")) + elif "+" in time_value or time_value.count("-") > 2: dt = datetime.fromisoformat(time_value) else: # Assume UTC if no timezone info dt = datetime.fromisoformat(time_value).replace(tzinfo=None) # Convert from UTC to local from datetime import timezone + dt = dt.replace(tzinfo=timezone.utc).astimezone() elif isinstance(time_value, datetime): dt = time_value diff --git a/aenv/src/cli/utils/table_formatter.py b/aenv/src/cli/utils/table_formatter.py index 69d6e3ba..8b122ea0 100644 --- a/aenv/src/cli/utils/table_formatter.py +++ b/aenv/src/cli/utils/table_formatter.py @@ -108,7 +108,7 @@ def truncate_text(text: str, max_length: int = 50) -> str: """ if len(text) <= max_length: return text - return text[:max_length - 3] + "..." + return text[: max_length - 3] + "..." def print_instance_list(instances: List[Dict[str, Any]], console: Console) -> None: @@ -190,7 +190,11 @@ def print_service_list(services: List[Dict[str, Any]], console: Console) -> None env_info = svc.get("env") or {} env_name = env_info.get("name") or "-" env_version = env_info.get("version") or "-" - env_display = f"{env_name}@{env_version}" if env_name != "-" and env_version != "-" else "-" + env_display = ( + f"{env_name}@{env_version}" + if env_name != "-" and env_version != "-" + else "-" + ) # Get other fields owner = svc.get("owner") or "-" @@ -220,7 +224,9 @@ def print_service_list(services: List[Dict[str, Any]], console: Console) -> None console.print(table) -def print_environment_list(environments: List[Dict[str, Any]], console: Console) -> None: +def print_environment_list( + environments: List[Dict[str, Any]], console: Console +) -> None: """Print a formatted list of environments. Args: diff --git a/api-service/Dockerfile b/api-service/Dockerfile index 1e55f277..ec0d1cad 100644 --- a/api-service/Dockerfile +++ b/api-service/Dockerfile @@ -51,4 +51,3 @@ COPY --from=builder /workspace/api-service/api-service /usr/bin/api-service EXPOSE 8080 ENTRYPOINT ["/usr/bin/api-service"] - diff --git a/api-service/models/env_service.go b/api-service/models/env_service.go index 79592367..b95ce46b 100644 --- a/api-service/models/env_service.go +++ b/api-service/models/env_service.go @@ -55,17 +55,17 @@ func (s EnvServiceStatus) String() string { // EnvService environment service object (Deployment + Service + PVC) type EnvService struct { - ID string `json:"id"` // Service id, corresponds to deployment name - Env *backend.Env `json:"env"` // Env object - Status string `json:"status"` // Service status - CreatedAt string `json:"created_at"` // Creation time - UpdatedAt string `json:"updated_at"` // Update time - Replicas int32 `json:"replicas"` // Number of replicas - AvailableReplicas int32 `json:"available_replicas"` // Number of available replicas - ServiceURL string `json:"service_url"` // Service URL (internal cluster DNS) - Owner string `json:"owner"` // Service owner (user who created it) - EnvironmentVariables map[string]string `json:"environment_variables"` // Environment variables - PVCName string `json:"pvc_name"` // PVC name (shared by same envName) + ID string `json:"id"` // Service id, corresponds to deployment name + Env *backend.Env `json:"env"` // Env object + Status string `json:"status"` // Service status + CreatedAt string `json:"created_at"` // Creation time + UpdatedAt string `json:"updated_at"` // Update time + Replicas int32 `json:"replicas"` // Number of replicas + AvailableReplicas int32 `json:"available_replicas"` // Number of available replicas + ServiceURL string `json:"service_url"` // Service URL (internal cluster DNS) + Owner string `json:"owner"` // Service owner (user who created it) + EnvironmentVariables map[string]string `json:"environment_variables"` // Environment variables + PVCName string `json:"pvc_name"` // PVC name (shared by same envName) } // NewEnvService creates a new environment service object diff --git a/deploy/controller/values.yaml b/deploy/controller/values.yaml index d64da7fe..71f0a4da 100644 --- a/deploy/controller/values.yaml +++ b/deploy/controller/values.yaml @@ -193,96 +193,3 @@ podTemplates: - name: shared-data emptyDir: {} restartPolicy: Never - - # Deployment 模板(用于 service 功能) - deployment: | - apiVersion: apps/v1 - kind: Deployment - metadata: - name: placeholder - namespace: {{ .Values.sandboxNamespace }} - labels: - app.kubernetes.io/name: aenv-service - template-type: deployment - spec: - replicas: 1 - selector: - matchLabels: - app: placeholder - template: - metadata: - labels: - app: placeholder - spec: - automountServiceAccountToken: false - containers: - - name: main - image: weather:v0.1.0 - imagePullPolicy: IfNotPresent - resources: - limits: - cpu: "2" - memory: "4Gi" - ephemeral-storage: "10Gi" - requests: - cpu: "1" - memory: "2Gi" - ephemeral-storage: "5Gi" - livenessProbe: - failureThreshold: 30 - initialDelaySeconds: 3 - periodSeconds: 10 - successThreshold: 1 - httpGet: - path: /health - port: 8081 - scheme: HTTP - timeoutSeconds: 60 - env: - - name: AENV_TYPE - value: "service" - volumeMounts: - - name: data - mountPath: /home/admin/data - volumes: - - name: data - persistentVolumeClaim: - claimName: placeholder-pvc - - # Service 模板(用于 service 功能) - service: | - apiVersion: v1 - kind: Service - metadata: - name: placeholder - namespace: {{ .Values.sandboxNamespace }} - labels: - app.kubernetes.io/name: aenv-service - template-type: service - spec: - type: ClusterIP - selector: - app: placeholder - ports: - - name: http - protocol: TCP - port: 8080 - targetPort: 8080 - - # PVC 模板(用于 service 功能) - pvc: | - apiVersion: v1 - kind: PersistentVolumeClaim - metadata: - name: placeholder - namespace: {{ .Values.sandboxNamespace }} - labels: - app.kubernetes.io/name: aenv-service - template-type: pvc - spec: - accessModes: - - ReadWriteOnce - storageClassName: alilocal-ssd - resources: - requests: - storage: 10Gi From 33ce8e3a9a6e186f48a3f14dfd4753f0c30ffc88 Mon Sep 17 00:00:00 2001 From: meijun Date: Mon, 19 Jan 2026 10:44:20 +0800 Subject: [PATCH 6/6] fix: remove unnecessary nil checks around range Remove unnecessary nil checks before ranging over maps as ranging over nil maps is safe in Go and will simply skip iteration. Co-Authored-By: Claude --- controller/pkg/aenvhub_http_server/util.go | 36 ++++++++++------------ 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/controller/pkg/aenvhub_http_server/util.go b/controller/pkg/aenvhub_http_server/util.go index b1535a77..474cd464 100644 --- a/controller/pkg/aenvhub_http_server/util.go +++ b/controller/pkg/aenvhub_http_server/util.go @@ -372,11 +372,9 @@ func MergeDeployment(deployment *appsv1.Deployment, name string, replicas int32, if deployment.Labels == nil { deployment.Labels = make(map[string]string) } - if labels != nil { - for k, v := range labels { - deployment.Labels[k] = v - deployment.Spec.Template.Labels[k] = v - } + for k, v := range labels { + deployment.Labels[k] = v + deployment.Spec.Template.Labels[k] = v } // Update container image, env vars, and resources @@ -389,23 +387,21 @@ func MergeDeployment(deployment *appsv1.Deployment, name string, replicas int32, } // Merge environment variables - if environs != nil { - for k, v := range environs { - found := false - for j := range container.Env { - if container.Env[j].Name == k { - container.Env[j].Value = v - found = true - break - } - } - if !found { - container.Env = append(container.Env, corev1.EnvVar{ - Name: k, - Value: v, - }) + for k, v := range environs { + found := false + for j := range container.Env { + if container.Env[j].Name == k { + container.Env[j].Value = v + found = true + break } } + if !found { + container.Env = append(container.Env, corev1.EnvVar{ + Name: k, + Value: v, + }) + } } // Update resource limits and requests