Compare commits
3
Commits
880139ab3e
...
08ac5067be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08ac5067be | ||
|
|
f4d7b9a5b1 | ||
|
|
253ea923cd |
@@ -7,6 +7,7 @@ WORKDIR /app
|
||||
# 安装系统依赖(包括 openssl 用于生成自签名证书)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
git \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Vault client for secrets management."""
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Optional, Dict, Any
|
||||
from urllib.parse import urlparse
|
||||
@@ -111,13 +112,16 @@ class VaultClient:
|
||||
if not self.validate_azkv_reference(ref):
|
||||
logger.error(f"Invalid Azure Key Vault reference: {ref}")
|
||||
return None
|
||||
env_name = self._env_name_from_secret_ref(ref)
|
||||
env_value = os.getenv(env_name)
|
||||
if env_value:
|
||||
return env_value
|
||||
if not self.enabled:
|
||||
logger.warning(f"Vault not configured, returning mock secret for {ref}")
|
||||
parsed_azkv = urlparse(ref)
|
||||
secret_name = parsed_azkv.path.rstrip("/").split("/")[-1]
|
||||
return f"mock-secret-azkv-{secret_name}"
|
||||
logger.warning("Azure Key Vault fetching is not configured in this client; returning None")
|
||||
return None
|
||||
return await self._fetch_azkv_secret(ref)
|
||||
|
||||
parsed = self.parse_vault_reference(ref)
|
||||
if not parsed:
|
||||
@@ -202,6 +206,51 @@ class VaultClient:
|
||||
# K8s secrets need base64 encoding, but the K8s client handles that
|
||||
return secrets
|
||||
|
||||
def _env_name_from_secret_ref(self, ref: str) -> str:
|
||||
"""Map an azkv secret ref to its conventional env var name."""
|
||||
parsed = urlparse(ref)
|
||||
secret_name = parsed.path.rstrip("/").split("/")[-1]
|
||||
return secret_name.upper().replace("-", "_")
|
||||
|
||||
async def _fetch_azkv_secret(self, ref: str) -> Optional[str]:
|
||||
"""Fetch azkv://<vault>/secrets/<name> using Azure app credentials."""
|
||||
parsed = urlparse(ref)
|
||||
parts = [part for part in parsed.path.split("/") if part]
|
||||
if parsed.scheme != "azkv" or not parsed.netloc or len(parts) < 2 or parts[0] != "secrets":
|
||||
logger.error(f"Invalid Azure Key Vault reference: {ref}")
|
||||
return None
|
||||
|
||||
tenant_id = os.getenv("AZURE_TENANT_ID")
|
||||
client_id = os.getenv("AZURE_CLIENT_ID")
|
||||
client_secret = os.getenv("AZURE_CLIENT_SECRET")
|
||||
if not (tenant_id and client_id and client_secret):
|
||||
logger.warning("Azure credentials unavailable for azkv secret resolution")
|
||||
return None
|
||||
|
||||
token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
|
||||
token_data = {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"scope": "https://vault.azure.net/.default",
|
||||
}
|
||||
secret_url = f"https://{parsed.netloc}/secrets/{parts[1]}?api-version=7.4"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
token_response = await client.post(token_url, data=token_data)
|
||||
token_response.raise_for_status()
|
||||
access_token = token_response.json()["access_token"]
|
||||
secret_response = await client.get(
|
||||
secret_url,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
secret_response.raise_for_status()
|
||||
return secret_response.json().get("value")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch Azure Key Vault secret: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# Global instance
|
||||
vault_client = VaultClient()
|
||||
|
||||
+86
-10
@@ -16,6 +16,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from database import Swarm, SwarmAgent, SwarmMessage, SwarmStatus, SwarmAgentStatus
|
||||
from k8s_manager import K8sManager
|
||||
from api.agnet.vault_client import vault_client
|
||||
from .agent_client import SwarmAgentClient
|
||||
from .artifact_store import (
|
||||
store_project_artifact,
|
||||
@@ -207,7 +208,7 @@ class SwarmOrchestrator:
|
||||
result = await self._execute_hybrid()
|
||||
|
||||
artifacts = self._ensure_result_artifacts(result)
|
||||
artifacts = self._attach_git_delivery_refs(artifacts)
|
||||
artifacts = await self._attach_git_delivery_refs(artifacts)
|
||||
|
||||
# Update swarm status
|
||||
self.swarm.status = SwarmStatus.COMPLETED
|
||||
@@ -1304,17 +1305,59 @@ class SwarmOrchestrator:
|
||||
"""Return repository context when Runtime should write delivery branches."""
|
||||
context = self._callback_context()
|
||||
repo_url = context.get("repo_url")
|
||||
git_binding_id = context.get("git_binding_id")
|
||||
resource_grants = context.get("resource_grants") or []
|
||||
git_grant = self._find_git_resource_grant(resource_grants, git_binding_id)
|
||||
if not repo_url and git_grant:
|
||||
repo_url = (
|
||||
git_grant.get("external_ref")
|
||||
or git_grant.get("repo_url")
|
||||
or (git_grant.get("metadata") or {}).get("repo_url")
|
||||
)
|
||||
if not repo_url:
|
||||
return None
|
||||
return {
|
||||
"repo_url": repo_url,
|
||||
"base_branch": context.get("branch") or "main",
|
||||
"git_binding_id": context.get("git_binding_id"),
|
||||
"git_binding_id": git_binding_id or (git_grant or {}).get("resource_id") or (git_grant or {}).get("grant_id"),
|
||||
"git_grant": git_grant,
|
||||
}
|
||||
|
||||
def _git_credentials(self) -> Optional[tuple[str, str]]:
|
||||
"""Resolve git credentials from runtime environment."""
|
||||
username = os.getenv("GITEE_USERNAME") or ""
|
||||
def _find_git_resource_grant(self, resource_grants: List[Dict[str, Any]], git_binding_id: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
"""Select the git resource grant attached to this runtime task."""
|
||||
if git_binding_id:
|
||||
for grant in resource_grants:
|
||||
if git_binding_id in {
|
||||
grant.get("resource_id"),
|
||||
grant.get("grant_id"),
|
||||
grant.get("binding_id"),
|
||||
}:
|
||||
return grant
|
||||
for grant in resource_grants:
|
||||
resource_type = (grant.get("resource_type") or grant.get("type") or "").lower()
|
||||
if resource_type == "git":
|
||||
return grant
|
||||
return None
|
||||
|
||||
async def _git_credentials(self) -> Optional[tuple[str, str]]:
|
||||
"""Resolve git credentials from Manager-provided resource grants first, then env fallback."""
|
||||
git_context = self._git_context() or {}
|
||||
git_grant = git_context.get("git_grant") or {}
|
||||
metadata = git_grant.get("metadata") or {}
|
||||
username = (
|
||||
metadata.get("username")
|
||||
or git_grant.get("username")
|
||||
or os.getenv("GITEE_USERNAME")
|
||||
or ""
|
||||
)
|
||||
secret_ref = git_grant.get("secret_ref") or git_grant.get("ref")
|
||||
if secret_ref:
|
||||
secret_value = await vault_client.get_secret(secret_ref)
|
||||
parsed = self._parse_git_secret(secret_value, username)
|
||||
if parsed:
|
||||
return parsed
|
||||
|
||||
# Compatibility fallback for older deployments that still rely on env injection.
|
||||
token = os.getenv("GITEE_TOKEN") or ""
|
||||
password = os.getenv("GITEE_PASSWORD") or ""
|
||||
if token and token != "your-gitee-token":
|
||||
@@ -1323,6 +1366,39 @@ class SwarmOrchestrator:
|
||||
return username or "git", password
|
||||
return None
|
||||
|
||||
def _parse_git_secret(self, secret_value: Any, default_username: str) -> Optional[tuple[str, str]]:
|
||||
"""Parse a git secret payload into username/password credentials."""
|
||||
if not secret_value:
|
||||
return None
|
||||
if isinstance(secret_value, str):
|
||||
stripped = secret_value.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
if stripped.startswith("{"):
|
||||
try:
|
||||
secret_value = json.loads(stripped)
|
||||
except Exception:
|
||||
return default_username or "oauth2", stripped
|
||||
else:
|
||||
return default_username or "oauth2", stripped
|
||||
|
||||
if isinstance(secret_value, dict):
|
||||
username = (
|
||||
secret_value.get("username")
|
||||
or secret_value.get("user")
|
||||
or default_username
|
||||
or "oauth2"
|
||||
)
|
||||
password = (
|
||||
secret_value.get("token")
|
||||
or secret_value.get("password")
|
||||
or secret_value.get("pat")
|
||||
or secret_value.get("access_token")
|
||||
)
|
||||
if password:
|
||||
return username, str(password)
|
||||
return None
|
||||
|
||||
def _inject_git_credentials(self, repo_url: str, username: str, password: str) -> str:
|
||||
"""Inject credentials into an HTTP(S) git URL without persisting them."""
|
||||
if "://" not in repo_url:
|
||||
@@ -1345,10 +1421,10 @@ class SwarmOrchestrator:
|
||||
raise RuntimeError(result.stderr.strip() or result.stdout.strip() or f"git command failed: {' '.join(args)}")
|
||||
return result
|
||||
|
||||
def _ensure_git_workspace(self) -> Optional[Path]:
|
||||
async def _ensure_git_workspace(self) -> Optional[Path]:
|
||||
"""Clone the target repository once and reuse it for role and delivery branches."""
|
||||
git_context = self._git_context()
|
||||
credentials = self._git_credentials()
|
||||
credentials = await self._git_credentials()
|
||||
if not git_context or not credentials:
|
||||
return None
|
||||
if self._git_workspace_dir and self._git_workspace_dir.exists():
|
||||
@@ -1389,14 +1465,14 @@ class SwarmOrchestrator:
|
||||
files[rel] = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
return files
|
||||
|
||||
def _attach_git_delivery_refs(self, artifacts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
async def _attach_git_delivery_refs(self, artifacts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Best-effort git branch/commit materialization for project-folder artifacts."""
|
||||
repo_dir = self._ensure_git_workspace()
|
||||
repo_dir = await self._ensure_git_workspace()
|
||||
if not repo_dir:
|
||||
return artifacts
|
||||
|
||||
git_context = self._git_context() or {}
|
||||
credentials = self._git_credentials()
|
||||
credentials = await self._git_credentials()
|
||||
if not credentials:
|
||||
return artifacts
|
||||
username, password = credentials
|
||||
|
||||
@@ -50,6 +50,62 @@ def generate_agent_id(role: str) -> str:
|
||||
return f"agi_{role}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _extract_git_project_context(plan: Dict[str, Any], payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Derive git runtime context from Manager-provided resource grants and metadata."""
|
||||
resource_grants = plan.get("resource_grants") or payload.get("resource_grants") or []
|
||||
metadata = plan.get("metadata") or payload.get("metadata") or {}
|
||||
git_binding_id = (
|
||||
payload.get("git_binding_id")
|
||||
or metadata.get("git_binding_id")
|
||||
or metadata.get("resource_binding_id")
|
||||
)
|
||||
|
||||
selected_git_grant = None
|
||||
for grant in resource_grants:
|
||||
grant_type = (grant.get("resource_type") or grant.get("type") or "").lower()
|
||||
if grant_type != "git":
|
||||
continue
|
||||
if git_binding_id and git_binding_id not in {
|
||||
grant.get("resource_id"),
|
||||
grant.get("grant_id"),
|
||||
grant.get("binding_id"),
|
||||
}:
|
||||
continue
|
||||
selected_git_grant = grant
|
||||
break
|
||||
|
||||
if not selected_git_grant:
|
||||
return {}
|
||||
|
||||
grant_metadata = selected_git_grant.get("metadata") or {}
|
||||
repo_url = (
|
||||
grant_metadata.get("repo_url")
|
||||
or selected_git_grant.get("external_ref")
|
||||
or selected_git_grant.get("repo_url")
|
||||
)
|
||||
branch = (
|
||||
payload.get("branch")
|
||||
or plan.get("branch")
|
||||
or grant_metadata.get("default_branch")
|
||||
or grant_metadata.get("base_branch")
|
||||
or "main"
|
||||
)
|
||||
allowed_paths = (
|
||||
payload.get("allowed_paths")
|
||||
or plan.get("allowed_paths")
|
||||
or grant_metadata.get("allowed_paths")
|
||||
)
|
||||
|
||||
return {
|
||||
"repo_url": repo_url,
|
||||
"branch": branch,
|
||||
"git_binding_id": git_binding_id
|
||||
or selected_git_grant.get("resource_id")
|
||||
or selected_git_grant.get("grant_id"),
|
||||
"allowed_paths": allowed_paths,
|
||||
}
|
||||
|
||||
|
||||
def _agent_infos_for_swarm(db: Session, swarm_id: str) -> list[SwarmAgentInfo]:
|
||||
"""Build response agent summaries for a swarm."""
|
||||
agents = db.query(SwarmAgent).filter(SwarmAgent.swarm_id == swarm_id).all()
|
||||
@@ -449,6 +505,7 @@ async def create_swarm_compat(
|
||||
project_context = plan.get("project_context") or {}
|
||||
if not isinstance(project_context, dict):
|
||||
project_context = {}
|
||||
git_project_context = _extract_git_project_context(plan, payload)
|
||||
project_context = {
|
||||
**project_context,
|
||||
"intent_id": plan.get("intent_id"),
|
||||
@@ -467,6 +524,7 @@ async def create_swarm_compat(
|
||||
"heicode_deployment_id": payload.get("deployment_id")
|
||||
or metadata.get("heicode_deployment_id")
|
||||
or metadata.get("manager_deployment_id"),
|
||||
**git_project_context,
|
||||
}
|
||||
|
||||
swarm_request = SwarmCreateRequest(
|
||||
|
||||
@@ -31,7 +31,7 @@ spec:
|
||||
|
||||
containers:
|
||||
- name: agent-manager
|
||||
image: agnettaiji.azurecr.io/ai-agents/agent-manager:heicode-v2-runtime-20260603110450-arm64
|
||||
image: agnettaiji.azurecr.io/ai-agents/agent-manager:heicode-v2-runtime-20260603140537-arm64
|
||||
imagePullPolicy: Always
|
||||
|
||||
ports:
|
||||
|
||||
@@ -22,7 +22,7 @@ spec:
|
||||
- name: acr-secret
|
||||
containers:
|
||||
- name: agent-manager
|
||||
image: agnettaiji.azurecr.io/ai-agents/agent-manager:heicode-v2-runtime-20260603110450-arm64
|
||||
image: agnettaiji.azurecr.io/ai-agents/agent-manager:heicode-v2-runtime-20260603140537-arm64
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ spec:
|
||||
- name: acr-secret
|
||||
containers:
|
||||
- name: agent-manager
|
||||
image: agnettaiji.azurecr.io/ai-agents/agent-manager:heicode-v2-runtime-20260603110450-arm64
|
||||
image: agnettaiji.azurecr.io/ai-agents/agent-manager:heicode-v2-runtime-20260603140537-arm64
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
|
||||
@@ -109,3 +109,6 @@ def test_runtime_rich_workflow_contract_is_present():
|
||||
deployments_source = _read("api/agnet/deployments.py")
|
||||
assert "_emit_artifact_edit_event(swarm, request, \"artifact.local_edit_conflict\")" in deployments_source
|
||||
assert "mode: Optional[str] = None" in swarm_models_source
|
||||
assert "_extract_git_project_context" in swarm_router_source
|
||||
assert "git_binding_id" in swarm_router_source
|
||||
assert "allowed_paths" in swarm_router_source
|
||||
|
||||
Reference in New Issue
Block a user