Skip to content
Draft
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
3 changes: 3 additions & 0 deletions .github/workflows/harness-sidecar-release-gate.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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 \
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/publish-studio-release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
103 changes: 97 additions & 6 deletions frontend/service/studio_release_server/publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from __future__ import annotations

import argparse
import ast
import hashlib
import importlib.util
import json
Expand Down Expand Up @@ -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]
Expand All @@ -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."
)
Expand Down Expand Up @@ -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()
Expand All @@ -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]
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions tests/cli/test_studio_self_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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(),
Expand Down
Loading
Loading