Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 80 additions & 3 deletions frontend/service/studio_scheduler/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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 [])
Expand Down
28 changes: 26 additions & 2 deletions frontend/service/studio_scheduler/http_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from __future__ import annotations

import json
from datetime import datetime, timezone

from fastapi import FastAPI
Expand Down Expand Up @@ -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":
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/adk/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<StudioUpdatePermissionStatus> {
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,
Expand Down
126 changes: 126 additions & 0 deletions frontend/src/ui/StudioUpdateControl.css
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading
Loading