diff --git a/tests/cli/test_frontend_deploy_iam.py b/tests/cli/test_frontend_deploy_iam.py index eaa35724a..f5359ed09 100644 --- a/tests/cli/test_frontend_deploy_iam.py +++ b/tests/cli/test_frontend_deploy_iam.py @@ -267,6 +267,7 @@ def test_frontend_policy_allows_release_download() -> None: assert "iam:UpdatePolicy" not in actions assert "vefaas:CodeUploadCallback" in actions assert "vefaas:UpdateFunction" in actions + assert "vefaas:UpdateFunctionResource" in actions assert "vefaas:ReleaseApplication" in actions diff --git a/tests/cli/test_studio_deploy_permissions.py b/tests/cli/test_studio_deploy_permissions.py index d149faf0d..f048c5202 100644 --- a/tests/cli/test_studio_deploy_permissions.py +++ b/tests/cli/test_studio_deploy_permissions.py @@ -36,7 +36,7 @@ def test_default_studio_deploy_requires_all_reachable_actions() -> None: specs = _default_specs() actions = [spec.action for spec in specs] - assert len(actions) == 39 + assert len(actions) == 40 assert len(actions) == len(set(actions)) assert "id:CreateUserPool" in actions assert "iam:UpdatePolicy" in actions @@ -45,6 +45,7 @@ def test_default_studio_deploy_requires_all_reachable_actions() -> None: assert "apig:CreateGateway" in actions assert "apig:UpdateRoute" in actions assert "vefaas:CreateApplication" in actions + assert "vefaas:UpdateFunctionResource" in actions assert "vefaas:DeleteApplication" in actions @@ -274,7 +275,7 @@ def _precheck(*, specs, **_kwargs): ) assert result.exit_code == 0, result.output - assert "All 39 required IAM Actions are satisfied." in result.output + assert "All 40 required IAM Actions are satisfied." in result.output assert "Pre-check only: no cloud resources were created." in result.output diff --git a/tests/cli/test_studio_self_update.py b/tests/cli/test_studio_self_update.py index 5b4da3b94..30f5d44b9 100644 --- a/tests/cli/test_studio_self_update.py +++ b/tests/cli/test_studio_self_update.py @@ -231,13 +231,14 @@ def __init__(self, **kwargs: str) -> None: captured["credentials"] = kwargs self.client = object() - def submit_application_code_bundle_update(self, **kwargs: Any) -> None: + def submit_application_code_bundle_update(self, **kwargs: Any) -> bool: package = Path(str(kwargs["path"])) assert "--provider byteplus" in (package / "run.sh").read_text( encoding="utf-8" ) assert (package / "requirements.txt").is_file() captured["update"] = kwargs + return False def _resources(**kwargs: Any) -> dict[str, str]: captured["resource_request"] = kwargs @@ -301,6 +302,10 @@ def _resources(**kwargs: Any) -> dict[str, str]: assert status["progressMessage"] == "已提交,正在等待新 Revision 发布" assert status["targetVersion"] == manifest.version assert status["startedAt"] > 0 + assert any( + "vefaas:UpdateFunctionResource" in line and "MinInstance 保持原值" in line + for line in status["updateLogs"] + ) def test_submit_latest_reports_missing_vefaas_permissions( diff --git a/tests/cli/test_studio_update.py b/tests/cli/test_studio_update.py index 672aeb502..65f0b6e45 100644 --- a/tests/cli/test_studio_update.py +++ b/tests/cli/test_studio_update.py @@ -1142,6 +1142,7 @@ def test_update_application_code_bundle_merges_only_explicit_environment( tmp_path: Path, ) -> None: updated_requests: list[Any] = [] + resource_requests: list[Any] = [] service = object.__new__(VeFaaS) service.session_token = "" cast(Any, service).client = SimpleNamespace( @@ -1152,6 +1153,7 @@ def test_update_application_code_bundle_merges_only_explicit_environment( ] ), update_function=updated_requests.append, + update_function_resource=resource_requests.append, ) monkeypatch.setattr(service, "_upload_and_mount_code", lambda *_: None) monkeypatch.setattr(service, "_release_application", lambda _: "https://same") @@ -1170,6 +1172,96 @@ def test_update_application_code_bundle_merges_only_explicit_environment( "EXISTING": "kept", "VEADK_SITE_TITLE": "新标题", } + resource_request = resource_requests[0] + assert resource_request.function_id == "function-id" + assert resource_request.min_instance == 1 + assert resource_request.max_instance is None + assert resource_request.reserved_frozen_instance is None + + +def test_submit_application_code_bundle_sets_only_minimum_instance( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + updated_requests: list[Any] = [] + resource_requests: list[Any] = [] + releases: list[str] = [] + service = object.__new__(VeFaaS) + cast(Any, service).client = SimpleNamespace( + update_function=updated_requests.append, + update_function_resource=resource_requests.append, + ) + monkeypatch.setattr(service, "_upload_and_mount_code", lambda *_: None) + monkeypatch.setattr(service, "_start_application_release", releases.append) + + minimum_instance_updated = service.submit_application_code_bundle_update( + application_id="app-id", + function_id="function-id", + path=str(tmp_path), + ) + + assert updated_requests[0].id == "function-id" + resource_request = resource_requests[0] + assert resource_request.function_id == "function-id" + assert resource_request.min_instance == 1 + assert resource_request.max_instance is None + assert resource_request.reserved_frozen_instance is None + assert releases == ["app-id"] + assert minimum_instance_updated is True + + +def test_submit_application_code_bundle_continues_without_resource_permission( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + releases: list[str] = [] + service = object.__new__(VeFaaS) + + def _permission_denied(_: Any) -> None: + raise RuntimeError("AccessDenied: vefaas:UpdateFunctionResource") + + cast(Any, service).client = SimpleNamespace( + update_function=lambda _: None, + update_function_resource=_permission_denied, + ) + monkeypatch.setattr(service, "_upload_and_mount_code", lambda *_: None) + monkeypatch.setattr(service, "_start_application_release", releases.append) + + minimum_instance_updated = service.submit_application_code_bundle_update( + application_id="app-id", + function_id="function-id", + path=str(tmp_path), + ) + + assert minimum_instance_updated is False + assert releases == ["app-id"] + + +def test_submit_application_code_bundle_stops_on_other_resource_errors( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + releases: list[str] = [] + service = object.__new__(VeFaaS) + + def _invalid_operation(_: Any) -> None: + raise RuntimeError("InvalidOperation") + + cast(Any, service).client = SimpleNamespace( + update_function=lambda _: None, + update_function_resource=_invalid_operation, + ) + monkeypatch.setattr(service, "_upload_and_mount_code", lambda *_: None) + monkeypatch.setattr(service, "_start_application_release", releases.append) + + with pytest.raises(RuntimeError, match="InvalidOperation"): + service.submit_application_code_bundle_update( + application_id="app-id", + function_id="function-id", + path=str(tmp_path), + ) + + assert releases == [] def test_application_operations_use_deployment_region( @@ -1452,6 +1544,7 @@ def test_update_application_code_bundle_preserves_unspecified_sandbox_tool( ] ), update_function=updated_requests.append, + update_function_resource=lambda _: None, ) monkeypatch.setattr(service, "_upload_and_mount_code", lambda *_: None) monkeypatch.setattr(service, "_release_application", lambda _: "https://same") @@ -1480,6 +1573,7 @@ def test_update_application_code_bundle_does_not_read_or_replace_environment( cast(Any, service).client = SimpleNamespace( get_function=lambda _: pytest.fail("environment should not be read"), update_function=updated_requests.append, + update_function_resource=lambda _: None, ) monkeypatch.setattr(service, "_upload_and_mount_code", lambda *_: None) monkeypatch.setattr(service, "_release_application", lambda _: "https://same") diff --git a/tests/test_cloud.py b/tests/test_cloud.py index 132efc78f..4c05abddd 100644 --- a/tests/test_cloud.py +++ b/tests/test_cloud.py @@ -14,6 +14,7 @@ import os import tempfile +from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, Mock, patch @@ -47,9 +48,27 @@ def test_vefaas_create_function_uses_configured_project() -> None: assert request.project_name == "studio-project" +@pytest.mark.parametrize("provider", ["volcengine", "byteplus"]) +def test_vefaas_sets_only_minimum_instance(provider: str) -> None: + requests = [] + service = object.__new__(VeFaaS) + service.provider = provider + service.client = SimpleNamespace(update_function_resource=requests.append) + + service._set_function_min_instance("function-id") + + assert len(requests) == 1 + request = requests[0] + assert request.function_id == "function-id" + assert request.min_instance == 1 + assert request.max_instance is None + assert request.reserved_frozen_instance is None + + def test_vefaas_deploy_cleans_created_resources_on_release_failure() -> None: service = object.__new__(VeFaaS) service._create_function = Mock(return_value=("studio-app-fn", "function-id")) + service._set_function_min_instance = Mock() service._create_application = Mock(return_value="application-id") service._release_application = Mock(side_effect=RuntimeError("release failed")) service.delete = Mock() @@ -66,11 +85,13 @@ def test_vefaas_deploy_cleans_created_resources_on_release_failure() -> None: service.delete.assert_called_once_with("application-id") service.delete_function.assert_called_once_with("function-id") + service._set_function_min_instance.assert_not_called() def test_vefaas_deploy_can_keep_failed_resources_for_inspection() -> None: service = object.__new__(VeFaaS) service._create_function = Mock(return_value=("studio-app-fn", "function-id")) + service._set_function_min_instance = Mock() service._create_application = Mock(return_value="application-id") service._release_application = Mock(side_effect=RuntimeError("release failed")) service.delete = Mock() @@ -88,6 +109,60 @@ def test_vefaas_deploy_can_keep_failed_resources_for_inspection() -> None: service.delete.assert_not_called() service.delete_function.assert_not_called() + service._set_function_min_instance.assert_not_called() + + +def test_vefaas_update_code_uses_resource_updating_bundle_path( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + project = tmp_path / "agent" + project.mkdir() + service = object.__new__(VeFaaS) + service.find_app_id_by_name = Mock(return_value="application-id") + service._get_application_status = Mock( + return_value=( + "deploy_success", + { + "Result": { + "CloudResource": ( + '{"framework":{"function":{"Name":"studio-app-fn",' + '"Id":"function-id"}}}' + ) + } + }, + ) + ) + service._replace_application_code_bundle = Mock() + service._release_application = Mock(return_value="https://studio.example") + service._set_function_min_instance = Mock() + service.ensure_application_route_methods = Mock() + + def _cookiecutter(*, output_dir: str, extra_context: dict, **_: object) -> None: + package = Path(output_dir) / str(extra_context["local_dir_name"]) + (package / "src").mkdir(parents=True) + + monkeypatch.setattr( + "veadk.integrations.ve_faas.ve_faas.formatted_timestamp", lambda: "stamp" + ) + monkeypatch.setattr( + "veadk.integrations.ve_faas.ve_faas.tempfile.gettempdir", + lambda: str(tmp_path), + ) + monkeypatch.setattr( + "veadk.integrations.ve_faas.ve_faas.cookiecutter", _cookiecutter + ) + + result = service._update_function_code("studio-app", str(project)) + + assert result == ("https://studio.example", "application-id", "function-id") + service._replace_application_code_bundle.assert_called_once_with( + function_id="function-id", + path=str(tmp_path / "agent_update_stamp" / "src"), + environment_overrides=None, + request_timeout=1800, + ) + service._set_function_min_instance.assert_called_once_with("function-id") def test_apig_uses_session_token() -> None: diff --git a/veadk/cli/frontend_deploy_policy.py b/veadk/cli/frontend_deploy_policy.py index ca0646500..76ccaffdd 100644 --- a/veadk/cli/frontend_deploy_policy.py +++ b/veadk/cli/frontend_deploy_policy.py @@ -196,6 +196,7 @@ "vefaas:ReleaseApplication", "vefaas:SetSandboxTimeout", "vefaas:UpdateFunction", + "vefaas:UpdateFunctionResource", "vikingdb:GetKnowledgeBaseServiceInfo", "vikingdb:GetMemorydbInstanceDetail", "vikingdb:ListCollections", diff --git a/veadk/cli/studio_deploy_permissions.py b/veadk/cli/studio_deploy_permissions.py index 889b054dd..99edd5fab 100644 --- a/veadk/cli/studio_deploy_permissions.py +++ b/veadk/cli/studio_deploy_permissions.py @@ -200,6 +200,11 @@ def _permission(action: str, purpose_zh: str, purpose_en: str) -> PermissionSpec "更新 Studio 函数环境变量", "Update Studio function environment variables", ), + _permission( + "vefaas:UpdateFunctionResource", + "设置 Studio 函数最小实例数", + "Set the Studio function minimum instance count", + ), _permission( "vefaas:Release", "重新发布 Studio 函数", "Release the updated Studio function" ), diff --git a/veadk/cli/studio_self_update.py b/veadk/cli/studio_self_update.py index 683c48367..819fd6dee 100644 --- a/veadk/cli/studio_self_update.py +++ b/veadk/cli/studio_self_update.py @@ -410,12 +410,21 @@ def submit_version(self, version: str | None) -> StudioReleaseManifest: } ) self._set_progress("submitting", "正在提交 VeFaaS Function 更新") - service.submit_application_code_bundle_update( - application_id=self._settings.application_id, - function_id=self._settings.function_id, - path=str(package_dir), - environment_overrides=environment_overrides, + minimum_instance_updated = ( + service.submit_application_code_bundle_update( + application_id=self._settings.application_id, + function_id=self._settings.function_id, + path=str(package_dir), + environment_overrides=environment_overrides, + ) ) + if minimum_instance_updated is False: + self._diagnostic_lines.append( + "warning: Function 角色缺少 " + "vefaas:UpdateFunctionResource;本次 Studio 更新继续发布," + "但 MinInstance 保持原值。请执行一次 `veadk studio update` " + "或在 IAM 中补充该权限。" + ) self._submitted_version = manifest.version self._set_progress("publishing", "已提交,正在等待新 Revision 发布") return manifest diff --git a/veadk/integrations/ve_faas/ve_faas.py b/veadk/integrations/ve_faas/ve_faas.py index e6b974fea..0d9e77446 100644 --- a/veadk/integrations/ve_faas/ve_faas.py +++ b/veadk/integrations/ve_faas/ve_faas.py @@ -115,6 +115,24 @@ def _release_revision_number(response: dict[str, Any]) -> int | None: return None +def _is_permission_denied(error: BaseException) -> bool: + """Return whether a cloud error represents a missing IAM permission.""" + status = getattr(error, "status", None) or getattr(error, "status_code", None) + if status == 403: + return True + message = str(error).lower() + return any( + marker in message + for marker in ( + "accessdenied", + "access denied", + "forbidden", + "permission denied", + "unauthorized", + ) + ) + + class VeFaaS: def __init__( self, @@ -478,9 +496,10 @@ def update_application_code_bundle( ) -> str: """Replace an application's function bundle and release it. - Existing function settings are left untouched. When environment overrides - are provided, they are merged with the complete current environment before - updating the function. + 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. Args: application_id: Existing VeFaaS Application ID. @@ -496,7 +515,9 @@ def update_application_code_bundle( path=path, environment_overrides=environment_overrides, ) - return self._release_application(application_id) + url = self._release_application(application_id) + self._set_function_min_instance(function_id) + return url def submit_application_code_bundle_update( self, @@ -505,20 +526,28 @@ def submit_application_code_bundle_update( function_id: str, path: str, environment_overrides: dict[str, str] | None = None, - ) -> None: + ) -> bool: """Replace a function bundle and submit its Application release. Unlike :meth:`update_application_code_bundle`, this method does not wait for the new revision. It is intended for a function updating itself, because the current process may stop as soon as the control plane activates the - replacement revision. + replacement revision. Legacy Studio roles may not have permission to update + Function resources; in that case the release continues and ``False`` is + returned so the caller can surface a migration warning. """ self._replace_application_code_bundle( function_id=function_id, path=path, environment_overrides=environment_overrides, ) + # The Function is already deployed for an in-app update, so apply the + # resource setting before starting a release that may stop this process. + minimum_instance_updated = self._set_function_min_instance_if_permitted( + function_id + ) self._start_application_release(application_id) + return minimum_instance_updated def _replace_application_code_bundle( self, @@ -526,9 +555,12 @@ def _replace_application_code_bundle( function_id: str, path: str, environment_overrides: dict[str, str] | None, + request_timeout: int | None = None, ) -> None: """Upload a bundle and update the Function without releasing it.""" request_options: dict[str, Any] = {"id": function_id} + if request_timeout is not None: + request_options["request_timeout"] = request_timeout if environment_overrides: function = cast( Any, @@ -550,6 +582,34 @@ 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. + + ``MaxInstance`` and the remaining resource fields are intentionally + omitted so the cloud service preserves their existing values. + """ + self.client.update_function_resource( + volcenginesdkvefaas.UpdateFunctionResourceRequest( + function_id=function_id, + min_instance=1, + ) + ) + + def _set_function_min_instance_if_permitted(self, function_id: str) -> bool: + """Best-effort minimum-instance migration for in-app self-updates.""" + try: + self._set_function_min_instance(function_id) + except Exception as error: + if not _is_permission_denied(error): + raise + logger.warning( + "Function role lacks vefaas:UpdateFunctionResource; continuing " + "the Studio self-update without changing MinInstance. Run " + "`veadk studio update` once or grant the permission manually." + ) + return False + return True + def _update_function_code( self, application_name: str, # application name @@ -624,17 +684,17 @@ def _update_function_code( else: logger.warning("No requirements.txt found, using template default") - self._upload_and_mount_code(function_id, str(tmp_path / "src")) - self.client.update_function( - volcenginesdkvefaas.UpdateFunctionRequest( - id=function_id, - request_timeout=1800, # Keep same timeout as deploy - ) + self._replace_application_code_bundle( + function_id=function_id, + path=str(tmp_path / "src"), + environment_overrides=None, + request_timeout=1800, # Keep same timeout as deploy ) logger.info( f"VeFaaS function {function_name} with ID {function_id} updated." ) url = self._release_application(app_id) + self._set_function_min_instance(function_id) self.ensure_application_route_methods(app_id) logger.info( f"VeFaaS application {application_name} with ID {app_id} released." @@ -907,7 +967,6 @@ def deploy( logger.info( f"VeFaaS function {function_name} with ID {function_id} created." ) - logger.info(f"Start to create VeFaaS application {name}.") app_id = self._create_application( name, @@ -922,6 +981,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.ensure_application_route_methods(app_id) logger.info(f"VeFaaS application {name} with ID {app_id} released.") except Exception: