mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-29 10:52:26 +00:00
689 lines
24 KiB
Python
689 lines
24 KiB
Python
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
|
# 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.
|
|
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
|
|
|
"""Capability-protected local API for review-first Bundle installation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from dataclasses import asdict
|
|
from pathlib import Path
|
|
from typing import Annotated, Literal
|
|
|
|
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
from app.auth import require_local_control_principal
|
|
from app.component.environment import env
|
|
from app.router_layer.hands_resolver import get_environment_hands
|
|
from app.run_journal import (
|
|
IdempotencyConflictError,
|
|
InvalidRunTransitionError,
|
|
OptimisticConcurrencyError,
|
|
configured_run_journal_path,
|
|
get_default_run_journal,
|
|
)
|
|
from app.utils.workspace_resolver import get_workspace_resolver
|
|
from app.workspace_bundle import (
|
|
HttpWorkspaceBundleCloudTransport,
|
|
WorkspaceBundleBindingsIncomplete,
|
|
WorkspaceBundleCloudError,
|
|
WorkspaceBundleInstaller,
|
|
WorkspaceBundleInstallError,
|
|
WorkspaceSecretBroker,
|
|
WorkspaceSecretBrokerError,
|
|
WorkspaceSecretIdentity,
|
|
)
|
|
from app.workspace_bundle.mcp_destination import (
|
|
attestation_from_grants,
|
|
secret_binding_attestation,
|
|
secret_binding_attestation_from_grants,
|
|
)
|
|
from app.workspace_config import ConfigPlacement
|
|
from app.workspace_git import ConfigurationRepositoryService
|
|
|
|
router = APIRouter(dependencies=[Depends(require_local_control_principal)])
|
|
|
|
|
|
class BundleProposalBody(BaseModel):
|
|
proposal_id: str = Field(min_length=1, max_length=128)
|
|
request_id: str = Field(min_length=1, max_length=128)
|
|
space_id: str = Field(min_length=1, max_length=256)
|
|
publisher_namespace: str = Field(
|
|
min_length=1,
|
|
max_length=80,
|
|
pattern=r"^[a-z0-9][a-z0-9._-]{0,79}$",
|
|
)
|
|
slug: str = Field(
|
|
min_length=1,
|
|
max_length=80,
|
|
pattern=r"^[a-z0-9][a-z0-9._-]{0,79}$",
|
|
)
|
|
version: int = Field(ge=1)
|
|
config_placement: Literal["in_repo", "sidecar"] = "sidecar"
|
|
|
|
|
|
class BundleDecisionBody(BaseModel):
|
|
expected_version: int = Field(ge=0)
|
|
approved: bool
|
|
actor_id: str = Field(min_length=1, max_length=200)
|
|
|
|
|
|
class BundleConnectorBindingBody(BaseModel):
|
|
expected_version: int = Field(ge=0)
|
|
slot_id: str = Field(min_length=1, max_length=255)
|
|
connector_id: str = Field(min_length=1, max_length=255)
|
|
connection_id: str = Field(min_length=1, max_length=255)
|
|
actor_id: str = Field(min_length=1, max_length=200)
|
|
|
|
|
|
class BundleLocalPathBindingBody(BaseModel):
|
|
expected_version: int = Field(ge=0)
|
|
slot_id: str = Field(min_length=1, max_length=255)
|
|
local_path: str = Field(min_length=1, max_length=4096)
|
|
actor_id: str = Field(min_length=1, max_length=200)
|
|
|
|
|
|
class BundleScriptApprovalBody(BaseModel):
|
|
expected_version: int = Field(ge=0)
|
|
action_id: str = Field(min_length=1, max_length=1024)
|
|
actor_id: str = Field(min_length=1, max_length=200)
|
|
|
|
|
|
class BundleMaterializeBody(BaseModel):
|
|
expected_version: int = Field(ge=0)
|
|
email: str = Field(min_length=1, max_length=512)
|
|
user_id: str | int | None = None
|
|
actor_id: str = Field(min_length=1, max_length=200)
|
|
allow_content_repository_init: bool = False
|
|
|
|
|
|
class BundleLocalValueBinding(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
requirement_key: str = Field(min_length=1, max_length=1024)
|
|
requirement_kind: Literal["environment", "mcp_secret"]
|
|
secret_ref: str = Field(pattern=r"^wsvault_[A-Za-z0-9_-]{32}$")
|
|
account_scope_digest: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
expected_binding_version: int | None = Field(default=None, ge=1)
|
|
|
|
|
|
class BundleLocalValuesBody(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
client_request_id: str = Field(min_length=1, max_length=200)
|
|
expected_version: int = Field(ge=0)
|
|
actor_id: str = Field(min_length=1, max_length=200)
|
|
bindings: list[BundleLocalValueBinding] = Field(
|
|
min_length=1,
|
|
max_length=100,
|
|
)
|
|
|
|
|
|
def _configuration_repository() -> ConfigurationRepositoryService:
|
|
journal = get_default_run_journal()
|
|
return ConfigurationRepositoryService(
|
|
journal,
|
|
state_root=configured_run_journal_path().parent / "workspace-git",
|
|
)
|
|
|
|
|
|
def _installer(cloud=None) -> WorkspaceBundleInstaller:
|
|
try:
|
|
secret_broker = WorkspaceSecretBroker.from_environment()
|
|
except WorkspaceSecretBrokerError:
|
|
secret_broker = None
|
|
return WorkspaceBundleInstaller(
|
|
get_default_run_journal(),
|
|
_configuration_repository(),
|
|
cloud,
|
|
secret_broker,
|
|
)
|
|
|
|
|
|
def _cloud(authorization: str) -> HttpWorkspaceBundleCloudTransport:
|
|
# The bearer credential may only be sent to the process-owned SERVER_URL.
|
|
# Renderer input cannot choose or override its destination.
|
|
server_url = env("SERVER_URL", "").strip()
|
|
if not server_url:
|
|
raise WorkspaceBundleInstallError("SERVER_URL is not configured")
|
|
return HttpWorkspaceBundleCloudTransport(
|
|
server_url=server_url,
|
|
authorization=authorization,
|
|
desktop_instance_id=os.environ.get("EIGENT_DESKTOP_INSTANCE_ID", ""),
|
|
)
|
|
|
|
|
|
def _payload(proposal_id: str) -> dict:
|
|
journal = get_default_run_journal()
|
|
proposal = journal.get_workspace_bundle_install_proposal(proposal_id)
|
|
if proposal is None:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail={"code": "bundle_install_proposal_not_found"},
|
|
)
|
|
local_bindings = journal.list_workspace_bundle_local_bindings(proposal_id)
|
|
secret_bindings = journal.list_workspace_bundle_secret_bindings(
|
|
proposal_id
|
|
)
|
|
configured_values = {item.requirement_key for item in secret_bindings}
|
|
available_values: set[str] = set()
|
|
try:
|
|
broker = WorkspaceSecretBroker.from_environment()
|
|
except WorkspaceSecretBrokerError:
|
|
broker = None
|
|
if broker is not None and secret_bindings:
|
|
identities = tuple(
|
|
WorkspaceSecretIdentity(
|
|
secret_ref=item.secret_ref,
|
|
account_scope_digest=item.account_scope_digest,
|
|
space_id=proposal.space_id,
|
|
revision_id=proposal.revision_id,
|
|
slot_id=item.requirement_key,
|
|
)
|
|
for item in secret_bindings
|
|
)
|
|
try:
|
|
available_values.update(
|
|
verification.identity.slot_id
|
|
for verification in broker.verify_many(identities)
|
|
if verification.state == "available"
|
|
)
|
|
except WorkspaceSecretBrokerError:
|
|
# Availability is advisory in this payload, but must fail closed.
|
|
available_values.clear()
|
|
binding_versions = {
|
|
item.requirement_key: item.binding_version for item in secret_bindings
|
|
}
|
|
install_plan = proposal.install_plan
|
|
environment_requirements = install_plan.get("environment_requirements", [])
|
|
mcp_secret_requirements = install_plan.get("mcp_secret_requirements", [])
|
|
value_requirements = [
|
|
{
|
|
**item,
|
|
"requirement_kind": "environment",
|
|
"configured": item["requirement_key"] in configured_values,
|
|
"available": item["requirement_key"] in available_values,
|
|
"binding_version": binding_versions.get(item["requirement_key"]),
|
|
}
|
|
for item in environment_requirements
|
|
] + [
|
|
{
|
|
**item,
|
|
"requirement_kind": "mcp_secret",
|
|
"configured": item["requirement_key"] in configured_values,
|
|
"available": item["requirement_key"] in available_values,
|
|
"binding_version": binding_versions.get(item["requirement_key"]),
|
|
}
|
|
for item in mcp_secret_requirements
|
|
]
|
|
required = {
|
|
item["slot_id"] for item in install_plan.get("connector_slots", [])
|
|
}
|
|
required.update(install_plan.get("local_path_slots", []))
|
|
required.update(install_plan.get("script_actions", []))
|
|
required.update(
|
|
item["requirement_key"]
|
|
for item in environment_requirements
|
|
if item.get("required")
|
|
)
|
|
required.update(
|
|
item["requirement_key"] for item in mcp_secret_requirements
|
|
)
|
|
# Materialization verifies every binding, including optional values that
|
|
# the user chose to configure. Do not show Ready while such a binding is
|
|
# unreadable and would fail one step later.
|
|
required.update(configured_values)
|
|
destinations = {
|
|
str(item.get("mcp_id")): item
|
|
for item in install_plan.get("mcp_destinations", [])
|
|
if isinstance(item, dict) and item.get("mcp_id")
|
|
}
|
|
mcp_secret_keys = {
|
|
str(item.get("mcp_id")): {
|
|
str(requirement.get("requirement_key"))
|
|
for requirement in mcp_secret_requirements
|
|
if requirement.get("mcp_id") == item.get("mcp_id")
|
|
}
|
|
for item in install_plan.get("mcp_destinations", [])
|
|
if isinstance(item, dict) and item.get("mcp_id")
|
|
}
|
|
current_secret_approvals: dict[str, bool] = {}
|
|
serialized_bindings: list[dict] = []
|
|
for item in local_bindings:
|
|
serialized = asdict(item)
|
|
current = True
|
|
prefix = "mcp.server.start:"
|
|
if item.slot_id.startswith(prefix):
|
|
server_id = item.slot_id.removeprefix(prefix)
|
|
destination = destinations.get(server_id)
|
|
if destination and destination.get("requires_secret_confirmation"):
|
|
relevant = [
|
|
{
|
|
"requirement_key": secret.requirement_key,
|
|
"secret_ref": secret.secret_ref,
|
|
"binding_version": secret.binding_version,
|
|
"account_scope_digest": (secret.account_scope_digest),
|
|
}
|
|
for secret in secret_bindings
|
|
if secret.requirement_key
|
|
in mcp_secret_keys.get(server_id, set())
|
|
]
|
|
current = bool(
|
|
destination.get("attestation_digest")
|
|
and destination.get("availability_issue") is None
|
|
and {value["requirement_key"] for value in relevant}
|
|
== mcp_secret_keys.get(server_id, set())
|
|
and attestation_from_grants(item.required_grants)
|
|
== destination.get("attestation_digest")
|
|
and secret_binding_attestation_from_grants(
|
|
item.required_grants
|
|
)
|
|
== secret_binding_attestation(
|
|
mcp_id=server_id,
|
|
bindings=relevant,
|
|
)
|
|
)
|
|
current_secret_approvals[server_id] = current
|
|
serialized["current"] = current
|
|
serialized_bindings.append(serialized)
|
|
|
|
configured = {
|
|
item.slot_id
|
|
for item, serialized in zip(
|
|
local_bindings, serialized_bindings, strict=True
|
|
)
|
|
if serialized["current"]
|
|
}
|
|
configured.update(available_values)
|
|
missing = sorted(required - configured)
|
|
runtime_issues: list[str] = []
|
|
manifest_spec = proposal.manifest.get("spec", {})
|
|
manifest_connectors = manifest_spec.get("connectors")
|
|
manifest_mcp_servers = manifest_spec.get(
|
|
"mcpServers",
|
|
manifest_spec.get("mcp_servers"),
|
|
)
|
|
manifest_agents = manifest_spec.get("agents", [])
|
|
manifest_skills = manifest_spec.get("skills", [])
|
|
connector_slots = (
|
|
manifest_connectors
|
|
if isinstance(manifest_connectors, list)
|
|
else install_plan.get("connector_slots", [])
|
|
)
|
|
agent_entries = (
|
|
manifest_agents if isinstance(manifest_agents, list) else []
|
|
)
|
|
agent_ids = {
|
|
str(agent.get("id", ""))
|
|
for agent in agent_entries
|
|
if isinstance(agent, dict)
|
|
}
|
|
# Portable Bundle agent ids are logical names. A single logical Agent is
|
|
# mapped to Desktop's concrete ``single_agent`` runtime; only a true
|
|
# multi-Agent graph requires the future workforce adapter.
|
|
has_unsupported_agents = len(agent_ids) > 1
|
|
has_unmaterialized_registry_dependencies = bool(
|
|
isinstance(manifest_skills, list)
|
|
and any(
|
|
isinstance(skill, dict)
|
|
and isinstance(skill.get("ref"), str)
|
|
and not skill["ref"].startswith("bundle://")
|
|
for skill in manifest_skills
|
|
)
|
|
) or bool(
|
|
isinstance(manifest_mcp_servers, list)
|
|
and any(
|
|
isinstance(server, dict)
|
|
and isinstance(server.get("definition"), str)
|
|
and not server["definition"].startswith("bundle://")
|
|
for server in manifest_mcp_servers
|
|
)
|
|
)
|
|
if connector_slots:
|
|
runtime_issues.append("connector_runtime_adapter_unavailable")
|
|
secret_mcp_ids = {
|
|
str(item.get("mcp_id"))
|
|
for item in mcp_secret_requirements
|
|
if item.get("mcp_id")
|
|
}
|
|
if not secret_mcp_ids and isinstance(manifest_mcp_servers, list):
|
|
secret_mcp_ids = {
|
|
str(item.get("id"))
|
|
for item in manifest_mcp_servers
|
|
if isinstance(item, dict)
|
|
and item.get("id")
|
|
and item.get("secretSlots", item.get("secret_slots", []))
|
|
}
|
|
needs_mcp_confirmation = False
|
|
has_unavailable_secret_mcp = False
|
|
for server_id in sorted(secret_mcp_ids):
|
|
destination = destinations.get(server_id)
|
|
action_id = f"mcp.server.start:{server_id}"
|
|
binding_exists = any(
|
|
item.slot_id == action_id for item in local_bindings
|
|
)
|
|
if destination and destination.get("availability_issue"):
|
|
runtime_issues.append(
|
|
f"{destination['availability_issue']}:{server_id}"
|
|
)
|
|
has_unavailable_secret_mcp = True
|
|
elif not binding_exists:
|
|
runtime_issues.append(
|
|
f"mcp_destination_confirmation_required:{server_id}"
|
|
)
|
|
needs_mcp_confirmation = True
|
|
elif not current_secret_approvals.get(server_id, False):
|
|
runtime_issues.append(
|
|
f"mcp_destination_confirmation_stale:{server_id}"
|
|
)
|
|
needs_mcp_confirmation = True
|
|
else:
|
|
runtime_issues.append(
|
|
f"mcp_secret_stdio_runtime_adapter_unavailable:{server_id}"
|
|
)
|
|
has_unavailable_secret_mcp = True
|
|
if has_unsupported_agents:
|
|
runtime_issues.append("multi_agent_runtime_adapter_unavailable")
|
|
if has_unmaterialized_registry_dependencies:
|
|
runtime_issues.append("registry_dependencies_unmaterialized")
|
|
if missing:
|
|
runtime_issues.append("local_setup_incomplete")
|
|
is_materialized = proposal.state == "materialized"
|
|
if not is_materialized:
|
|
runtime_issues.append("workspace_bundle_not_materialized")
|
|
|
|
if (
|
|
connector_slots
|
|
or has_unsupported_agents
|
|
or has_unmaterialized_registry_dependencies
|
|
or has_unavailable_secret_mcp
|
|
or missing
|
|
or not is_materialized
|
|
):
|
|
runtime_readiness = "unavailable"
|
|
elif needs_mcp_confirmation:
|
|
runtime_readiness = "needs_confirmation"
|
|
else:
|
|
runtime_readiness = "ready"
|
|
return {
|
|
"proposal": asdict(proposal),
|
|
"bindings": serialized_bindings,
|
|
"value_requirements": value_requirements,
|
|
"readiness": {
|
|
"ready": not missing,
|
|
"missing_requirements": missing,
|
|
},
|
|
# File materialization and runtime dispatch readiness are deliberately
|
|
# separate. A materialized proposal may still require an MCP
|
|
# destination attestation or an executable connector adapter.
|
|
"runtime_readiness": runtime_readiness,
|
|
"runtime_readiness_issues": runtime_issues,
|
|
}
|
|
|
|
|
|
def _error(exc: Exception) -> HTTPException:
|
|
if isinstance(exc, HTTPException):
|
|
return exc
|
|
if isinstance(exc, WorkspaceBundleCloudError):
|
|
return HTTPException(
|
|
status_code=exc.status_code,
|
|
detail={"code": "bundle_cloud_error", "upstream": exc.detail},
|
|
)
|
|
if isinstance(exc, WorkspaceBundleBindingsIncomplete):
|
|
return HTTPException(
|
|
status_code=409,
|
|
detail={
|
|
"code": "bundle_bindings_incomplete",
|
|
"missing_slots": list(exc.missing_slots),
|
|
},
|
|
)
|
|
if isinstance(
|
|
exc,
|
|
(
|
|
IdempotencyConflictError,
|
|
InvalidRunTransitionError,
|
|
OptimisticConcurrencyError,
|
|
),
|
|
):
|
|
return HTTPException(
|
|
status_code=409,
|
|
detail={"code": "bundle_install_conflict", "message": str(exc)},
|
|
)
|
|
if isinstance(exc, (WorkspaceBundleInstallError, ValueError)):
|
|
return HTTPException(
|
|
status_code=422,
|
|
detail={"code": "bundle_install_invalid", "message": str(exc)},
|
|
)
|
|
return HTTPException(
|
|
status_code=500,
|
|
detail={"code": "bundle_install_failed"},
|
|
)
|
|
|
|
|
|
def _authorized_local_path(request: Request, value: str) -> Path:
|
|
hands = getattr(request.state, "hands", None) or get_environment_hands()
|
|
validator = getattr(hands, "validate_workspace_binding_path", None)
|
|
if validator is not None:
|
|
ok, reason = validator(value)
|
|
if not ok:
|
|
raise WorkspaceBundleInstallError(
|
|
f"Local path is not allowed: {reason or 'path_not_allowed'}"
|
|
)
|
|
else:
|
|
can_access = getattr(hands, "can_access_filesystem", None)
|
|
if can_access is None or not can_access(value):
|
|
raise WorkspaceBundleInstallError(
|
|
"Local path is outside the Desktop filesystem capability"
|
|
)
|
|
return Path(value)
|
|
|
|
|
|
@router.post("/workspace-bundles/install-proposals")
|
|
async def propose_bundle_install(
|
|
body: BundleProposalBody,
|
|
authorization: Annotated[str, Header(alias="Authorization")],
|
|
) -> dict:
|
|
cloud = None
|
|
try:
|
|
cloud = _cloud(authorization)
|
|
await _installer(cloud).propose(
|
|
proposal_id=body.proposal_id,
|
|
request_id=body.request_id,
|
|
space_id=body.space_id,
|
|
publisher_namespace=body.publisher_namespace,
|
|
slug=body.slug,
|
|
version=body.version,
|
|
config_placement=ConfigPlacement(body.config_placement),
|
|
)
|
|
return _payload(body.proposal_id)
|
|
except Exception as exc:
|
|
raise _error(exc) from exc
|
|
finally:
|
|
if cloud is not None:
|
|
await cloud.close()
|
|
|
|
|
|
@router.get("/workspace-bundles/install-proposals/{proposal_id}")
|
|
async def get_bundle_install_proposal(proposal_id: str) -> dict:
|
|
return _payload(proposal_id)
|
|
|
|
|
|
@router.get("/spaces/{space_id}/workspace-bundle-installation")
|
|
async def get_space_bundle_installation(space_id: str) -> dict:
|
|
proposal = (
|
|
get_default_run_journal().get_latest_workspace_bundle_install_proposal(
|
|
space_id=space_id
|
|
)
|
|
)
|
|
if proposal is None:
|
|
# A locally-authored Space normally has no Bundle installation. This
|
|
# endpoint is an optional relationship lookup, so absence is a normal
|
|
# empty state rather than a missing-resource error. Keep the 404 on
|
|
# the proposal-id endpoint, where the caller asked for a concrete
|
|
# resource that does not exist.
|
|
return {"proposal": None}
|
|
return _payload(proposal.proposal_id)
|
|
|
|
|
|
@router.post("/workspace-bundles/install-proposals/{proposal_id}/decision")
|
|
async def decide_bundle_install(
|
|
proposal_id: str, body: BundleDecisionBody
|
|
) -> dict:
|
|
try:
|
|
_installer().decide(
|
|
proposal_id,
|
|
expected_version=body.expected_version,
|
|
approved=body.approved,
|
|
decided_by=body.actor_id,
|
|
)
|
|
return _payload(proposal_id)
|
|
except Exception as exc:
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.post(
|
|
"/workspace-bundles/install-proposals/{proposal_id}/connector-bindings"
|
|
)
|
|
async def bind_bundle_connector(
|
|
proposal_id: str, body: BundleConnectorBindingBody
|
|
) -> dict:
|
|
try:
|
|
_installer().bind_connector(
|
|
proposal_id,
|
|
expected_version=body.expected_version,
|
|
slot_id=body.slot_id,
|
|
connector_id=body.connector_id,
|
|
opaque_connection_id=body.connection_id,
|
|
authorized_by=body.actor_id,
|
|
)
|
|
return _payload(proposal_id)
|
|
except Exception as exc:
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.post(
|
|
"/workspace-bundles/install-proposals/{proposal_id}/local-path-bindings"
|
|
)
|
|
async def bind_bundle_local_path(
|
|
proposal_id: str, body: BundleLocalPathBindingBody, request: Request
|
|
) -> dict:
|
|
try:
|
|
_installer().bind_local_path(
|
|
proposal_id,
|
|
expected_version=body.expected_version,
|
|
slot_id=body.slot_id,
|
|
local_path=_authorized_local_path(request, body.local_path),
|
|
authorized_by=body.actor_id,
|
|
)
|
|
return _payload(proposal_id)
|
|
except Exception as exc:
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.post(
|
|
"/workspace-bundles/install-proposals/{proposal_id}/script-approvals"
|
|
)
|
|
async def approve_bundle_script(
|
|
proposal_id: str, body: BundleScriptApprovalBody
|
|
) -> dict:
|
|
try:
|
|
_installer().approve_script_action(
|
|
proposal_id,
|
|
expected_version=body.expected_version,
|
|
action_id=body.action_id,
|
|
authorized_by=body.actor_id,
|
|
)
|
|
return _payload(proposal_id)
|
|
except Exception as exc:
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.post("/workspace-bundles/install-proposals/{proposal_id}/materialize")
|
|
async def materialize_bundle(
|
|
proposal_id: str,
|
|
body: BundleMaterializeBody,
|
|
authorization: Annotated[str, Header(alias="Authorization")],
|
|
) -> dict:
|
|
proposal = get_default_run_journal().get_workspace_bundle_install_proposal(
|
|
proposal_id
|
|
)
|
|
if proposal is None:
|
|
return _payload(proposal_id)
|
|
binding = get_workspace_resolver().store.get_binding(
|
|
body.email,
|
|
proposal.space_id,
|
|
body.user_id,
|
|
)
|
|
if binding is None:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail={"code": "workspace_binding_not_found"},
|
|
)
|
|
space_root = Path(binding.workspace_root).expanduser().resolve()
|
|
if not space_root.is_dir():
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={"code": "workspace_binding_unavailable"},
|
|
)
|
|
cloud = None
|
|
try:
|
|
cloud = _cloud(authorization)
|
|
await _installer(cloud).materialize(
|
|
proposal_id,
|
|
expected_version=body.expected_version,
|
|
space_root=space_root,
|
|
actor_id=body.actor_id,
|
|
allow_content_repository_init=(body.allow_content_repository_init),
|
|
)
|
|
return _payload(proposal_id)
|
|
except Exception as exc:
|
|
raise _error(exc) from exc
|
|
finally:
|
|
if cloud is not None:
|
|
await cloud.close()
|
|
|
|
|
|
@router.put("/workspace-bundles/install-proposals/{proposal_id}/local-values")
|
|
async def bind_bundle_local_values(
|
|
proposal_id: str,
|
|
body: BundleLocalValuesBody,
|
|
) -> dict:
|
|
try:
|
|
journal = get_default_run_journal()
|
|
previous_refs = {
|
|
item.requirement_key: item.secret_ref
|
|
for item in journal.list_workspace_bundle_secret_bindings(
|
|
proposal_id
|
|
)
|
|
}
|
|
updated, _ = _installer().bind_local_values(
|
|
proposal_id,
|
|
client_request_id=body.client_request_id,
|
|
expected_version=body.expected_version,
|
|
bindings=[item.model_dump() for item in body.bindings],
|
|
authorized_by=body.actor_id,
|
|
)
|
|
payload = _payload(proposal_id)
|
|
payload["cleanup_secret_refs"] = sorted(
|
|
{
|
|
previous_refs[item.requirement_key]
|
|
for item in updated
|
|
if item.requirement_key in previous_refs
|
|
and previous_refs[item.requirement_key] != item.secret_ref
|
|
}
|
|
)
|
|
return payload
|
|
except Exception as exc:
|
|
raise _error(exc) from exc
|