diff --git a/docs/integration/agnet-platform-request-contract.md b/docs/integration/agnet-platform-request-contract.md index 8a3d97b..5591c8d 100644 --- a/docs/integration/agnet-platform-request-contract.md +++ b/docs/integration/agnet-platform-request-contract.md @@ -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 不得把“本地占位通过”误报为“生产日志/监控已上线”。 diff --git a/heicode/bin/azure_vm_deploy.sh b/heicode/bin/azure_vm_deploy.sh index 079e707..73932b3 100755 --- a/heicode/bin/azure_vm_deploy.sh +++ b/heicode/bin/azure_vm_deploy.sh @@ -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}' && \ diff --git a/heicode/controller/agnet_control_plane.go b/heicode/controller/agnet_control_plane.go index 44d1781..156860a 100644 --- a/heicode/controller/agnet_control_plane.go +++ b/heicode/controller/agnet_control_plane.go @@ -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 == "" { diff --git a/heicode/controller/agnet_control_plane_test.go b/heicode/controller/agnet_control_plane_test.go index a9e0526..db5aac7 100644 --- a/heicode/controller/agnet_control_plane_test.go +++ b/heicode/controller/agnet_control_plane_test.go @@ -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`) +} diff --git a/heicode/docs/deploy/new-api-rebuild-deploy-runbook.md b/heicode/docs/deploy/new-api-rebuild-deploy-runbook.md index dbd4c9d..73aa3ee 100644 --- a/heicode/docs/deploy/new-api-rebuild-deploy-runbook.md +++ b/heicode/docs/deploy/new-api-rebuild-deploy-runbook.md @@ -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= VM_USER= REMOTE_DIR=/opt/heicode/heicode +GIT_REF=main HEICODE_DATA_ROOT=/var/lib/heicode/heicode SQL_DSN=postgresql://:REDACTED@:5432/heicode?sslmode=require REDIS_CONN_STRING=rediss://:REDACTED@:6380 @@ -98,6 +99,7 @@ cd heicode VM_HOST= \ VM_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/`. +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 diff --git a/heicode/router/api-router.go b/heicode/router/api-router.go index 74bc19e..8d25e2b 100644 --- a/heicode/router/api-router.go +++ b/heicode/router/api-router.go @@ -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)