From a194d204f65dafc0070ae9be03304478d1c96b6f Mon Sep 17 00:00:00 2001 From: evanlowe <62918515+evanlowe@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:04:20 +0800 Subject: [PATCH] feat(studio): provision identity resources on deploy --- docs/content/docs/framework/frontend.en.mdx | 6 +- docs/content/docs/framework/frontend.mdx | 6 +- frontend/README.md | 6 +- tests/cli/test_studio_deploy_target.py | 297 ++++++++++++++++++++ veadk/cli/cli_frontend.py | 124 ++++++-- 5 files changed, 417 insertions(+), 22 deletions(-) diff --git a/docs/content/docs/framework/frontend.en.mdx b/docs/content/docs/framework/frontend.en.mdx index 9e6ff824..4c8219bc 100644 --- a/docs/content/docs/framework/frontend.en.mdx +++ b/docs/content/docs/framework/frontend.en.mdx @@ -255,11 +255,13 @@ identify the missing permission or account resource. ```bash veadk studio deploy \ - --user-pool-id \ - --allowed-client-id \ --vefaas-app-name ``` +When `--user-pool-id` and `--allowed-client-id` are omitted, the deploy command +creates or reuses a named user pool and Client in the selected `--region`, then +prints their IDs. Pass both options to keep using existing resources. + Deployment credentials can be supplied explicitly with `--volcengine-access-key` / `--volcengine-secret-key`, through the current process's `VOLCENGINE_ACCESS_KEY` / `VOLCENGINE_SECRET_KEY` environment diff --git a/docs/content/docs/framework/frontend.mdx b/docs/content/docs/framework/frontend.mdx index 86638a3a..51a4d774 100644 --- a/docs/content/docs/framework/frontend.mdx +++ b/docs/content/docs/framework/frontend.mdx @@ -208,11 +208,13 @@ CodeEnv Tool。也可以分别使用 `--sandbox-chat-codex-tool-id` 和 ```bash veadk studio deploy \ - --user-pool-id \ - --allowed-client-id \ --vefaas-app-name ``` +未指定 `--user-pool-id` 和 `--allowed-client-id` 时,部署命令会在用户选择的 +`--region` 中自动创建或复用同名用户池和 Client,并在完成后回显它们的 ID。 +也可以继续通过这两个参数使用已有资源。 + 部署凭据支持通过 `--volcengine-access-key` / `--volcengine-secret-key` 显式传入,也支持当前进程的 `VOLCENGINE_ACCESS_KEY` / `VOLCENGINE_SECRET_KEY` 环境变量。两者均未提供时,会读取 diff --git a/frontend/README.md b/frontend/README.md index 84e642ca..b47f5f16 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -222,11 +222,13 @@ the navbar without extra options: ```bash veadk studio deploy \ - --user-pool-id \ - --allowed-client-id \ --vefaas-app-name ``` +When `--user-pool-id` and `--allowed-client-id` are omitted, deployment creates +or reuses them in the selected `--region` and prints the resolved IDs. Pass both +options to keep using existing Identity resources. + Studio checks `latest.json` every three minutes and lists newer releases with their changelog and Git SHA. An accepted update verifies the selected complete Bundle, replaces the current Function code, and releases the existing diff --git a/tests/cli/test_studio_deploy_target.py b/tests/cli/test_studio_deploy_target.py index efe2e7b2..9792d6af 100644 --- a/tests/cli/test_studio_deploy_target.py +++ b/tests/cli/test_studio_deploy_target.py @@ -18,6 +18,7 @@ from unittest.mock import Mock from uuid import uuid4 +import click import pytest from click.testing import CliRunner from typing_extensions import Self @@ -27,6 +28,7 @@ from volcenginesdkcore.rest import ApiException from veadk.cli.cli_frontend import ( + _resolve_or_create_studio_identity_resources, _resolve_studio_cloud_credentials, _resolve_studio_identity_region, studio, @@ -103,6 +105,301 @@ def test_studio_credentials_prefer_inline_environment( assert credentials == ("env-ak", "env-sk", "env-token") +@pytest.mark.parametrize( + ("provider", "region"), + [ + ("volcengine", "cn-beijing"), + ("volcengine", "cn-shanghai"), + ("byteplus", "ap-southeast-1"), + ], +) +def test_studio_identity_resources_are_created_in_deployment_region( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + provider: str, + region: str, +) -> None: + captured: dict[str, object] = {} + + class _FakeIdentityClient: + def __init__(self, **kwargs: object) -> None: + captured["client_options"] = kwargs + + def get_user_pool(self, **kwargs: object) -> None: + captured["get_pool"] = kwargs + return None + + def create_user_pool(self, name: str) -> tuple[str, str]: + captured["create_pool"] = name + return "pool-created", "identity.example.com" + + def get_user_pool_client(self, **kwargs: object) -> None: + captured["get_client"] = kwargs + return None + + def create_user_pool_client(self, **kwargs: object) -> tuple[str, str]: + captured["create_client"] = kwargs + return "client-created", "secret-not-printed" + + monkeypatch.setattr( + "veadk.integrations.ve_identity.identity_client.IdentityClient", + _FakeIdentityClient, + ) + + resolved = _resolve_or_create_studio_identity_resources( + access_key="ak", + secret_key="sk", + session_token="token", + user_pool_id=None, + client_id=None, + application_name="my-studio", + region=region, + provider=provider, # type: ignore[arg-type] + ) + + assert resolved == ( + "pool-created", + "identity.example.com", + "client-created", + ) + assert captured["client_options"] == { + "access_key": "ak", + "secret_key": "sk", + "session_token": "token", + "region": region, + "provider": provider, + } + assert captured["get_pool"] == {"name": "veadk-studio-my-studio"} + assert captured["create_pool"] == "veadk-studio-my-studio" + assert captured["get_client"] == { + "user_pool_uid": "pool-created", + "name": "veadk-studio-my-studio-web", + } + assert captured["create_client"] == { + "user_pool_uid": "pool-created", + "name": "veadk-studio-my-studio-web", + "client_type": "WEB_APPLICATION", + } + output = capsys.readouterr().out + assert f"in {region}" in output + assert "secret-not-printed" not in output + + +def test_studio_identity_resources_reuse_existing_names( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _FakeIdentityClient: + def __init__(self, **_: object) -> None: + pass + + def get_user_pool(self, **kwargs: object) -> tuple[str, str]: + assert kwargs == {"name": "veadk-studio-my-studio"} + return "pool-existing", "existing.example.com" + + def create_user_pool(self, name: str) -> tuple[str, str]: + raise AssertionError(f"unexpected pool creation: {name}") + + def get_user_pool_client(self, **kwargs: object) -> tuple[str, str]: + assert kwargs == { + "user_pool_uid": "pool-existing", + "name": "veadk-studio-my-studio-web", + } + return "client-existing", "secret" + + def create_user_pool_client(self, **kwargs: object) -> tuple[str, str]: + raise AssertionError(f"unexpected client creation: {kwargs}") + + monkeypatch.setattr( + "veadk.integrations.ve_identity.identity_client.IdentityClient", + _FakeIdentityClient, + ) + + assert _resolve_or_create_studio_identity_resources( + access_key="ak", + secret_key="sk", + user_pool_id=None, + client_id=None, + application_name="my-studio", + region="cn-beijing", + ) == ("pool-existing", "existing.example.com", "client-existing") + + +def test_studio_identity_resources_create_client_for_provided_pool( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _FakeIdentityClient: + def __init__(self, **_: object) -> None: + pass + + def get_user_pool(self, **kwargs: object) -> tuple[str, str]: + assert kwargs == {"uid": "pool-provided"} + return "pool-provided", "provided.example.com" + + def get_user_pool_client(self, **_: object) -> None: + return None + + def create_user_pool_client(self, **kwargs: object) -> tuple[str, str]: + assert kwargs["user_pool_uid"] == "pool-provided" + return "client-created", "secret" + + monkeypatch.setattr( + "veadk.integrations.ve_identity.identity_client.IdentityClient", + _FakeIdentityClient, + ) + + assert _resolve_or_create_studio_identity_resources( + access_key="ak", + secret_key="sk", + user_pool_id="pool-provided", + client_id=None, + application_name="my-studio", + region="cn-beijing", + ) == ("pool-provided", "provided.example.com", "client-created") + + +def test_studio_identity_resources_reject_client_without_pool() -> None: + with pytest.raises( + click.ClickException, + match="--allowed-client-id requires --user-pool-id", + ): + _resolve_or_create_studio_identity_resources( + access_key="ak", + secret_key="sk", + user_pool_id=None, + client_id="client-only", + application_name="my-studio", + region="cn-beijing", + ) + + +@pytest.mark.parametrize( + ("provider_args", "expected_region", "expected_provider", "dev_args"), + [ + ( + [ + "--region", + "cn-beijing", + "--volcengine-access-key", + "ak", + "--volcengine-secret-key", + "sk", + ], + "cn-beijing", + "volcengine", + ["--sandbox-dev-tool-id", "dev-tool"], + ), + ( + [ + "--region", + "cn-shanghai", + "--volcengine-access-key", + "ak", + "--volcengine-secret-key", + "sk", + ], + "cn-shanghai", + "volcengine", + ["--sandbox-dev-tool-id", "dev-tool"], + ), + ( + [ + "--provider", + "byteplus", + "--byteplus-access-key", + "ak", + "--byteplus-secret-key", + "sk", + ], + "ap-southeast-1", + "byteplus", + [], + ), + ], +) +def test_studio_deploy_auto_identity_is_injected_and_printed( + monkeypatch: pytest.MonkeyPatch, + provider_args: list[str], + expected_region: str, + expected_provider: str, + dev_args: list[str], +) -> None: + captured: dict[str, object] = {} + environments: dict[str, str] = {} + + def _resolve_identity(**kwargs: object) -> tuple[str, str, str]: + captured["identity"] = kwargs + return "pool-created", "identity.example.com", "client-created" + + class _FakeCloudAgentEngine: + def __init__(self, **_: object) -> None: + self._vefaas_service = SimpleNamespace() + + def deploy(self, **_: object) -> SimpleNamespace: + return SimpleNamespace( + vefaas_endpoint="https://studio.example.com", + vefaas_application_id="app-id", + vefaas_function_id="", + ) + + class _FakeIdentityClient: + def __init__(self, **kwargs: object) -> None: + captured["callback_client"] = kwargs + + def register_callback_for_user_pool_client(self, **kwargs: object) -> None: + captured["callback"] = kwargs + + monkeypatch.setattr("veadk.config.veadk_environments", environments) + monkeypatch.setattr( + "veadk.cli.cli_frontend._resolve_or_create_studio_identity_resources", + _resolve_identity, + ) + monkeypatch.setattr( + "veadk.cloud.cloud_agent_engine.CloudAgentEngine", _FakeCloudAgentEngine + ) + monkeypatch.setattr( + "veadk.integrations.ve_identity.identity_client.IdentityClient", + _FakeIdentityClient, + ) + + result = CliRunner().invoke( + studio, + [ + "deploy", + "--vefaas-app-name", + "studio-app", + "--iam-role", + "trn:iam::role/test", + "--gateway-name", + "gateway", + "--sandbox-chat-codex-tool-id", + "codex-tool", + "--sandbox-chat-openclaw-tool-id", + "openclaw-tool", + "--sandbox-chat-hermes-tool-id", + "hermes-tool", + "--sandbox-skill-creator-tool-id", + "skill-tool", + *provider_args, + *dev_args, + ], + ) + + assert result.exit_code == 0, result.output + identity = captured["identity"] + assert isinstance(identity, dict) + assert identity["region"] == expected_region + assert identity["provider"] == expected_provider + assert identity["user_pool_id"] is None + assert identity["client_id"] is None + assert environments["OAUTH2_USER_POOL_ID"] == "pool-created" + assert environments["OAUTH2_USER_POOL_CLIENT_ID"] == "client-created" + assert environments["VEIDENTITY_REGION"] == expected_region + assert f"identity region: {expected_region}" in result.output + assert "user pool id: pool-created" in result.output + assert "user pool domain: identity.example.com" in result.output + assert "client id: client-created" in result.output + + def test_studio_credentials_fall_back_to_volc_default_profile( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, diff --git a/veadk/cli/cli_frontend.py b/veadk/cli/cli_frontend.py index 6c9038b7..cbc4248c 100644 --- a/veadk/cli/cli_frontend.py +++ b/veadk/cli/cli_frontend.py @@ -7167,6 +7167,75 @@ def _resolve_studio_identity_region( ) +def _resolve_or_create_studio_identity_resources( + *, + access_key: str, + secret_key: str, + user_pool_id: str | None, + client_id: str | None, + application_name: str, + region: str, + session_token: str = "", + provider: CloudProvider = DEFAULT_CLOUD_PROVIDER, +) -> tuple[str, str, str]: + """Resolve or create the Studio user pool and web client in one region.""" + from veadk.integrations.ve_identity.identity_client import IdentityClient + + resolved_pool_id = (user_pool_id or "").strip() + resolved_client_id = (client_id or "").strip() + if resolved_client_id and not resolved_pool_id: + raise click.ClickException( + "--allowed-client-id requires --user-pool-id so the client can be " + "resolved within its user pool." + ) + + identity_client = IdentityClient( + access_key=access_key, + secret_key=secret_key, + session_token=session_token, + region=region, + provider=provider, + ) + + if resolved_pool_id: + user_pool = identity_client.get_user_pool(uid=resolved_pool_id) + if user_pool is None: + raise click.ClickException( + f"Identity user pool '{resolved_pool_id}' was not found in {region}." + ) + resolved_pool_id, user_pool_domain = user_pool + click.echo(f"Using Identity user pool '{resolved_pool_id}' in {region}.") + else: + user_pool_name = f"veadk-studio-{application_name}" + user_pool = identity_client.get_user_pool(name=user_pool_name) + if user_pool is None: + click.echo(f"Creating Identity user pool '{user_pool_name}' in {region}…") + user_pool = identity_client.create_user_pool(name=user_pool_name) + else: + click.echo(f"Reusing Identity user pool '{user_pool_name}' in {region}.") + resolved_pool_id, user_pool_domain = user_pool + + if resolved_client_id: + return resolved_pool_id, user_pool_domain, resolved_client_id + + client_name = f"veadk-studio-{application_name}-web" + user_pool_client = identity_client.get_user_pool_client( + user_pool_uid=resolved_pool_id, + name=client_name, + ) + if user_pool_client is None: + click.echo(f"Creating Identity client '{client_name}' in {region}…") + user_pool_client = identity_client.create_user_pool_client( + user_pool_uid=resolved_pool_id, + name=client_name, + client_type="WEB_APPLICATION", + ) + else: + click.echo(f"Reusing Identity client '{client_name}' in {region}.") + resolved_client_id, _client_secret = user_pool_client + return resolved_pool_id, user_pool_domain, resolved_client_id + + def _resolve_studio_cloud_credentials( access_key: str | None, secret_key: str | None, @@ -7249,13 +7318,15 @@ def _resolve_studio_cloud_credentials( @studio.command("deploy") @click.option( "--user-pool-id", - required=True, - help="Identity User Pool UID that gates access (the gateway does SSO against it).", + default=None, + help="Existing Identity User Pool UID. When omitted, Studio creates or " + "reuses a pool in the deployment region.", ) @click.option( "--allowed-client-id", - required=True, - help="Identity client UID used for the SSO login at the gateway.", + default=None, + help="Existing Identity client UID used for SSO. When omitted, Studio " + "creates or reuses a web client in the deployment region.", ) @click.option( "--client-secret", @@ -7440,8 +7511,8 @@ def _resolve_studio_cloud_credentials( help=f"APMPlus environment name. Default: {STUDIO_APMPLUS_ENV}.", ) def frontend_deploy( - user_pool_id: str, - allowed_client_id: str, + user_pool_id: str | None, + allowed_client_id: str | None, client_secret: str, vefaas_app_name: str, provider: str, @@ -7480,7 +7551,7 @@ def frontend_deploy( """Deploy the SSO web frontend to VeFaaS. Builds a minimal function that runs `veadk studio --auth-mode frontend`, - with in-app SSO bound to the given Identity user pool + client, and prints + with in-app SSO bound to a resolved Identity user pool + client, and prints the public URL. Inside the function the frontend uses the bound IAM role's STS credentials to manage AgentKit runtimes. """ @@ -7538,15 +7609,31 @@ def frontend_deploy( if session_token: os.environ["BYTEPLUS_SESSION_TOKEN"] = session_token - identity_region = _resolve_studio_identity_region( - access_key=ak, - secret_key=sk, - user_pool_id=user_pool_id, - client_id=allowed_client_id, - deployment_region=region, - session_token=session_token, - provider=provider_id, - ) + user_pool_domain = "" + if user_pool_id and allowed_client_id: + identity_region = _resolve_studio_identity_region( + access_key=ak, + secret_key=sk, + user_pool_id=user_pool_id, + client_id=allowed_client_id, + deployment_region=region, + session_token=session_token, + provider=provider_id, + ) + else: + identity_region = region + user_pool_id, user_pool_domain, allowed_client_id = ( + _resolve_or_create_studio_identity_resources( + access_key=ak, + secret_key=sk, + session_token=session_token, + user_pool_id=user_pool_id, + client_id=allowed_client_id, + application_name=vefaas_app_name, + region=identity_region, + provider=provider_id, + ) + ) if identity_region != region: click.secho( f"Warning: Studio deploys to {region}, but the Identity user " @@ -7978,6 +8065,11 @@ def frontend_deploy( click.echo("") click.echo(f"✅ Frontend deployed: {url}") click.echo(f" application id: {app.vefaas_application_id}") + click.echo(f" identity region: {identity_region}") + click.echo(f" user pool id: {user_pool_id}") + if user_pool_domain: + click.echo(f" user pool domain: {user_pool_domain}") + click.echo(f" client id: {allowed_client_id}") click.echo(" (open the URL — you'll be redirected through SSO login)") finally: shutil.rmtree(tmp, ignore_errors=True)