diff --git a/frontend/README.md b/frontend/README.md index 7ad74bdd0..bae28f033 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -823,6 +823,13 @@ run `npm ci` from `frontend/` to synchronize dependencies with the lockfile before rebuilding. Reusing another checkout's `node_modules` can retain older dependencies even when the current `package.json` already declares them +### Studio deployment resources + +`veadk studio deploy` configures the Studio function with 8 vCPU, 16 GB memory, +and both minimum and maximum instance counts set to 1 on Volcengine and BytePlus +These settings apply to new deployments and redeployments of an existing Studio +Ordinary Agent deployments retain their existing resource defaults + ### Identity-backed user management Deploy with `--super-admin ` to select the first diff --git a/frontend/server/studio_deployment.py b/frontend/server/studio_deployment.py new file mode 100644 index 000000000..a91818cb2 --- /dev/null +++ b/frontend/server/studio_deployment.py @@ -0,0 +1,19 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# 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. + +"""Default function resources for Studio on Volcengine and BytePlus""" + +STUDIO_CPU_MILLI = 8000 +STUDIO_MEMORY_MB = 16384 +STUDIO_MAX_INSTANCE = 1 diff --git a/tests/cli/test_studio_deploy_target.py b/tests/cli/test_studio_deploy_target.py index 7ad8d6737..beda6a6a1 100644 --- a/tests/cli/test_studio_deploy_target.py +++ b/tests/cli/test_studio_deploy_target.py @@ -1040,6 +1040,7 @@ def __init__(self, **kwargs: object) -> None: captured.update(kwargs) def deploy(self, **kwargs: object) -> SimpleNamespace: + captured["deployment"] = kwargs return SimpleNamespace( vefaas_endpoint="https://studio.example.com", vefaas_application_id="app-id", @@ -1122,6 +1123,11 @@ def configure_user_pool_for_idp_only(self, user_pool_uid: str) -> None: assert result.exit_code == 0, result.output assert captured["region"] == expected_region assert captured["project"] == expected_project + deployment = captured["deployment"] + assert isinstance(deployment, dict) + assert deployment["cpu_milli"] == 8000 + assert deployment["memory_mb"] == 16384 + assert deployment["max_instance"] == 1 assert captured["volcengine_session_token"] == "sts-token" assert captured["identity_client"] == { "access_key": "ak", @@ -1504,6 +1510,9 @@ def _resolve_account_id(**kwargs: object) -> str: assert identity["enable_vefaas_iam_fallback"] is False deploy = captured["deploy"] assert isinstance(deploy, dict) + assert deploy["cpu_milli"] == 8000 + assert deploy["memory_mb"] == 16384 + assert deploy["max_instance"] == 1 assert deploy["enable_mcp_session"] is False assert deploy["disable_gateway_cors"] is True assert "--provider byteplus --auth-mode frontend" in str(captured["run_script"]) diff --git a/tests/test_cloud.py b/tests/test_cloud.py index ac81186bc..80f901227 100644 --- a/tests/test_cloud.py +++ b/tests/test_cloud.py @@ -30,6 +30,7 @@ from veadk.integrations.ve_code_pipeline.ve_code_pipeline import VeCodePipeline from veadk.integrations.ve_faas.ve_faas import VeFaaS from veadk.utils.cloud_provider import ( + CloudProvider, agentkit_openapi_base, apmplus_otlp_endpoint, cp_openapi_host, @@ -66,6 +67,80 @@ def test_vefaas_create_function_uses_configured_project() -> None: request = service.client.create_function.call_args.args[0] assert request.project_name == "studio-project" + assert request.cpu_milli is None + assert request.memory_mb == 2048 + + +@pytest.mark.parametrize("provider", ["volcengine", "byteplus"]) +@pytest.mark.parametrize("existing", [False, True]) +@pytest.mark.parametrize("studio_resources", [False, True]) +def test_cloud_deploy_function_resources( + provider: CloudProvider, existing: bool, studio_resources: bool, tmp_path: Path +) -> None: + engine = CloudAgentEngine( + volcengine_access_key="test_access_key", + volcengine_secret_key="test_secret_key", + provider=provider, + region="ap-southeast-1" if provider == "byteplus" else "cn-beijing", + ) + service = engine._vefaas_service + service.client = Mock() + service.client.create_function.return_value = SimpleNamespace( + id="function-id", project_name="default" + ) + service.client.get_function.return_value = SimpleNamespace( + command="bash ./run.sh", envs=[] + ) + service.find_app_id_by_name = Mock( + return_value="application-id" if existing else None + ) + service._get_application_status = Mock( + return_value=( + "deploy_success", + { + "Result": { + "CloudResource": '{"framework":{"function":' + '{"Id":"function-id","Name":"studio-app-fn"}}}' + } + }, + ) + ) + service._upload_and_mount_code = Mock() + service._create_application = Mock(return_value="application-id") + service._release_application = Mock(return_value="https://studio.example") + service.ensure_application_route_methods = Mock() + service.get_application_route = Mock(return_value=("gateway", "service", "route")) + + with ( + patch("veadk.cloud.cloud_agent_engine.CloudApp"), + patch.dict("veadk.config.veadk_environments", {}, clear=True), + ): + engine.deploy( + "studio-app", + str(tmp_path), + gateway_name="gateway", + gateway_service_name="service", + gateway_upstream_name="upstream", + cpu_milli=8000 if studio_resources else None, + memory_mb=16384 if studio_resources else None, + max_instance=1 if studio_resources else None, + ) + + operation = ( + service.client.update_function if existing else service.client.create_function + ) + operation.assert_called_once() + request = operation.call_args.args[0] + assert request.cpu_milli == (8000 if studio_resources else None) + assert request.memory_mb == ( + 16384 if studio_resources else None if existing else 2048 + ) + service.client.update_function_resource.assert_called_once() + resources = service.client.update_function_resource.call_args.args[0] + assert resources.function_id == "function-id" + assert resources.min_instance == 1 + assert resources.max_instance == (1 if studio_resources else None) + assert resources.reserved_frozen_instance is None @pytest.mark.parametrize("provider", ["volcengine", "byteplus"]) @@ -230,6 +305,9 @@ def test_vefaas_deploy_updates_existing_application_in_place() -> None: environment_overrides={"VEADK_STUDIO_DEPLOY_ID": "deploy-id"}, disable_gateway_cors=True, normalize_studio_entrypoint=True, + cpu_milli=None, + memory_mb=None, + max_instance=None, ) service._create_function.assert_not_called() service._create_application.assert_not_called() @@ -389,7 +467,9 @@ def test_vefaas_deploy_can_disable_gateway_cors() -> None: "application-id", disable_cors=True, ) - service._set_function_min_instance.assert_called_once_with("function-id") + service._set_function_min_instance.assert_called_once_with( + "function-id", max_instance=None + ) def test_vefaas_code_upload_callback_uses_configured_region() -> None: diff --git a/veadk/cli/cli_frontend.py b/veadk/cli/cli_frontend.py index 9434a6456..1c04f2d9a 100644 --- a/veadk/cli/cli_frontend.py +++ b/veadk/cli/cli_frontend.py @@ -16538,6 +16538,11 @@ def frontend_deploy( # 3) Deploy the function + a plain public APIG trigger on the serverless # gateway (auth_method="none" — no gateway SSO plugin / domain upstream). + from frontend.server.studio_deployment import ( + STUDIO_CPU_MILLI, + STUDIO_MAX_INSTANCE, + STUDIO_MEMORY_MB, + ) from veadk.cloud.cloud_agent_engine import CloudAgentEngine engine = CloudAgentEngine( @@ -16564,6 +16569,9 @@ def frontend_deploy( enable_mcp_session=False, keep_failed_deploy=keep_failed_deploy, disable_gateway_cors=True, + cpu_milli=STUDIO_CPU_MILLI, + memory_mb=STUDIO_MEMORY_MB, + max_instance=STUDIO_MAX_INSTANCE, ) url = (app.vefaas_endpoint or "").rstrip("/") redirect_uri = f"{url}/oauth2/callback" diff --git a/veadk/cloud/cloud_agent_engine.py b/veadk/cloud/cloud_agent_engine.py index b4752f572..00bf34a60 100644 --- a/veadk/cloud/cloud_agent_engine.py +++ b/veadk/cloud/cloud_agent_engine.py @@ -254,6 +254,9 @@ def deploy( enable_mcp_session: bool = True, keep_failed_deploy: bool = False, disable_gateway_cors: bool = False, + cpu_milli: int | None = None, + memory_mb: int | None = None, + max_instance: int | None = None, ) -> CloudApp: """Deploys a local agent project to Volcengine FaaS, creating necessary resources. @@ -271,6 +274,9 @@ def deploy( identity_client_name (str, optional): Custom client name. Defaults to timestamped. local_test (bool): Perform FastAPI server test before deploy. Defaults to False. disable_gateway_cors (bool): Disable route-wide APIG CORS. Defaults to False. + cpu_milli: Function CPU in millicores; omitted to use platform defaults. + memory_mb: Function memory in MB; omitted to preserve existing settings. + max_instance: Maximum function instances; omitted to preserve cloud settings. Returns: CloudApp: Deployed application with endpoint, name, and ID. @@ -334,6 +340,9 @@ def deploy( enable_mcp_session=enable_mcp_session, keep_failed_deploy=keep_failed_deploy, disable_gateway_cors=disable_gateway_cors, + cpu_milli=cpu_milli, + memory_mb=memory_mb, + max_instance=max_instance, ) _ = function_id # for future use diff --git a/veadk/integrations/ve_faas/ve_faas.py b/veadk/integrations/ve_faas/ve_faas.py index 58951ea3a..f1cbc0d5d 100644 --- a/veadk/integrations/ve_faas/ve_faas.py +++ b/veadk/integrations/ve_faas/ve_faas.py @@ -354,7 +354,14 @@ def _upload_and_mount_code(self, function_id: str, path: str): return res - def _create_function(self, function_name: str, path: str): + def _create_function( + self, + function_name: str, + path: str, + *, + cpu_milli: int | None = None, + memory_mb: int | None = None, + ): # Read envs envs = [] for key, value in veadk.config.veadk_environments.items(): @@ -373,7 +380,8 @@ def _create_function(self, function_name: str, path: str): runtime="native-python3.12/v1", request_timeout=1800, envs=envs, - memory_mb=2048, + cpu_milli=cpu_milli, + memory_mb=memory_mb if memory_mb is not None else 2048, role=getenv("IAM_ROLE", None, allow_false_values=True), project_name=self.project_name, ) @@ -590,13 +598,15 @@ def update_application_code_bundle( environment_overrides: dict[str, str] | None = None, disable_gateway_cors: bool = False, normalize_studio_entrypoint: bool = False, + cpu_milli: int | None = None, + memory_mb: int | None = None, + max_instance: int | None = None, ) -> str: """Replace an application's function bundle and release it. - Existing function settings are left untouched except that the minimum - instance count is set to one. When environment overrides are provided, - they are merged with the complete current environment before updating the - function. + Existing resource settings are preserved unless explicitly overridden; + the minimum instance count is set to one. Environment overrides are merged + with the complete current environment before updating the function. Args: application_id: Existing VeFaaS Application ID. @@ -608,6 +618,9 @@ def update_application_code_bundle( command with ``bash ./run.sh`` so platform archive mode normalization cannot prevent Studio from starting. Custom commands are preserved. + cpu_milli: Explicit function CPU override in millicores. + memory_mb: Explicit function memory override in MB. + max_instance: Explicit maximum instance count override. Returns: The existing Application URL after the new revision is released. @@ -617,9 +630,11 @@ def update_application_code_bundle( path=path, environment_overrides=environment_overrides, normalize_studio_entrypoint=normalize_studio_entrypoint, + cpu_milli=cpu_milli, + memory_mb=memory_mb, ) url = self._release_application(application_id) - self._set_function_min_instance(function_id) + self._set_function_min_instance(function_id, max_instance=max_instance) if disable_gateway_cors: self.ensure_application_route_methods( application_id, @@ -661,9 +676,15 @@ def _replace_application_code_bundle( environment_overrides: dict[str, str] | None, request_timeout: int | None = None, normalize_studio_entrypoint: bool = False, + cpu_milli: int | None = None, + memory_mb: int | None = None, ) -> None: """Upload a bundle and update the Function without releasing it.""" request_options: dict[str, Any] = {"id": function_id} + if cpu_milli is not None: + request_options["cpu_milli"] = cpu_milli + if memory_mb is not None: + request_options["memory_mb"] = memory_mb if request_timeout is not None: request_options["request_timeout"] = request_timeout function: Any | None = None @@ -694,12 +715,15 @@ def _replace_application_code_bundle( volcenginesdkvefaas.UpdateFunctionRequest(**request_options) ) - def _set_function_min_instance(self, function_id: str) -> None: - """Set only a Function's minimum instance count to one.""" + def _set_function_min_instance( + self, function_id: str, *, max_instance: int | None = None + ) -> None: + """Keep one warm instance and optionally set the maximum instance count""" self.client.update_function_resource( volcenginesdkvefaas.UpdateFunctionResourceRequest( function_id=function_id, min_instance=1, + max_instance=max_instance, ) ) @@ -1016,6 +1040,9 @@ def deploy( enable_mcp_session: bool = True, keep_failed_deploy: bool = False, disable_gateway_cors: bool = False, + cpu_milli: int | None = None, + memory_mb: int | None = None, + max_instance: int | None = None, ) -> tuple[str, str, str]: """Deploy an agent project to VeFaaS service. @@ -1027,6 +1054,9 @@ def deploy( gateway_upstream_name (str, optional): Gateway upstream name. Defaults to "". enable_key_auth (bool, optional): Enable key auth. Defaults to False. disable_gateway_cors (bool, optional): Disable route-wide APIG CORS. + cpu_milli: Function CPU override in millicores. + memory_mb: Function memory override in MB; new functions default to 2048. + max_instance: Maximum instance override; omitted to preserve cloud settings. Returns: tuple[str, str, str]: (url, app_id, function_id) @@ -1068,6 +1098,9 @@ def deploy( }, disable_gateway_cors=disable_gateway_cors, normalize_studio_entrypoint=True, + cpu_milli=cpu_milli, + memory_mb=memory_mb, + max_instance=max_instance, ) logger.info( f"VeFaaS application {name} with ID {existing_app_id} updated on {url}." @@ -1103,7 +1136,9 @@ def deploy( f"Start to create VeFaaS function {function_name} with path {path}. Gateway: {gateway_name}, Gateway Service: {gateway_service_name}, Gateway Upstream: {gateway_upstream_name}." ) try: - function_name, function_id = self._create_function(function_name, path) + function_name, function_id = self._create_function( + function_name, path, cpu_milli=cpu_milli, memory_mb=memory_mb + ) logger.info( f"VeFaaS function {function_name} with ID {function_id} created." ) @@ -1122,7 +1157,7 @@ def deploy( logger.info(f"VeFaaS application {name} with ID {app_id} created.") logger.info(f"Start to release VeFaaS application {app_id}.") url = self._release_application(app_id) - self._set_function_min_instance(function_id) + self._set_function_min_instance(function_id, max_instance=max_instance) self.ensure_application_route_methods( app_id, disable_cors=disable_gateway_cors,