agnet: add deployment logs metrics readiness endpoints

This commit is contained in:
gongzhiyong
2026-05-03 21:48:26 +08:00
parent fb61f385fb
commit 50bf3de6f0
6 changed files with 185 additions and 7 deletions
@@ -334,7 +334,9 @@ POST /api/agnet/deployments
"project_id": "newapi-prod",
"correlation_id": "corr_newapi_20260503_001",
"service": "new-api",
"environment": "production"
"environment": "production",
"commit": "origin/main",
"runbook_ref": "heicode/docs/deploy/new-api-rebuild-deploy-runbook.md"
},
"agents": [
{
@@ -405,6 +407,14 @@ POST /api/agnet/deployments
Agnet 平台返回的部署详情、日志、监控和审计中应至少能证明:构建版本/commit、服务重启结果、健康检查结果、资源使用情况、失败回滚状态。未执行真实 SSH/生产动作时,只能返回 `phase=planned` 或 `phase=pending_approval`。
NewAPI 重建/部署的完成判定必须同时满足:
1. `metadata.commit` 或部署详情中的 resolved commit 已在 VM 仓库中生效。
2. 部署日志包含构建/compose/restart 的脱敏摘要。
3. 健康检查返回成功,且监控接口能返回本次 deployment 的状态或资源摘要。
4. 审计日志包含高风险审批 ID、执行者、目标环境和结果。
5. 回滚指针或回滚命令引用已记录。
### 2.5 成功响应
```json
@@ -870,6 +880,8 @@ Manager 发给 Agnet 平台前必须执行:
| `GET /api/agnet/deployments` | 按 tenant/project 查询部署。 |
| `GET /api/agnet/deployments/:deployment_id` | 查询部署详情。 |
| `POST /api/agnet/deployments/:deployment_id/stop` | 停止部署。 |
| `GET /api/agnet/deployments/:deployment_id/logs` | 查询脱敏日志占位/联调日志。 |
| `GET /api/agnet/deployments/:deployment_id/metrics` | 查询单部署指标占位/联调指标。 |
| `GET /api/agnet/deployments/:deployment_id/events` | 查询事件。 |
| `POST /api/agnet/sk-snapshots/resolve` | 解析 SK 快照。 |
| `GET /api/agnet/deployments/:deployment_id/sk-snapshots` | 查询 SK 快照。 |
@@ -878,4 +890,4 @@ Manager 发给 Agnet 平台前必须执行:
生产对接时,Manager 应把相同契约的请求发送给 Agnet 平台;本地 Manager 端点仅作为最小验证与控制面占位,不代表所有日志/监控平台能力已完整实现。
当前文档中 `GET /api/agnet/deployments/{deployment_id}/logs`、`GET /api/agnet/deployments/{deployment_id}/logs/stream`、`GET /api/agnet/deployments/{deployment_id}/metrics` 等属于 Agnet 平台生产联调契约;如 Manager 本地代码尚未提供占位实现,应在联调网关层由 Agnet 平台返回,Manager 不得把“文档已定义”误报为“本地已上线”。
当前本地 `logs` 与 `metrics` 端点只返回脱敏占位/联调数据,用于验证 Manager ↔ Agnet payload、路由和验收流程。生产级实时日志流 `GET /api/agnet/deployments/{deployment_id}/logs/stream` 仍属于 Agnet 平台能力;Manager 不得把“本地占位通过”误报为“生产日志/监控已上线”。
+17
View File
@@ -19,6 +19,7 @@ HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:3000/api/status}"
COMPOSE_FILES="${COMPOSE_FILES:--f docker-compose.azure-vm.yml -f docker-compose.override.yml}"
ENV_FILE="${ENV_FILE:-.env}"
SSH_OPTS="${SSH_OPTS:-}"
GIT_REF="${GIT_REF:-}"
if [[ -z "${VM_HOST}" ]]; then
echo "ERROR: VM_HOST is required"
@@ -34,6 +35,22 @@ echo "dir=${REMOTE_DIR}"
echo "image=${IMAGE_TAG}"
echo "compose_files=${COMPOSE_FILES}"
echo "env_file=${ENV_FILE}"
if [[ -n "${GIT_REF}" ]]; then
echo "git_ref=${GIT_REF}"
fi
${SSH} "cd '${REMOTE_DIR}' && \
test -f '${ENV_FILE}' && \
test -f docker-compose.azure-vm.yml && \
docker compose version >/dev/null && \
git rev-parse --is-inside-work-tree >/dev/null"
if [[ -n "${GIT_REF}" ]]; then
${SSH} "cd '${REMOTE_DIR}' && \
git fetch --prune origin && \
git checkout '${GIT_REF}' && \
git pull --ff-only origin '${GIT_REF}'"
fi
# Persist previous image for rollback.
${SSH} "cd '${REMOTE_DIR}' && \
+82
View File
@@ -547,6 +547,88 @@ func AgnetListDeploymentEvents(c *gin.Context) {
})
}
func AgnetListDeploymentLogs(c *gin.Context) {
deploymentID := strings.TrimSpace(c.Param("deployment_id"))
if deploymentID == "" {
agnetError(c, "POLICY_REJECTED", "deployment_id is required")
return
}
agnetMu.RLock()
record, ok := agnetDeployments[deploymentID]
events := agnetEvents[deploymentID]
agnetMu.RUnlock()
if !ok {
agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
return
}
items := make([]gin.H, 0, len(events)+1)
items = append(items, gin.H{
"timestamp": record.CreatedAt,
"deployment_id": deploymentID,
"stream": "control",
"level": "info",
"message": "deployment accepted by Manager control-plane placeholder",
"phase": record.Phase,
"correlation_id": record.Plan.Metadata.CorrelationID,
"redacted": true,
})
for _, event := range events {
items = append(items, gin.H{
"timestamp": event.OccurredAt,
"deployment_id": event.DeploymentID,
"stream": "event",
"level": "info",
"message": event.Event,
"phase": record.Phase,
"correlation_id": event.CorrelationID,
"redacted": true,
})
}
common.ApiSuccess(c, gin.H{
"deployment_id": deploymentID,
"items": items,
"next_cursor": "",
"redacted": true,
"total": len(items),
})
}
func AgnetGetDeploymentMetrics(c *gin.Context) {
deploymentID := strings.TrimSpace(c.Param("deployment_id"))
if deploymentID == "" {
agnetError(c, "POLICY_REJECTED", "deployment_id is required")
return
}
agnetMu.RLock()
record, ok := agnetDeployments[deploymentID]
agnetMu.RUnlock()
if !ok {
agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
return
}
common.ApiSuccess(c, gin.H{
"deployment_id": deploymentID,
"window": strings.TrimSpace(c.DefaultQuery("window", "15m")),
"step": strings.TrimSpace(c.DefaultQuery("step", "60s")),
"phase": record.Phase,
"status": record.Status,
"resource_usage": gin.H{
"cpu_percent": 0,
"memory_bytes": 0,
"network_rx_bytes": 0,
"network_tx_bytes": 0,
"task_duration_sec": 0,
"platform_estimated": true,
},
"series": []gin.H{},
})
}
func AgnetProjectDashboardSnapshot(c *gin.Context) {
projectID := strings.TrimSpace(c.Param("project_id"))
if projectID == "" {
@@ -180,3 +180,42 @@ func TestAgnetCreateDeploymentRejectsCrossTenantResourceGrant(t *testing.T) {
require.Equal(t, "RESOURCE_GRANT_INVALID", envelope.Error.Code)
require.Empty(t, agnetDeployments)
}
func TestAgnetDeploymentLogsAndMetricsExposeRedactedReadiness(t *testing.T) {
resetAgnetControlPlaneState(t)
_, envelope := postAgnetCreateDeployment(t, baseAgnetResourceGrantPlan())
require.True(t, envelope.Success)
agnetMu.RLock()
var deploymentID string
for id := range agnetDeployments {
deploymentID = id
break
}
agnetMu.RUnlock()
require.NotEmpty(t, deploymentID)
logRecorder := httptest.NewRecorder()
logCtx, _ := gin.CreateTestContext(logRecorder)
logCtx.Request = httptest.NewRequest(http.MethodGet, "/api/agnet/deployments/"+deploymentID+"/logs", nil)
logCtx.Params = gin.Params{{Key: "deployment_id", Value: deploymentID}}
AgnetListDeploymentLogs(logCtx)
require.Equal(t, http.StatusOK, logRecorder.Code)
require.Contains(t, logRecorder.Body.String(), `"redacted":true`)
require.NotContains(t, strings.ToLower(logRecorder.Body.String()), "password")
require.NotContains(t, strings.ToLower(logRecorder.Body.String()), "token")
metricRecorder := httptest.NewRecorder()
metricCtx, _ := gin.CreateTestContext(metricRecorder)
metricCtx.Request = httptest.NewRequest(http.MethodGet, "/api/agnet/deployments/"+deploymentID+"/metrics?window=15m&step=60s", nil)
metricCtx.Params = gin.Params{{Key: "deployment_id", Value: deploymentID}}
AgnetGetDeploymentMetrics(metricCtx)
require.Equal(t, http.StatusOK, metricRecorder.Code)
require.Contains(t, metricRecorder.Body.String(), `"deployment_id":"`+deploymentID+`"`)
require.Contains(t, metricRecorder.Body.String(), `"platform_estimated":true`)
}
@@ -11,7 +11,7 @@ Security rule: never put real passwords, tokens, SSH keys, Redis keys, PostgreSQ
| `heicode/Dockerfile` | Production image build | Builds default and classic web assets, then compiles the Go binary. |
| `heicode/docker-compose.azure-vm.yml` | Azure VM Manager service | Runs only `heicode`; PostgreSQL and Redis are expected to be managed Azure services. |
| `heicode/docker-compose.override.yml` | Local-source image override | Builds `heicode-manager:local` from the checked-out repo. Keep it in the compose file list when deploying this repo state. |
| `heicode/bin/azure_vm_deploy.sh` | SSH deployment helper | Uses env vars only; performs remote compose up, health gate, and rollback pointer capture. |
| `heicode/bin/azure_vm_deploy.sh` | SSH deployment helper | Uses env vars only; can fast-forward a remote branch, performs remote compose up, health gate, and rollback pointer capture. |
| `heicode/bin/acceptance_agnet_local.sh` | Local Agnet control-plane smoke/acceptance probe | Requires an admin session cookie supplied via env; does not store credentials. |
| `heicode/.env.example` | Env-var reference | Placeholder-only reference; production `.env` must stay on the VM and out of Git. |
@@ -23,6 +23,7 @@ Prepare these values outside the repo, for example in a secret manager, CI secre
VM_HOST=<azure-vm-host-or-ip>
VM_USER=<ssh-user>
REMOTE_DIR=/opt/heicode/heicode
GIT_REF=main
HEICODE_DATA_ROOT=/var/lib/heicode/heicode
SQL_DSN=postgresql://<user>:REDACTED@<azure-postgres-host>:5432/heicode?sslmode=require
REDIS_CONN_STRING=rediss://:REDACTED@<azure-redis-host>:6380
@@ -98,6 +99,7 @@ cd heicode
VM_HOST=<azure-vm-host-or-ip> \
VM_USER=<ssh-user> \
REMOTE_DIR=/opt/heicode/heicode \
GIT_REF=main \
HEALTH_URL=http://127.0.0.1:3000/api/status \
./bin/azure_vm_deploy.sh
```
@@ -105,10 +107,34 @@ HEALTH_URL=http://127.0.0.1:3000/api/status \
The script will:
1. SSH to the VM.
2. Record the currently running container image in `.last_success_image` when available.
3. Run `ENV_FILE=.env docker compose -f docker-compose.azure-vm.yml -f docker-compose.override.yml --env-file .env up -d --build --force-recreate heicode`.
4. Poll `/api/status` through the VM-local health URL.
5. Update `.last_success_image` only after the health gate passes.
2. Verify the remote repo, compose CLI, compose file, and VM-local env file exist.
3. If `GIT_REF` is set, fast-forward the remote checkout from `origin/<GIT_REF>`.
4. Record the currently running container image in `.last_success_image` when available.
5. Run `ENV_FILE=.env docker compose -f docker-compose.azure-vm.yml -f docker-compose.override.yml --env-file .env up -d --build --force-recreate heicode`.
6. Poll `/api/status` through the VM-local health URL.
7. Update `.last_success_image` only after the health gate passes.
## 5.1 Agnet operator handoff
When Agnet is the executor, Manager should create a high-risk `newapi-rebuild-deploy` deployment using `docs/integration/agnet-platform-request-contract.md` and pass only references:
| Field | Required reference |
|---|---|
| `resource_grants[].secret_ref` | Secret Store reference for SSH access and runtime env access; never plaintext. |
| `resource_grants[].metadata.host_ref` | `env://NEWAPI_VM_HOST` or equivalent platform secret/env ref. |
| `resource_grants[].constraints.rollback_command_ref` | `runbook://newapi/rollback` or this runbook section. |
| `orchestration_plan.constraints.healthcheck_url_ref` | `env://NEWAPI_HEALTHCHECK_URL`, expected to resolve to the VM-local `/api/status` probe. |
| `orchestration_plan.metadata.commit` | Intended Git commit or branch to deploy, such as `origin/main` after push. |
Minimum evidence Agnet must return before Manager marks the operation deployed:
1. Remote commit after fetch/pull.
2. `docker compose ... ps` status for `heicode`.
3. Health check response proving `"success":true`.
4. Redacted log tail or log digest.
5. Rollback pointer (`.last_success_image`) update or explicit note that no previous image existed.
If any item is missing, Manager must keep the deployment in `running`, `failed`, or `pending_operator_review`; it must not report production deployment as completed.
## 6. Post-deploy verification checklist
+2
View File
@@ -421,6 +421,8 @@ func SetApiRouter(router *gin.Engine) {
agnetRoute.POST("/deployments", controller.AgnetCreateDeployment)
agnetRoute.GET("/deployments/:deployment_id", controller.AgnetGetDeployment)
agnetRoute.POST("/deployments/:deployment_id/stop", controller.AgnetStopDeployment)
agnetRoute.GET("/deployments/:deployment_id/logs", controller.AgnetListDeploymentLogs)
agnetRoute.GET("/deployments/:deployment_id/metrics", controller.AgnetGetDeploymentMetrics)
agnetRoute.GET("/deployments/:deployment_id/events", controller.AgnetListDeploymentEvents)
agnetRoute.GET("/deployments/:deployment_id/sk-snapshots", controller.AgnetListSKSnapshots)
agnetRoute.POST("/sk-snapshots/resolve", controller.AgnetResolveSKSnapshots)