From 8b806f1ee131506f4035ce42b650aac492d7ab80 Mon Sep 17 00:00:00 2001 From: malik203 <72547228+malik203@users.noreply.github.com> Date: Wed, 27 May 2026 22:52:24 +0000 Subject: [PATCH] feat: add deploy_health handler with DB, Redis caching, and tests --- backend/README.md | 66 ++- .../migrations/20260527000000_deployments.sql | 17 + backend/src/api/handlers/deploy_health.rs | 442 ++++++++++++++++++ backend/src/api/handlers/mod.rs | 1 + 4 files changed, 522 insertions(+), 4 deletions(-) create mode 100644 backend/migrations/20260527000000_deployments.sql create mode 100644 backend/src/api/handlers/deploy_health.rs diff --git a/backend/README.md b/backend/README.md index 2cc093f..ea27dfc 100644 --- a/backend/README.md +++ b/backend/README.md @@ -251,10 +251,68 @@ Extensions enabled: `uuid-ossp`, `pgcrypto`, `citext` ## API Endpoints -| Method | Path | Description | -|--------|------------------|--------------------------------| -| `GET` | `/health` | Health check (DB + Redis) | -| `GET` | `/api/v1/status` | API status and version info | +| Method | Path | Description | +|----------|---------------------------------------------------|--------------------------------------| +| `GET` | `/health` | Health check (DB + Redis) | +| `GET` | `/api/v1/status` | API status and version info | +| `POST` | `/api/v1/deployments` | Register a new deployment | +| `GET` | `/api/v1/deployments/:id` | Get a deployment by UUID | +| `GET` | `/api/v1/deployments/contract/:contract_id` | List deployments for a contract | +| `PATCH` | `/api/v1/deployments/:id/status` | Update deployment health status | + +### Deploy Health + +The deploy-health API tracks the lifecycle and health of contract deployments. + +#### Register a deployment + +```http +POST /api/v1/deployments +Content-Type: application/json + +{ + "contract_id": "CAABC123...", + "version": "1.2.0", + "metadata": { "network": "testnet" } +} +``` + +Response `201 Created`: + +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "contract_id": "CAABC123...", + "version": "1.2.0", + "status": "pending", + "deployed_at": "2026-05-27T22:00:00Z", + "last_checked_at": null, + "error_message": null, + "metadata": { "network": "testnet" }, + "created_at": "2026-05-27T22:00:00Z", + "updated_at": "2026-05-27T22:00:00Z" +} +``` + +#### Update deployment status + +```http +PATCH /api/v1/deployments/:id/status +Content-Type: application/json + +{ + "status": "healthy" +} +``` + +Valid status values: `pending` | `healthy` | `degraded` | `failed`. + +Pass `"error_message"` alongside `"status": "failed"` or `"status": "degraded"` to record a reason. + +#### Caching + +Single-deployment and contract-list responses are cached in Redis for 30 seconds. +A `PATCH` to update status invalidates the per-deployment cache entry immediately. ## Production Deployment diff --git a/backend/migrations/20260527000000_deployments.sql b/backend/migrations/20260527000000_deployments.sql new file mode 100644 index 0000000..0a3b6f5 --- /dev/null +++ b/backend/migrations/20260527000000_deployments.sql @@ -0,0 +1,17 @@ +-- Deployments table for tracking contract deployment health +CREATE TABLE IF NOT EXISTS deployments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contract_id VARCHAR(255) NOT NULL, + version VARCHAR(100) NOT NULL, + status VARCHAR(50) NOT NULL CHECK (status IN ('pending', 'healthy', 'degraded', 'failed')), + deployed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_checked_at TIMESTAMPTZ, + error_message TEXT, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_deployments_contract_id ON deployments(contract_id); +CREATE INDEX IF NOT EXISTS idx_deployments_status ON deployments(status); +CREATE INDEX IF NOT EXISTS idx_deployments_deployed_at ON deployments(deployed_at); diff --git a/backend/src/api/handlers/deploy_health.rs b/backend/src/api/handlers/deploy_health.rs new file mode 100644 index 0000000..b691863 --- /dev/null +++ b/backend/src/api/handlers/deploy_health.rs @@ -0,0 +1,442 @@ +//! Deploy health API handlers. +//! +//! Provides endpoints for tracking and querying the health of contract deployments. +//! +//! # Endpoints +//! +//! | Method | Path | Description | +//! |--------|------|-------------| +//! | `POST` | `/api/v1/deployments` | Register a new deployment | +//! | `GET` | `/api/v1/deployments/:id` | Get a single deployment by ID | +//! | `GET` | `/api/v1/deployments/contract/:contract_id` | List deployments for a contract | +//! | `PATCH`| `/api/v1/deployments/:id/status` | Update deployment health status | +//! +//! Results are cached in Redis with a short TTL to reduce database load. + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use chrono::{DateTime, Utc}; +use redis::AsyncCommands; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use std::sync::Arc; +use tracing::{instrument, warn}; +use uuid::Uuid; + +use crate::error::AppError; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const CACHE_TTL_SECS: u64 = 30; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +/// Shared state for deploy-health handlers. +pub struct DeployHealthState { + pub db: PgPool, + pub redis: redis::aio::ConnectionManager, +} + +// --------------------------------------------------------------------------- +// Domain types +// --------------------------------------------------------------------------- + +/// Health status of a deployment. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "text")] +#[serde(rename_all = "snake_case")] +pub enum DeployStatus { + Pending, + Healthy, + Degraded, + Failed, +} + +impl std::fmt::Display for DeployStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + DeployStatus::Pending => "pending", + DeployStatus::Healthy => "healthy", + DeployStatus::Degraded => "degraded", + DeployStatus::Failed => "failed", + }; + f.write_str(s) + } +} + +impl std::str::FromStr for DeployStatus { + type Err = AppError; + + fn from_str(s: &str) -> Result { + match s { + "pending" => Ok(DeployStatus::Pending), + "healthy" => Ok(DeployStatus::Healthy), + "degraded" => Ok(DeployStatus::Degraded), + "failed" => Ok(DeployStatus::Failed), + other => Err(AppError::BadRequest(format!("invalid status: {other}"))), + } + } +} + +/// A deployment record as stored in the database. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Deployment { + pub id: Uuid, + pub contract_id: String, + pub version: String, + pub status: String, + pub deployed_at: DateTime, + pub last_checked_at: Option>, + pub error_message: Option, + pub metadata: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +// --------------------------------------------------------------------------- +// Request / response types +// --------------------------------------------------------------------------- + +/// Request body for registering a new deployment. +#[derive(Debug, Deserialize)] +pub struct CreateDeploymentRequest { + pub contract_id: String, + pub version: String, + #[serde(default)] + pub metadata: Option, +} + +/// Request body for updating a deployment's health status. +#[derive(Debug, Deserialize)] +pub struct UpdateStatusRequest { + pub status: String, + pub error_message: Option, +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +/// `POST /api/v1/deployments` — register a new deployment. +/// +/// Creates a deployment record with `status = pending` and returns it. +#[instrument(skip(state))] +pub async fn create_deployment( + State(state): State>, + Json(body): Json, +) -> Result { + if body.contract_id.trim().is_empty() { + return Err(AppError::BadRequest("contract_id is required".into())); + } + if body.version.trim().is_empty() { + return Err(AppError::BadRequest("version is required".into())); + } + + let deployment = sqlx::query_as!( + Deployment, + r#" + INSERT INTO deployments (contract_id, version, status, metadata) + VALUES ($1, $2, 'pending', $3) + RETURNING + id, contract_id, version, status, + deployed_at, last_checked_at, error_message, + metadata, created_at, updated_at + "#, + body.contract_id, + body.version, + body.metadata, + ) + .fetch_one(&state.db) + .await?; + + Ok((StatusCode::CREATED, Json(deployment))) +} + +/// `GET /api/v1/deployments/:id` — fetch a single deployment by UUID. +/// +/// Responses are cached in Redis for [`CACHE_TTL_SECS`] seconds. +#[instrument(skip(state))] +pub async fn get_deployment( + State(state): State>, + Path(id): Path, +) -> Result { + let cache_key = format!("deploy_health:deployment:{id}"); + let mut redis = state.redis.clone(); + + if let Ok(raw) = redis.get::<_, String>(&cache_key).await { + if let Ok(cached) = serde_json::from_str::(&raw) { + return Ok(Json(cached)); + } + } + + let deployment = sqlx::query_as!( + Deployment, + r#" + SELECT id, contract_id, version, status, + deployed_at, last_checked_at, error_message, + metadata, created_at, updated_at + FROM deployments + WHERE id = $1 + "#, + id, + ) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| AppError::NotFound(format!("deployment {id} not found")))?; + + if let Ok(json) = serde_json::to_string(&deployment) { + let _: Result<(), _> = redis.set_ex(&cache_key, json, CACHE_TTL_SECS).await; + } + + Ok(Json(deployment)) +} + +/// `GET /api/v1/deployments/contract/:contract_id` — list all deployments for a contract. +/// +/// Returns deployments ordered by `deployed_at DESC`. Results are cached. +#[instrument(skip(state))] +pub async fn list_deployments_for_contract( + State(state): State>, + Path(contract_id): Path, +) -> Result { + let cache_key = format!("deploy_health:contract:{contract_id}:deployments"); + let mut redis = state.redis.clone(); + + if let Ok(raw) = redis.get::<_, String>(&cache_key).await { + if let Ok(cached) = serde_json::from_str::>(&raw) { + return Ok(Json(cached)); + } + } + + let deployments = sqlx::query_as!( + Deployment, + r#" + SELECT id, contract_id, version, status, + deployed_at, last_checked_at, error_message, + metadata, created_at, updated_at + FROM deployments + WHERE contract_id = $1 + ORDER BY deployed_at DESC + "#, + contract_id, + ) + .fetch_all(&state.db) + .await?; + + if let Ok(json) = serde_json::to_string(&deployments) { + let _: Result<(), _> = redis.set_ex(&cache_key, json, CACHE_TTL_SECS).await; + } + + Ok(Json(deployments)) +} + +/// `PATCH /api/v1/deployments/:id/status` — update the health status of a deployment. +/// +/// Accepts `status` (one of `pending`, `healthy`, `degraded`, `failed`) and an optional +/// `error_message`. Invalidates the per-deployment cache entry on success. +#[instrument(skip(state))] +pub async fn update_deployment_status( + State(state): State>, + Path(id): Path, + Json(body): Json, +) -> Result { + // Validate status value + body.status.parse::()?; + + let deployment = sqlx::query_as!( + Deployment, + r#" + UPDATE deployments + SET status = $2, + error_message = $3, + last_checked_at = NOW(), + updated_at = NOW() + WHERE id = $1 + RETURNING + id, contract_id, version, status, + deployed_at, last_checked_at, error_message, + metadata, created_at, updated_at + "#, + id, + body.status, + body.error_message, + ) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| AppError::NotFound(format!("deployment {id} not found")))?; + + // Invalidate cache + let cache_key = format!("deploy_health:deployment:{id}"); + let mut redis = state.redis.clone(); + if let Err(e) = redis.del::<_, ()>(&cache_key).await { + warn!(error = %e, "Failed to invalidate deployment cache"); + } + + Ok(Json(deployment)) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // --- DeployStatus unit tests --- + + #[test] + fn test_status_display() { + assert_eq!(DeployStatus::Pending.to_string(), "pending"); + assert_eq!(DeployStatus::Healthy.to_string(), "healthy"); + assert_eq!(DeployStatus::Degraded.to_string(), "degraded"); + assert_eq!(DeployStatus::Failed.to_string(), "failed"); + } + + #[test] + fn test_status_from_str_valid() { + assert_eq!("pending".parse::().unwrap(), DeployStatus::Pending); + assert_eq!("healthy".parse::().unwrap(), DeployStatus::Healthy); + assert_eq!("degraded".parse::().unwrap(), DeployStatus::Degraded); + assert_eq!("failed".parse::().unwrap(), DeployStatus::Failed); + } + + #[test] + fn test_status_from_str_invalid() { + let err = "unknown".parse::().unwrap_err(); + assert!(matches!(err, AppError::BadRequest(_))); + } + + #[test] + fn test_status_serde_roundtrip() { + let statuses = [ + DeployStatus::Pending, + DeployStatus::Healthy, + DeployStatus::Degraded, + DeployStatus::Failed, + ]; + for status in &statuses { + let json = serde_json::to_string(status).unwrap(); + let back: DeployStatus = serde_json::from_str(&json).unwrap(); + assert_eq!(&back, status); + } + } + + // --- Deployment serialization --- + + #[test] + fn test_deployment_serialization_roundtrip() { + let deployment = Deployment { + id: Uuid::new_v4(), + contract_id: "CAABC123".to_string(), + version: "1.0.0".to_string(), + status: "healthy".to_string(), + deployed_at: Utc::now(), + last_checked_at: Some(Utc::now()), + error_message: None, + metadata: Some(serde_json::json!({"network": "testnet"})), + created_at: Utc::now(), + updated_at: Utc::now(), + }; + + let json = serde_json::to_string(&deployment).unwrap(); + let back: Deployment = serde_json::from_str(&json).unwrap(); + + assert_eq!(back.id, deployment.id); + assert_eq!(back.contract_id, deployment.contract_id); + assert_eq!(back.version, deployment.version); + assert_eq!(back.status, deployment.status); + } + + #[test] + fn test_deployment_with_error_message() { + let deployment = Deployment { + id: Uuid::new_v4(), + contract_id: "CAABC123".to_string(), + version: "1.0.0".to_string(), + status: "failed".to_string(), + deployed_at: Utc::now(), + last_checked_at: Some(Utc::now()), + error_message: Some("out of gas".to_string()), + metadata: None, + created_at: Utc::now(), + updated_at: Utc::now(), + }; + + let json = serde_json::to_string(&deployment).unwrap(); + assert!(json.contains("out of gas")); + } + + // --- Request validation --- + + #[test] + fn test_create_request_deserialization() { + let json = r#"{"contract_id":"CAABC","version":"2.1.0"}"#; + let req: CreateDeploymentRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.contract_id, "CAABC"); + assert_eq!(req.version, "2.1.0"); + assert!(req.metadata.is_none()); + } + + #[test] + fn test_create_request_with_metadata() { + let json = r#"{"contract_id":"CAABC","version":"2.1.0","metadata":{"env":"prod"}}"#; + let req: CreateDeploymentRequest = serde_json::from_str(json).unwrap(); + assert!(req.metadata.is_some()); + assert_eq!(req.metadata.unwrap()["env"], "prod"); + } + + #[test] + fn test_update_status_request_deserialization() { + let json = r#"{"status":"degraded","error_message":"high latency"}"#; + let req: UpdateStatusRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.status, "degraded"); + assert_eq!(req.error_message.as_deref(), Some("high latency")); + } + + #[test] + fn test_update_status_request_no_error() { + let json = r#"{"status":"healthy"}"#; + let req: UpdateStatusRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.status, "healthy"); + assert!(req.error_message.is_none()); + } + + // --- Handler-level validation (no DB required) --- + + #[tokio::test] + async fn test_create_deployment_rejects_empty_contract_id() { + let err = validate_create_request("", "1.0.0").unwrap_err(); + assert!(matches!(err, AppError::BadRequest(_))); + } + + #[tokio::test] + async fn test_create_deployment_rejects_empty_version() { + let err = validate_create_request("CAABC", "").unwrap_err(); + assert!(matches!(err, AppError::BadRequest(_))); + } + + #[tokio::test] + async fn test_create_deployment_accepts_valid_input() { + validate_create_request("CAABC", "1.0.0").unwrap(); + } + + // Helper that mirrors the validation logic in `create_deployment`. + fn validate_create_request(contract_id: &str, version: &str) -> Result<(), AppError> { + if contract_id.trim().is_empty() { + return Err(AppError::BadRequest("contract_id is required".into())); + } + if version.trim().is_empty() { + return Err(AppError::BadRequest("version is required".into())); + } + Ok(()) + } +} diff --git a/backend/src/api/handlers/mod.rs b/backend/src/api/handlers/mod.rs index 6b82151..d9ab92d 100644 --- a/backend/src/api/handlers/mod.rs +++ b/backend/src/api/handlers/mod.rs @@ -1,3 +1,4 @@ pub mod dashboard; +pub mod deploy_health; pub mod profiling; pub mod stellar;