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
7 changes: 7 additions & 0 deletions .github/workflows/publish-studio-release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ on:
description: User-facing Studio release summary
required: true
type: string
thin_bundles:
description: Publish provider-local thin bundles (requires server opt-in)
required: true
default: false
type: boolean
pull_request:
paths:
- '.github/workflows/publish-studio-release.yaml'
Expand Down Expand Up @@ -254,6 +259,7 @@ jobs:
RELEASE_SERVER_URL: ${{ secrets[matrix.url_secret] }}
RELEASE_SERVER_API_KEY: ${{ secrets[matrix.key_secret] }}
RELEASE_VERSION: ${{ needs.release-context.outputs.version }}
RELEASE_THIN_BUNDLES: ${{ inputs.thin_bundles }}

steps:
- name: Validate release server configuration
Expand Down Expand Up @@ -303,6 +309,7 @@ jobs:
"requestId": job_id,
"version": os.environ["RELEASE_VERSION"],
"changelog": [os.environ["STUDIO_CHANGELOG"]],
"thinBundle": os.environ["RELEASE_THIN_BUNDLES"].lower() == "true",
}
payload = json.dumps(payload_data).encode()
request = urllib.request.Request(
Expand Down
9 changes: 9 additions & 0 deletions frontend/service/studio_release_server/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
)
_NODE_HEAP_MEMORY_RATIO = 0.75
_DEFAULT_NODE_HEAP_MB = 4096
_STUDIO_RELEASE_CONTRACT = "agentkit-cli-v1"

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -607,6 +608,10 @@ def _run_publisher(
frontend_assets: Path | None,
dependency_wheels: Path | None,
) -> None:
if request.thin_bundle and not self._settings.thin_releases:
raise RuntimeError(
"Studio thin releases are not enabled on this Release Server."
)
credentials = resolve_credentials(self._settings.provider)
command = [
sys.executable,
Expand All @@ -627,7 +632,11 @@ def _run_publisher(
self._settings.provider,
"--prefix",
self._settings.release_prefix,
"--release-contract",
_STUDIO_RELEASE_CONTRACT,
]
if request.thin_bundle:
command.append("--thin")
for item in request.changelog:
command.extend(("--changelog", item))
if frontend_assets is not None:
Expand Down
165 changes: 165 additions & 0 deletions frontend/service/studio_release_server/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@
from typing import Any

from veadk.cloud.cloud_agent_engine import CloudAgentEngine
from veadk.cli.studio_artifacts import (
STUDIO_ARTIFACT_BUCKETS,
STUDIO_ARTIFACT_PREFIX,
STUDIO_ARTIFACT_REGIONS,
)
from veadk.utils.cloud_provider import (
CloudProvider,
default_region,
Expand All @@ -54,6 +59,7 @@
_REPOSITORY = "volcengine/veadk-python"
_ROLE_NAME = "VeADKStudioReleaseServerRole"
_POLICY_NAME = "VeADKStudioReleaseServerPolicy"
_PUBLIC_ARTIFACT_POLICY_SID = "PublicReadStudioRuntimeArtifacts"
_NODE_VERSION = "22.17.0"
_NODE_ARCHIVE_NAME = f"node-v{_NODE_VERSION}-linux-x64.tar.xz"
_NODE_ARCHIVE_URL = (
Expand Down Expand Up @@ -278,6 +284,7 @@ def _runtime_environment(
bucket: str,
provider: CloudProvider,
region: str,
thin_bundles: bool = False,
) -> dict[str, str]:
return {
"STUDIO_RELEASE_SERVER_API_KEY": api_key,
Expand All @@ -287,6 +294,7 @@ def _runtime_environment(
"STUDIO_RELEASE_PREFIX": _RELEASE_PREFIX,
"STUDIO_RELEASE_JOB_PREFIX": _JOB_PREFIX,
"STUDIO_RELEASE_REPOSITORY": _REPOSITORY,
"STUDIO_RELEASE_THIN_BUNDLES": "true" if thin_bundles else "false",
}


Expand Down Expand Up @@ -425,6 +433,124 @@ def _ensure_release_bucket(client: Any, bucket: str) -> None:
)


def _public_artifact_policy(bucket: str) -> dict[str, object]:
"""Allow anonymous reads only below the immutable artifact namespace."""

return {
"Statement": [
{
"Sid": _PUBLIC_ARTIFACT_POLICY_SID,
"Effect": "Allow",
"Principal": "*",
"Action": ["tos:GetObject"],
"Resource": [f"trn:tos:::{bucket}/{STUDIO_ARTIFACT_PREFIX}/*"],
}
]
}


def _is_tos_not_found(error: Exception) -> bool:
return isinstance(error, KeyError) or getattr(error, "status_code", None) == 404


def _merge_public_artifact_policy(
current: object,
bucket: str,
) -> tuple[dict[str, object], bool]:
"""Add only the policy statement owned by this deployer."""

if current is None:
policy: dict[str, object] = {}
elif isinstance(current, dict):
policy = dict(current)
else:
raise RuntimeError("Studio public artifact bucket policy is invalid.")
raw_statements = policy.get("Statement", [])
if isinstance(raw_statements, dict):
statements = [dict(raw_statements)]
elif isinstance(raw_statements, list) and all(
isinstance(item, dict) for item in raw_statements
):
statements = [dict(item) for item in raw_statements]
else:
raise RuntimeError("Studio public artifact bucket policy is invalid.")
expected_policy = _public_artifact_policy(bucket)
expected_statements = expected_policy["Statement"]
if not isinstance(expected_statements, list) or not isinstance(
expected_statements[0], dict
):
raise RuntimeError("Studio public artifact bucket policy is invalid.")
expected = dict(expected_statements[0])
owned = [
statement
for statement in statements
if statement.get("Sid") == _PUBLIC_ARTIFACT_POLICY_SID
]
if len(owned) > 1 or (owned and owned[0] != expected):
raise RuntimeError("Studio public artifact bucket policy has a conflict.")
if owned:
policy["Statement"] = statements
return policy, False
statements.append(expected)
policy["Statement"] = statements
return policy, True


def _ensure_do_not_delete_tag(client: Any, bucket: str) -> None:
"""Add the marker without replacing unrelated bucket tags."""

import tos

try:
current = list(
getattr(client.get_bucket_tagging(bucket=bucket), "tag_set", []) or []
)
except Exception as error:
if not _is_tos_not_found(error):
raise
current = []
if any(getattr(item, "key", None) == "note" for item in current):
return
client.put_bucket_tagging(
bucket=bucket,
tag_set=[*current, tos.models2.Tag(key="note", value="勿删")],
)


def _ensure_public_artifact_bucket(client: Any, provider: CloudProvider) -> str:
"""Create one isolated bucket and merge its prefix-only public-read policy."""

bucket = STUDIO_ARTIFACT_BUCKETS[provider]
buckets = list(getattr(client.list_buckets(), "buckets", []) or [])
if not any(getattr(item, "name", "") == bucket for item in buckets):
client.create_bucket(bucket=bucket)
_ensure_do_not_delete_tag(client, bucket)
try:
current = json.loads(client.get_bucket_policy(bucket=bucket).policy)
except Exception as error:
if not _is_tos_not_found(error):
raise RuntimeError(
"Studio public artifact bucket policy lookup failed."
) from error
current = None
expected, changed = _merge_public_artifact_policy(current, bucket)
if changed:
client.put_bucket_policy(
bucket=bucket,
policy=json.dumps(expected, sort_keys=True),
)
try:
actual = json.loads(client.get_bucket_policy(bucket=bucket).policy)
except Exception as error:
raise RuntimeError(
"Studio public artifact bucket policy verification failed."
) from error
verified, _ = _merge_public_artifact_policy(actual, bucket)
if verified != expected:
raise RuntimeError("Studio public artifact bucket policy verification failed.")
return bucket


def _release_function(service: Any, function_id: str) -> None:
from volcenginesdkvefaas import GetReleaseStatusRequest, ReleaseRequest

Expand Down Expand Up @@ -726,6 +852,7 @@ def _deploy(
access_key: str,
secret_key: str,
session_token: str,
thin_bundles: bool = False,
) -> tuple[str, str, str]:
"""Create or update the Function and bind it to an existing gateway."""
engine = CloudAgentEngine(
Expand All @@ -741,6 +868,7 @@ def _deploy(
bucket=_release_bucket(provider),
provider=provider,
region=region,
thin_bundles=thin_bundles,
)
with tempfile.TemporaryDirectory(prefix="studio_release_server_") as tmp:
deployment_root = Path(tmp)
Expand Down Expand Up @@ -829,6 +957,16 @@ def _parser() -> argparse.ArgumentParser:
action="store_true",
help="Deploy without changing repository secrets.",
)
parser.add_argument(
"--provision-public-artifacts-only",
action="store_true",
help="Provision only the isolated public runtime artifact bucket.",
)
parser.add_argument(
"--enable-thin-bundles",
action="store_true",
help="Enable public-artifact thin Studio bundles (default: disabled).",
)
return parser


Expand All @@ -846,6 +984,31 @@ def main() -> None:
f"{credential_prefix}_ACCESS_KEY and {credential_prefix}_SECRET_KEY "
"are required."
)
if args.provision_public_artifacts_only:
artifact_region = STUDIO_ARTIFACT_REGIONS[provider]
bucket = _ensure_public_artifact_bucket(
_tos_client(
access_key,
secret_key,
session_token,
provider=provider,
region=artifact_region,
),
provider,
)
print(
json.dumps(
{
"provider": provider,
"region": artifact_region,
"bucket": bucket,
"publicPrefix": STUDIO_ARTIFACT_PREFIX,
"provisioned": True,
},
ensure_ascii=False,
)
)
return
if not args.skip_github_secrets:
_validate_github_secret_access()
_ensure_release_bucket(
Expand Down Expand Up @@ -874,6 +1037,7 @@ def main() -> None:
access_key=access_key,
secret_key=secret_key,
session_token=session_token,
thin_bundles=args.enable_thin_bundles,
)
_wait_for_health(endpoint, api_key)
if not args.skip_github_secrets:
Expand All @@ -890,6 +1054,7 @@ def main() -> None:
"functionId": function_id,
"endpoint": endpoint,
"githubSecretsConfigured": not args.skip_github_secrets,
"thinBundlesEnabled": args.enable_thin_bundles,
},
ensure_ascii=False,
)
Expand Down
19 changes: 18 additions & 1 deletion frontend/service/studio_release_server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from dataclasses import dataclass
from typing import Literal, cast

