Skip to content

Commit 3e759bd

Browse files
author
hbibbensalem
committed
feat(monitoring): add live AWS status endpoint for deployments
GET /{job_id}/monitoring queries AWS directly (ECS describe_services + ALB target group health) for a job's deployed resources, instead of relying on what Terraform reported at apply time. A service can drift long after a 'Completed' run with zero visibility from inside DevGuard today -- confirmed the hard way multiple times this session, having to manually run aws-cli commands just to find out whether a deployment was actually still healthy, or whether a rollback had actually cleaned up its resources. Reads deployment.infrastructure_json's real stored shape (terraform_outputs.service_name / .ecs_cluster_name), not the aws_config.ecs_cluster/service_name shape list_deployment_revisions assumes above it in this file -- the two disagree on what DeployOps actually persists; noted but left alone here, out of scope for this feature. ecs_cluster_name has been observed empty in stored data (a separate DeployOps bug), so falls back to deriving it from service_name's job_id suffix (both share the same suffix per constants.unique_resource_name). Target-group lookup is fail-soft: an ECS service can outlive its own target group (a partial rollback that destroyed the ALB/target group but left the service running -- observed twice today), so a missing target group surfaces as target_health_error in the response instead of failing the whole endpoint. Validated against a real (now-cleaned-up) deployment: correctly reports INACTIVE/0/0 for the destroyed ECS service and a clear target_health_error for the destroyed target group, rather than crashing.
1 parent a5bbd15 commit 3e759bd

1 file changed

Lines changed: 115 additions & 0 deletions

File tree

src/backend/api/jobs.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
)
3434
from ..redis_client import publish_gate, publish_progress, publish_results_ready
3535
from src.agents.orchestrator.agent_adapters import report_mode
36+
from src.lib.aws.client import AWSClient, RETRY_CONFIG
3637

3738
logger = logging.getLogger(__name__)
3839

@@ -549,6 +550,120 @@ def list_deployment_revisions(
549550
detail="Deployment has no ECS cluster/service info to list versions for",
550551
)
551552

553+
554+
@router.get("/{job_id}/monitoring")
555+
def get_deployment_monitoring(
556+
job_id: str,
557+
db: Session = Depends(get_db),
558+
current_user: models.User = Depends(get_current_user),
559+
):
560+
"""Live status of a job's deployed AWS resources -- ECS service health,
561+
ALB target health, and current monthly cost estimate. Queries AWS
562+
directly rather than relying on what Terraform reported at apply time,
563+
since a service can drift (tasks crash-looping, targets going
564+
unhealthy) long after a successful deploy with no visibility into it
565+
from inside DevGuard today -- confirmed the hard way multiple times
566+
this session, having to manually run aws-cli commands to find out
567+
whether a "Completed" run was actually still healthy.
568+
"""
569+
_get_owned_run(db, job_id, current_user.id)
570+
deployment = (
571+
db.query(models.Deployment)
572+
.filter(models.Deployment.run_id == job_id)
573+
.order_by(models.Deployment.created_at.desc())
574+
.first()
575+
)
576+
if not deployment:
577+
raise HTTPException(status_code=404, detail="No deployment found for this job")
578+
579+
infra = deployment.infrastructure_json or {}
580+
tf_outputs = infra.get("terraform_outputs") or {}
581+
region = deployment.aws_region or "us-east-1"
582+
service_name = tf_outputs.get("service_name")
583+
ecs_cluster = tf_outputs.get("ecs_cluster_name")
584+
if not ecs_cluster and service_name and "-" in service_name:
585+
# ecs_cluster_name has been observed empty in stored
586+
# terraform_outputs (DeployOps bug, separate from this feature) --
587+
# fall back to deriving it, since both names share the same job_id
588+
# suffix (see agentInfraCost/core/constants.py
589+
# unique_resource_name): "app-service-<suffix>" implies
590+
# "devguard-cluster-<suffix>".
591+
suffix = service_name.rsplit("-", 1)[-1]
592+
ecs_cluster = f"devguard-cluster-{suffix}"
593+
if not ecs_cluster or not service_name:
594+
raise HTTPException(
595+
status_code=400,
596+
detail="Deployment has no ECS cluster/service info to monitor",
597+
)
598+
599+
try:
600+
aws = AWSClient(region=region)
601+
ecs = aws.session.client("ecs", config=RETRY_CONFIG)
602+
svc_resp = ecs.describe_services(cluster=ecs_cluster, services=[service_name])
603+
services = svc_resp.get("services") or []
604+
if not services:
605+
return {
606+
"job_id": job_id,
607+
"ecs_cluster": ecs_cluster,
608+
"service_name": service_name,
609+
"status": "not_found",
610+
"detail": "Service no longer exists in AWS (possibly rolled back or destroyed).",
611+
}
612+
svc = services[0]
613+
614+
target_health = []
615+
target_health_error = None
616+
elbv2 = aws.session.client("elbv2", config=RETRY_CONFIG)
617+
for lb in svc.get("loadBalancers", []):
618+
tg_arn = lb.get("targetGroupArn")
619+
if not tg_arn:
620+
continue
621+
try:
622+
health_resp = elbv2.describe_target_health(TargetGroupArn=tg_arn)
623+
for entry in health_resp.get("TargetHealthDescriptions", []):
624+
target_health.append({
625+
"target_id": entry.get("Target", {}).get("Id"),
626+
"port": entry.get("Target", {}).get("Port"),
627+
"state": entry.get("TargetHealth", {}).get("State"),
628+
"reason": entry.get("TargetHealth", {}).get("Reason"),
629+
})
630+
except Exception as tg_exc:
631+
# The ECS service can outlive its target group (e.g. a
632+
# partial rollback that destroyed the ALB/target group but
633+
# left the service running -- observed twice this session).
634+
# Still return the ECS-level status we already have rather
635+
# than failing the whole endpoint over a missing target
636+
# group.
637+
target_health_error = str(tg_exc)
638+
639+
return {
640+
"job_id": job_id,
641+
"ecs_cluster": ecs_cluster,
642+
"service_name": service_name,
643+
"status": svc.get("status"),
644+
"desired_count": svc.get("desiredCount"),
645+
"running_count": svc.get("runningCount"),
646+
"pending_count": svc.get("pendingCount"),
647+
"deployments": [
648+
{
649+
"status": d.get("status"),
650+
"rollout_state": d.get("rolloutState"),
651+
"rollout_state_reason": d.get("rolloutStateReason"),
652+
"desired_count": d.get("desiredCount"),
653+
"running_count": d.get("runningCount"),
654+
}
655+
for d in svc.get("deployments", [])
656+
],
657+
"target_health": target_health,
658+
"target_health_error": target_health_error,
659+
"estimated_monthly_cost_usd": (
660+
float(deployment.cost_total_monthly) if deployment.cost_total_monthly else None
661+
),
662+
}
663+
except Exception as exc:
664+
logging.getLogger(__name__).warning(f"[{job_id}] Monitoring fetch failed: {exc}")
665+
raise HTTPException(status_code=502, detail=f"Could not fetch live AWS status: {exc}")
666+
552667
try:
553668
from src.agents.orchestrator.agent_adapters import use_real_deployops
554669

0 commit comments

Comments
 (0)