diff --git a/README.md b/README.md index 7af2d4b..c9fbede 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ complementarios como `rust-devops`, `rust-api-design` y | 06 | Servicios manejados | `src/managed_services.rs` | implemented | | 07 | Serverless | `src/serverless.rs` | implemented | | 08 | Costos y FinOps | `src/finops.rs` | implemented | -| 09 | AWS en la práctica | `src/aws_practice.rs` | draft | +| 09 | AWS en la práctica | `src/aws_practice.rs` | implemented | | 10 | GCP en la práctica | `src/gcp_practice.rs` | planned | Estados posibles: `planned`, `draft`, `implemented`, `tested`, diff --git a/ROADMAP.md b/ROADMAP.md index 744f5b5..fa5dad2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -50,7 +50,7 @@ esperada es: | 06 | Servicios manejados | implemented | | 07 | Serverless | implemented | | 08 | Costos y FinOps | implemented | -| 09 | AWS en la práctica | draft | +| 09 | AWS en la práctica | implemented | | 10 | GCP en la práctica | planned | ## Alineación RFC-0001 @@ -74,6 +74,6 @@ esperada es: ## Siguiente paso natural -Completar el milestone `09. AWS en la práctica` con el flujo restante: modelo -Rust mínimo, tests, capítulo narrativo expandido, diagrama Mermaid, ejemplo -ejecutable, ejercicios, soluciones y análisis de costos. +Completar el milestone `09. AWS en la práctica` con capítulo narrativo +expandido, diagrama Mermaid, ejemplo ejecutable, ejercicios, soluciones y +análisis de costos. diff --git a/course.manifest.json b/course.manifest.json index 936bdcb..30851a6 100644 --- a/course.manifest.json +++ b/course.manifest.json @@ -126,7 +126,7 @@ "number": 9, "title": "AWS en la práctica", "slug": "aws-en-la-practica", - "status": "draft", + "status": "implemented", "milestone": "09. AWS en la práctica", "document": "docs/09-aws-en-la-practica.md", "module": "src/aws_practice.rs", diff --git a/docs/09-aws-en-la-practica.md b/docs/09-aws-en-la-practica.md index 40526a4..c8923c9 100644 --- a/docs/09-aws-en-la-practica.md +++ b/docs/09-aws-en-la-practica.md @@ -2,9 +2,9 @@ - **Curso:** rust-cloud - **Semestre:** 5 -- **Estado:** draft +- **Estado:** implemented - **Milestone:** 09. AWS en la práctica -- **Issue:** #33 +- **Issues:** #33, #34 - **Módulo Rust:** `src/aws_practice.rs` ## Concepto @@ -115,6 +115,23 @@ RFC-0001 §10: proveedor después de fundamentos. - Las recomendaciones dependientes de precios, cuotas o límites concretos se consideran material vivo y no se publican sin fecha y revisión humana. +## Modelo Rust mínimo + +El módulo Rust mínimo vive en `src/aws_practice.rs` y modela, sin dependencias +externas: + +- conceptos cloud del curso que se quieren aterrizar; +- servicios AWS candidatos para cada concepto; +- región, ambiente, dueño y propósito; +- permisos mínimos, credenciales temporales y exposición de red; +- límites, observabilidad, tags de costo y ciclo de vida; +- hallazgos cuando un servicio no corresponde al concepto, faltan señales o el + ejemplo intenta usar credenciales reales. + +El módulo no usa SDKs ni contacta AWS. Su función es pedagógica: practicar la +traducción de concepto a servicio antes de tocar una cuenta real. + ## Nota editorial -Este capítulo está en borrador. No está marcado como `reviewed` ni `published`. +Este capítulo queda en `implemented`. No está marcado como `reviewed` ni +`published`. diff --git a/src/aws_practice.rs b/src/aws_practice.rs new file mode 100644 index 0000000..23cf4f6 --- /dev/null +++ b/src/aws_practice.rs @@ -0,0 +1,305 @@ +//! AWS como aterrizaje práctico de conceptos cloud. +//! +//! Este módulo no usa SDKs ni credenciales de AWS. Modela decisiones +//! educativas: concepto base, servicio candidato, región, ambiente, permisos, +//! red, límites, observabilidad, costo y responsabilidades retenidas. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CloudConcept { + /// Formas de ejecutar trabajo. + Compute, + /// Datos, objetos, volúmenes o archivos. + Storage, + /// Aislamiento, rutas y exposición. + Networking, + /// Identidad, permisos y credenciales. + Identity, + /// Servicio administrado por la plataforma. + ManagedService, + /// Ejecución orientada a eventos. + Serverless, + /// Señales económicas y gobierno de costo. + FinOps, + /// Señales para investigar operación. + Observability, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AwsService { + /// Máquinas virtuales. + Ec2, + /// Contenedores administrados sin manejar hosts directamente. + EcsFargate, + /// Funciones event-driven. + Lambda, + /// Almacenamiento de objetos. + S3, + /// Distribución de contenido. + CloudFront, + /// Red virtual privada. + Vpc, + /// Roles y permisos. + IamRole, + /// Base de datos relacional administrada. + Rds, + /// Cola administrada. + Sqs, + /// Bus o scheduler de eventos. + EventBridge, + /// Logs, métricas y alarmas. + CloudWatch, + /// Presupuestos y alertas económicas. + Budgets, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AwsEnvironment { + /// Producción. + Production, + /// Staging o preproducción. + Staging, + /// Desarrollo. + Development, + /// Ambiente temporal. + Sandbox, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NetworkExposure { + /// Sin exposición pública. + Private, + /// Entrada pública controlada. + PublicEntrypoint, + /// Exposición pública sin frontera clara. + PublicUnbounded, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DataLifecycle { + /// No aplica. + NotApplicable, + /// Datos temporales. + Temporary, + /// Retención explícita. + Retained, + /// Retención indefinida sin justificación. + Indefinite, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AwsPracticeRequirements { + /// Concepto del curso que se aterriza. + pub concept: CloudConcept, + /// Servicio AWS candidato. + pub service: AwsService, + /// Ambiente. + pub environment: AwsEnvironment, + /// Región declarada. + pub region: &'static str, + /// Dueño humano o equipo responsable. + pub owner: &'static str, + /// Propósito humano. + pub purpose: &'static str, + /// Permisos mínimos declarados. + pub least_privilege: bool, + /// Credenciales temporales o rol asumible. + pub temporary_credentials: bool, + /// Exposición de red. + pub network_exposure: NetworkExposure, + /// Límite explícito de capacidad, concurrencia, retención o consumo. + pub has_limit: bool, + /// Logs, métricas, auditoría o trazas suficientes para el caso. + pub observability: bool, + /// Tags para costo, ambiente y dueño. + pub cost_tags: bool, + /// Ciclo de vida de datos o recursos. + pub data_lifecycle: DataLifecycle, + /// Si el ejemplo requiere credenciales reales. + pub uses_real_credentials: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AwsWorkload { + name: &'static str, + requirements: AwsPracticeRequirements, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AwsPracticeDecisionError { + /// Falta nombre. + MissingName, + /// Falta región. + MissingRegion, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AwsPracticeFinding { + /// El servicio no representa bien el concepto declarado. + ServiceDoesNotMatchConcept(&'static str), + /// Falta dueño. + MissingOwner(&'static str), + /// Falta propósito. + MissingPurpose(&'static str), + /// Permisos demasiado amplios. + BroadPermissions(&'static str), + /// Credenciales permanentes. + PermanentCredentials(&'static str), + /// El ejemplo requiere credenciales reales. + RealCredentialsInExample(&'static str), + /// Exposición pública sin frontera. + PublicNetworkWithoutBoundary(&'static str), + /// Falta límite explícito. + MissingLimit(&'static str), + /// Falta observabilidad. + MissingObservability(&'static str), + /// Falta atribución de costo. + MissingCostTags(&'static str), + /// Datos retenidos sin ciclo de vida claro. + IndefiniteDataLifecycle(&'static str), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AwsPracticeEvaluation { + findings: Vec, +} + +impl AwsWorkload { + /// Crea un workload educativo aterrizado en AWS. + pub fn new( + name: &'static str, + requirements: AwsPracticeRequirements, + ) -> Result { + if name.is_empty() { + return Err(AwsPracticeDecisionError::MissingName); + } + + if requirements.region.is_empty() { + return Err(AwsPracticeDecisionError::MissingRegion); + } + + Ok(Self { name, requirements }) + } + + /// Evalúa señales educativas de riesgo. + pub fn evaluate(&self) -> AwsPracticeEvaluation { + let mut findings = Vec::new(); + + if !service_matches_concept(self.requirements.service, self.requirements.concept) { + findings.push(AwsPracticeFinding::ServiceDoesNotMatchConcept(self.name)); + } + + if self.requirements.owner.is_empty() { + findings.push(AwsPracticeFinding::MissingOwner(self.name)); + } + + if self.requirements.purpose.is_empty() { + findings.push(AwsPracticeFinding::MissingPurpose(self.name)); + } + + if !self.requirements.least_privilege { + findings.push(AwsPracticeFinding::BroadPermissions(self.name)); + } + + if !self.requirements.temporary_credentials { + findings.push(AwsPracticeFinding::PermanentCredentials(self.name)); + } + + if self.requirements.uses_real_credentials { + findings.push(AwsPracticeFinding::RealCredentialsInExample(self.name)); + } + + if self.requirements.network_exposure == NetworkExposure::PublicUnbounded { + findings.push(AwsPracticeFinding::PublicNetworkWithoutBoundary(self.name)); + } + + if !self.requirements.has_limit { + findings.push(AwsPracticeFinding::MissingLimit(self.name)); + } + + if !self.requirements.observability { + findings.push(AwsPracticeFinding::MissingObservability(self.name)); + } + + if !self.requirements.cost_tags { + findings.push(AwsPracticeFinding::MissingCostTags(self.name)); + } + + if self.requirements.data_lifecycle == DataLifecycle::Indefinite { + findings.push(AwsPracticeFinding::IndefiniteDataLifecycle(self.name)); + } + + AwsPracticeEvaluation { findings } + } + + /// Nombre del workload. + pub const fn name(&self) -> &'static str { + self.name + } + + /// Requisitos declarados. + pub const fn requirements(&self) -> AwsPracticeRequirements { + self.requirements + } +} + +impl AwsPracticeEvaluation { + /// Indica si no hay hallazgos educativos. + pub fn is_low_risk(&self) -> bool { + self.findings.is_empty() + } + + /// Hallazgos detectados. + pub fn findings(&self) -> &[AwsPracticeFinding] { + &self.findings + } +} + +const fn service_matches_concept(service: AwsService, concept: CloudConcept) -> bool { + matches!( + (service, concept), + (AwsService::Ec2, CloudConcept::Compute) + | (AwsService::EcsFargate, CloudConcept::Compute) + | (AwsService::Lambda, CloudConcept::Compute) + | (AwsService::Lambda, CloudConcept::Serverless) + | (AwsService::S3, CloudConcept::Storage) + | (AwsService::CloudFront, CloudConcept::Networking) + | (AwsService::Vpc, CloudConcept::Networking) + | (AwsService::IamRole, CloudConcept::Identity) + | (AwsService::Rds, CloudConcept::ManagedService) + | (AwsService::Sqs, CloudConcept::ManagedService) + | (AwsService::Sqs, CloudConcept::Serverless) + | (AwsService::EventBridge, CloudConcept::Serverless) + | (AwsService::CloudWatch, CloudConcept::Observability) + | (AwsService::Budgets, CloudConcept::FinOps) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workload_requires_region() { + let requirements = AwsPracticeRequirements { + concept: CloudConcept::Storage, + service: AwsService::S3, + environment: AwsEnvironment::Development, + region: "", + owner: "equipo academy", + purpose: "guardar assets temporales", + least_privilege: true, + temporary_credentials: true, + network_exposure: NetworkExposure::Private, + has_limit: true, + observability: true, + cost_tags: true, + data_lifecycle: DataLifecycle::Temporary, + uses_real_credentials: false, + }; + + assert_eq!( + AwsWorkload::new("assets", requirements), + Err(AwsPracticeDecisionError::MissingRegion) + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index f5cddd4..4263514 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ //! gobernanza, el plan y el contrato educativo. Los capítulos posteriores //! agregarán módulos por concepto de cloud conforme a RFC-0001 §14. +pub mod aws_practice; pub mod compute; pub mod finops; pub mod iam; @@ -98,7 +99,7 @@ const PLANNED_CHAPTERS: [Chapter; 10] = [ Chapter { number: 9, title: "AWS en la práctica", - status: ChapterStatus::Draft, + status: ChapterStatus::Implemented, }, Chapter { number: 10, @@ -123,7 +124,7 @@ mod tests { assert_eq!(chapters.len(), 10); assert_eq!(chapters[7].title, "Costos y FinOps"); - assert_eq!(chapters[8].status, ChapterStatus::Draft); + assert_eq!(chapters[8].status, ChapterStatus::Implemented); assert_eq!(chapters[9].status, ChapterStatus::Planned); assert_eq!(chapters[0].status, ChapterStatus::Implemented); assert_eq!(chapters[1].status, ChapterStatus::Implemented); diff --git a/tests/aws_practice.rs b/tests/aws_practice.rs new file mode 100644 index 0000000..a4162d1 --- /dev/null +++ b/tests/aws_practice.rs @@ -0,0 +1,122 @@ +use rust_cloud::aws_practice::{ + AwsEnvironment, AwsPracticeDecisionError, AwsPracticeFinding, AwsPracticeRequirements, + AwsService, AwsWorkload, CloudConcept, DataLifecycle, NetworkExposure, +}; + +#[test] +fn private_s3_assets_with_tags_and_lifecycle_are_low_risk() { + let workload = AwsWorkload::new( + "academy-assets", + AwsPracticeRequirements { + concept: CloudConcept::Storage, + service: AwsService::S3, + environment: AwsEnvironment::Production, + region: "us-east-1", + owner: "equipo academy", + purpose: "guardar assets publicados del curso", + least_privilege: true, + temporary_credentials: true, + network_exposure: NetworkExposure::Private, + has_limit: true, + observability: true, + cost_tags: true, + data_lifecycle: DataLifecycle::Retained, + uses_real_credentials: false, + }, + ) + .unwrap(); + + assert_eq!(workload.name(), "academy-assets"); + assert!(workload.evaluate().is_low_risk()); +} + +#[test] +fn risky_lambda_example_exposes_architecture_and_security_findings() { + let workload = AwsWorkload::new( + "preview-runner", + AwsPracticeRequirements { + concept: CloudConcept::Serverless, + service: AwsService::Ec2, + environment: AwsEnvironment::Development, + region: "us-east-1", + owner: "", + purpose: "ejecutar previews", + least_privilege: false, + temporary_credentials: false, + network_exposure: NetworkExposure::PublicUnbounded, + has_limit: false, + observability: false, + cost_tags: false, + data_lifecycle: DataLifecycle::Indefinite, + uses_real_credentials: true, + }, + ) + .unwrap(); + let evaluation = workload.evaluate(); + + assert!( + evaluation + .findings() + .contains(&AwsPracticeFinding::ServiceDoesNotMatchConcept( + "preview-runner" + )) + ); + assert!( + evaluation + .findings() + .contains(&AwsPracticeFinding::MissingOwner("preview-runner")) + ); + assert!( + evaluation + .findings() + .contains(&AwsPracticeFinding::BroadPermissions("preview-runner")) + ); + assert!( + evaluation + .findings() + .contains(&AwsPracticeFinding::RealCredentialsInExample( + "preview-runner" + )) + ); + assert!( + evaluation + .findings() + .contains(&AwsPracticeFinding::MissingCostTags("preview-runner")) + ); +} + +#[test] +fn workload_requires_name_and_region() { + let requirements = AwsPracticeRequirements { + concept: CloudConcept::FinOps, + service: AwsService::Budgets, + environment: AwsEnvironment::Staging, + region: "us-east-1", + owner: "equipo academy", + purpose: "alertar gasto de staging", + least_privilege: true, + temporary_credentials: true, + network_exposure: NetworkExposure::Private, + has_limit: true, + observability: true, + cost_tags: true, + data_lifecycle: DataLifecycle::NotApplicable, + uses_real_credentials: false, + }; + + assert_eq!( + AwsWorkload::new("", requirements), + Err(AwsPracticeDecisionError::MissingName) + ); + + assert_eq!( + AwsWorkload::new( + "staging-budget", + AwsPracticeRequirements { + region: "", + ..requirements + }, + ), + Err(AwsPracticeDecisionError::MissingRegion) + ); +}