from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, StrictBool, field_validator

_GIT_SHA_PATTERN = re.compile(r"^[0-9a-f]{40}$")
_JOB_ID_PATTERN = re.compile(r"^[A-Za-z0-9_.-]{1,100}$")
Expand All @@ -31,6 +31,18 @@
ReleaseProvider = Literal["volcengine", "byteplus"]


def _strict_env_bool(name: str, *, default: bool = False) -> bool:
value = os.getenv(name)
if value is None or not value.strip():
return default
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
raise ValueError(f"{name} must be an explicit boolean value.")


@dataclass(frozen=True)
class ReleaseServerSettings:
"""Runtime settings injected into the VeFaaS Function."""
Expand All @@ -42,6 +54,7 @@ class ReleaseServerSettings:
job_prefix: str
repository: str
provider: ReleaseProvider = "volcengine"
thin_releases: bool = False

def __post_init__(self) -> None:
if len(self.api_key) < 32:
Expand All @@ -52,6 +65,8 @@ def __post_init__(self) -> None:
raise ValueError("STUDIO_RELEASE_REGION is required.")
if self.provider not in {"volcengine", "byteplus"}:
raise ValueError("STUDIO_RELEASE_PROVIDER is invalid.")
if not isinstance(self.thin_releases, bool):
raise ValueError("STUDIO_RELEASE_THIN_BUNDLES must be boolean.")
for value, name in (
(self.release_prefix, "STUDIO_RELEASE_PREFIX"),
(self.job_prefix, "STUDIO_RELEASE_JOB_PREFIX"),
Expand Down Expand Up @@ -84,6 +99,7 @@ def from_env(cls) -> ReleaseServerSettings:
ReleaseProvider,
os.getenv("STUDIO_RELEASE_PROVIDER", "volcengine").strip(),
),
thin_releases=_strict_env_bool("STUDIO_RELEASE_THIN_BUNDLES"),
)


Expand All @@ -98,6 +114,7 @@ class ReleaseRequest(BaseModel):
changelog: tuple[str, ...] = ()
source_key: str = Field(default="", alias="sourceKey")
version: str = ""
thin_bundle: StrictBool = Field(default=False, alias="thinBundle")

@field_validator("repository")
@classmethod
Expand Down
Loading
Loading