From 59841607d7a1a8152df305e06abd286903be53f3 Mon Sep 17 00:00:00 2001 From: zhengchuyi Date: Mon, 24 Aug 2026 18:05:21 +0800 Subject: [PATCH 1/2] fix(studio): improve deploy failure telemetry --- docs/studio-tea-telemetry.md | 20 +- .../feishu/FeishuBotIntegration.tsx | 4 + frontend/src/telemetry/index.ts | 2 +- frontend/src/telemetry/privacy.ts | 73 +++++- frontend/src/telemetry/runtime.ts | 1 + frontend/src/telemetry/schema.ts | 1 + frontend/src/ui/ProjectPreview.tsx | 70 +++-- frontend/tests/agentWorkspace.test.mjs | 13 +- frontend/tests/teaTelemetry.test.mjs | 65 ++++- tests/cli/test_frontend_deploy_errors.py | 87 +++++++ veadk/cli/cli_frontend.py | 246 +++++++++++++----- 11 files changed, 495 insertions(+), 87 deletions(-) diff --git a/docs/studio-tea-telemetry.md b/docs/studio-tea-telemetry.md index 83618ef02..722a67cd0 100644 --- a/docs/studio-tea-telemetry.md +++ b/docs/studio-tea-telemetry.md @@ -47,8 +47,17 @@ Studio 的产品行为数据统一上报到 TEA App `1050062`。火山引擎和 ## 数据边界 只允许上报已登记的扁平 string/number 字段。禁止上报 Prompt、消息正文、模型响应、 -源码、文件路径、自由文本错误、错误堆栈、Cookie、Token、AK/SK 或其他密钥。错误只保留 -稳定的 `error_kind`、可选 `error_code` 和适用事件的 `failed_phase`。 +源码、文件路径、错误堆栈、Cookie、Token、AK/SK 或其他密钥。错误默认保留稳定的 +`error_kind`、可选 `error_code` 和适用事件的 `failed_phase`。 +`studio_agent_deploy` 失败事件额外允许上报经过前端脱敏与长度限制的 `error_message`, +用于定位部署、构建和 Runtime 初始化失败原因。构建阶段失败时,`error_message` 优先来自 +前端已收到的 CodePipeline 构建日志文本;没有可用构建日志时再回退到部署接口返回的 +错误摘要。构建日志较长时保留末尾内容,避免截掉通常位于日志尾部的真实失败行。 +后端在构建失败的最终同步中会有限重试拉取 CodePipeline 日志,直到保留的日志尾部包含 +构建错误 marker,降低日志服务延迟导致埋点只拿到镜像下载/解压进度的概率。 +部署主阶段按 `prepare -> upload -> build -> deploy -> publish -> update -> evaluation` +单调推进;CodePipeline 日志事件只更新构建日志,不应把已经进入部署阶段的任务回退为 +构建阶段。 `user_unique_id` 通过 TEA `config` 设置,不作为自定义事件属性重复发送。所有资源 ID 在模块边界转换为字符串;布尔属性使用 `0/1`。 @@ -58,3 +67,10 @@ Studio 的产品行为数据统一上报到 TEA App `1050062`。火山引擎和 本迁移仅删除 Studio 产品行为埋点使用的前端 APMPlus Web SDK 和配置传递链路。 `veadk/tracing/telemetry/`、APMPlus OpenTelemetry exporter、Runtime trace 和问题反馈中的 APMPlus 查询能力不在迁移范围内并继续保留。 + +## 开发排障记录 + +- 检查 Studio 后端使用的 AgentKit SDK 时,应优先使用仓库虚拟环境 + `.venv/bin/python`。当前全局 `python` 命令可能不存在,且全局 `python3` 环境可能未安装 + `agentkit` 包,直接执行 `python3 -c 'import agentkit'` 会得到 + `ModuleNotFoundError: No module named 'agentkit'`。 diff --git a/frontend/src/automations/feishu/FeishuBotIntegration.tsx b/frontend/src/automations/feishu/FeishuBotIntegration.tsx index a21ff6777..3bc0fd776 100644 --- a/frontend/src/automations/feishu/FeishuBotIntegration.tsx +++ b/frontend/src/automations/feishu/FeishuBotIntegration.tsx @@ -15,6 +15,7 @@ import { import { beginAgentDeploy, classifyTelemetryError, + safeTelemetryErrorMessage, type AgentDeployFailedProps, } from "../../telemetry"; import feishuLogo from "../../assets/feishu-logo.svg"; @@ -227,6 +228,7 @@ export function FeishuBotIntegration({ onBack }: FeishuBotIntegrationProps) { operation.fail({ failedPhase: telemetryDeployPhase(latestPhaseRef.current), errorKind: "abort", + errorMessage: safeTelemetryErrorMessage("用户取消部署"), }); return; } @@ -242,6 +244,7 @@ export function FeishuBotIntegration({ onBack }: FeishuBotIntegrationProps) { ...(cancelledRef.current ? { errorKind: "abort" as const } : classifyTelemetryError(error, { phase: latestPhaseRef.current })), + errorMessage: safeTelemetryErrorMessage(error), }); if (!mountedRef.current || cancelledRef.current) return; setDeploymentStatus("failed"); @@ -268,6 +271,7 @@ export function FeishuBotIntegration({ onBack }: FeishuBotIntegrationProps) { deploymentOperationRef.current?.fail({ failedPhase: telemetryDeployPhase(latestPhaseRef.current), errorKind: "abort", + errorMessage: safeTelemetryErrorMessage("用户取消部署"), }); if (mountedRef.current) setDeploymentStatus("cancelled"); } catch (error) { diff --git a/frontend/src/telemetry/index.ts b/frontend/src/telemetry/index.ts index a72e4f396..c88ce8746 100644 --- a/frontend/src/telemetry/index.ts +++ b/frontend/src/telemetry/index.ts @@ -1,6 +1,6 @@ import { TeaClient, type TeaClientConfig } from "./client"; import { TelemetryRuntime } from "./runtime"; -export { classifyTelemetryError } from "./privacy"; +export { classifyTelemetryError, safeTelemetryErrorMessage } from "./privacy"; export type { ClassifiedTelemetryError, TelemetryErrorContext, diff --git a/frontend/src/telemetry/privacy.ts b/frontend/src/telemetry/privacy.ts index d90734d8c..722105087 100644 --- a/frontend/src/telemetry/privacy.ts +++ b/frontend/src/telemetry/privacy.ts @@ -16,10 +16,19 @@ export interface TelemetryErrorContext { interface ErrorShape { code?: unknown; + message?: unknown; name?: unknown; status?: unknown; } +interface TelemetryMessageOptions { + preserveEnd?: boolean; +} + +const DEFAULT_STRING_MAX_LENGTH = 256; +const ERROR_MESSAGE_MAX_LENGTH = 1024; +const REDACTED = "[REDACTED]"; + function stableErrorCode(value: unknown): string | undefined { if (typeof value !== "string" && typeof value !== "number") return undefined; const code = String(value).trim(); @@ -33,6 +42,58 @@ function classifiedTelemetryError( return errorCode === undefined ? { errorKind } : { errorKind, errorCode }; } +function truncateTelemetryString( + value: string, + maxLength: number, + options: TelemetryMessageOptions = {}, +): string { + if (value.length <= maxLength) return value; + if (options.preserveEnd) { + const prefix = "[truncated] ..."; + return `${prefix}${value.slice(-Math.max(0, maxLength - prefix.length))}`; + } + const suffix = "... [truncated]"; + return `${value.slice(0, Math.max(0, maxLength - suffix.length))}${suffix}`; +} + +function redactTelemetryMessage(value: string): string { + return value + .replace( + /\b(Authorization\s*[:=]\s*)(Bearer\s+)?[^\s"',;&]+/gi, + (_match, prefix: string, bearer: string | undefined) => + `${prefix}${bearer ?? ""}${REDACTED}`, + ) + .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, `Bearer ${REDACTED}`) + .replace( + /\b([\w.-]*(?:token|password|passwd|secret|api[_-]?key|access[_-]?key|secret[_-]?key|cookie)[\w.-]*\s*[:=]\s*)(["']?)[^\s"',;&]+/gi, + (_match, prefix: string, quote: string) => `${prefix}${quote}${REDACTED}`, + ); +} + +/** Returns a compact, redacted error message suitable for product telemetry. */ +export function safeTelemetryErrorMessage( + error: unknown, + options: TelemetryMessageOptions = {}, +): string | undefined { + const shape = error !== null && typeof error === "object" + ? error as ErrorShape + : {}; + const raw = typeof shape.message === "string" + ? shape.message + : typeof error === "string" || + typeof error === "number" || + typeof error === "boolean" + ? String(error) + : ""; + const normalized = raw.replace(/\s+/g, " ").trim(); + if (!normalized) return undefined; + return truncateTelemetryString( + redactTelemetryMessage(normalized), + ERROR_MESSAGE_MAX_LENGTH, + options, + ); +} + /** Converts an unknown failure to an approved category without reading its text. */ export function classifyTelemetryError( error: unknown, @@ -120,6 +181,7 @@ const EVENT_KEYS: Record = { "failed_phase", "error_kind", "error_code", + "error_message", ], studio_sandbox_create: [ "status", @@ -194,7 +256,16 @@ export function sanitizeTelemetryPayload( const payload: TelemetryPayload = {}; for (const [key, value] of Object.entries(input)) { if (!allowed.has(key) || !isTelemetryValue(value)) continue; - payload[key] = typeof value === "string" ? value.slice(0, 256) : value; + if (typeof value === "string") { + payload[key] = truncateTelemetryString( + value, + key === "error_message" + ? ERROR_MESSAGE_MAX_LENGTH + : DEFAULT_STRING_MAX_LENGTH, + ); + } else { + payload[key] = value; + } } return payload; } diff --git a/frontend/src/telemetry/runtime.ts b/frontend/src/telemetry/runtime.ts index 33d8bffb1..db916a0b6 100644 --- a/frontend/src/telemetry/runtime.ts +++ b/frontend/src/telemetry/runtime.ts @@ -146,6 +146,7 @@ export class TelemetryRuntime { failed_phase: result.failedPhase, error_kind: result.errorKind, error_code: result.errorCode, + error_message: result.errorMessage, })); } diff --git a/frontend/src/telemetry/schema.ts b/frontend/src/telemetry/schema.ts index 70db452c3..107a27f0d 100644 --- a/frontend/src/telemetry/schema.ts +++ b/frontend/src/telemetry/schema.ts @@ -105,6 +105,7 @@ export interface AgentDeployFailedProps { | "unknown"; errorKind: ErrorKind; errorCode?: string; + errorMessage?: string; } export type SandboxKind = "codex" | "deepseek-harness" | "openclaw" | "hermes"; diff --git a/frontend/src/ui/ProjectPreview.tsx b/frontend/src/ui/ProjectPreview.tsx index 893606fb7..ad057c827 100644 --- a/frontend/src/ui/ProjectPreview.tsx +++ b/frontend/src/ui/ProjectPreview.tsx @@ -92,6 +92,7 @@ import { beginAgentDeploy, beginAgentSourceDownload, classifyTelemetryError, + safeTelemetryErrorMessage, type AgentDeployFailedProps, type AgentDeployStartedProps, } from "../telemetry"; @@ -145,6 +146,30 @@ function telemetryDeployPhase( } } +const DEPLOY_PHASE_ORDER: Record = { + prepare: 0, + upload: 1, + build: 2, + deploy: 3, + publish: 4, + update: 5, + evaluation: 6, + complete: 7, + github: 8, +}; + +function advanceDeploymentPhase( + current: string | undefined, + next: string | undefined, +): string { + if (!next) return current ?? "prepare"; + if (!current) return next; + const currentOrder = DEPLOY_PHASE_ORDER[current]; + const nextOrder = DEPLOY_PHASE_ORDER[next]; + if (currentOrder === undefined || nextOrder === undefined) return next; + return nextOrder >= currentOrder ? next : current; +} + const CodeEditor = lazy(() => import("./CodeEditor")); const ignoreCanvasAction = () => undefined; @@ -1559,6 +1584,7 @@ export function ProjectPreview({ let latestBuildLog: DeployBuildLogSnapshot | undefined; let latestGithubLog: DeployBuildLogSnapshot | undefined; let latestPhase = initialTask.phase ?? "prepare"; + let latestMessage = initialTask.message; const terminalBuildLog = ( status: DeployBuildLogSnapshot["status"], ): DeployBuildLogSnapshot | undefined => ( @@ -1598,23 +1624,21 @@ export function ProjectPreview({ }; return latestGithubLog; }; - const mergeBuildFailureLog = (message: string): DeployBuildLogSnapshot | undefined => { - if (latestPhase !== "build") return undefined; - const failureText = [ - "", - "----- 构建失败 -----", - message, - ].join("\n"); - latestBuildLog = mergeDeployBuildLog(latestBuildLog, { - source: "code-pipeline", + const finalizeBuildFailureLog = (): DeployBuildLogSnapshot | undefined => { + if (latestPhase !== "build" || !latestBuildLog?.text) return undefined; + latestBuildLog = { + ...latestBuildLog, status: "error", - text: failureText, - lineCount: failureText.split("\n").length, - truncated: false, updatedAt: Date.now(), - }); + }; return latestBuildLog; }; + const telemetryErrorMessage = (error: unknown): string | undefined => { + if (latestPhase === "build" && latestBuildLog?.text) { + return safeTelemetryErrorMessage(latestBuildLog.text, { preserveEnd: true }); + } + return safeTelemetryErrorMessage(error); + }; try { let activeGithubBinding = githubCicdBinding; if (deploymentRuntimeId && githubCicdBinding?.pipelineId) { @@ -1689,15 +1713,19 @@ export function ProjectPreview({ project, (s) => { if (s.runtimeName) taskRuntimeName = s.runtimeName; - latestPhase = s.phase; + const nextPhase = advanceDeploymentPhase(latestPhase, s.phase); if (s.buildLog) { latestBuildLog = mergeDeployBuildLog(latestBuildLog, s.buildLog); } else if (s.phase === "build" && !latestBuildLog) { latestBuildLog = pendingBuildLog(); } + if (s.phase === nextPhase) { + latestMessage = s.message; + } + latestPhase = nextPhase; if (mountedRef.current) { setStageMap((prev) => ({ ...prev, [s.phase]: s })); - setActivePhase(s.phase); + setActivePhase(latestPhase); } onDeploymentTaskChange?.({ id: taskId, @@ -1707,11 +1735,11 @@ export function ProjectPreview({ region: deployRegion, startedAt: taskStartedAt, status: "running", - phase: s.phase, + phase: latestPhase, label: - deploymentSteps.find((step) => step.phase === s.phase)?.label ?? - s.phase, - message: s.message, + deploymentSteps.find((step) => step.phase === latestPhase)?.label ?? + latestPhase, + message: latestMessage, pct: s.pct, ...(latestBuildLog ? { buildLog: latestBuildLog } : {}), }); @@ -1916,6 +1944,7 @@ export function ProjectPreview({ operation.fail({ failedPhase: telemetryDeployPhase(latestPhase), ...classifyTelemetryError(err, { phase: latestPhase }), + errorMessage: safeTelemetryErrorMessage(err), }); if (mountedRef.current) { setDeployError(null); @@ -1937,10 +1966,11 @@ export function ProjectPreview({ } if (mountedRef.current) setDeployError(message); if (mountedRef.current) setDeployResult(null); - const buildLog = mergeBuildFailureLog(message); + const buildLog = finalizeBuildFailureLog(); operation.fail({ failedPhase: telemetryDeployPhase(latestPhase), ...classifyTelemetryError(err, { phase: latestPhase }), + errorMessage: telemetryErrorMessage(err), }); const failedInBuild = Boolean(buildLog); const failedInGithub = latestPhase === "github" && Boolean(latestGithubLog); diff --git a/frontend/tests/agentWorkspace.test.mjs b/frontend/tests/agentWorkspace.test.mjs index 091fc90d1..7f5d82b9b 100644 --- a/frontend/tests/agentWorkspace.test.mjs +++ b/frontend/tests/agentWorkspace.test.mjs @@ -398,9 +398,16 @@ test("workspace publish flow restores PR 748 deployment lifecycle hooks", () => assert.match(projectPreviewSource, /const pendingBuildLog = \(\): DeployBuildLogSnapshot/); assert.match(projectPreviewSource, /s\.phase === "build" && !latestBuildLog[\s\S]*?latestBuildLog = pendingBuildLog\(\)/); assert.match(projectPreviewSource, /let latestPhase = initialTask\.phase \?\? "prepare"/); - assert.match(projectPreviewSource, /const mergeBuildFailureLog = \(message: string\): DeployBuildLogSnapshot \| undefined =>/); - assert.match(projectPreviewSource, /"----- 构建失败 -----"[\s\S]*?latestBuildLog = mergeDeployBuildLog\(latestBuildLog/); - assert.match(projectPreviewSource, /latestPhase = s\.phase/); + assert.match(projectPreviewSource, /function advanceDeploymentPhase\(\s*current: string \| undefined,\s*next: string \| undefined,\s*\): string/); + assert.match(projectPreviewSource, /const nextPhase = advanceDeploymentPhase\(latestPhase, s\.phase\)/); + assert.match(projectPreviewSource, /const finalizeBuildFailureLog = \(\): DeployBuildLogSnapshot \| undefined =>/); + assert.match(projectPreviewSource, /latestPhase !== "build" \|\| !latestBuildLog\?\.text/); + assert.doesNotMatch(projectPreviewSource, /"----- 构建失败 -----"/); + assert.match(projectPreviewSource, /const telemetryErrorMessage = \(error: unknown\): string \| undefined =>/); + assert.match(projectPreviewSource, /latestPhase === "build" && latestBuildLog\?\.text[\s\S]*?safeTelemetryErrorMessage\(latestBuildLog\.text, \{ preserveEnd: true \}\)/); + assert.match(projectPreviewSource, /errorMessage: telemetryErrorMessage\(err\)/); + assert.doesNotMatch(projectPreviewSource, /latestPhase = s\.phase/); + assert.match(projectPreviewSource, /setActivePhase\(latestPhase\)/); assert.match( projectPreviewSource, /label: "部署失败"[\s\S]*?message: failedInBuild[\s\S]*?\.\.\.\(buildLog/, diff --git a/frontend/tests/teaTelemetry.test.mjs b/frontend/tests/teaTelemetry.test.mjs index 84b8aaa14..6905f8c2c 100644 --- a/frontend/tests/teaTelemetry.test.mjs +++ b/frontend/tests/teaTelemetry.test.mjs @@ -57,7 +57,10 @@ const privacyResult = await build({ write: false, }); const privacyModuleUrl = `data:text/javascript;base64,${Buffer.from(privacyResult.outputFiles[0].contents).toString("base64")}`; -const { classifyTelemetryError } = await import(privacyModuleUrl); +const { + classifyTelemetryError, + safeTelemetryErrorMessage, +} = await import(privacyModuleUrl); const clientResult = await build({ entryPoints: [ @@ -236,6 +239,66 @@ test("links started and terminal events while making the terminal idempotent", ( assert.equal(events[1].payload.runtime_id, "runtime-1"); }); +test("records compact redacted deploy failure messages", () => { + const { events, runtime, setNow } = harness(); + const operation = runtime.beginAgentDeploy({ + agentId: "agent-1", + deployAction: "create", + deploySource: "scratch", + createMode: "custom", + aiAssisted: 0, + deployRegion: "cn-beijing", + runtimeNetworkType: "public", + feishuEnabled: 0, + }); + const error = new Error( + `Deploy failed +Authorization: Bearer abc.def.ghi token=plain-secret password="quoted-secret" ${"x".repeat(1200)}`, + ); + setNow(200); + operation.fail({ + failedPhase: "deploy", + errorKind: "server", + errorCode: "500", + errorMessage: safeTelemetryErrorMessage(error), + }); + + assert.equal(events.length, 2); + const failed = events[1].payload; + assert.equal(failed.status, "failed"); + assert.equal(failed.error_kind, "server"); + assert.equal(failed.error_code, "500"); + assert.equal(failed.failed_phase, "deploy"); + assert.equal(typeof failed.error_message, "string"); + assert.ok(failed.error_message.length <= 1024); + assert.match(failed.error_message, /Deploy failed Authorization:/); + assert.match(failed.error_message, /Bearer \[REDACTED\]/); + assert.match(failed.error_message, /token=\[REDACTED\]/); + assert.match(failed.error_message, /password="\[REDACTED\]"/); + assert.match(failed.error_message, /\[truncated\]$/); + assert.doesNotMatch(failed.error_message, /abc\.def\.ghi/); + assert.doesNotMatch(failed.error_message, /plain-secret/); + assert.doesNotMatch(failed.error_message, /quoted-secret/); + assert.equal(safeTelemetryErrorMessage({ code: "E_UNKNOWN" }), undefined); +}); + +test("can preserve the tail of long build log telemetry messages", () => { + const message = safeTelemetryErrorMessage( + `${"installing dependency\n".repeat(200)} +error: failed to solve: process "/bin/sh -c uv pip install -r requirements.txt" did not complete successfully: exit code: 1 +Authorization: Bearer build.secret.token`, + { preserveEnd: true }, + ); + + assert.equal(typeof message, "string"); + assert.ok(message.length <= 1024); + assert.match(message, /^\[truncated\] \.\.\./); + assert.match(message, /uv pip install -r requirements\.txt/); + assert.match(message, /exit code: 1/); + assert.match(message, /Bearer \[REDACTED\]/); + assert.doesNotMatch(message, /build\.secret\.token/); +}); + test("provides all six typed operation event families", () => { const { events, runtime } = harness(); runtime.beginSandboxCreate({ diff --git a/tests/cli/test_frontend_deploy_errors.py b/tests/cli/test_frontend_deploy_errors.py index 65cdb7be6..99ca685da 100644 --- a/tests/cli/test_frontend_deploy_errors.py +++ b/tests/cli/test_frontend_deploy_errors.py @@ -13,9 +13,12 @@ # limitations under the License. from veadk.cli.cli_frontend import ( + _advance_deploy_phase, + _build_log_tail_has_error_marker, _cp_metadata_from_reporter_message, _extract_build_error_excerpt, _sanitize_build_log_snapshot, + _wait_for_cp_build_error_log_snapshot, ) @@ -68,6 +71,90 @@ def test_extract_build_error_excerpt_ignores_successful_logs() -> None: assert _extract_build_error_excerpt(lines) == "" +def test_advance_deploy_phase_classifies_runtime_initialization_failure() -> None: + message = ( + "Deploy failed: Runtime status is Error. Initialization failed " + "ErrorCode.RUNTIME_NOT_READY" + ) + + assert _advance_deploy_phase("build", message) == "deploy" + assert _advance_deploy_phase("deploy", "Step 1/2: Building image") == "deploy" + + +def test_build_log_tail_error_marker_checks_retained_tail() -> None: + text = "error: failed to solve\n" + ("progress line\n" * 20) + + assert not _build_log_tail_has_error_marker(text, tail_chars=20) + assert _build_log_tail_has_error_marker(text, tail_chars=len(text)) + + +def test_wait_for_cp_build_error_log_snapshot_retries_until_tail_has_marker() -> None: + snapshots = iter( + [ + {"text": "downloading base image"}, + {"text": "extracting base image"}, + {"text": "error: failed to solve: exit code: 1"}, + ] + ) + sleeps: list[float] = [] + + snapshot = _wait_for_cp_build_error_log_snapshot( + lambda: next(snapshots), + attempts=5, + interval_seconds=2.0, + sleep_fn=sleeps.append, + ) + + assert snapshot["text"].startswith("error: failed to solve") + assert sleeps == [2.0, 2.0] + + +def test_wait_for_cp_build_error_log_snapshot_returns_last_snapshot_on_timeout() -> ( + None +): + snapshots = iter( + [ + {"text": "downloading base image"}, + {"text": "extracting base image"}, + ] + ) + sleeps: list[float] = [] + + snapshot = _wait_for_cp_build_error_log_snapshot( + lambda: next(snapshots), + attempts=2, + interval_seconds=0.5, + sleep_fn=sleeps.append, + ) + + assert snapshot == {"text": "extracting base image"} + assert sleeps == [0.5] + + +def test_wait_for_cp_build_error_log_snapshot_keeps_last_success_on_later_error() -> ( + None +): + calls = 0 + sleeps: list[float] = [] + + def read_snapshot() -> dict[str, str]: + nonlocal calls + calls += 1 + if calls == 1: + return {"text": "extracting base image"} + raise RuntimeError("temporary log download failure") + + snapshot = _wait_for_cp_build_error_log_snapshot( + read_snapshot, + attempts=2, + interval_seconds=0.5, + sleep_fn=sleeps.append, + ) + + assert snapshot == {"text": "extracting base image"} + assert sleeps == [0.5] + + def test_sanitize_build_log_snapshot_redacts_and_bounds_logs() -> None: text = """Authorization: Bearer temporary.jwt.token installing dependencies diff --git a/veadk/cli/cli_frontend.py b/veadk/cli/cli_frontend.py index 39e558619..a19218e0c 100644 --- a/veadk/cli/cli_frontend.py +++ b/veadk/cli/cli_frontend.py @@ -32,7 +32,7 @@ import tempfile import unicodedata import zipfile -from collections.abc import Iterable, Mapping +from collections.abc import Callable, Iterable, Mapping from concurrent.futures import ThreadPoolExecutor from pathlib import Path from time import monotonic, sleep @@ -83,6 +83,8 @@ "unsatisfiable", "failed to solve", "did not complete successfully", + "exit code:", + "error:", "no matching distribution", "modulenotfounderror", "command not found", @@ -110,8 +112,30 @@ r"\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\." r"[A-Za-z0-9_-]{10,}\b" ), ) -_CP_BUILD_LOG_MAX_CHARS = 16000 -_CP_BUILD_LOG_MAX_LINES = 260 +_CP_BUILD_LOG_MAX_CHARS = 50000 +_CP_BUILD_LOG_MAX_LINES = 1000 +_CP_BUILD_LOG_FINAL_ERROR_RETRIES = 5 +_CP_BUILD_LOG_FINAL_ERROR_RETRY_INTERVAL_SECONDS = 2.0 +_CP_BUILD_LOG_ERROR_TAIL_CHECK_CHARS = 1024 +_DEPLOY_PHASE_ORDER = {"build": 0, "deploy": 1, "publish": 2, "update": 3} +_DEPLOY_PHASE_MARKERS = ( + "step 2/2", + "deploy failed", + "deploying service", + "creating runtime", + "create runtime", + "waiting for runtime", + "runtime status is error", + "runtime status: error", + "initialization failed", + "runtime_not_ready", +) +_PUBLISH_PHASE_MARKERS = ( + "launch successful", + "service endpoint:", + "runtime status: ready", + "endpoint: http", +) _SANDBOX_TOOL_CREATE_STAGGER_SECONDS = 0.5 _CP_PIPELINE_CREATED_RE = re.compile( r"Pipeline created successfully:\s*(?P.*?)\s*\(ID:\s*(?P[^)]+)\)" @@ -436,6 +460,67 @@ def _extract_build_error_excerpt( ) +def _build_log_tail_has_error_marker( + text: object, + *, + tail_chars: int = _CP_BUILD_LOG_ERROR_TAIL_CHECK_CHARS, +) -> bool: + """Return whether the retained log tail contains a known build error marker.""" + value = str(text or "").lower() + if tail_chars > 0: + value = value[-tail_chars:] + return any(marker in value for marker in _BUILD_ERROR_MARKERS) + + +def _wait_for_cp_build_error_log_snapshot( + read_snapshot: Callable[[], dict[str, Any]], + *, + attempts: int = _CP_BUILD_LOG_FINAL_ERROR_RETRIES, + interval_seconds: float = _CP_BUILD_LOG_FINAL_ERROR_RETRY_INTERVAL_SECONDS, + sleep_fn: Callable[[float], None] = sleep, +) -> dict[str, Any]: + """Read CodePipeline logs until the retained tail includes an error marker.""" + max_attempts = max(1, attempts) + snapshot: dict[str, Any] = {} + last_error: Exception | None = None + for attempt in range(max_attempts): + try: + snapshot = read_snapshot() + last_error = None + except Exception as error: + last_error = error + if attempt >= max_attempts - 1 and not snapshot: + raise + if attempt < max_attempts - 1: + sleep_fn(interval_seconds) + continue + if _build_log_tail_has_error_marker(snapshot.get("text", "")): + return snapshot + if attempt < max_attempts - 1: + sleep_fn(interval_seconds) + if last_error is not None and not snapshot: + raise last_error + return snapshot + + +def _advance_deploy_phase(current: str, message: object) -> str: + """Advance the deployment phase from textual AgentKit progress or errors.""" + m = str(message or "").lower() + if any(marker in m for marker in _PUBLISH_PHASE_MARKERS): + candidate = "publish" + elif any(marker in m for marker in _DEPLOY_PHASE_MARKERS): + candidate = "deploy" + elif "step 1/2" in m: + candidate = "build" + else: + candidate = current + return ( + candidate + if _DEPLOY_PHASE_ORDER.get(candidate, 0) >= _DEPLOY_PHASE_ORDER.get(current, 0) + else current + ) + + def _sanitize_build_log_snapshot( text: object, *, @@ -5455,7 +5540,6 @@ def _agentkit_sdk_credential_env(): cp_log_stop_event = _threading.Event() task_state["cp_log_stop_event"] = cp_log_stop_event - _PHASE_ORDER = {"build": 0, "deploy": 1, "publish": 2, "update": 3} _CP_WORKSPACE_NAME = str( deployment_resource_config.get("cp_workspace_name") or "agentkit-cli-workspace" @@ -5512,32 +5596,12 @@ def _error_with_build_excerpt(error_text: str) -> str: def _classify(message: str) -> str: """Map a reporter message to a deploy phase, monotonically. - The SDK prints two authoritative high-level markers — "Step 1/2: - Building image" and "Step 2/2: Deploying service" — so the phase - switches on those, and only advances to "publish" on a strong - readiness/endpoint signal. The phase never regresses: many - build/deploy sub-messages mention words like "endpoint", "ready", - or "create" (e.g. "Ensuring CR public endpoint access", "Waiting for - Runtime to be ready") that would otherwise flap the UI stepper - backward. + The SDK usually prints high-level markers such as "Step 1/2: + Building image" and "Step 2/2: Deploying service". Some failures + only surface in the final error, so deployment/runtime failure + markers also advance to the deploy phase. The phase never regresses. """ - m = message.lower() - cur = state["phase"] - if "step 2/2" in m: - cand = "deploy" - elif "step 1/2" in m: - cand = "build" - elif ( - "launch successful" in m - or "service endpoint:" in m - or "runtime status: ready" in m - or "endpoint: http" in m - ): - cand = "publish" - else: - cand = cur - # Phase only ever moves forward (build -> deploy -> publish). - return cand if _PHASE_ORDER[cand] >= _PHASE_ORDER[cur] else cur + return _advance_deploy_phase(state["phase"], message) from agentkit.toolkit.reporter import Reporter, TaskHandle @@ -5704,44 +5768,89 @@ def _download_cp_build_log_text( ) return "\n\n".join(parts) - def _poll_cp_build_logs() -> None: - last_text = "" - try: - from agentkit.toolkit.volcengine.code_pipeline import VeCodePipeline + def _new_cp_client(): + from agentkit.toolkit.volcengine.code_pipeline import VeCodePipeline - ak, sk, token = _resolve_ve_credentials() - cp_client = VeCodePipeline( - access_key=ak, - secret_key=sk, - session_token=token or "", - region=region, - provider=provider, + ak, sk, token = _resolve_ve_credentials() + return VeCodePipeline( + access_key=ak, + secret_key=sk, + session_token=token or "", + region=region, + provider=provider, + ) + + def _read_cp_build_log_snapshot(cp_client) -> dict[str, Any]: + workspace_id = _resolve_cp_workspace_id(cp_client) + pipeline_id = _resolve_cp_pipeline_id(cp_client, workspace_id) + with _deploy_tasks_lock: + pipeline_run_id = str(task_state.get("cp_pipeline_run_id") or "") + if not pipeline_run_id: + raise RuntimeError("Code Pipeline run id is not available yet") + + text = _download_cp_build_log_text( + cp_client, + workspace_id=workspace_id, + pipeline_id=pipeline_id, + pipeline_run_id=pipeline_run_id, + ) + snapshot = _sanitize_build_log_snapshot( + _redact_managed_artifact_text( + text, + [sidecar_base_image], ) - workspace_id = _resolve_cp_workspace_id(cp_client) - pipeline_id = _resolve_cp_pipeline_id(cp_client, workspace_id) + ) + current_text = str(snapshot.get("text") or "") + if current_text: with _deploy_tasks_lock: - pipeline_run_id = str(task_state.get("cp_pipeline_run_id") or "") - if not pipeline_run_id: - raise RuntimeError("Code Pipeline run id is not available yet") + task_state["cp_build_log"] = snapshot + return snapshot - while not cp_log_stop_event.is_set(): - text = _download_cp_build_log_text( - cp_client, - workspace_id=workspace_id, - pipeline_id=pipeline_id, - pipeline_run_id=pipeline_run_id, + def _refresh_cp_build_log_event(status: str) -> dict[str, Any] | None: + try: + cp_client = _new_cp_client() + if status == "error": + snapshot = _wait_for_cp_build_error_log_snapshot( + lambda: _read_cp_build_log_snapshot(cp_client) ) - snapshot = _sanitize_build_log_snapshot( - _redact_managed_artifact_text( - text, - [sidecar_base_image], - ) + else: + snapshot = _read_cp_build_log_snapshot(cp_client) + except Exception as log_error: + if cp_log_stop_event.is_set(): + logger.debug( + "final Code Pipeline build log refresh skipped: %s", + log_error, ) + return None + logger.warning( + "final Code Pipeline build log refresh failed: %s", + log_error, + exc_info=True, + ) + return _cp_log_event( + status="error", + message="暂时无法读取最终构建日志。", + error=_safe_exception_detail(log_error), + ) + if not str(snapshot.get("text") or ""): + return None + message = ( + "构建镜像失败,已同步最终构建日志。" + if status == "error" + else "构建日志同步完成。" + ) + return _cp_log_event(status=status, message=message, snapshot=snapshot) + + def _poll_cp_build_logs() -> None: + last_text = "" + try: + cp_client = _new_cp_client() + + while not cp_log_stop_event.is_set(): + snapshot = _read_cp_build_log_snapshot(cp_client) current_text = str(snapshot.get("text") or "") if current_text and current_text != last_text: last_text = current_text - with _deploy_tasks_lock: - task_state["cp_build_log"] = snapshot events.put( _cp_log_event( status="running", @@ -5756,7 +5865,11 @@ def _poll_cp_build_logs() -> None: _cp_log_event( status="complete", message="构建日志同步完成。", - snapshot=_sanitize_build_log_snapshot(last_text), + snapshot={ + "text": last_text, + "lineCount": len(last_text.splitlines()), + "truncated": False, + }, ) ) except Exception as log_error: @@ -6397,6 +6510,21 @@ async def _stream(): break yield f"data: {_json.dumps(ev, ensure_ascii=False)}\n\n" + if result_box.get("error"): + state["phase"] = _classify(str(result_box["error"])) + + if result_box.get("error") and state["phase"] == "build": + cp_error_event = await loop.run_in_executor( + None, + _refresh_cp_build_log_event, + "error", + ) + if cp_error_event is not None: + yield ( + f"data: " + f"{_json.dumps(cp_error_event, ensure_ascii=False)}\n\n" + ) + final: dict[str, Any] = {"done": True} if result_box.get("error"): error_text = str(result_box["error"]) From 508fbf46c304cc79f733fa3b42373c5b2ca84e7d Mon Sep 17 00:00:00 2001 From: zhengchuyi Date: Mon, 24 Aug 2026 20:03:25 +0800 Subject: [PATCH 2/2] fix(studio): resolve telemetry account id --- frontend/server/studio_update_resources.py | 28 ++- frontend/src/App.tsx | 1 + frontend/src/adk/client.ts | 5 + frontend/src/telemetry/privacy.ts | 1 + frontend/src/telemetry/runtime.ts | 5 + frontend/src/telemetry/schema.ts | 1 + frontend/tests/studioAccess.test.mjs | 2 + frontend/tests/teaTelemetry.test.mjs | 3 + tests/cli/test_frontend_runtime_proxy.py | 2 + tests/cli/test_studio_account_id.py | 85 ++++++++ tests/cli/test_studio_telemetry.py | 2 + tests/cli/test_studio_update.py | 1 + .../server/test_studio_update_resources.py | 105 +++++++--- veadk/cli/cli_frontend.py | 118 +++++------ veadk/cli/studio_account_id.py | 189 ++++++++++++++++++ veadk/cli/studio_telemetry.py | 5 + 16 files changed, 456 insertions(+), 97 deletions(-) create mode 100644 tests/cli/test_studio_account_id.py create mode 100644 veadk/cli/studio_account_id.py diff --git a/frontend/server/studio_update_resources.py b/frontend/server/studio_update_resources.py index 2b3efc58e..abe3ae47f 100644 --- a/frontend/server/studio_update_resources.py +++ b/frontend/server/studio_update_resources.py @@ -20,6 +20,10 @@ from typing import Any, Literal from frontend.server.storage.provisioning import resolve_studio_storage_for_deploy +from veadk.cli.studio_account_id import ( + resolve_studio_account_id_metadata, + studio_account_id_environment, +) from veadk.utils.cloud_provider import CloudProvider SnapshotKind = Literal["codex", "openclaw", "hermes"] @@ -31,10 +35,10 @@ ) -def _function_config( +def _function_state( function_client: Any, function_id: str, -) -> dict[str, str]: +) -> tuple[object, dict[str, str]]: import volcenginesdkvefaas function = function_client.get_function( @@ -45,7 +49,7 @@ def _function_config( for item in (getattr(function, "envs", None) or []) if getattr(item, "key", None) } - return environment + return function, environment def _provision_snapshot_tool( @@ -135,7 +139,7 @@ def reconcile_studio_update_resources( session_token: str, ) -> dict[str, str]: """Return environment overrides for resources missing from an older Studio.""" - environment = _function_config(function_client, function_id) + function, environment = _function_state(function_client, function_id) overrides: dict[str, str] = {} from veadk.cli.studio_knowledge_signing import ( @@ -167,6 +171,22 @@ def reconcile_studio_update_resources( } ) + account_resolution = resolve_studio_account_id_metadata( + environment={**environment, **overrides}, + remote_function=function, + access_key=access_key, + secret_key=secret_key, + session_token=session_token, + region=region, + provider=provider, + ) + overrides.update( + studio_account_id_environment( + account_resolution, + clear_error_on_success=True, + ) + ) + missing_snapshot_tools = [ item for item in _SNAPSHOT_ENVIRONMENTS if not environment.get(item[0]) ] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6aa3b4f18..4adf438a5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2937,6 +2937,7 @@ export default function App() { environment, cloudProvider: cfg.provider, accountId: studio?.accountId ?? "", + accountIdResolutionError: studio?.accountIdResolutionError ?? "", }); trackStudioEntryViewed({ authState: "anonymous" }); setFeatures(cfg.features); diff --git a/frontend/src/adk/client.ts b/frontend/src/adk/client.ts index 40baa339e..c71ef65b1 100644 --- a/frontend/src/adk/client.ts +++ b/frontend/src/adk/client.ts @@ -2637,6 +2637,7 @@ export interface StudioTelemetryContext { project: string; version: string; accountId?: string; + accountIdResolutionError?: string; } export interface StudioTelemetryConfig { @@ -2715,6 +2716,10 @@ function normalizeStudioTelemetryConfig(value: unknown): StudioTelemetryConfig { project: typeof studio.project === "string" ? studio.project : "", version: typeof studio.version === "string" ? studio.version : "", accountId: typeof studio.accountId === "string" ? studio.accountId : "", + accountIdResolutionError: + typeof studio.accountIdResolutionError === "string" + ? studio.accountIdResolutionError + : "", }, }; } diff --git a/frontend/src/telemetry/privacy.ts b/frontend/src/telemetry/privacy.ts index 722105087..f38439f14 100644 --- a/frontend/src/telemetry/privacy.ts +++ b/frontend/src/telemetry/privacy.ts @@ -158,6 +158,7 @@ const COMMON_KEYS = [ "environment", "cloud_provider", "account_id", + "account_id_resolution_error", "user_role", "user_source", "page_instance_id", diff --git a/frontend/src/telemetry/runtime.ts b/frontend/src/telemetry/runtime.ts index db916a0b6..116ce8478 100644 --- a/frontend/src/telemetry/runtime.ts +++ b/frontend/src/telemetry/runtime.ts @@ -77,6 +77,7 @@ export class TelemetryRuntime { this.context = { ...context, accountId: context.accountId?.trim() ?? "", + accountIdResolutionError: context.accountIdResolutionError?.trim() ?? "", }; } @@ -124,6 +125,8 @@ export class TelemetryRuntime { environment: this.context.environment, cloud_provider: this.context.cloudProvider, account_id: this.context.accountId, + account_id_resolution_error: + this.context.accountIdResolutionError || undefined, page_instance_id: this.pageInstanceId, auth_state: props.authState, })); @@ -290,6 +293,8 @@ export class TelemetryRuntime { environment: this.context.environment, cloud_provider: this.context.cloudProvider, account_id: this.identity.accountId, + account_id_resolution_error: + this.context.accountIdResolutionError || undefined, user_role: this.identity.userRole, user_source: this.identity.userSource, page_instance_id: this.pageInstanceId, diff --git a/frontend/src/telemetry/schema.ts b/frontend/src/telemetry/schema.ts index 107a27f0d..fe4f8d56d 100644 --- a/frontend/src/telemetry/schema.ts +++ b/frontend/src/telemetry/schema.ts @@ -29,6 +29,7 @@ export interface StudioTelemetryContext { environment: TelemetryEnvironment; cloudProvider: "volcengine" | "byteplus"; accountId?: string; + accountIdResolutionError?: string; } export interface TelemetryIdentity { diff --git a/frontend/tests/studioAccess.test.mjs b/frontend/tests/studioAccess.test.mjs index 39de57e1d..94b39dd54 100644 --- a/frontend/tests/studioAccess.test.mjs +++ b/frontend/tests/studioAccess.test.mjs @@ -30,7 +30,9 @@ test("Studio access fails closed until the server-derived role is known", () => test("Studio entry telemetry uses anonymous UI config metadata", () => { assert.match(clientSource, /accountId: typeof studio\.accountId === "string"/); + assert.match(clientSource, /accountIdResolutionError:[\s\S]*?typeof studio\.accountIdResolutionError === "string"/); assert.match(appSource, /accountId: studio\?\.accountId \?\? ""/); + assert.match(appSource, /accountIdResolutionError: studio\?\.accountIdResolutionError \?\? ""/); assert.match(appSource, /trackStudioEntryViewed\(\{ authState: "anonymous" \}\)/); }); diff --git a/frontend/tests/teaTelemetry.test.mjs b/frontend/tests/teaTelemetry.test.mjs index 6905f8c2c..5691433da 100644 --- a/frontend/tests/teaTelemetry.test.mjs +++ b/frontend/tests/teaTelemetry.test.mjs @@ -103,6 +103,7 @@ function harness() { environment: "staging", cloudProvider: "volcengine", accountId: "2100123456", + accountIdResolutionError: "sts unavailable", }); runtime.identify({ userUniqueId: " user-1 ", @@ -188,6 +189,7 @@ test("records one anonymous Studio page entry before login", () => { assert.equal(events[0].payload.auth_state, "anonymous"); assert.equal(events[0].payload.cloud_provider, "volcengine"); assert.equal(events[0].payload.account_id, "2100123456"); + assert.equal(events[0].payload.account_id_resolution_error, "sts unavailable"); assert.equal(events[0].payload.page_instance_id, "id-1"); assert.equal("user_role" in events[0].payload, false); assert.equal("user_source" in events[0].payload, false); @@ -207,6 +209,7 @@ test("records one authenticated page-ready Studio visit, not an Agent chat sessi assert.equal("auth_session_id" in events[0].payload, false); assert.equal(events[0].payload.cloud_provider, "volcengine"); assert.equal(events[0].payload.account_id, "2100123456"); + assert.equal(events[0].payload.account_id_resolution_error, "sts unavailable"); assert.equal(events[0].payload.page_instance_id, "id-1"); assert.equal("session_id" in events[0].payload, false); }); diff --git a/tests/cli/test_frontend_runtime_proxy.py b/tests/cli/test_frontend_runtime_proxy.py index 7aa711d82..453190223 100644 --- a/tests/cli/test_frontend_runtime_proxy.py +++ b/tests/cli/test_frontend_runtime_proxy.py @@ -916,6 +916,7 @@ def test_ui_config_serves_studio_telemetry_config( monkeypatch.setenv("VEADK_STUDIO_DEPLOY_REGION", "cn-beijing") monkeypatch.setenv("VEADK_STUDIO_PROJECT", "studio-project") monkeypatch.setenv("VEADK_STUDIO_ACCOUNT_ID", "2100123456") + monkeypatch.setenv("VEADK_STUDIO_ACCOUNT_ID_RESOLUTION_ERROR", "sts unavailable") app = _create_frontend_app(monkeypatch, tmp_path, studio=True) with TestClient(app) as client: @@ -934,6 +935,7 @@ def test_ui_config_serves_studio_telemetry_config( "project": "studio-project", "version": response.json()["version"], "accountId": "2100123456", + "accountIdResolutionError": "sts unavailable", }, } diff --git a/tests/cli/test_studio_account_id.py b/tests/cli/test_studio_account_id.py new file mode 100644 index 000000000..1ace0b011 --- /dev/null +++ b/tests/cli/test_studio_account_id.py @@ -0,0 +1,85 @@ +# 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. + +from types import SimpleNamespace +from typing import Any + +import pytest + +from veadk.cli.studio_account_id import ( + resolve_studio_account_id_metadata, + studio_account_id_from_tos_bucket, +) + + +def test_resolves_account_id_from_function_role() -> None: + result = resolve_studio_account_id_metadata( + remote_function=SimpleNamespace( + role="trn:iam::2100123456:role/VeADKFrontendServiceRole" + ) + ) + + assert result.account_id == "2100123456" + assert result.error == "" + + +def test_resolves_account_id_from_studio_tos_bucket_after_sts_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def _fail(**_: Any) -> str: + raise RuntimeError("sts failed with secret-ak") + + monkeypatch.setattr( + "frontend.server.storage.provisioning.resolve_studio_account_id_for_deploy", + _fail, + ) + + result = resolve_studio_account_id_metadata( + environment={"VEADK_STUDIO_TOS_BUCKET": "veadk-studio-2100123456"}, + access_key="secret-ak", + secret_key="secret-sk", + region="cn-beijing", + ) + + assert result.account_id == "2100123456" + assert result.error == "" + + +def test_reports_sanitized_account_id_resolution_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def _fail(**_: Any) -> str: + raise RuntimeError("sts failed with secret-ak") + + monkeypatch.setattr( + "frontend.server.storage.provisioning.resolve_studio_account_id_for_deploy", + _fail, + ) + + result = resolve_studio_account_id_metadata( + environment={"VEADK_STUDIO_TOS_BUCKET": "custom-studio-bucket"}, + access_key="secret-ak", + secret_key="secret-sk", + region="cn-beijing", + ) + + assert result.account_id == "" + assert result.error == "sts failed with ***" + + +def test_parses_account_id_from_studio_tos_bucket_path() -> None: + assert ( + studio_account_id_from_tos_bucket("tos://veadk-studio-2100123456/data") + == "2100123456" + ) diff --git a/tests/cli/test_studio_telemetry.py b/tests/cli/test_studio_telemetry.py index 37dcdc6dd..5812a2268 100644 --- a/tests/cli/test_studio_telemetry.py +++ b/tests/cli/test_studio_telemetry.py @@ -27,6 +27,7 @@ def test_studio_telemetry_config_builds_ui_payload_from_environment() -> None: "VEADK_STUDIO_DEPLOY_REGION": "cn-beijing", "VEADK_STUDIO_PROJECT": "default", "VEADK_STUDIO_ACCOUNT_ID": "2100123456", + "VEADK_STUDIO_ACCOUNT_ID_RESOLUTION_ERROR": "sts unavailable", }, ) @@ -41,5 +42,6 @@ def test_studio_telemetry_config_builds_ui_payload_from_environment() -> None: "project": "default", "version": "20260805120000", "accountId": "2100123456", + "accountIdResolutionError": "sts unavailable", }, } diff --git a/tests/cli/test_studio_update.py b/tests/cli/test_studio_update.py index 06bd31607..88f28868c 100644 --- a/tests/cli/test_studio_update.py +++ b/tests/cli/test_studio_update.py @@ -1055,6 +1055,7 @@ def update_application_code_bundle(self, **kwargs: object) -> str: "VEADK_STUDIO_DEPLOY_REGION": "cn-beijing", "VEADK_STUDIO_PROJECT": "default", "VEADK_STUDIO_ACCOUNT_ID": "123", + "VEADK_STUDIO_ACCOUNT_ID_RESOLUTION_ERROR": "", "OAUTH2_REDIRECT_URI": "https://studio.example.com/oauth2/callback", "SANDBOX_CHAT_CODEX_SNAPSHOT": "codex-snapshot-tool", "SANDBOX_CHAT_OPENCLAW_SNAPSHOT": "openclaw-snapshot-tool", diff --git a/tests/frontend/server/test_studio_update_resources.py b/tests/frontend/server/test_studio_update_resources.py index e91a3bd3f..3caf7843b 100644 --- a/tests/frontend/server/test_studio_update_resources.py +++ b/tests/frontend/server/test_studio_update_resources.py @@ -73,22 +73,22 @@ def test_reconcile_does_not_mutate_the_function_role_policy( "SANDBOX_CHAT_HERMES_SNAPSHOT": "hermes-tool", } - assert ( - reconcile_studio_update_resources( - provider=provider, - region=region, - application_id="application-id", - function_id="function-id", - function_client=_client( - environment, - role="trn:iam::123:role/VeADKFrontendServiceRole", - ), - access_key="ak", - secret_key="sk", - session_token="token", - ) - == {} - ) + assert reconcile_studio_update_resources( + provider=provider, + region=region, + application_id="application-id", + function_id="function-id", + function_client=_client( + environment, + role="trn:iam::123:role/VeADKFrontendServiceRole", + ), + access_key="ak", + secret_key="sk", + session_token="token", + ) == { + "VEADK_STUDIO_ACCOUNT_ID": "123", + "VEADK_STUDIO_ACCOUNT_ID_RESOLUTION_ERROR": "", + } def test_reconcile_studio_update_resources_reuses_existing_resources( @@ -111,19 +111,19 @@ def test_reconcile_studio_update_resources_reuses_existing_resources( lambda **_kwargs: pytest.fail("snapshot tools must not be reprovisioned"), ) - assert ( - reconcile_studio_update_resources( - provider="byteplus", - region="ap-southeast-1", - application_id="application-id", - function_id="function-id", - function_client=_client(environment), - access_key="ak", - secret_key="sk", - session_token="token", - ) - == {} - ) + assert reconcile_studio_update_resources( + provider="byteplus", + region="ap-southeast-1", + application_id="application-id", + function_id="function-id", + function_client=_client(environment), + access_key="ak", + secret_key="sk", + session_token="token", + ) == { + "VEADK_STUDIO_ACCOUNT_ID": "123", + "VEADK_STUDIO_ACCOUNT_ID_RESOLUTION_ERROR": "", + } def test_reconcile_studio_update_resources_provisions_missing_resources( @@ -168,6 +168,8 @@ def _tool(**kwargs: Any) -> str: "VEADK_STUDIO_KNOWLEDGE_SIGNING_KEY": "generated-key", "VEADK_STUDIO_TOS_BUCKET": "studio-bucket", "VEADK_STUDIO_TOS_REGION": "ap-southeast-1", + "VEADK_STUDIO_ACCOUNT_ID": "123", + "VEADK_STUDIO_ACCOUNT_ID_RESOLUTION_ERROR": "", "SANDBOX_CHAT_CODEX_SNAPSHOT": "codex-tool", "SANDBOX_CHAT_OPENCLAW_SNAPSHOT": "openclaw-tool", "SANDBOX_CHAT_HERMES_SNAPSHOT": "hermes-tool", @@ -222,10 +224,53 @@ def test_reconcile_studio_update_resources_only_repairs_missing_items( session_token="", ) - assert overrides == {"SANDBOX_CHAT_HERMES_SNAPSHOT": "hermes-tool"} + assert overrides == { + "VEADK_STUDIO_ACCOUNT_ID": "123", + "VEADK_STUDIO_ACCOUNT_ID_RESOLUTION_ERROR": "", + "SANDBOX_CHAT_HERMES_SNAPSHOT": "hermes-tool", + } assert tool_calls == ["hermes"] +def test_reconcile_studio_update_resources_recovers_account_id_from_tos_bucket( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "frontend.server.studio_update_resources.resolve_studio_storage_for_deploy", + lambda **_kwargs: pytest.fail("existing storage must be reused"), + ) + monkeypatch.setattr( + "frontend.server.studio_update_resources._provision_snapshot_tool", + lambda **_kwargs: pytest.fail("snapshot tools must not be reprovisioned"), + ) + + overrides = reconcile_studio_update_resources( + provider="volcengine", + region="cn-beijing", + application_id="application-id", + function_id="function-id", + function_client=_client( + { + "VEADK_STUDIO_KNOWLEDGE_SIGNING_KEY": "stable-key", + "VEADK_STUDIO_TOS_BUCKET": "veadk-studio-2100123456", + "VEADK_STUDIO_TOS_REGION": "cn-beijing", + "SANDBOX_CHAT_CODEX_SNAPSHOT": "codex-tool", + "SANDBOX_CHAT_OPENCLAW_SNAPSHOT": "openclaw-tool", + "SANDBOX_CHAT_HERMES_SNAPSHOT": "hermes-tool", + }, + role="custom-role-without-account-id", + ), + access_key="", + secret_key="", + session_token="", + ) + + assert overrides == { + "VEADK_STUDIO_ACCOUNT_ID": "2100123456", + "VEADK_STUDIO_ACCOUNT_ID_RESOLUTION_ERROR": "", + } + + def test_provision_codex_snapshot_tool_binds_model_credential( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/veadk/cli/cli_frontend.py b/veadk/cli/cli_frontend.py index a19218e0c..69aa9f64c 100644 --- a/veadk/cli/cli_frontend.py +++ b/veadk/cli/cli_frontend.py @@ -56,6 +56,10 @@ provider_allows_model, studio_agent_model_name, ) +from veadk.cli.studio_account_id import ( + resolve_studio_account_id_metadata, + studio_account_id_environment, +) from veadk.cli.studio_telemetry import studio_telemetry_config from veadk.utils.cloud_provider import ( DEFAULT_BYTEPLUS_REGION, @@ -76,8 +80,6 @@ _BYTEPLUS_VEFAAS_APPLICATION_NAME_RE = re.compile( r"^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$" ) -_CLOUD_ACCOUNT_ID_RE = re.compile(r"[0-9]+") -_IAM_ACCOUNT_ID_RE = re.compile(r"\biam::(?P[0-9]+):") _BUILD_ERROR_MARKERS = ( "no solution found", "unsatisfiable", @@ -376,24 +378,6 @@ def _normalize_runtime_description(value: object) -> str: return re.sub(r" +", " ", "".join(normalized)).rstrip() -def _cloud_account_id_from_value(value: object) -> str: - normalized = str(value or "").strip() - return normalized if _CLOUD_ACCOUNT_ID_RE.fullmatch(normalized) else "" - - -def _studio_account_id_from_remote_function(function: object) -> str: - for attribute in ("owner", "Owner"): - account_id = _cloud_account_id_from_value(getattr(function, attribute, "")) - if account_id: - return account_id - for attribute in ("role", "Role"): - role = str(getattr(function, attribute, "") or "") - match = _IAM_ACCOUNT_ID_RE.search(role) - if match: - return match.group("account_id") - return "" - - def _is_malformed_runtime_description_error(error: object) -> bool: return "invaliddescription.malformed" in str(error or "").lower() @@ -11094,27 +11078,26 @@ def frontend_deploy( from frontend.server.storage.provisioning import ( StudioStorageProvisioningError, - resolve_studio_account_id_for_deploy, resolve_studio_storage_for_deploy, ) - studio_account_id = "" - try: - studio_account_id = resolve_studio_account_id_for_deploy( - access_key=ak, - secret_key=sk, - session_token=session_token or "", - region=region, - provider=provider_id, - ) - except StudioStorageProvisioningError as error: - detail = _safe_exception_detail( + account_resolution = resolve_studio_account_id_metadata( + access_key=ak, + secret_key=sk, + session_token=session_token or "", + region=region, + provider=provider_id, + error_formatter=lambda error: _safe_exception_detail( error, secrets=(ak, sk, session_token), - ) + ), + ) + studio_account_id = account_resolution.account_id + studio_account_id_resolution_error = account_resolution.error + if studio_account_id_resolution_error: click.echo( "Warning: Could not resolve Studio cloud account ID for telemetry: " - f"{detail}" + f"{studio_account_id_resolution_error}" ) click.echo("Ensuring Studio persistent storage…") @@ -11142,6 +11125,13 @@ def frontend_deploy( "VEADK_STUDIO_TOS_REGION": storage_config.region, } ) + if not studio_account_id: + bucket_resolution = resolve_studio_account_id_metadata( + environment=studio_storage_environment, + ) + if bucket_resolution.account_id: + studio_account_id = bucket_resolution.account_id + studio_account_id_resolution_error = "" click.echo(f"Studio persistent storage ready: {storage_config.object_host}") sandbox_tool_ids = { @@ -11410,6 +11400,10 @@ def frontend_deploy( veadk_environments["VEADK_STUDIO_DEPLOY_REGION"] = region if studio_account_id: veadk_environments["VEADK_STUDIO_ACCOUNT_ID"] = studio_account_id + elif studio_account_id_resolution_error: + veadk_environments["VEADK_STUDIO_ACCOUNT_ID_RESOLUTION_ERROR"] = ( + studio_account_id_resolution_error + ) veadk_environments.update(studio_storage_environment) if client_secret: veadk_environments["OAUTH2_CLIENT_SECRET"] = client_secret @@ -11562,6 +11556,10 @@ def frontend_deploy( } if studio_account_id: release_environment["VEADK_STUDIO_ACCOUNT_ID"] = studio_account_id + elif studio_account_id_resolution_error: + release_environment["VEADK_STUDIO_ACCOUNT_ID_RESOLUTION_ERROR"] = ( + studio_account_id_resolution_error + ) if studio_update_bucket: release_environment.update( { @@ -11991,36 +11989,30 @@ def frontend_update( f"{redirect_uri} to the user-pool client's allowed callback URLs manually." ) - studio_account_id = str( - current_env.get("VEADK_STUDIO_ACCOUNT_ID") or "" - ).strip() - if not studio_account_id and remote_function is not None: - studio_account_id = _studio_account_id_from_remote_function(remote_function) - if not studio_account_id and service_client is not None: - from frontend.server.storage.provisioning import ( - StudioStorageProvisioningError, - resolve_studio_account_id_for_deploy, + account_resolution = resolve_studio_account_id_metadata( + environment=current_env, + remote_function=remote_function, + access_key=ak if service_client is not None else "", + secret_key=sk if service_client is not None else "", + session_token=session_token or "", + region=target.region, + provider=provider_id, + error_formatter=lambda error: _safe_exception_detail( + error, + secrets=(ak, sk, session_token), + ), + ) + if account_resolution.error: + click.echo( + "Warning: Could not resolve Studio cloud account ID for telemetry: " + f"{account_resolution.error}" ) - - try: - studio_account_id = resolve_studio_account_id_for_deploy( - access_key=ak, - secret_key=sk, - session_token=session_token or "", - region=target.region, - provider=provider_id, - ) - except StudioStorageProvisioningError as error: - detail = _safe_exception_detail( - error, - secrets=(ak, sk, session_token), - ) - click.echo( - "Warning: Could not resolve Studio cloud account ID for telemetry: " - f"{detail}" - ) - if studio_account_id: - environment_overrides["VEADK_STUDIO_ACCOUNT_ID"] = studio_account_id + environment_overrides.update( + studio_account_id_environment( + account_resolution, + clear_error_on_success=True, + ) + ) snapshot_tool_ids = { "codex_snapshot": sandbox_chat_codex_snapshot_tool_id diff --git a/veadk/cli/studio_account_id.py b/veadk/cli/studio_account_id.py new file mode 100644 index 000000000..0e6f1fe9a --- /dev/null +++ b/veadk/cli/studio_account_id.py @@ -0,0 +1,189 @@ +# 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. + +"""Helpers for resolving Studio cloud account metadata.""" + +from __future__ import annotations + +import re +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass + +from veadk.cli.studio_telemetry import ( + STUDIO_ACCOUNT_ID_ENV, + STUDIO_ACCOUNT_ID_RESOLUTION_ERROR_ENV, +) +from veadk.utils.cloud_provider import CloudProvider + +_ACCOUNT_ID_RE = re.compile(r"[0-9]+") +_IAM_ACCOUNT_ID_RE = re.compile(r"\biam::(?P[0-9]+):") +_STUDIO_TOS_BUCKET_ACCOUNT_ID_RE = re.compile( + r"(?:^|/)veadk-studio-(?P[0-9]+)(?:/|$)" +) +_MAX_RESOLUTION_ERROR_LENGTH = 1000 + + +@dataclass(frozen=True) +class StudioAccountIdResolution: + """Resolved Studio account id or a sanitized diagnostic error.""" + + account_id: str = "" + error: str = "" + + +def cloud_account_id_from_value(value: object) -> str: + """Return a normalized numeric account id from a cloud metadata value.""" + normalized = str(value or "").strip() + return normalized if _ACCOUNT_ID_RE.fullmatch(normalized) else "" + + +def studio_account_id_from_remote_function(function: object | None) -> str: + """Infer a cloud account id from a VeFaaS Function owner or role field.""" + if function is None: + return "" + for attribute in ("owner", "Owner"): + account_id = cloud_account_id_from_value(getattr(function, attribute, "")) + if account_id: + return account_id + for attribute in ("role", "Role"): + role = str(getattr(function, attribute, "") or "") + match = _IAM_ACCOUNT_ID_RE.search(role) + if match: + return match.group("account_id") + return "" + + +def studio_account_id_from_tos_bucket(value: object) -> str: + """Infer the Studio account id from the deterministic Studio TOS bucket.""" + text = str(value or "").strip() + if not text: + return "" + match = _STUDIO_TOS_BUCKET_ACCOUNT_ID_RE.search(text) + if not match: + return "" + return match.group("account_id") + + +def studio_account_id_from_environment( + environment: Mapping[str, object] | None, +) -> str: + """Resolve account id from Studio environment values when available.""" + if not environment: + return "" + account_id = cloud_account_id_from_value(environment.get(STUDIO_ACCOUNT_ID_ENV)) + if account_id: + return account_id + return studio_account_id_from_tos_bucket(environment.get("VEADK_STUDIO_TOS_BUCKET")) + + +def sanitize_account_id_resolution_error( + error: BaseException, + *, + secrets: Iterable[str | None] = (), +) -> str: + """Return a bounded, credential-redacted error for telemetry diagnostics.""" + message = str(error).strip() or type(error).__name__ + for secret in secrets: + if secret: + message = message.replace(secret, "***") + message = re.sub(r"\s+", " ", message).strip() + if len(message) > _MAX_RESOLUTION_ERROR_LENGTH: + message = message[: _MAX_RESOLUTION_ERROR_LENGTH - 1].rstrip() + "…" + return message or type(error).__name__ + + +def resolve_studio_account_id_metadata( + *, + environment: Mapping[str, object] | None = None, + remote_function: object | None = None, + access_key: str = "", + secret_key: str = "", + session_token: str = "", + region: str = "", + provider: CloudProvider | None = None, + error_formatter: Callable[[BaseException], str] | None = None, +) -> StudioAccountIdResolution: + """Resolve account id for Studio telemetry without blocking deployment paths.""" + account_id = cloud_account_id_from_value( + (environment or {}).get(STUDIO_ACCOUNT_ID_ENV) + ) + if account_id: + return StudioAccountIdResolution(account_id=account_id) + + account_id = studio_account_id_from_remote_function(remote_function) + if account_id: + return StudioAccountIdResolution(account_id=account_id) + + error = "" + if access_key and secret_key and region: + from frontend.server.storage.provisioning import ( + resolve_studio_account_id_for_deploy, + ) + + try: + account_id = resolve_studio_account_id_for_deploy( + access_key=access_key, + secret_key=secret_key, + session_token=session_token, + region=region, + provider=provider, + ) + account_id = cloud_account_id_from_value(account_id) + if account_id: + return StudioAccountIdResolution(account_id=account_id) + except Exception as exc: + error = ( + error_formatter(exc) + if error_formatter is not None + else sanitize_account_id_resolution_error( + exc, + secrets=(access_key, secret_key, session_token), + ) + ) + + account_id = studio_account_id_from_environment(environment) + if account_id: + return StudioAccountIdResolution(account_id=account_id) + + return StudioAccountIdResolution(error=error) + + +def studio_account_id_environment( + resolution: StudioAccountIdResolution, + *, + clear_error_on_success: bool = False, +) -> dict[str, str]: + """Build Function environment overrides for account id telemetry metadata.""" + if resolution.account_id: + environment = {STUDIO_ACCOUNT_ID_ENV: resolution.account_id} + if clear_error_on_success: + environment[STUDIO_ACCOUNT_ID_RESOLUTION_ERROR_ENV] = "" + return environment + if resolution.error: + return {STUDIO_ACCOUNT_ID_RESOLUTION_ERROR_ENV: resolution.error} + return {} + + +__all__ = [ + "STUDIO_ACCOUNT_ID_ENV", + "STUDIO_ACCOUNT_ID_RESOLUTION_ERROR_ENV", + "StudioAccountIdResolution", + "cloud_account_id_from_value", + "resolve_studio_account_id_metadata", + "sanitize_account_id_resolution_error", + "studio_account_id_environment", + "studio_account_id_from_environment", + "studio_account_id_from_remote_function", + "studio_account_id_from_tos_bucket", +] diff --git a/veadk/cli/studio_telemetry.py b/veadk/cli/studio_telemetry.py index 4f9cefeb2..3f280c635 100644 --- a/veadk/cli/studio_telemetry.py +++ b/veadk/cli/studio_telemetry.py @@ -27,6 +27,7 @@ STUDIO_DEPLOY_REGION_ENV = "VEADK_STUDIO_DEPLOY_REGION" STUDIO_PROJECT_ENV = "VEADK_STUDIO_PROJECT" STUDIO_ACCOUNT_ID_ENV = "VEADK_STUDIO_ACCOUNT_ID" +STUDIO_ACCOUNT_ID_RESOLUTION_ERROR_ENV = "VEADK_STUDIO_ACCOUNT_ID_RESOLUTION_ERROR" AGENTKIT_SANDBOX_REGION_ENV = "AGENTKIT_SANDBOX_REGION" @@ -64,5 +65,9 @@ def studio_telemetry_config( "project": _env_value(current_env, STUDIO_PROJECT_ENV), "version": version, "accountId": _env_value(current_env, STUDIO_ACCOUNT_ID_ENV), + "accountIdResolutionError": _env_value( + current_env, + STUDIO_ACCOUNT_ID_RESOLUTION_ERROR_ENV, + ), }, }