diff --git a/README.md b/README.md index 64bbd79..8ad5575 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ complementarios como `rust-devops`, `rust-api-design` y | 04 | Redes y VPC | `src/networking.rs` | implemented | | 05 | Identidad y accesos | `src/iam.rs` | implemented | | 06 | Servicios manejados | `src/managed_services.rs` | implemented | -| 07 | Serverless | `src/serverless.rs` | draft | +| 07 | Serverless | `src/serverless.rs` | implemented | | 08 | Costos y FinOps | `src/finops.rs` | planned | | 09 | AWS en la práctica | `src/aws_practice.rs` | planned | | 10 | GCP en la práctica | `src/gcp_practice.rs` | planned | diff --git a/ROADMAP.md b/ROADMAP.md index c18fc06..9a917e1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -48,7 +48,7 @@ esperada es: | 04 | Redes y VPC | implemented | | 05 | Identidad y accesos | implemented | | 06 | Servicios manejados | implemented | -| 07 | Serverless | draft | +| 07 | Serverless | implemented | | 08 | Costos y FinOps | planned | | 09 | AWS en la práctica | planned | | 10 | GCP en la práctica | planned | @@ -74,6 +74,6 @@ esperada es: ## Siguiente paso natural -Completar el milestone `07. Serverless` con el flujo restante: modelo Rust -mínimo, capítulo narrativo, diagrama Mermaid, ejemplos progresivos, ejercicios, -soluciones y análisis de costos. +Completar el milestone `07. Serverless` con el flujo restante: capítulo +narrativo, diagrama Mermaid, ejemplos progresivos, ejercicios, soluciones y +análisis de costos. diff --git a/course.manifest.json b/course.manifest.json index fad32bd..253dc40 100644 --- a/course.manifest.json +++ b/course.manifest.json @@ -98,7 +98,7 @@ "number": 7, "title": "Serverless", "slug": "serverless", - "status": "draft", + "status": "implemented", "milestone": "07. Serverless", "document": "docs/07-serverless.md", "module": "src/serverless.rs", diff --git a/docs/07-serverless.md b/docs/07-serverless.md index 5fbf1c9..b4a58c6 100644 --- a/docs/07-serverless.md +++ b/docs/07-serverless.md @@ -2,9 +2,9 @@ - **Curso:** rust-cloud - **Semestre:** 5 -- **Estado:** draft +- **Estado:** implemented - **Milestone:** 07. Serverless -- **Issue:** #25 +- **Issues:** #25, #26 - **Módulo Rust:** `src/serverless.rs` ## Concepto @@ -74,9 +74,10 @@ RFC-0001 §10: proveedor después de fundamentos. - Los nombres, precios y límites de proveedor son material vivo y deben revisarse cuando se usen ejemplos fechados. -## Requisitos para `src/serverless.rs` +## Modelo Rust mínimo -El módulo Rust mínimo deberá modelar, sin dependencias externas: +El módulo Rust mínimo vive en `src/serverless.rs` y modela, sin dependencias +externas: - tipos de disparador: HTTP, cola, storage, scheduler y evento interno; - unidad serverless con nombre, propósito y límites; @@ -92,14 +93,22 @@ El módulo no debe intentar simular una plataforma serverless real. Su función pedagógica: hacer visible el contrato de ejecución antes de hablar de AWS Lambda, Cloud Functions, Cloud Run, workflows u otros productos. -## Decisiones pendientes - -- Definir si una carga serverless se modela como función, workflow o workload - genérico. -- Nombrar errores y hallazgos públicos antes de escribir ejemplos. -- Definir umbrales educativos de timeout y concurrencia. -- Decidir cómo representar idempotencia sin sobreprometer seguridad real. +## Decisiones registradas + +- La unidad serverless se modela como `ServerlessWorkload` para cubrir función, + contenedor y workflow sin atarse a un proveedor. +- Los disparadores viven en `TriggerKind`; el runtime educativo en + `RuntimeProfile`. +- Los límites viven en `timeout_seconds` y `max_concurrency`. +- Retries e idempotencia se modelan por separado con `RetryPolicy` e + `IdempotencyStrategy`. +- `StateAccess` distingue funciones sin estado de lecturas o escrituras en + estado externo. +- `ServerlessFinding` vuelve visibles retries sin idempotencia, concurrencia sin + límite, timeout alto, escritura de estado sin idempotencia y observabilidad + incompleta. ## Estado editorial -Este capítulo queda en `draft`. No está marcado como `reviewed` ni `published`. +Este capítulo queda en `implemented`. No está marcado como `reviewed` ni +`published`. diff --git a/src/lib.rs b/src/lib.rs index f7d7a3c..7401d5b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod compute; pub mod iam; pub mod managed_services; pub mod networking; +pub mod serverless; pub mod service_models; pub mod storage; @@ -86,7 +87,7 @@ const PLANNED_CHAPTERS: [Chapter; 10] = [ Chapter { number: 7, title: "Serverless", - status: ChapterStatus::Draft, + status: ChapterStatus::Implemented, }, Chapter { number: 8, @@ -133,6 +134,6 @@ mod tests { assert_eq!(chapters[3].status, ChapterStatus::Implemented); assert_eq!(chapters[4].status, ChapterStatus::Implemented); assert_eq!(chapters[5].status, ChapterStatus::Implemented); - assert_eq!(chapters[6].status, ChapterStatus::Draft); + assert_eq!(chapters[6].status, ChapterStatus::Implemented); } } diff --git a/src/serverless.rs b/src/serverless.rs new file mode 100644 index 0000000..1bdfec1 --- /dev/null +++ b/src/serverless.rs @@ -0,0 +1,270 @@ +//! Serverless como contrato explícito de ejecución por eventos. +//! +//! Este módulo no simula una plataforma serverless real. Hace visibles +//! decisiones educativas: disparador, propósito, límite temporal, concurrencia, +//! retries, idempotencia, estado externo y observabilidad. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TriggerKind { + /// Petición HTTP o API gateway. + Http, + /// Mensaje en cola o stream. + Queue, + /// Cambio en almacenamiento de objetos. + Storage, + /// Ejecución programada. + Schedule, + /// Evento interno del dominio. + DomainEvent, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuntimeProfile { + /// Handler pequeño de función. + Function, + /// Contenedor serverless. + Container, + /// Workflow orquestado. + Workflow, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RetryPolicy { + /// Sin retry automático. + None, + /// Retry acotado por intentos. + Fixed { attempts: u8 }, + /// Retry con backoff. + Backoff { attempts: u8 }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IdempotencyStrategy { + /// No se declaró estrategia. + None, + /// Clave idempotente por evento. + EventKey, + /// Escritura condicional en estado externo. + ConditionalWrite, + /// Compensación explícita si se duplica trabajo. + Compensation, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StateAccess { + /// No toca estado durable. + Stateless, + /// Lee estado externo. + ExternalRead, + /// Escribe estado externo. + ExternalWrite, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ObservabilityPlan { + /// Registra inicio, salida y errores. + pub logs: bool, + /// Emite métricas de latencia, error o throughput. + pub metrics: bool, + /// Permite seguir un evento entre componentes. + pub correlation_id: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ServerlessRequirements { + /// Disparador de la ejecución. + pub trigger: TriggerKind, + /// Runtime educativo. + pub runtime: RuntimeProfile, + /// Timeout máximo en segundos. + pub timeout_seconds: Option, + /// Concurrencia máxima declarada. + pub max_concurrency: Option, + /// Política de retry. + pub retry_policy: RetryPolicy, + /// Estrategia de idempotencia. + pub idempotency: IdempotencyStrategy, + /// Estado durable tocado. + pub state_access: StateAccess, + /// Observabilidad mínima. + pub observability: ObservabilityPlan, + /// Propósito humano. + pub purpose: &'static str, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ServerlessWorkload { + name: &'static str, + requirements: ServerlessRequirements, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ServerlessDecisionError { + /// Falta nombre. + MissingName, + /// Falta propósito humano. + MissingPurpose, + /// Falta timeout. + MissingTimeout, + /// Timeout inválido. + InvalidTimeout(&'static str), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ServerlessFinding { + /// Retry sin idempotencia. + RetryWithoutIdempotency(&'static str), + /// Concurrencia sin límite explícito. + UnboundedConcurrency(&'static str), + /// Timeout alto para una función pequeña. + HighFunctionTimeout(&'static str), + /// Escritura de estado sin idempotencia. + StatefulWriteWithoutIdempotency(&'static str), + /// Observabilidad insuficiente. + MissingObservability(&'static str), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServerlessEvaluation { + findings: Vec, +} + +impl ObservabilityPlan { + /// Observabilidad mínima recomendada para este modelo. + pub const fn standard() -> Self { + Self { + logs: true, + metrics: true, + correlation_id: true, + } + } + + /// Sin observabilidad explícita. + pub const fn none() -> Self { + Self { + logs: false, + metrics: false, + correlation_id: false, + } + } + + const fn is_complete(self) -> bool { + self.logs && self.metrics && self.correlation_id + } +} + +impl ServerlessWorkload { + /// Crea una unidad serverless educativa. + pub fn new( + name: &'static str, + requirements: ServerlessRequirements, + ) -> Result { + if name.is_empty() { + return Err(ServerlessDecisionError::MissingName); + } + + if requirements.purpose.is_empty() { + return Err(ServerlessDecisionError::MissingPurpose); + } + + let timeout = requirements + .timeout_seconds + .ok_or(ServerlessDecisionError::MissingTimeout)?; + if timeout == 0 { + return Err(ServerlessDecisionError::InvalidTimeout( + "timeout_seconds debe ser mayor que cero", + )); + } + + Ok(Self { name, requirements }) + } + + /// Evalúa señales de riesgo. + pub fn evaluate(&self) -> ServerlessEvaluation { + let mut findings = Vec::new(); + + if retry_attempts(self.requirements.retry_policy) > 0 + && self.requirements.idempotency == IdempotencyStrategy::None + { + findings.push(ServerlessFinding::RetryWithoutIdempotency(self.name)); + } + + if self.requirements.max_concurrency.is_none() { + findings.push(ServerlessFinding::UnboundedConcurrency(self.name)); + } + + if self.requirements.runtime == RuntimeProfile::Function + && self.requirements.timeout_seconds.unwrap_or_default() > 60 + { + findings.push(ServerlessFinding::HighFunctionTimeout(self.name)); + } + + if self.requirements.state_access == StateAccess::ExternalWrite + && self.requirements.idempotency == IdempotencyStrategy::None + { + findings.push(ServerlessFinding::StatefulWriteWithoutIdempotency( + self.name, + )); + } + + if !self.requirements.observability.is_complete() { + findings.push(ServerlessFinding::MissingObservability(self.name)); + } + + ServerlessEvaluation { findings } + } + + /// Nombre del workload. + pub const fn name(&self) -> &'static str { + self.name + } + + /// Requisitos declarados. + pub const fn requirements(&self) -> ServerlessRequirements { + self.requirements + } +} + +impl ServerlessEvaluation { + /// Indica si no hay hallazgos educativos. + pub fn is_low_risk(&self) -> bool { + self.findings.is_empty() + } + + /// Hallazgos detectados. + pub fn findings(&self) -> &[ServerlessFinding] { + &self.findings + } +} + +const fn retry_attempts(policy: RetryPolicy) -> u8 { + match policy { + RetryPolicy::None => 0, + RetryPolicy::Fixed { attempts } | RetryPolicy::Backoff { attempts } => attempts, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workload_requires_timeout() { + let requirements = ServerlessRequirements { + trigger: TriggerKind::Http, + runtime: RuntimeProfile::Function, + timeout_seconds: None, + max_concurrency: Some(10), + retry_policy: RetryPolicy::None, + idempotency: IdempotencyStrategy::None, + state_access: StateAccess::Stateless, + observability: ObservabilityPlan::standard(), + purpose: "responder healthcheck", + }; + + assert_eq!( + ServerlessWorkload::new("healthcheck", requirements), + Err(ServerlessDecisionError::MissingTimeout) + ); + } +} diff --git a/tests/serverless.rs b/tests/serverless.rs new file mode 100644 index 0000000..b232005 --- /dev/null +++ b/tests/serverless.rs @@ -0,0 +1,120 @@ +use rust_cloud::serverless::{ + IdempotencyStrategy, ObservabilityPlan, RetryPolicy, RuntimeProfile, ServerlessDecisionError, + ServerlessFinding, ServerlessRequirements, ServerlessWorkload, StateAccess, TriggerKind, +}; + +#[test] +fn queue_handler_with_idempotency_and_limits_is_low_risk() { + let workload = ServerlessWorkload::new( + "process-publication", + ServerlessRequirements { + trigger: TriggerKind::Queue, + runtime: RuntimeProfile::Function, + timeout_seconds: Some(30), + max_concurrency: Some(50), + retry_policy: RetryPolicy::Backoff { attempts: 3 }, + idempotency: IdempotencyStrategy::EventKey, + state_access: StateAccess::ExternalWrite, + observability: ObservabilityPlan::standard(), + purpose: "procesar eventos de publicación de contenido", + }, + ) + .unwrap(); + + assert_eq!(workload.name(), "process-publication"); + assert!(workload.evaluate().is_low_risk()); +} + +#[test] +fn retrying_stateful_function_without_idempotency_is_visible_risk() { + let workload = ServerlessWorkload::new( + "charge-payment", + ServerlessRequirements { + trigger: TriggerKind::Http, + runtime: RuntimeProfile::Function, + timeout_seconds: Some(120), + max_concurrency: None, + retry_policy: RetryPolicy::Fixed { attempts: 2 }, + idempotency: IdempotencyStrategy::None, + state_access: StateAccess::ExternalWrite, + observability: ObservabilityPlan::none(), + purpose: "registrar pago de estudiante", + }, + ) + .unwrap(); + let evaluation = workload.evaluate(); + + assert!( + evaluation + .findings() + .contains(&ServerlessFinding::RetryWithoutIdempotency( + "charge-payment" + ),) + ); + assert!( + evaluation + .findings() + .contains(&ServerlessFinding::StatefulWriteWithoutIdempotency( + "charge-payment" + ),) + ); + assert!( + evaluation + .findings() + .contains(&ServerlessFinding::UnboundedConcurrency("charge-payment")) + ); + assert!( + evaluation + .findings() + .contains(&ServerlessFinding::HighFunctionTimeout("charge-payment")) + ); + assert!( + evaluation + .findings() + .contains(&ServerlessFinding::MissingObservability("charge-payment")) + ); +} + +#[test] +fn workload_requires_name_purpose_and_positive_timeout() { + let requirements = ServerlessRequirements { + trigger: TriggerKind::Schedule, + runtime: RuntimeProfile::Function, + timeout_seconds: Some(1), + max_concurrency: Some(1), + retry_policy: RetryPolicy::None, + idempotency: IdempotencyStrategy::None, + state_access: StateAccess::Stateless, + observability: ObservabilityPlan::standard(), + purpose: "limpiar sesiones expiradas", + }; + + assert_eq!( + ServerlessWorkload::new("", requirements), + Err(ServerlessDecisionError::MissingName) + ); + + assert_eq!( + ServerlessWorkload::new( + "cleanup", + ServerlessRequirements { + purpose: "", + ..requirements + }, + ), + Err(ServerlessDecisionError::MissingPurpose) + ); + + assert_eq!( + ServerlessWorkload::new( + "cleanup", + ServerlessRequirements { + timeout_seconds: Some(0), + ..requirements + }, + ), + Err(ServerlessDecisionError::InvalidTimeout( + "timeout_seconds debe ser mayor que cero", + )) + ); +}