From 872ed53b4e461d9b81993c5360c700496bb0bcf1 Mon Sep 17 00:00:00 2001 From: "yanan.zhangyn" Date: Fri, 4 Sep 2026 19:34:36 +0800 Subject: [PATCH] fix(studio): preserve updates when scheduler fails --- .../studio_release_server/offline_runtime.py | 17 ++- .../studio_release_server/publisher.py | 41 +++++-- tests/cli/test_studio_deploy_target.py | 16 +-- tests/cli/test_studio_offline_runtime.py | 34 +++++- tests/cli/test_studio_release.py | 26 ++++ tests/cli/test_studio_self_update.py | 113 ++++++++++++++++-- tests/test_studio_release_server.py | 43 ++++--- veadk/cli/studio_self_update.py | 59 +++++++-- 8 files changed, 270 insertions(+), 79 deletions(-) diff --git a/frontend/service/studio_release_server/offline_runtime.py b/frontend/service/studio_release_server/offline_runtime.py index 54b51b0e2..454bb312b 100644 --- a/frontend/service/studio_release_server/offline_runtime.py +++ b/frontend/service/studio_release_server/offline_runtime.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Build the locked Linux wheelhouse consumed by VeFaaS Studio releases.""" +"""Build the locked Linux wheel set consumed by VeFaaS Studio releases.""" from __future__ import annotations @@ -193,9 +193,20 @@ def build_studio_offline_runtime( if not staged_veadk.is_file(): raise ValueError("Studio offline wheelhouse is incomplete.") _pin_runtime_lock_to_wheelhouse(runtime_lock, wheelhouse, staged_veadk) + for wheel in sorted(wheelhouse.glob("*.whl")): + destination = package_dir / wheel.name + if destination.exists(): + raise ValueError("Studio offline wheel has a root-level conflict.") + shutil.move(str(wheel), destination) + try: + wheelhouse.rmdir() + except OSError as error: + raise ValueError( + "Studio offline wheelhouse contains unexpected files." + ) from error requirements = build_studio_offline_requirements( - wheelhouse, - wheel_prefix=f"./{STUDIO_RUNTIME_WHEELHOUSE}/", + package_dir, + wheel_prefix="./", ) _verify_offline_resolution( package_dir, diff --git a/frontend/service/studio_release_server/publisher.py b/frontend/service/studio_release_server/publisher.py index 3d8385708..905b076e4 100644 --- a/frontend/service/studio_release_server/publisher.py +++ b/frontend/service/studio_release_server/publisher.py @@ -45,7 +45,10 @@ import tomli as tomllib # pyright: ignore[reportMissingImports] if __package__: - from .offline_runtime import build_studio_offline_runtime + from .offline_runtime import ( + build_studio_offline_requirements, + build_studio_offline_runtime, + ) else: _offline_runtime_path = Path(__file__).with_name("offline_runtime.py") _offline_runtime_spec = importlib.util.spec_from_file_location( @@ -56,6 +59,9 @@ raise RuntimeError("Studio offline runtime builder is unavailable.") _offline_runtime = importlib.util.module_from_spec(_offline_runtime_spec) _offline_runtime_spec.loader.exec_module(_offline_runtime) + build_studio_offline_requirements = ( + _offline_runtime.build_studio_offline_requirements + ) build_studio_offline_runtime = _offline_runtime.build_studio_offline_runtime _VERSION_PATTERN = re.compile(r"^\d{14}$") @@ -917,6 +923,23 @@ def validate_studio_bundle_dependencies(package_dir: Path) -> Path: raise StudioPublisherError( "The Studio release must contain exactly one local VeADK wheel." ) + try: + expected_requirements = build_studio_offline_requirements( + package_dir, + wheel_prefix="./", + ) + except ValueError as error: + raise StudioPublisherError( + "Studio full release dependency contract is invalid." + ) from error + if ( + (package_dir / "wheelhouse").exists() + or requirements_path.read_text(encoding="utf-8") != expected_requirements + or set(local_wheels) != {path.resolve() for path in package_dir.glob("*.whl")} + ): + raise StudioPublisherError( + "Studio full release dependency contract is invalid." + ) return validate_studio_agentkit_cli_archive(list(package_dir.iterdir())) @@ -1023,25 +1046,19 @@ def stage_studio_thin_runtime( """Replace local runtime payloads with one exact public artifact manifest.""" contract = _load_studio_artifact_contract(source_root) - wheelhouse = package_dir / "wheelhouse" - wheels = sorted(wheelhouse.glob("*.whl")) + wheels = sorted(package_dir.glob("*.whl")) runtime_veadk_wheels = [ path for path in wheels if path.name.startswith(("veadk_python-", "veadk-python-")) ] dependency_wheels = [path for path in wheels if path not in runtime_veadk_wheels] - local_veadk_wheels = sorted(package_dir.glob("veadk*.whl")) - if len(runtime_veadk_wheels) == 1 and not local_veadk_wheels: - local_veadk = package_dir / runtime_veadk_wheels[0].name - shutil.copy2(runtime_veadk_wheels[0], local_veadk) - local_veadk_wheels = [local_veadk] + local_veadk_wheels = runtime_veadk_wheels cli_archive = package_dir / _AGENTKIT_CLI_ARCHIVE if ( not dependency_wheels or len(runtime_veadk_wheels) != 1 or len(local_veadk_wheels) != 1 - or _sha256_file(runtime_veadk_wheels[0]) != _sha256_file(local_veadk_wheels[0]) or not cli_archive.is_file() ): raise StudioPublisherError("Studio offline runtime is incomplete.") @@ -1075,12 +1092,12 @@ def stage_studio_thin_runtime( artifact_dir = output_dir / f"runtime-artifacts-{runtime_manifest.runtime_epoch}" artifact_dir.mkdir(parents=True, exist_ok=False) for path in (*public_wheels, cli_archive): - shutil.copy2(path, artifact_dir / path.name) + shutil.move(str(path), artifact_dir / path.name) if bundled_wheels: bundled_wheelhouse = package_dir / "bundled-wheelhouse" bundled_wheelhouse.mkdir() for path in bundled_wheels: - shutil.copy2(path, bundled_wheelhouse / path.name) + shutil.move(str(path), bundled_wheelhouse / path.name) manifest_content = runtime_manifest.to_json() (package_dir / _STUDIO_RUNTIME_MANIFEST).write_bytes(manifest_content) ( @@ -1092,8 +1109,6 @@ def stage_studio_thin_runtime( + f"--hash=sha256:{_sha256_file(local_veadk_wheels[0])}\n", encoding="utf-8", ) - shutil.rmtree(wheelhouse) - cli_archive.unlink() (package_dir / "run.sh").write_text( _studio_run_script(thin=True), encoding="utf-8", diff --git a/tests/cli/test_studio_deploy_target.py b/tests/cli/test_studio_deploy_target.py index ba5e59c8b..70e47c04f 100644 --- a/tests/cli/test_studio_deploy_target.py +++ b/tests/cli/test_studio_deploy_target.py @@ -95,20 +95,13 @@ def _build_test_offline_runtime( veadk_wheel: Path, **_kwargs: object, ) -> str: - wheelhouse = package_dir / "wheelhouse" - wheelhouse.mkdir() - target = wheelhouse / veadk_wheel.name + target = package_dir / veadk_wheel.name target.write_bytes(veadk_wheel.read_bytes()) (package_dir / "studio-runtime.lock").write_text( "dependency==1\n", encoding="utf-8", ) - return ( - "--no-index\n" - "--find-links ./wheelhouse\n" - "-r ./studio-runtime.lock\n" - f"./wheelhouse/{target.name}\n" - ) + return f"--no-index\n--require-hashes\n./{target.name} --hash=sha256:test\n" def _stage_test_agentkit_cli_archive( @@ -2445,9 +2438,8 @@ def _fake_build(command: list[str], check: bool) -> None: assert expected_provider in {"volcengine", "byteplus"} expected_requirements = ( "--no-index\n" - "--find-links ./wheelhouse\n" - "-r ./studio-runtime.lock\n" - "./wheelhouse/veadk_python-test-py3-none-any.whl\n" + "--require-hashes\n" + "./veadk_python-test-py3-none-any.whl --hash=sha256:test\n" ) assert captured["requirements"] == expected_requirements diff --git a/tests/cli/test_studio_offline_runtime.py b/tests/cli/test_studio_offline_runtime.py index f1163e69b..331d3fd7d 100644 --- a/tests/cli/test_studio_offline_runtime.py +++ b/tests/cli/test_studio_offline_runtime.py @@ -41,6 +41,14 @@ def _write_pure_python_wheel(path: Path, *, name: str, version: str) -> None: wheel.writestr(f"{dist_info}/RECORD", "") +def _stage_root_only_updater(package_root: Path, destination: Path) -> None: + """Mirror the oldest released Scheduler staging contract exactly.""" + requirements = package_root / "requirements.txt" + shutil.copy2(requirements, destination / requirements.name) + for wheel in package_root.glob("*.whl"): + shutil.copy2(wheel, destination / wheel.name) + + def test_lock_check_environment_uses_canonical_pypi() -> None: environment = offline_runtime._lock_check_environment( { @@ -232,10 +240,10 @@ def fake_run(command: list[str], **_kwargs: object) -> subprocess.CompletedProce environment={"PATH": "/usr/bin"}, ) - staged_veadk = package_dir / "wheelhouse" / veadk_wheel.name - expected_wheels = sorted((package_dir / "wheelhouse").glob("*.whl")) + staged_veadk = package_dir / veadk_wheel.name + expected_wheels = sorted(package_dir.glob("*.whl")) assert requirements == "--no-index\n--require-hashes\n" + "".join( - f"./wheelhouse/{wheel.name} --hash=sha256:{offline_runtime._sha256(wheel)}\n" + f"./{wheel.name} --hash=sha256:{offline_runtime._sha256(wheel)}\n" for wheel in expected_wheels ) assert "--find-links" not in requirements @@ -255,21 +263,35 @@ def fake_run(command: list[str], **_kwargs: object) -> subprocess.CompletedProce assert verification[1:3] == ["pip", "install"] assert verification[-2:] == ["--requirements", "-"] + historical_stage = tmp_path / "historical-scheduler" + historical_stage.mkdir() + (package_dir / "requirements.txt").write_text(requirements, encoding="utf-8") + _stage_root_only_updater(package_dir, historical_stage) + assert sorted(path.name for path in historical_stage.glob("*.whl")) == sorted( + path.name for path in expected_wheels + ) + platform_parse = original_run( [ uv, "pip", "install", "--dry-run", - "--no-deps", + "--offline", + "--no-index", + "--require-hashes", "--no-python-downloads", + "--python-version", + "3.12", + "--python-platform", + "x86_64-manylinux_2_28", "--target", str(tmp_path / "platform-target"), "--requirements", "-", ], - cwd=package_dir, - input=requirements, + cwd=historical_stage, + input=(historical_stage / "requirements.txt").read_text(encoding="utf-8"), text=True, capture_output=True, check=False, diff --git a/tests/cli/test_studio_release.py b/tests/cli/test_studio_release.py index d1a10620c..2975ae9c7 100644 --- a/tests/cli/test_studio_release.py +++ b/tests/cli/test_studio_release.py @@ -18,6 +18,7 @@ import io import json import os +import shutil import subprocess import sys import zipfile @@ -614,6 +615,8 @@ def test_extracted_bundle_requires_local_pinned_archive( package = tmp_path / "package" veadk_wheel, cli_archive = _write_release_package(package, monkeypatch) (package / "requirements.txt").write_text( + "--no-index\n" + "--require-hashes\n" f"./{veadk_wheel.name} --hash=sha256:{hashlib.sha256(veadk_wheel.read_bytes()).hexdigest()}\n", encoding="utf-8", ) @@ -621,6 +624,29 @@ def test_extracted_bundle_requires_local_pinned_archive( assert validate_studio_bundle_dependencies(package) == cli_archive +def test_extracted_bundle_rejects_nested_wheelhouse_contract( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + package = tmp_path / "package" + wheelhouse = package / "wheelhouse" + veadk_wheel, _cli_archive = _write_release_package(wheelhouse, monkeypatch) + shutil.move( + str(wheelhouse / STUDIO_AGENTKIT_CLI_ARTIFACT.filename), + package / STUDIO_AGENTKIT_CLI_ARTIFACT.filename, + ) + (package / "requirements.txt").write_text( + "--no-index\n" + "--require-hashes\n" + f"./wheelhouse/{veadk_wheel.name} " + f"--hash=sha256:{hashlib.sha256(veadk_wheel.read_bytes()).hexdigest()}\n", + encoding="utf-8", + ) + + with pytest.raises(StudioPublisherError, match="full release dependency"): + validate_studio_bundle_dependencies(package) + + def test_release_entrypoint_reads_deployed_provider() -> None: run_script = studio_run_script(provider=None) diff --git a/tests/cli/test_studio_self_update.py b/tests/cli/test_studio_self_update.py index 605aa8cf5..c66eea3de 100644 --- a/tests/cli/test_studio_self_update.py +++ b/tests/cli/test_studio_self_update.py @@ -218,20 +218,23 @@ def _bundle( cli_archive_content: bytes = b"pinned-cli", ) -> None: veadk_wheel = "veadk_python-1.2.3-py3-none-any.whl" + wheel_path = path.parent / veadk_wheel + with zipfile.ZipFile(wheel_path, "w") as wheel: + wheel.writestr( + "veadk_python-1.2.3.dist-info/METADATA", + "Metadata-Version: 2.1\nName: veadk-python\nVersion: 1.2.3\n", + ) + wheel_digest = hashlib.sha256(wheel_path.read_bytes()).hexdigest() with zipfile.ZipFile(path, "w") as archive: archive.writestr("run.sh", "#!/bin/bash\n") - requirements = [f"./{veadk_wheel}"] - archive.writestr("requirements.txt", "\n".join(requirements) + "\n") - with zipfile.ZipFile( - Path(path.parent) / veadk_wheel, - "w", - ) as wheel: - wheel.writestr( - "veadk_python-1.2.3.dist-info/METADATA", - "Metadata-Version: 2.1\nName: veadk-python\nVersion: 1.2.3\n", - ) - archive.write(path.parent / veadk_wheel, veadk_wheel) - (path.parent / veadk_wheel).unlink() + archive.writestr( + "requirements.txt", + "--no-index\n" + "--require-hashes\n" + f"./{veadk_wheel} --hash=sha256:{wheel_digest}\n", + ) + archive.write(wheel_path, veadk_wheel) + wheel_path.unlink() if include_cli_archive: archive.writestr( STUDIO_AGENTKIT_CLI_ARTIFACT.filename, @@ -704,6 +707,92 @@ def _deploy_scheduler_for_update( assert status["startedAt"] > 0 +def test_submit_latest_continues_main_function_when_scheduler_update_fails( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + archive = tmp_path / "source.zip" + _bundle(archive) + content = archive.read_bytes() + manifest = StudioReleaseManifest( + version="20260724153045", + git_sha="a" * 40, + sha256=hashlib.sha256(content).hexdigest(), + size=len(content), + created_at="2026-07-24T15:30:45+08:00", + ) + captured: dict[str, Any] = {} + + class _Store: + def latest_manifest(self) -> StudioReleaseManifest: + return manifest + + def release_catalog(self) -> list[StudioReleaseManifest]: + return [manifest] + + def download_bundle( + self, release: StudioReleaseManifest, destination: Path + ) -> None: + assert release == manifest + destination.write_bytes(content) + + class _VeFaaS: + def __init__(self, **_kwargs: str) -> None: + self.client = object() + + def submit_application_code_bundle_update(self, **kwargs: Any) -> None: + captured["main_update"] = kwargs + + def _fail_scheduler(*_args: Any, **_kwargs: Any) -> Any: + raise RuntimeError( + "Scheduler dependency installation failed for sts-ak/sts-sk " + "at https://upload.example.com/object?token=sts-token" + ) + + updater = StudioSelfUpdater( + settings=_settings(), + credential_resolver=lambda: ("sts-ak", "sts-sk", "sts-token"), + branding_logo=None, + ) + monkeypatch.setattr(updater, "_store", lambda *_args: _Store()) + monkeypatch.setattr("veadk.integrations.ve_faas.ve_faas.VeFaaS", _VeFaaS) + monkeypatch.setattr( + "frontend.server.studio_update_resources.reconcile_studio_update_resources", + lambda **_kwargs: { + "VEADK_STUDIO_TOS_BUCKET": "studio-bucket", + "VEADK_STUDIO_TOS_REGION": "cn-beijing", + }, + ) + monkeypatch.setattr( + "frontend.service.studio_scheduler.deploy.deploy_scheduler_for_studio_update", + _fail_scheduler, + ) + monkeypatch.setenv("VEADK_STUDIO_RELEASE_VERSION", "bundled") + + assert updater.submit_latest() == manifest + + update = captured["main_update"] + assert update["function_id"] == "function-id" + assert update["environment_overrides"] == { + "VEADK_STUDIO_RELEASE_VERSION": manifest.version, + "VEADK_STUDIO_TOS_BUCKET": "studio-bucket", + "VEADK_STUDIO_TOS_REGION": "cn-beijing", + } + status = updater.status() + assert status["state"] == "updating" + assert status["progressStage"] == "publishing" + assert status["errorId"] == "" + assert status["errorStage"] == "" + assert "warningId=" in status["errorLog"] + assert "stage=scheduler" in status["errorLog"] + assert "定时任务更新未全部完成,继续更新 Studio 主函数" in status["errorLog"] + assert "Scheduler dependency installation failed" in status["errorLog"] + assert "sts-ak" not in status["errorLog"] + assert "sts-sk" not in status["errorLog"] + assert "sts-token" not in status["errorLog"] + assert "?[REDACTED]" in status["errorLog"] + + def test_submit_latest_reports_missing_vefaas_permissions( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, diff --git a/tests/test_studio_release_server.py b/tests/test_studio_release_server.py index 96dd6b767..08fa0bfc1 100644 --- a/tests/test_studio_release_server.py +++ b/tests/test_studio_release_server.py @@ -832,12 +832,10 @@ def _offline_runtime( veadk_wheel: Path, **_kwargs: Any, ) -> str: - wheelhouse = package_dir / "wheelhouse" - wheelhouse.mkdir() - target = wheelhouse / veadk_wheel.name + target = package_dir / veadk_wheel.name target.write_bytes(veadk_wheel.read_bytes()) _write_test_wheel( - wheelhouse / "six-1.17.0-py2.py3-none-any.whl", + package_dir / "six-1.17.0-py2.py3-none-any.whl", name="six", version="1.17.0", ) @@ -847,9 +845,9 @@ def _offline_runtime( ) return ( "--no-index\n" - "--find-links ./wheelhouse\n" - "-r ./studio-runtime.lock\n" - f"./wheelhouse/{target.name}\n" + "--require-hashes\n" + "./six-1.17.0-py2.py3-none-any.whl --hash=sha256:test\n" + f"./{target.name} --hash=sha256:test\n" ) monkeypatch.setattr(release_publisher.subprocess, "run", _run) @@ -873,10 +871,12 @@ def _offline_runtime( with zipfile.ZipFile(bundle) as archive: assert archive.read("requirements.txt").decode() == ( "--no-index\n" - "--find-links ./wheelhouse\n" - "-r ./studio-runtime.lock\n" - "./wheelhouse/veadk_python-1.0.0-py3-none-any.whl\n" + "--require-hashes\n" + "./six-1.17.0-py2.py3-none-any.whl --hash=sha256:test\n" + "./veadk_python-1.0.0-py3-none-any.whl --hash=sha256:test\n" ) + assert not any(name.startswith("wheelhouse/") for name in archive.namelist()) + assert len([name for name in archive.namelist() if name.endswith(".whl")]) == 2 assert archive.read("agentkit-linux-x64.tar.gz") == b"pinned-cli" assert ".studio-release-environment.json" not in archive.namelist() assert ( @@ -926,7 +926,8 @@ def _offline_runtime( assert b"--runtime-manifest" in archive.read("run.sh") with zipfile.ZipFile(full_bundle) as archive: assert archive.read("agentkit-linux-x64.tar.gz") == b"pinned-cli" - assert any(name.startswith("wheelhouse/") for name in archive.namelist()) + assert not any(name.startswith("wheelhouse/") for name in archive.namelist()) + assert len([name for name in archive.namelist() if name.endswith(".whl")]) == 2 extracted = tmp_path / "thin-extracted" with zipfile.ZipFile(thin_bundle) as archive: archive.extractall(extracted) @@ -1047,9 +1048,8 @@ def test_publisher_stages_provider_local_thin_runtime( encoding="utf-8", ) package_dir = tmp_path / "package" - wheelhouse = package_dir / "wheelhouse" - wheelhouse.mkdir(parents=True) - dependency_wheel = wheelhouse / "dependency-1.0-py3-none-any.whl" + package_dir.mkdir(parents=True) + dependency_wheel = package_dir / "dependency-1.0-py3-none-any.whl" _write_test_wheel( dependency_wheel, name="dependency", @@ -1059,7 +1059,7 @@ def test_publisher_stages_provider_local_thin_runtime( _pypi_lock(("dependency", "1.0", dependency_wheel)), encoding="utf-8", ) - veadk_wheel = wheelhouse / "veadk_python-1.0.0-py3-none-any.whl" + veadk_wheel = package_dir / "veadk_python-1.0.0-py3-none-any.whl" _write_test_wheel( veadk_wheel, name="veadk-python", @@ -1087,7 +1087,8 @@ def test_publisher_stages_provider_local_thin_runtime( assert manifest["runtimeEpoch"] == epoch assert manifest["provider"] == provider assert len(list(artifact_dir.iterdir())) == 2 - assert not wheelhouse.exists() + assert not (package_dir / "wheelhouse").exists() + assert not dependency_wheel.exists() assert not cli_archive.exists() assert len(list(package_dir.glob("veadk*.whl"))) == 1 assert ( @@ -1410,10 +1411,9 @@ def test_runtime_epoch_reuses_dependencies_across_veadk_releases( artifact_names: list[set[str]] = [] for version, content in (("1.0.0", b"app-one"), ("1.0.1", b"app-two")): package = tmp_path / f"package-{version}" - wheelhouse = package / "wheelhouse" - wheelhouse.mkdir(parents=True) + package.mkdir(parents=True) _write_test_wheel( - wheelhouse / "dependency-1.0-py3-none-any.whl", + package / "dependency-1.0-py3-none-any.whl", name="dependency", version="1.0", ) @@ -1422,20 +1422,19 @@ def test_runtime_epoch_reuses_dependencies_across_veadk_releases( ( "dependency", "1.0", - wheelhouse / "dependency-1.0-py3-none-any.whl", + package / "dependency-1.0-py3-none-any.whl", ) ), encoding="utf-8", ) veadk_name = f"veadk_python-{version}-py3-none-any.whl" _write_test_wheel( - wheelhouse / veadk_name, + package / veadk_name, name="veadk-python", version=version, license_expression="Apache-2.0", marker=content.decode(), ) - shutil.copy2(wheelhouse / veadk_name, package / veadk_name) (package / "agentkit-linux-x64.tar.gz").write_bytes(cli_content) (package / "requirements.txt").write_text("local\n", encoding="utf-8") (package / "run.sh").write_text("local\n", encoding="utf-8") diff --git a/veadk/cli/studio_self_update.py b/veadk/cli/studio_self_update.py index 6d329788a..8f7ae4564 100644 --- a/veadk/cli/studio_self_update.py +++ b/veadk/cli/studio_self_update.py @@ -464,17 +464,26 @@ def submit_version(self, version: str | None) -> StudioReleaseManifest: "scheduler", "正在更新定时任务调度服务与分钟触发器", ) - _, _, _, _, scheduler_base = deploy_scheduler_for_studio_update( - service, - studio_function_id=self._settings.function_id, - package_root=package_dir, - provider=self._settings.provider, - project=self._settings.project, - environment_overrides=resource_environment, - ) - environment_overrides["VEADK_STUDIO_CRONJOB_SCHEDULER_BASE"] = ( - scheduler_base - ) + try: + _, _, _, _, scheduler_base = deploy_scheduler_for_studio_update( + service, + studio_function_id=self._settings.function_id, + package_root=package_dir, + provider=self._settings.provider, + project=self._settings.project, + environment_overrides=resource_environment, + ) + except Exception as error: # noqa: BLE001 - scheduler is best effort + self._record_warning( + error, + "定时任务更新未全部完成,继续更新 Studio 主函数", + stage="scheduler", + secrets=credentials, + ) + else: + environment_overrides["VEADK_STUDIO_CRONJOB_SCHEDULER_BASE"] = ( + scheduler_base + ) self._set_progress("submitting", "正在提交 VeFaaS Function 更新") service.submit_application_code_bundle_update( application_id=self._settings.application_id, @@ -564,6 +573,34 @@ def _record_failure( ) self._set_progress("error", message) + def _record_warning( + self, + error: BaseException, + message: str, + *, + stage: str, + secrets: tuple[str, ...] = (), + ) -> None: + """Record a non-blocking component failure without masking main update state.""" + warning_id = uuid.uuid4().hex[:12] + self._diagnostic_lines.extend( + ( + f"warningId={warning_id}", + f"stage={stage}", + f"region={self._settings.deployment_region}", + f"project={self._settings.project}", + f"applicationId={self._settings.application_id}", + f"functionId={self._settings.function_id}", + "", + _redact_diagnostic( + "".join(traceback.format_exception(error)).rstrip(), + secrets, + ), + ) + ) + logger.warning(message) + self._set_progress(stage, message) + def _progress_payload(self) -> dict[str, Any]: """Return stable progress fields for every status response.""" return {