diff --git a/README.md b/README.md index 54c3e67..b667a0e 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ complementarios como `rust-devops`, `rust-api-design` y | 05 | Identidad y accesos | `src/iam.rs` | implemented | | 06 | Servicios manejados | `src/managed_services.rs` | implemented | | 07 | Serverless | `src/serverless.rs` | implemented | -| 08 | Costos y FinOps | `src/finops.rs` | draft | +| 08 | Costos y FinOps | `src/finops.rs` | implemented | | 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/course.manifest.json b/course.manifest.json index cc7ebd8..9c8834d 100644 --- a/course.manifest.json +++ b/course.manifest.json @@ -112,7 +112,7 @@ "number": 8, "title": "Costos y FinOps", "slug": "costos-y-finops", - "status": "draft", + "status": "implemented", "milestone": "08. Costos y FinOps", "document": "docs/08-costos-y-finops.md", "module": "src/finops.rs", diff --git a/docs/08-costos-y-finops.md b/docs/08-costos-y-finops.md index ab230d2..c891eaf 100644 --- a/docs/08-costos-y-finops.md +++ b/docs/08-costos-y-finops.md @@ -2,9 +2,9 @@ - **Curso:** rust-cloud - **Semestre:** 5 -- **Estado:** draft +- **Estado:** implemented - **Milestone:** 08. Costos y FinOps -- **Issues:** #29 +- **Issues:** #29, #30 - **Módulo Rust:** `src/finops.rs` ## Concepto @@ -99,28 +99,99 @@ RFC-0001 §10: proveedor después de fundamentos. | Señal de uso | Cuándo investigar | Acción adecuada | Alertar tarde o sin dueño | | Tradeoff | Qué riesgo se acepta | Ahorro garantizado | Romper confiabilidad por ahorrar | -## Requisitos para `src/finops.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/finops.rs` y modela, sin dependencias +externas: - unidades educativas de costo: cómputo, almacenamiento, red, observabilidad, invocaciones y servicios manejados; - dueño, propósito y ambiente del gasto; - frecuencia o intensidad de uso; - criticidad y elasticidad; -- hallazgos cuando falten dueño, propósito, límite, señal de uso o ambiente; +- hallazgos cuando falten dueño, propósito, presupuesto, unidad económica o + visibilidad suficiente; - evaluación de riesgos por gasto no atribuido, elasticidad sin límite, observabilidad excesiva o recursos constantes sobredimensionados. El módulo no debe intentar calcular precios reales. Su función es pedagógica: hacer visibles las variables que explican por qué aparece un costo. -## Decisiones pendientes - -- Definir si la unidad de costo será enum cerrado o perfil extensible. -- Definir cómo representar intensidad de uso sin prometer estimaciones reales. -- Nombrar hallazgos públicos antes de escribir ejemplos. -- Decidir qué umbrales educativos son suficientes sin depender de proveedor. +## Cómo leer el módulo Rust + +El módulo `finops` empieza con un perfil explícito: + +```rust +use rust_cloud::finops::{ + BudgetControl, CostCategory, CostVisibility, ElasticityLimit, Environment, + FinOpsCriticality, FinOpsProfile, FinOpsRequirements, OptimizationIntent, + UsagePattern, +}; + +let profile = FinOpsProfile::new( + "academy-api", + FinOpsRequirements { + category: CostCategory::Compute, + environment: Environment::Production, + usage_pattern: UsagePattern::Steady, + criticality: FinOpsCriticality::High, + elasticity: ElasticityLimit::Bounded { max_units: 20 }, + visibility: CostVisibility::UnitEconomics, + budget_control: BudgetControl::ForecastAndReview, + optimization_intent: OptimizationIntent::ReduceWaste, + owner: "equipo academy", + purpose: "servir rutas de aprendizaje a estudiantes", + unit_economics: "costo por estudiante activo", + }, +) +.unwrap(); + +assert!(profile.evaluate().is_low_risk()); +``` + +Un costo sin dueño, sin unidad económica y con elasticidad sin límite produce +hallazgos: + +```rust +use rust_cloud::finops::{ + BudgetControl, CostCategory, CostVisibility, ElasticityLimit, Environment, + FinOpsCriticality, FinOpsFinding, FinOpsProfile, FinOpsRequirements, + OptimizationIntent, UsagePattern, +}; + +let profile = FinOpsProfile::new( + "preview-workers", + FinOpsRequirements { + category: CostCategory::Invocations, + environment: Environment::Development, + usage_pattern: UsagePattern::Growing, + criticality: FinOpsCriticality::Medium, + elasticity: ElasticityLimit::Unbounded, + visibility: CostVisibility::Aggregate, + budget_control: BudgetControl::None, + optimization_intent: OptimizationIntent::None, + owner: "", + purpose: "ejecutar previews automáticos", + unit_economics: "", + }, +) +.unwrap(); + +assert!(profile.evaluate().findings().contains( + &FinOpsFinding::UnboundedElasticity("preview-workers"), +)); +``` + +## Decisiones registradas + +- El perfil principal se llama `FinOpsProfile`. +- Las categorías educativas viven en `CostCategory`. +- La elasticidad se modela con `ElasticityLimit` para distinguir recursos no + elásticos, recursos acotados y crecimiento sin límite visible. +- La visibilidad se modela con `CostVisibility`, separando gasto agregado de + atribución y unidad económica. +- `FinOpsFinding` hace visibles costos sin dueño, propósito, unidad económica, + presupuesto, límite o visibilidad suficiente. ## Práctica sugerida @@ -141,4 +212,5 @@ optimizarlo: primero debes entenderlo. ## 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/finops.rs b/src/finops.rs new file mode 100644 index 0000000..ddc14ab --- /dev/null +++ b/src/finops.rs @@ -0,0 +1,260 @@ +//! Costos y FinOps como señales explícitas de arquitectura. +//! +//! Este módulo no calcula precios reales ni reemplaza una factura de proveedor. +//! Hace visibles decisiones educativas: unidad de costo, ambiente, dueño, +//! propósito, visibilidad, límites y tradeoffs. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CostCategory { + /// Capacidad de CPU y memoria. + Compute, + /// Datos retenidos en disco, objetos, backups o índices. + Storage, + /// Transferencia entre zonas, regiones o internet. + Network, + /// Logs, métricas, trazas y retención operativa. + Observability, + /// Ejecuciones por evento, función o workflow. + Invocations, + /// Servicio de plataforma administrado. + ManagedService, + /// Operación humana necesaria para sostener el sistema. + HumanOperation, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Environment { + /// Producción visible para usuarios. + Production, + /// Ambiente estable previo a producción. + Staging, + /// Ambiente de desarrollo compartido o personal. + Development, + /// Ambiente temporal que debe tener ciclo de vida corto. + Ephemeral, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UsagePattern { + /// Uso constante y predecible. + Steady, + /// Uso por picos o eventos. + Bursty, + /// Uso que crece con el producto. + Growing, + /// Recurso casi sin uso visible. + Idle, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FinOpsCriticality { + /// Costo menor o experimental. + Low, + /// Costo relevante para operación normal. + Medium, + /// Costo crítico para producto, margen o disponibilidad. + High, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ElasticityLimit { + /// No aplica o no escala automáticamente. + NotElastic, + /// Tiene límite explícito. + Bounded { max_units: u32 }, + /// Puede crecer sin límite educativo visible. + Unbounded, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CostVisibility { + /// Sin señal declarada. + None, + /// La señal existe solo como total agregado. + Aggregate, + /// El costo puede atribuirse por etiquetas, equipo o producto. + Attributed, + /// El costo se conecta con una unidad económica. + UnitEconomics, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BudgetControl { + /// Sin control de presupuesto. + None, + /// Alerta básica. + AlertOnly, + /// Presupuesto con dueño explícito. + BudgetWithOwner, + /// Presupuesto, forecast y revisión periódica. + ForecastAndReview, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OptimizationIntent { + /// Todavía no se declaró intención. + None, + /// Reducir consumo que no compra valor. + ReduceWaste, + /// Comprar mayor resiliencia o disponibilidad. + IncreaseReliability, + /// Comprar velocidad de entrega o aprendizaje. + BuySpeed, + /// Aceptar un riesgo explícito para bajar costo. + AcceptRisk, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FinOpsRequirements { + /// Categoría educativa del costo. + pub category: CostCategory, + /// Ambiente donde aparece el gasto. + pub environment: Environment, + /// Patrón de uso observado. + pub usage_pattern: UsagePattern, + /// Criticidad económica u operativa. + pub criticality: FinOpsCriticality, + /// Límite de elasticidad. + pub elasticity: ElasticityLimit, + /// Visibilidad disponible. + pub visibility: CostVisibility, + /// Control de presupuesto. + pub budget_control: BudgetControl, + /// Intención del cambio económico. + pub optimization_intent: OptimizationIntent, + /// Dueño humano o equipo responsable. + pub owner: &'static str, + /// Propósito humano del gasto. + pub purpose: &'static str, + /// Unidad económica: estudiante, curso, evento, petición, documento, etc. + pub unit_economics: &'static str, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FinOpsProfile { + name: &'static str, + requirements: FinOpsRequirements, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FinOpsDecisionError { + /// Falta nombre. + MissingName, + /// Límite inválido. + InvalidLimit(&'static str), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FinOpsFinding { + /// El costo no tiene dueño. + MissingOwner(&'static str), + /// El costo no tiene propósito visible. + MissingPurpose(&'static str), + /// El costo no se conecta con unidad económica. + MissingUnitEconomics(&'static str), + /// Falta presupuesto o alerta. + MissingBudgetControl(&'static str), + /// Elasticidad sin límite explícito. + UnboundedElasticity(&'static str), + /// La visibilidad no permite atribuir costo. + LowVisibility(&'static str), + /// Recurso de producción casi sin uso. + IdleProductionResource(&'static str), + /// Observabilidad sin intención económica clara. + ObservabilityWithoutIntent(&'static str), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FinOpsEvaluation { + findings: Vec, +} + +impl FinOpsProfile { + /// Crea un perfil FinOps educativo. + pub fn new( + name: &'static str, + requirements: FinOpsRequirements, + ) -> Result { + if name.is_empty() { + return Err(FinOpsDecisionError::MissingName); + } + + if let ElasticityLimit::Bounded { max_units } = requirements.elasticity + && max_units == 0 + { + return Err(FinOpsDecisionError::InvalidLimit( + "max_units debe ser mayor que cero", + )); + } + + Ok(Self { name, requirements }) + } + + /// Evalúa señales educativas de costo. + pub fn evaluate(&self) -> FinOpsEvaluation { + let mut findings = Vec::new(); + + if self.requirements.owner.is_empty() { + findings.push(FinOpsFinding::MissingOwner(self.name)); + } + + if self.requirements.purpose.is_empty() { + findings.push(FinOpsFinding::MissingPurpose(self.name)); + } + + if self.requirements.unit_economics.is_empty() { + findings.push(FinOpsFinding::MissingUnitEconomics(self.name)); + } + + if self.requirements.budget_control == BudgetControl::None { + findings.push(FinOpsFinding::MissingBudgetControl(self.name)); + } + + if self.requirements.elasticity == ElasticityLimit::Unbounded { + findings.push(FinOpsFinding::UnboundedElasticity(self.name)); + } + + if matches!( + self.requirements.visibility, + CostVisibility::None | CostVisibility::Aggregate + ) { + findings.push(FinOpsFinding::LowVisibility(self.name)); + } + + if self.requirements.environment == Environment::Production + && self.requirements.usage_pattern == UsagePattern::Idle + { + findings.push(FinOpsFinding::IdleProductionResource(self.name)); + } + + if self.requirements.category == CostCategory::Observability + && self.requirements.optimization_intent == OptimizationIntent::None + { + findings.push(FinOpsFinding::ObservabilityWithoutIntent(self.name)); + } + + FinOpsEvaluation { findings } + } + + /// Nombre del perfil. + pub const fn name(&self) -> &'static str { + self.name + } + + /// Requisitos declarados. + pub const fn requirements(&self) -> FinOpsRequirements { + self.requirements + } +} + +impl FinOpsEvaluation { + /// Indica si no hay hallazgos educativos. + pub fn is_low_risk(&self) -> bool { + self.findings.is_empty() + } + + /// Hallazgos detectados. + pub fn findings(&self) -> &[FinOpsFinding] { + &self.findings + } +} diff --git a/src/lib.rs b/src/lib.rs index ae0caa8..4713cd5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ //! agregarán módulos por concepto de cloud conforme a RFC-0001 §14. pub mod compute; +pub mod finops; pub mod iam; pub mod managed_services; pub mod networking; @@ -92,7 +93,7 @@ const PLANNED_CHAPTERS: [Chapter; 10] = [ Chapter { number: 8, title: "Costos y FinOps", - status: ChapterStatus::Draft, + status: ChapterStatus::Implemented, }, Chapter { number: 9, @@ -135,6 +136,6 @@ mod tests { assert_eq!(chapters[4].status, ChapterStatus::Implemented); assert_eq!(chapters[5].status, ChapterStatus::Implemented); assert_eq!(chapters[6].status, ChapterStatus::Implemented); - assert_eq!(chapters[7].status, ChapterStatus::Draft); + assert_eq!(chapters[7].status, ChapterStatus::Implemented); } } diff --git a/tests/finops.rs b/tests/finops.rs new file mode 100644 index 0000000..4c411ce --- /dev/null +++ b/tests/finops.rs @@ -0,0 +1,142 @@ +use rust_cloud::finops::{ + BudgetControl, CostCategory, CostVisibility, ElasticityLimit, Environment, FinOpsCriticality, + FinOpsDecisionError, FinOpsFinding, FinOpsProfile, FinOpsRequirements, OptimizationIntent, + UsagePattern, +}; + +#[test] +fn attributed_production_cost_with_budget_is_low_risk() { + let profile = FinOpsProfile::new( + "academy-api", + FinOpsRequirements { + category: CostCategory::Compute, + environment: Environment::Production, + usage_pattern: UsagePattern::Steady, + criticality: FinOpsCriticality::High, + elasticity: ElasticityLimit::Bounded { max_units: 20 }, + visibility: CostVisibility::UnitEconomics, + budget_control: BudgetControl::ForecastAndReview, + optimization_intent: OptimizationIntent::ReduceWaste, + owner: "equipo academy", + purpose: "servir rutas de aprendizaje a estudiantes", + unit_economics: "costo por estudiante activo", + }, + ) + .unwrap(); + + assert_eq!(profile.name(), "academy-api"); + assert!(profile.evaluate().is_low_risk()); +} + +#[test] +fn unbounded_cost_without_attribution_is_visible_risk() { + let profile = FinOpsProfile::new( + "preview-workers", + FinOpsRequirements { + category: CostCategory::Invocations, + environment: Environment::Development, + usage_pattern: UsagePattern::Growing, + criticality: FinOpsCriticality::Medium, + elasticity: ElasticityLimit::Unbounded, + visibility: CostVisibility::Aggregate, + budget_control: BudgetControl::None, + optimization_intent: OptimizationIntent::None, + owner: "", + purpose: "ejecutar previews automáticos", + unit_economics: "", + }, + ) + .unwrap(); + let evaluation = profile.evaluate(); + + assert!( + evaluation + .findings() + .contains(&FinOpsFinding::MissingOwner("preview-workers")) + ); + assert!( + evaluation + .findings() + .contains(&FinOpsFinding::MissingUnitEconomics("preview-workers")) + ); + assert!( + evaluation + .findings() + .contains(&FinOpsFinding::MissingBudgetControl("preview-workers")) + ); + assert!( + evaluation + .findings() + .contains(&FinOpsFinding::UnboundedElasticity("preview-workers")) + ); + assert!( + evaluation + .findings() + .contains(&FinOpsFinding::LowVisibility("preview-workers")) + ); +} + +#[test] +fn idle_production_resource_is_visible_risk() { + let profile = FinOpsProfile::new( + "legacy-search-index", + FinOpsRequirements { + category: CostCategory::ManagedService, + environment: Environment::Production, + usage_pattern: UsagePattern::Idle, + criticality: FinOpsCriticality::Low, + elasticity: ElasticityLimit::NotElastic, + visibility: CostVisibility::Attributed, + budget_control: BudgetControl::BudgetWithOwner, + optimization_intent: OptimizationIntent::ReduceWaste, + owner: "equipo academy", + purpose: "mantener búsquedas heredadas", + unit_economics: "costo por búsqueda activa", + }, + ) + .unwrap(); + + assert!( + profile + .evaluate() + .findings() + .contains(&FinOpsFinding::IdleProductionResource( + "legacy-search-index" + ),) + ); +} + +#[test] +fn profile_requires_name_and_positive_elasticity_limit() { + let requirements = FinOpsRequirements { + category: CostCategory::Storage, + environment: Environment::Staging, + usage_pattern: UsagePattern::Bursty, + criticality: FinOpsCriticality::Medium, + elasticity: ElasticityLimit::Bounded { max_units: 1 }, + visibility: CostVisibility::Attributed, + budget_control: BudgetControl::AlertOnly, + optimization_intent: OptimizationIntent::BuySpeed, + owner: "equipo academy", + purpose: "probar artefactos antes de producción", + unit_economics: "costo por artefacto retenido", + }; + + assert_eq!( + FinOpsProfile::new("", requirements), + Err(FinOpsDecisionError::MissingName) + ); + + assert_eq!( + FinOpsProfile::new( + "staging-storage", + FinOpsRequirements { + elasticity: ElasticityLimit::Bounded { max_units: 0 }, + ..requirements + }, + ), + Err(FinOpsDecisionError::InvalidLimit( + "max_units debe ser mayor que cero", + )) + ); +}