3 Commits
Author SHA1 Message Date
elipitc 08ac5067be Wire secret-ref git delivery end to end 2026-06-03 16:52:12 +08:00
elipitc f4d7b9a5b1 Install git in runtime image 2026-06-03 14:05:37 +08:00
elipitc 253ea923cd Resolve git credentials from manager grants 2026-06-03 13:51:49 +08:00
8 changed files with 202 additions and 15 deletions
+1
View File
@@ -7,6 +7,7 @@ WORKDIR /app
# 安装系统依赖(包括 openssl 用于生成自签名证书)
RUN apt-get update && apt-get install -y \
curl \
git \
openssl \
&& rm -rf /var/lib/apt/lists/*
+51 -2
View File
@@ -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
View File
@@ -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
+58
View File
@@ -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(
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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
View File
@@ -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
+3
View File
@@ -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