diff --git a/frontend/service/studio_scheduler/deploy.py b/frontend/service/studio_scheduler/deploy.py index 3dd385618..78ba41332 100644 --- a/frontend/service/studio_scheduler/deploy.py +++ b/frontend/service/studio_scheduler/deploy.py @@ -22,6 +22,10 @@ import tempfile import time import urllib.request +from collections.abc import Iterator +from contextlib import contextmanager +from functools import partial +from inspect import Parameter, signature from pathlib import Path from typing import Any @@ -30,6 +34,60 @@ _SCAN_TIMER_NAME = "veadk-studio-cronjobs-minute" _WORKER_TIMER_NAME = "veadk-studio-cronjobs-worker-minute" _MINUTE_CRONTAB = "* * * * *" +_VEFAAS_REQUEST_TIMEOUT_SECONDS = 600 +_VEFAAS_LONG_REQUEST_METHODS = ( + "code_upload_callback", + "create_dependency_install_task", + "create_function", + "create_timer", + "get_code_upload_address", + "get_dependency_install_task_log_download_uri", + "get_dependency_install_task_status", + "get_function", + "get_release_status", + "list_functions", + "list_triggers", + "release", + "update_function", + "update_timer", +) + + +def _accepts_request_timeout(method: Any) -> bool: + try: + parameters = signature(method).parameters.values() + except (TypeError, ValueError): + return False + return any( + parameter.name == "_request_timeout" or parameter.kind is Parameter.VAR_KEYWORD + for parameter in parameters + ) + + +@contextmanager +def _extended_vefaas_request_timeout(service: Any) -> Iterator[None]: + """Keep BytePlus/VeFaaS long mutations alive for the release window.""" + client = getattr(service, "client", None) + originals: dict[str, Any] = {} + if client is not None: + for method_name in _VEFAAS_LONG_REQUEST_METHODS: + method = getattr(client, method_name, None) + if method is None or not _accepts_request_timeout(method): + continue + originals[method_name] = method + setattr( + client, + method_name, + partial( + method, + _request_timeout=_VEFAAS_REQUEST_TIMEOUT_SECONDS, + ), + ) + try: + yield + finally: + for method_name, method in originals.items(): + setattr(client, method_name, method) def scheduler_function_name(studio_application_name: str) -> str: @@ -55,6 +113,24 @@ def deploy_scheduler( environment: dict[str, str], ) -> tuple[str, str, str, str]: """Create/update independent scan and async-worker Functions and timers.""" + with _extended_vefaas_request_timeout(service): + return _deploy_scheduler( + service, + studio_application_name=studio_application_name, + package_root=package_root, + role_trn=role_trn, + environment=environment, + ) + + +def _deploy_scheduler( + service: Any, + *, + studio_application_name: str, + package_root: Path, + role_trn: str, + environment: dict[str, str], +) -> tuple[str, str, str, str]: function_name = scheduler_function_name(studio_application_name) worker_name = scheduler_worker_function_name(studio_application_name) with tempfile.TemporaryDirectory(prefix="studio_cronjob_scheduler_") as tmp: @@ -125,9 +201,10 @@ def deploy_scheduler_for_studio_update( """Update the scheduler from the same bundle used by Studio self-update.""" from volcenginesdkvefaas import GetFunctionRequest - current_function = service.client.get_function( - GetFunctionRequest(id=studio_function_id) - ) + with _extended_vefaas_request_timeout(service): + current_function = service.client.get_function( + GetFunctionRequest(id=studio_function_id) + ) current_environment = { str(item.key): str(item.value) for item in (getattr(current_function, "envs", None) or []) diff --git a/frontend/service/studio_scheduler/http_app.py b/frontend/service/studio_scheduler/http_app.py index becef11df..295d86dd9 100644 --- a/frontend/service/studio_scheduler/http_app.py +++ b/frontend/service/studio_scheduler/http_app.py @@ -16,6 +16,7 @@ from __future__ import annotations +import json from datetime import datetime, timezone from fastapi import FastAPI @@ -52,9 +53,32 @@ async def execute_ready_runs() -> dict[str, int]: return _summary(summary) +def _timer_event(value: dict[str, object] | str | None) -> dict[str, object]: + """Normalize provider-specific timer payload shapes. + + Volcengine sends the configured JSON payload as an object, while BytePlus + currently delivers the same value as a JSON-encoded string. + """ + if value is None: + return {} + if isinstance(value, dict): + return value + try: + decoded = json.loads(value) + except json.JSONDecodeError as error: + raise ValueError("Scheduler timer payload is not valid JSON") from error + if not isinstance(decoded, dict): + raise ValueError( # noqa: TRY004 - invalid external payload, not API misuse + "Scheduler timer payload must be a JSON object" + ) + return decoded + + @app.post("/") -async def handle_timer(event: dict[str, object] | None = None) -> dict[str, int]: - phase = str((event or {}).get("phase") or "scan") +async def handle_timer( + event: dict[str, object] | str | None = None, +) -> dict[str, int]: + phase = str(_timer_event(event).get("phase") or "scan") if phase == "scan": return await dispatch_current_minute() if phase == "execute": diff --git a/frontend/src/adk/client.ts b/frontend/src/adk/client.ts index 4dba93075..40baa339e 100644 --- a/frontend/src/adk/client.ts +++ b/frontend/src/adk/client.ts @@ -2840,6 +2840,15 @@ export interface StudioUpdateStatus { permissionConsoleUrl: string; } +export interface StudioUpdatePermissionStatus { + ready: boolean; + missingActions: string[]; + policyName: string; + authorizationUrl: string; + iamConsoleUrl: string; + principalName: string; +} + /** Check the configured immutable Studio main release channel. */ export async function getStudioUpdateStatus( targetVersion?: string, @@ -2854,6 +2863,22 @@ export async function getStudioUpdateStatus( return (await res.json()) as StudioUpdateStatus; } +/** Verify every IAM Action needed by OTA before starting any cloud mutation. */ +export async function getStudioUpdatePermissions(): Promise { + const res = await apiFetch("/web/studio-update/permissions"); + if (!res.ok) { + let detail = ""; + try { + const payload = (await res.json()) as { detail?: unknown }; + detail = typeof payload.detail === "string" ? payload.detail : ""; + } catch { + detail = ""; + } + throw new Error(detail || `Studio 更新权限预检失败 (${res.status})`); + } + return (await res.json()) as StudioUpdatePermissionStatus; +} + /** Stage the latest full Studio bundle and submit a VeFaaS release. */ export async function startStudioUpdate( version: string, diff --git a/frontend/src/ui/StudioUpdateControl.css b/frontend/src/ui/StudioUpdateControl.css index 63880ccce..912da5d56 100644 --- a/frontend/src/ui/StudioUpdateControl.css +++ b/frontend/src/ui/StudioUpdateControl.css @@ -610,6 +610,132 @@ text-underline-offset: 2px; } +.studio-update-permission-checking { + display: grid; + gap: 8px; + padding: 14px; + border: 1px solid hsl(var(--border)); + border-radius: 9px; + background: hsl(var(--canvas) / 0.5); +} + +.studio-update-permission-checking p { + margin: 0; + color: hsl(var(--muted-foreground)); + font-size: 12px; + line-height: 1.55; +} + +.studio-update-authorization-panel { + display: grid; + gap: 12px; + min-width: 0; +} + +.studio-update-authorization-panel .confirm-text { + margin-bottom: 0; +} + +.studio-update-authorization-principal { + display: grid; + gap: 8px; + padding: 10px 12px; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + background: hsl(var(--canvas) / 0.5); +} + +.studio-update-authorization-principal div { + display: grid; + gap: 3px; + min-width: 0; +} + +.studio-update-authorization-principal dt, +.studio-update-missing-actions > span { + color: hsl(var(--muted-foreground)); + font-size: 11px; +} + +.studio-update-authorization-principal dd { + overflow: hidden; + margin: 0; + color: hsl(var(--foreground)); + font-size: 12px; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.studio-update-authorization-steps { + margin: 0; + padding-left: 20px; + color: hsl(var(--muted-foreground)); + font-size: 12px; + line-height: 1.6; +} + +.studio-update-missing-actions { + display: grid; + gap: 7px; +} + +.studio-update-missing-actions ul { + display: grid; + gap: 4px; + max-height: 204px; + margin: 0; + padding: 9px 12px; + overflow-y: auto; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + background: hsl(var(--muted) / 0.22); + list-style: none; +} + +.studio-update-missing-actions code { + color: hsl(var(--foreground)); + font-size: 11px; + overflow-wrap: anywhere; +} + +.studio-update-authorization-link { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + width: fit-content; + min-height: 34px; + padding: 0 12px; + border-radius: 7px; + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + font-size: 12px; + font-weight: 500; + text-decoration: none; +} + +.studio-update-authorization-link:hover { + background: hsl(var(--primary) / 0.88); +} + +.studio-update-authorization-link svg { + width: 14px; + height: 14px; + flex: 0 0 14px; + stroke: currentColor; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +.studio-update-authorization-note { + margin: -4px 0 0; + color: hsl(var(--muted-foreground)); + font-size: 11px; + line-height: 1.55; +} + .studio-update-progress-summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/frontend/src/ui/StudioUpdateControl.tsx b/frontend/src/ui/StudioUpdateControl.tsx index 9ec6c83bd..378026c55 100644 --- a/frontend/src/ui/StudioUpdateControl.tsx +++ b/frontend/src/ui/StudioUpdateControl.tsx @@ -2,7 +2,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { getStudioUpdateStatus, + getStudioUpdatePermissions, startStudioUpdate, + type StudioUpdatePermissionStatus, type StudioUpdateStatus, } from "../adk/client"; import { splitReleaseNotes } from "./releaseNotes"; @@ -16,11 +18,19 @@ const COMPLETION_LOG_SETTLE_TIMEOUT_MS = 45_000; const STUDIO_UPDATE_STORAGE_KEY = "veadk.studio.pending-update"; const STUDIO_UPDATE_HANDOFF_KEY = "veadk.studio.update-handoff"; -type UpdatePhase = "idle" | "confirm" | "submitting" | "published" | "error"; +type UpdatePhase = + | "idle" + | "confirm" + | "checking-permissions" + | "permission" + | "submitting" + | "published" + | "error"; type PendingStudioUpdate = { targetVersion: string; startedAt: number }; type LogCopyState = "idle" | "copied" | "error"; const UPDATE_STEPS = [ + { id: "permissions", label: "预检 OTA 所需权限" }, { id: "resolving", label: "读取目标版本信息" }, { id: "downloading", label: "下载并校验完整更新包" }, { id: "preparing", label: "准备 VeFaaS Function 代码" }, @@ -30,6 +40,7 @@ const UPDATE_STEPS = [ ] as const; const UPDATE_STAGE_LABELS: Record = { + permissions: "预检 OTA 权限", resolving: "读取版本信息", downloading: "下载更新包", preparing: "准备 Function 代码", @@ -240,6 +251,8 @@ export function StudioUpdateControl({ ); const [dialogOpen, setDialogOpen] = useState(Boolean(initialPending)); const [message, setMessage] = useState(""); + const [permissionStatus, setPermissionStatus] = + useState(null); const [selectedVersion, setSelectedVersion] = useState( initialPending?.targetVersion ?? "", ); @@ -394,17 +407,30 @@ export function StudioUpdateControl({ handoffTargetRef.current = ""; targetVersionRef.current = targetVersion; startedAtRef.current = Date.now(); - persistPendingUpdate(targetVersion, startedAtRef.current); - setPhase("submitting"); + setPhase("checking-permissions"); setMessage(""); setLogCopyState("idle"); try { + const permissions = await getStudioUpdatePermissions(); + setPermissionStatus(permissions); + if (!permissions.ready) { + clearPendingUpdate(); + setPhase("permission"); + return; + } + setPermissionStatus(null); + persistPendingUpdate(targetVersion, startedAtRef.current); + setPhase("submitting"); const result = await startStudioUpdate(targetVersion); targetVersionRef.current = result.version; persistPendingUpdate(result.version, startedAtRef.current); setMessage("更新已提交,正在等待 VeFaaS 发布新版本"); } catch (error) { - if (error instanceof TypeError) { + if ( + error instanceof TypeError || + (error instanceof Error && + (error.name === "TimeoutError" || error.name === "AbortError")) + ) { setMessage("连接已切换,正在确认新版本状态"); return; } @@ -439,6 +465,7 @@ export function StudioUpdateControl({ setVersionMenuOpen(false); setLogCopyState("idle"); setMessage(""); + setPermissionStatus(null); setSelectedVersion(targetVersionRef.current || releases[0]?.version || ""); setPhase("confirm"); }; @@ -453,7 +480,11 @@ export function StudioUpdateControl({ : `studio-update-trigger is-${phase}` } title={ - phase === "submitting" + phase === "checking-permissions" + ? "正在检查 OTA 权限" + : phase === "permission" + ? "需要 IAM 授权" + : phase === "submitting" ? "正在更新 Studio" : phase === "published" ? "Studio 已更新" @@ -462,7 +493,12 @@ export function StudioUpdateControl({ onClick={() => { if (phase === "published") { window.location.reload(); - } else if (phase === "submitting" || phase === "error") { + } else if ( + phase === "checking-permissions" || + phase === "permission" || + phase === "submitting" || + phase === "error" + ) { setDialogOpen(true); } else { setSelectedVersion(releases[0]?.version || status.latestVersion); @@ -474,7 +510,11 @@ export function StudioUpdateControl({ {variant !== "feature-link" && ( )} - {phase === "submitting" ? ( + {phase === "checking-permissions" ? ( + 检查更新权限 + ) : phase === "permission" ? ( + 需要授权 + ) : phase === "submitting" ? ( 正在更新 ) : phase === "published" ? ( 刷新使用新版 @@ -492,7 +532,9 @@ export function StudioUpdateControl({
- {phase === "error" ? ( + {phase === "checking-permissions" ? ( +
+ 正在核对 OTA 与定时任务所需的全部 IAM 权限… +

权限全部满足后才会开始下载、更新或发布云资源。

+
+ ) : phase === "permission" && permissionStatus ? ( +
+

+ 当前 Function 角色缺少 {permissionStatus.missingActions.length} 项 + OTA 更新权限,尚未执行任何云资源变更。 +

+
+
+
Function 角色
+
{permissionStatus.principalName || "当前运行角色"}
+
+ {permissionStatus.policyName && ( +
+
将更新策略
+
{permissionStatus.policyName}
+
+ )} +
+
    +
  1. 打开授权页面,确认已预填的策略名称和完整策略内容。
  2. +
  3. 点击页面中的“发起调试”,完成策略更新。
  4. +
  5. 返回此窗口,点击“我已授权,重新检查”。
  6. +
+
+ 缺少的权限 +
    + {permissionStatus.missingActions.map((action) => ( +
  • {action}
  • + ))} +
+
+ + {permissionStatus.authorizationUrl + ? "打开已预填的 IAM 授权页面" + : "前往 IAM 控制台手动配置"} + + + {!permissionStatus.authorizationUrl && ( +

+ 当前角色没有唯一可安全更新的自定义策略,请由管理员将上述权限加入该角色。 +

+ )} +
+ ) : phase === "error" ? (

{message}

@@ -705,7 +804,11 @@ export function StudioUpdateControl({ } }} > - {phase === "submitting" ? "后台运行" : phase === "confirm" ? "取消" : "关闭"} + {phase === "submitting" + ? "后台运行" + : phase === "confirm" + ? "取消" + : "关闭"} {phase === "confirm" && ( + )} {phase === "error" && (