diff --git a/.github/workflows/harness-sidecar-release-gate.yaml b/.github/workflows/harness-sidecar-release-gate.yaml index 540e33d32..fc595dbde 100644 --- a/.github/workflows/harness-sidecar-release-gate.yaml +++ b/.github/workflows/harness-sidecar-release-gate.yaml @@ -23,6 +23,7 @@ on: - 'tests/test_harness_sidecar_release_gate.py' - 'tests/test_cloud.py' - 'tests/test_studio_release_server.py' + - 'tests/cli/test_studio_update_cli_contract.py' - 'tests/test_studio_release_workflow.py' - 'veadk/cli/generated_agent_codegen.py' - 'veadk/cli/generated_agent_mcp.py' @@ -60,6 +61,7 @@ on: - 'tests/test_harness_sidecar_release_gate.py' - 'tests/test_cloud.py' - 'tests/test_studio_release_server.py' + - 'tests/cli/test_studio_update_cli_contract.py' - 'tests/test_studio_release_workflow.py' - 'veadk/cli/generated_agent_codegen.py' - 'veadk/cli/generated_agent_mcp.py' @@ -148,6 +150,7 @@ jobs: pids+=("$!") ( python -m pytest -q \ + tests/cli/test_studio_update_cli_contract.py \ tests/test_studio_release_server.py::test_release_server_agentkit_cli_pin_matches_veadk \ tests/test_studio_release_server.py::test_publisher_repairs_missing_agentkit_cli_before_manifest \ tests/test_studio_release_server.py::test_release_server_zip_normalizes_runtime_file_permissions \ diff --git a/.github/workflows/publish-studio-release.yaml b/.github/workflows/publish-studio-release.yaml index 0f1398314..15b35ca0a 100644 --- a/.github/workflows/publish-studio-release.yaml +++ b/.github/workflows/publish-studio-release.yaml @@ -29,6 +29,7 @@ on: pull_request: paths: - '.github/workflows/publish-studio-release.yaml' + - 'tests/cli/test_studio_update_cli_contract.py' - 'frontend/**' - 'veadk/**' - 'pyproject.toml' @@ -103,6 +104,11 @@ jobs: - name: Validate frozen Studio runtime lock run: uv lock --check + - name: Validate cross-version Studio update contracts + run: >- + uv run --frozen --group dev python -m pytest -q + tests/cli/test_studio_update_cli_contract.py + - name: Build and validate Studio bundle run: | set -euo pipefail diff --git a/frontend/README.md b/frontend/README.md index 58ef4b6d6..b57f625f7 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1019,6 +1019,20 @@ their changelog and Git SHA. An accepted update verifies the selected complete Bundle, replaces the current Function code, and releases the existing Application without changing its URL or SSO configuration. +After verifying the release manifest's bundle size and SHA-256, the updater +checks the AgentKit CLI against the pin declared in the target VeADK wheel. +It reads that declaration without importing downloaded Python code. Full and +thin bundles can therefore move to a different CLI version while rejecting a +wheel/CLI mismatch. These cases run in the mandatory release gates. + +Older Studio installations that compare the bundle against their own CLI pin +can reject a newer release with `AgentKit CLI archive checksum is invalid`. +Updating the Release Server alone cannot replace that installed validator. +An administrator must first deploy a Studio containing the corrected updater +to the existing Function through the control plane, preserving its environment, +identity, storage, and gateway configuration. Subsequent in-product updates +use the target release's CLI pin. + When an update fails, the administrator dialog shows the failed stage, a searchable error ID, the complete diagnostic timeline and exception chain, and a direct link to the deployed Function in the VeFaaS console. The log can be diff --git a/frontend/service/studio_release_server/publisher.py b/frontend/service/studio_release_server/publisher.py index 6a8aa0d0e..bf56d5747 100644 --- a/frontend/service/studio_release_server/publisher.py +++ b/frontend/service/studio_release_server/publisher.py @@ -17,6 +17,7 @@ from __future__ import annotations import argparse +import ast import hashlib import importlib.util import json @@ -761,7 +762,9 @@ def validate_studio_wheel(wheel: Path, source_root: Path) -> None: ) -def validate_studio_agentkit_cli_archive(artifacts: list[Path]) -> Path: +def validate_studio_agentkit_cli_archive( + artifacts: list[Path], *, expected_sha256: str | None = None +) -> Path: """Require the exact pinned Linux/x64 native CLI archive.""" candidates = [path for path in artifacts if path.name == _AGENTKIT_CLI_ARCHIVE] @@ -776,7 +779,9 @@ def validate_studio_agentkit_cli_archive(artifacts: list[Path]) -> Path: raise StudioPublisherError( "The Studio release AgentKit CLI archive is unavailable." ) from error - if digest != _AGENTKIT_CLI_ARCHIVE_SHA256: + if digest != ( + _AGENTKIT_CLI_ARCHIVE_SHA256 if expected_sha256 is None else expected_sha256 + ): raise StudioPublisherError( "The Studio release AgentKit CLI archive checksum is invalid." ) @@ -832,8 +837,86 @@ def ensure_studio_bundle_agentkit_cli( ) -def validate_studio_bundle_dependencies(package_dir: Path) -> Path: - """Validate the local VeADK/CLI dependency pair in an extracted bundle.""" +def _target_agentkit_cli_sha256(wheel: Path) -> str: + """Read the target wheel's literal Linux/x64 pin without executing its code. + + The updater verifies the outer bundle against its release manifest + before extraction. A running Studio's own CLI pin cannot constrain future + releases; the target wheel and CLI must instead agree with each other. + """ + message = "Studio target VeADK AgentKit CLI contract is invalid." + try: + with zipfile.ZipFile(wheel) as archive: + members = [ + item + for item in archive.infolist() + if item.filename == "veadk/cli/agentkit_cli.py" + ] + if len(members) != 1 or members[0].file_size > 256 * 1024: + raise StudioPublisherError(message) + source = ast.parse(archive.read(members[0])) + except (OSError, ValueError, SyntaxError, zipfile.BadZipFile) as error: + raise StudioPublisherError(message) from error + assignments = [ + node.value + for node in source.body + if isinstance(node, ast.Assign) + and any( + isinstance(target, ast.Name) and target.id == "AGENTKIT_CLI_ARTIFACTS" + for target in node.targets + ) + ] + if len(assignments) != 1 or not isinstance(assignments[0], ast.Dict): + raise StudioPublisherError(message) + artifacts = assignments[0] + if any( + not isinstance(key, ast.Constant) or not isinstance(key.value, str) + for key in artifacts.keys + ): + raise StudioPublisherError(message) + candidates = [ + value + for key, value in zip(artifacts.keys, artifacts.values) + if isinstance(key, ast.Constant) and key.value == "linux-x64" + ] + if len(candidates) != 1 or not isinstance(candidates[0], ast.Call): + raise StudioPublisherError(message) + artifact = candidates[0] + if ( + not isinstance(artifact.func, ast.Name) + or artifact.func.id != "AgentKitCliArtifact" + or artifact.args + or any(keyword.arg is None for keyword in artifact.keywords) + ): + raise StudioPublisherError(message) + fields = {keyword.arg: keyword.value for keyword in artifact.keywords} + if len(fields) != len(artifact.keywords): + raise StudioPublisherError(message) + for name, expected in ( + ("platform_key", "linux-x64"), + ("filename", _AGENTKIT_CLI_ARCHIVE), + ): + value = fields.get(name) + if not isinstance(value, ast.Constant) or value.value != expected: + raise StudioPublisherError(message) + digest = fields.get("sha256") + if ( + not isinstance(digest, ast.Constant) + or not isinstance(digest.value, str) + or not _SHA256_PATTERN.fullmatch(digest.value) + ): + raise StudioPublisherError(message) + return digest.value + + +def validate_studio_bundle_dependencies( + package_dir: Path, *, use_target_cli_pin: bool = False +) -> Path: + """Validate the local VeADK/CLI dependency pair in an extracted bundle. + + Only consumers of a manifest-verified release may use the target wheel's + CLI pin. Build callers retain the publisher's fixed source pin by default. + """ requirements_path = package_dir / "requirements.txt" try: lines = requirements_path.read_text(encoding="utf-8").splitlines() @@ -859,6 +942,9 @@ def validate_studio_bundle_dependencies(package_dir: Path) -> Path: and item.filename.startswith(("veadk_python-", "veadk-python-")) ] local_veadk_wheels = sorted(package_dir.glob("veadk*.whl")) + expected_cli_sha256 = _AGENTKIT_CLI_ARCHIVE_SHA256 + if use_target_cli_pin and len(local_veadk_wheels) == 1: + expected_cli_sha256 = _target_agentkit_cli_sha256(local_veadk_wheels[0]) expected_requirements = runtime_manifest.remote_requirements() if len(local_veadk_wheels) == 1: local_veadk = local_veadk_wheels[0] @@ -884,7 +970,7 @@ def validate_studio_bundle_dependencies(package_dir: Path) -> Path: ) if ( cli_artifact.filename != _AGENTKIT_CLI_ARCHIVE - or cli_artifact.sha256 != _AGENTKIT_CLI_ARCHIVE_SHA256 + or cli_artifact.sha256 != expected_cli_sha256 or remote_veadk_wheels or len(local_veadk_wheels) != 1 or requirements_path.read_text(encoding="utf-8") != expected_requirements @@ -942,7 +1028,12 @@ def validate_studio_bundle_dependencies(package_dir: Path) -> Path: raise StudioPublisherError( "Studio full release dependency contract is invalid." ) - return validate_studio_agentkit_cli_archive(list(package_dir.iterdir())) + return validate_studio_agentkit_cli_archive( + list(package_dir.iterdir()), + expected_sha256=( + _target_agentkit_cli_sha256(veadk_wheels[0]) if use_target_cli_pin else None + ), + ) def _build_local_requirements( diff --git a/tests/cli/test_studio_self_update.py b/tests/cli/test_studio_self_update.py index 88e18a04b..50942527e 100644 --- a/tests/cli/test_studio_self_update.py +++ b/tests/cli/test_studio_self_update.py @@ -225,6 +225,12 @@ def _bundle( "veadk_python-1.2.3.dist-info/METADATA", "Metadata-Version: 2.1\nName: veadk-python\nVersion: 1.2.3\n", ) + wheel.writestr( + "veadk/cli/agentkit_cli.py", + "AGENTKIT_CLI_ARTIFACTS = {'linux-x64': AgentKitCliArtifact(" + "platform_key='linux-x64', filename='agentkit-linux-x64.tar.gz', " + f"sha256='{hashlib.sha256(b'pinned-cli').hexdigest()}')}}", + ) if identity_roles_support: wheel.writestr( "frontend/server/user_management/service.py", @@ -525,6 +531,7 @@ def __init__(self, **_kwargs: str) -> None: def submit_application_code_bundle_update(self, **kwargs: Any) -> bool: package = Path(kwargs["path"]) captured["full_selected"] = (package / "full-marker").read_text() + return True updater = StudioSelfUpdater( settings=_settings(), diff --git a/tests/cli/test_studio_update_cli_contract.py b/tests/cli/test_studio_update_cli_contract.py new file mode 100644 index 000000000..632de80ee --- /dev/null +++ b/tests/cli/test_studio_update_cli_contract.py @@ -0,0 +1,292 @@ +# 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. + +"""Exercise cross-version updates at the real download/extraction boundary.""" + +from dataclasses import replace +import hashlib +import io +from pathlib import Path +from types import SimpleNamespace +import zipfile + +import pytest +import yaml + +from frontend.service.studio_release_server import publisher +from veadk.cli import agentkit_cli +from veadk.cli.studio_artifacts import StudioArtifact, StudioRuntimeManifest +from veadk.cli.studio_release import ( + StudioReleaseError, + StudioReleaseManifest, + StudioReleaseStore, +) +from veadk.cli.studio_self_update import ( + StudioSelfUpdater, + StudioUpdateSettings, + extract_studio_bundle, +) +from veadk.utils.cloud_provider import CloudProvider + + +CLI_NAME = "agentkit-linux-x64.tar.gz" +TARGET_CLI = b"next release native CLI" +TARGET_SHA = hashlib.sha256(TARGET_CLI).hexdigest() +WHEEL_NAME = "veadk_python-1.2.3-py3-none-any.whl" +SOURCE_NAME = "veadk/cli/agentkit_cli.py" + + +def _source() -> str: + # Use the actual source shape; executing any part of it is forbidden. + return ( + Path(agentkit_cli.__file__) + .read_text() + .replace(agentkit_cli.AGENTKIT_CLI_ARTIFACTS["linux-x64"].sha256, TARGET_SHA) + + "\nraise AssertionError('downloaded code executed')\n" + ) + + +def _bundles( + tmp_path: Path, + provider: CloudProvider = "volcengine", + *, + cli: bytes = TARGET_CLI, + source: str | None = None, +) -> tuple[bytes, bytes, StudioReleaseManifest]: + wheel = io.BytesIO() + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr( + "veadk_python-1.2.3.dist-info/METADATA", + "Metadata-Version: 2.1\nName: veadk-python\nVersion: 1.2.3\n", + ) + archive.writestr(SOURCE_NAME, _source() if source is None else source) + wheel_bytes = wheel.getvalue() + cli_path = tmp_path / CLI_NAME + cli_path.write_bytes(cli) + dependency = tmp_path / "dependency-1.0-py3-none-any.whl" + dependency.write_bytes(b"public dependency") + runtime = StudioRuntimeManifest.create( + provider, + ( + StudioArtifact.from_path(dependency, provider=provider, kind="wheel"), + StudioArtifact.from_path(cli_path, provider=provider, kind="agentkit-cli"), + ), + ) + wheel_requirement = ( + f"./{WHEEL_NAME} --hash=sha256:{hashlib.sha256(wheel_bytes).hexdigest()}\n" + ) + contents = [] + for thin in (False, True): + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as archive: + archive.writestr("run.sh", "#!/bin/bash\n") + archive.writestr(WHEEL_NAME, wheel_bytes) + archive.writestr( + "requirements.txt", + ( + runtime.remote_requirements() + if thin + else "--no-index\n--require-hashes\n" + ) + + wheel_requirement, + ) + if thin: + archive.writestr("studio-runtime.json", runtime.to_json()) + else: + archive.writestr(CLI_NAME, cli) + contents.append(output.getvalue()) + full, thin = contents + release = StudioReleaseManifest( + version="20260917150000", + git_sha="a" * 40, + sha256=hashlib.sha256(full).hexdigest(), + size=len(full), + created_at="2026-09-17T15:00:00+08:00", + runtime_epoch=runtime.runtime_epoch, + thin_sha256=hashlib.sha256(thin).hexdigest(), + thin_size=len(thin), + ) + return full, thin, release + + +def _store(full: bytes, thin: bytes) -> StudioReleaseStore: + # Keep the production streaming size/digest checks, replacing only TOS I/O. + return StudioReleaseStore( + bucket="test-releases", + region="cn-beijing", + access_key="test-ak", + secret_key="test-sk", + client=SimpleNamespace( + get_object=lambda **kwargs: iter( + [thin if kwargs["key"].endswith("-thin.zip") else full] + ) + ), + ) + + +def _updater(provider: CloudProvider = "volcengine") -> StudioSelfUpdater: + return StudioSelfUpdater( + settings=StudioUpdateSettings( + bucket="test-releases", + deployment_region="cn-shanghai", + prefix="veadk/studio/main", + application_id="application-id", + function_id="function-id", + project="default", + provider=provider, + ), + credential_resolver=lambda: ("", "", None), + branding_logo=None, + ) + + +@pytest.mark.parametrize("provider", ["volcengine", "byteplus"]) +@pytest.mark.parametrize("mode", ["full", "thin", "fallback"]) +@pytest.mark.parametrize( + "installed_sha", + [ + "4e76e32c60473b5037c331a7c74bb99b1c23b62eb8ce26379d3a8c41af38a64e", + "4439d14b4be6ccb90f6eea896adf959ffef4ab4983f41e449d80c79d4cd95de3", + ], +) +def test_update_accepts_target_cli_across_installed_versions( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + provider: CloudProvider, + mode: str, + installed_sha: str, +) -> None: + full, thin, release = _bundles(tmp_path, provider) + monkeypatch.setattr(publisher, "_AGENTKIT_CLI_ARCHIVE_SHA256", installed_sha) + if mode == "full": + release = replace(release, runtime_epoch="", thin_sha256="", thin_size=0) + probes = [] + + def probe(artifact: StudioArtifact) -> None: + probes.append(artifact.sha256) + if mode == "fallback" and artifact.kind == "agentkit-cli": + raise ValueError("artifact unavailable") + + monkeypatch.setattr("veadk.cli.studio_self_update.probe_studio_artifact", probe) + selected = _updater(provider)._download_runtime_package( + _store(full, thin), release, tmp_path + ) + assert selected.name == ("package-thin" if mode == "thin" else "package") + assert len(probes) == {"full": 0, "thin": 2, "fallback": 2}[mode] + if mode != "full": + assert TARGET_SHA in probes + if mode != "thin": + assert (selected / CLI_NAME).read_bytes() == TARGET_CLI + # The builder must still reject a CLI that differs from its own source pin. + with pytest.raises(publisher.StudioPublisherError): + publisher.validate_studio_bundle_dependencies(selected) + + +@pytest.mark.parametrize("thin", [False, True]) +def test_update_rejects_cli_mismatched_with_target_wheel( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, thin: bool +) -> None: + full, thin_bytes, _ = _bundles(tmp_path, cli=b"different CLI") + # An archive matching the installed CLI is still wrong for this target. + monkeypatch.setattr( + publisher, + "_AGENTKIT_CLI_ARCHIVE_SHA256", + hashlib.sha256(b"different CLI").hexdigest(), + ) + archive = tmp_path / "bundle.zip" + archive.write_bytes(thin_bytes if thin else full) + with pytest.raises(StudioReleaseError, match="checksum|dependency contract"): + extract_studio_bundle(archive, tmp_path / "package") + + +@pytest.mark.parametrize("mutation", ["digest", "size"]) +def test_update_checks_release_manifest_before_target_contract( + tmp_path: Path, mutation: str +) -> None: + full, thin, release = _bundles(tmp_path) + release = replace(release, runtime_epoch="", thin_sha256="", thin_size=0) + release = replace( + release, + **({"sha256": "0" * 64} if mutation == "digest" else {"size": len(full) + 1}), + ) + with pytest.raises(StudioReleaseError, match="manifest"): + _updater()._download_runtime_package(_store(full, thin), release, tmp_path) + assert not (tmp_path / "package").exists() + + +@pytest.mark.parametrize( + "source", + [ + "", + "AGENTKIT_CLI_ARTIFACTS = load_from_network()", + "AGENTKIT_CLI_ARTIFACTS = {}", + "not valid python!", + ], +) +def test_update_rejects_missing_or_dynamic_target_cli_contract( + tmp_path: Path, source: str +) -> None: + full, _, _ = _bundles(tmp_path, source=source) + archive = tmp_path / "bundle.zip" + archive.write_bytes(full) + with pytest.raises(StudioReleaseError, match="CLI contract"): + extract_studio_bundle(archive, tmp_path / "package") + + +@pytest.mark.parametrize( + "mutation", ["computed", "duplicate", "unpacked", "filename", "digest", "oversized"] +) +def test_update_rejects_ambiguous_target_cli_contract( + tmp_path: Path, mutation: str +) -> None: + source = _source() + if mutation == "computed": + source = source.replace(f'"{TARGET_SHA}"', "compute_digest()", 1) + elif mutation == "duplicate": + source += "\nAGENTKIT_CLI_ARTIFACTS = {}\n" + elif mutation == "unpacked": + source = source.replace( + '"linux-x64": AgentKitCliArtifact(', + '**more_artifacts, "linux-x64": AgentKitCliArtifact(', + 1, + ) + elif mutation == "filename": + source = source.replace(CLI_NAME, "another.tar.gz") + elif mutation == "digest": + source = source.replace(TARGET_SHA, "not-a-digest") + else: + source += "\n#" + "x" * (256 * 1024) + full, _, _ = _bundles(tmp_path, source=source) + archive = tmp_path / "bundle.zip" + archive.write_bytes(full) + with pytest.raises(StudioReleaseError, match="CLI contract"): + extract_studio_bundle(archive, tmp_path / "package") + + +@pytest.mark.parametrize( + "workflow_name,job", + [ + ("publish-studio-release.yaml", "verify"), + ("harness-sidecar-release-gate.yaml", "backend-gate"), + ], +) +def test_cross_version_regression_is_required_by_release_gates( + workflow_name: str, job: str +) -> None: + root = Path(__file__).parents[2] + test_file = Path(__file__).relative_to(root).as_posix() + workflow = yaml.safe_load((root / ".github/workflows" / workflow_name).read_text()) + assert test_file in workflow[True]["pull_request"]["paths"] + commands = "\n".join(step.get("run", "") for step in workflow["jobs"][job]["steps"]) + assert test_file in commands diff --git a/tests/test_harness_sidecar_release_gate.py b/tests/test_harness_sidecar_release_gate.py index bf1f3e6dc..91fd1b3f8 100644 --- a/tests/test_harness_sidecar_release_gate.py +++ b/tests/test_harness_sidecar_release_gate.py @@ -71,6 +71,7 @@ def test_sidecar_release_gate_runs_backend_and_frontend_in_parallel() -> None: in (backend_run) ) assert "test_release_server_agentkit_cli_pin_matches_veadk" in backend_run + assert "tests/cli/test_studio_update_cli_contract.py" in backend_run assert "test_smoke_gate_survives_platform_entrypoint_mode_normalization" in ( backend_run ) diff --git a/tests/test_studio_release_lock_contract.py b/tests/test_studio_release_lock_contract.py index 018b8d8b3..372099188 100644 --- a/tests/test_studio_release_lock_contract.py +++ b/tests/test_studio_release_lock_contract.py @@ -29,4 +29,13 @@ def test_release_workflow_requires_committed_frozen_uv_lock() -> None: assert (repository / "uv.lock").is_file() assert "uv.lock" not in ignored assert "uv lock --check" in workflow - assert workflow.count("uv run --frozen --group dev python") == 2 + uv_commands = [ + line.strip() + for line in workflow.splitlines() + if line.strip().startswith("uv run ") + ] + assert len(uv_commands) >= 2 + assert all( + command.startswith("uv run --frozen --group dev python") + for command in uv_commands + ) diff --git a/veadk/cli/studio_self_update.py b/veadk/cli/studio_self_update.py index 7d66e4465..4db083220 100644 --- a/veadk/cli/studio_self_update.py +++ b/veadk/cli/studio_self_update.py @@ -969,7 +969,7 @@ def extract_studio_bundle(archive: Path, destination: Path) -> None: ) try: - validate_studio_bundle_dependencies(destination) + validate_studio_bundle_dependencies(destination, use_target_cli_pin=True) except ValueError as error: raise StudioReleaseError(str(error)) from error (destination / "run.sh").chmod(0o755)