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
+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)