agnet: add deployment logs metrics readiness endpoints
This commit is contained in:
@@ -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}' && \
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user