diff --git a/.vamos.txt.kate-swp b/.vamos.txt.kate-swp index 93d6636..917c06c 100644 Binary files a/.vamos.txt.kate-swp and b/.vamos.txt.kate-swp differ diff --git a/Cargo.lock b/Cargo.lock index 159c4ce..f535b12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7058,6 +7058,37 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "matilda" +version = "0.1.0" +dependencies = [ + "matilda-config", + "matilda-core", + "matilda-plan", +] + +[[package]] +name = "matilda-config" +version = "0.1.0" +dependencies = [ + "matilda-core", +] + +[[package]] +name = "matilda-core" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "matilda-plan" +version = "0.1.0" +dependencies = [ + "matilda-core", + "serde", +] + [[package]] name = "matrixmultiply" version = "0.3.10" diff --git a/Cargo.toml b/Cargo.toml index 1fa99a7..a991cdc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -136,6 +136,13 @@ members = [ # ============================================================ "crates/modules/takiy/takiy-core", + # ============================================================ + # modules/matilda/ — Administración de servidores + # ============================================================ + "crates/modules/matilda/matilda-core", + "crates/modules/matilda/matilda-config", + "crates/modules/matilda/matilda-plan", + # ============================================================ # modules/nakui/ — ERP matemático (categórico) # ============================================================ @@ -248,6 +255,7 @@ members = [ "crates/apps/fana", "crates/apps/agorapura", "crates/apps/badu", + "crates/apps/matilda", ] [workspace.package] diff --git a/crates/apps/matilda/Cargo.toml b/crates/apps/matilda/Cargo.toml new file mode 100644 index 0000000..3f68da1 --- /dev/null +++ b/crates/apps/matilda/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "matilda" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "matilda — demostración de administración de servidores: inventario declarativo, renderizado de docker-compose y nginx, y plan de reconciliación." + +[[bin]] +name = "matilda" +path = "src/main.rs" + +[dependencies] +matilda-core = { path = "../../modules/matilda/matilda-core" } +matilda-config = { path = "../../modules/matilda/matilda-config" } +matilda-plan = { path = "../../modules/matilda/matilda-plan" } diff --git a/crates/apps/matilda/src/main.rs b/crates/apps/matilda/src/main.rs new file mode 100644 index 0000000..f4a0543 --- /dev/null +++ b/crates/apps/matilda/src/main.rs @@ -0,0 +1,98 @@ +//! `matilda` — demostración de administración de servidores. +//! +//! Declara un inventario *deseado*, renderiza su `docker-compose.yml` y +//! su configuración nginx, y luego calcula el *plan* que lleva un +//! servidor desde un estado actual distinto hasta el deseado. +//! +//! Smoke test legible del módulo: `cargo run -p matilda`. + +use matilda_core::{Container, Host, Inventory, RestartPolicy, VHost}; +use matilda_config::{compose_file, nginx_sites}; +use matilda_plan::plan; + +/// El inventario que queremos tener en el servidor. +fn desired() -> Inventory { + let mut inv = Inventory::new(); + inv.add_host(Host::new("edge-1", "10.0.0.1").with_tag("prod")); + + inv.add_container( + Container::new("web", "nginx:1.27") + .with_port(8080, 80) + .with_volume("/srv/site", "/usr/share/nginx/html") + .with_restart(RestartPolicy::Always), + ); + inv.add_container( + Container::new("api", "ghcr.io/jls/api:2.4") + .with_port(9000, 9000) + .with_env("DATABASE_URL", "postgres://db/app") + .with_restart(RestartPolicy::UnlessStopped), + ); + inv.add_container( + Container::new("db", "postgres:16") + .with_env("POSTGRES_DB", "app") + .with_volume("/srv/pgdata", "/var/lib/postgresql/data") + .with_restart(RestartPolicy::Always), + ); + + inv.add_vhost(VHost::to_container("jlsoltech.com", "web", 80).with_alias("www.jlsoltech.com").with_tls()); + inv.add_vhost(VHost::to_container("api.jlsoltech.com", "api", 9000).with_tls()); + inv +} + +/// El estado en que está el servidor hoy: `web` con imagen vieja, sin +/// `api`, y un contenedor `legacy` que ya no se quiere. +fn current() -> Inventory { + let mut inv = Inventory::new(); + inv.add_host(Host::new("edge-1", "10.0.0.1").with_tag("prod")); + inv.add_container(Container::new("web", "nginx:1.25").with_port(8080, 80)); + inv.add_container(Container::new("db", "postgres:16") + .with_env("POSTGRES_DB", "app") + .with_volume("/srv/pgdata", "/var/lib/postgresql/data") + .with_restart(RestartPolicy::Always)); + inv.add_container(Container::new("legacy", "old/cgi:1")); + inv.add_vhost(VHost::to_container("jlsoltech.com", "web", 80)); + inv +} + +fn rule(title: &str) { + println!("\n── {title} {}", "─".repeat(56usize.saturating_sub(title.len()))); +} + +fn main() { + let desired = desired(); + + rule("docker-compose.yml (deseado)"); + print!("{}", compose_file(&desired)); + + rule("nginx — sites (deseado)"); + print!("{}", nginx_sites(&desired)); + + rule("plan de reconciliación (actual → deseado)"); + let current = current(); + let p = plan(¤t, &desired); + if p.is_empty() { + println!(" sin cambios: el servidor ya está al día."); + } else { + for (i, action) in p.actions.iter().enumerate() { + println!(" {:>2}. {}", i + 1, action.describe()); + } + println!( + "\n {} acciones — {} crear, {} actualizar, {} eliminar.", + p.len(), + p.count(matilda_plan::Op::Create), + p.count(matilda_plan::Op::Update), + p.count(matilda_plan::Op::Remove), + ); + } + + let broken = desired.broken_vhosts(); + rule("consistencia"); + if broken.is_empty() { + println!(" todos los vhosts apuntan a contenedores existentes. ✔"); + } else { + for v in broken { + println!(" ✘ vhost «{}» apunta a un contenedor inexistente", v.domain); + } + } + println!(); +} diff --git a/crates/modules/matilda/SDD.md b/crates/modules/matilda/SDD.md new file mode 100644 index 0000000..d5f3168 --- /dev/null +++ b/crates/modules/matilda/SDD.md @@ -0,0 +1,48 @@ +# modules/matilda/ — Administración de servidores + +**Propósito.** Administrar servidores de forma declarativa: se describe +el inventario deseado (hosts, contenedores Docker, vhosts de proxy +inverso) y matilda renderiza la configuración y calcula el plan que +reconcilia el estado actual con el deseado. + +## Crates + +| crate | tipo | rol | +| ---------------- | ---- | ------------------------------------------------------------ | +| `matilda-core` | lib | Modelo: `Host`, `Container`, `VHost`, `Inventory` | +| `matilda-config` | lib | Renderizado: `Container` → docker-compose / `docker run`; `VHost` → nginx | +| `matilda-plan` | lib | Reconciliación: `plan(actual, deseado)` → lista ordenada de `Action`s | + +App: `apps/matilda` — demo CLI (`cargo run -p matilda`). + +## Flujo + +```text + Inventory (deseado) ──► matilda-config ──► docker-compose.yml + nginx + │ + └──► matilda-plan ◄── Inventory (actual) ──► Plan { actions } +``` + +- **Declarativo**: el inventario describe *qué* debe existir; nadie + ejecuta nada en `core`/`config`/`plan` — son funciones puras. +- **Plan ordenado**: crear contenedores antes que vhosts; eliminar + vhosts antes que sus contenedores. Determinista (inventario en + `BTreeMap`). + +## Dependencias + +- `config` y `plan` ← `matilda-core`. Todos `#![forbid(unsafe_code)]`. +- Cero Docker, cero SSH, cero disco — sólo modelos y strings. + +## Estado + +`core` + `config` + `plan` implementados y verdes (29 tests) + demo CLI. + +**Pendiente** (la capa de I/O, ~7 sub-crates del plan original): + +| crate pendiente | rol | +| ----------------- | ------------------------------------------------ | +| `matilda-linker` | transporte SSH (sobre `transport-ssh-multiplex`) | +| `matilda-ghost` | agente remoto que aplica el plan en el servidor | +| `matilda-docker` | ejecución real de Docker vía Linker/Ghost | +| `matilda-app` | frontend GPUI | diff --git a/crates/modules/matilda/matilda-config/Cargo.toml b/crates/modules/matilda/matilda-config/Cargo.toml new file mode 100644 index 0000000..70f5434 --- /dev/null +++ b/crates/modules/matilda/matilda-config/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "matilda-config" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "matilda — renderizado de configuración: del modelo declarativo a comandos docker run, servicios docker-compose y bloques server de nginx." + +[dependencies] +matilda-core = { path = "../matilda-core" } diff --git a/crates/modules/matilda/matilda-config/src/docker.rs b/crates/modules/matilda/matilda-config/src/docker.rs new file mode 100644 index 0000000..0259ef7 --- /dev/null +++ b/crates/modules/matilda/matilda-config/src/docker.rs @@ -0,0 +1,105 @@ +//! Renderizado de un [`Container`] a Docker — `docker run` y compose. + +use matilda_core::Container; + +/// Comando `docker run` de un contenedor, en una sola línea. El orden de +/// los flags es fijo (determinista): `-d --name --restart -p -e -v img`. +pub fn docker_run_command(c: &Container) -> String { + let mut parts: Vec = vec![ + "docker".into(), + "run".into(), + "-d".into(), + "--name".into(), + c.name.clone(), + "--restart".into(), + c.restart.docker_flag().into(), + ]; + for p in &c.ports { + parts.push("-p".into()); + parts.push(format!("{}:{}", p.host, p.container)); + } + for (k, v) in &c.env { + parts.push("-e".into()); + parts.push(format!("{k}={v}")); + } + for (host, container) in &c.volumes { + parts.push("-v".into()); + parts.push(format!("{host}:{container}")); + } + parts.push(c.image.clone()); + parts.join(" ") +} + +/// Bloque de servicio para un `docker-compose.yml`. Viene indentado para +/// colocarse tal cual bajo la clave `services:`. +pub fn compose_service(c: &Container) -> String { + let mut out = String::new(); + out.push_str(&format!(" {}:\n", c.name)); + out.push_str(&format!(" image: {}\n", c.image)); + out.push_str(&format!(" restart: {}\n", c.restart.docker_flag())); + if !c.ports.is_empty() { + out.push_str(" ports:\n"); + for p in &c.ports { + out.push_str(&format!(" - \"{}:{}\"\n", p.host, p.container)); + } + } + if !c.env.is_empty() { + out.push_str(" environment:\n"); + for (k, v) in &c.env { + out.push_str(&format!(" - {k}={v}\n")); + } + } + if !c.volumes.is_empty() { + out.push_str(" volumes:\n"); + for (host, container) in &c.volumes { + out.push_str(&format!(" - {host}:{container}\n")); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use matilda_core::RestartPolicy; + + fn sample() -> Container { + Container::new("web", "nginx:1.27") + .with_port(8080, 80) + .with_env("TZ", "America/Caracas") + .with_volume("/srv/web", "/usr/share/nginx/html") + .with_restart(RestartPolicy::Always) + } + + #[test] + fn run_command_has_all_flags() { + let cmd = docker_run_command(&sample()); + assert!(cmd.starts_with("docker run -d --name web --restart always")); + assert!(cmd.contains("-p 8080:80")); + assert!(cmd.contains("-e TZ=America/Caracas")); + assert!(cmd.contains("-v /srv/web:/usr/share/nginx/html")); + assert!(cmd.ends_with("nginx:1.27")); + } + + #[test] + fn run_command_is_deterministic() { + assert_eq!(docker_run_command(&sample()), docker_run_command(&sample())); + } + + #[test] + fn compose_service_indents_under_services() { + let yaml = compose_service(&sample()); + assert!(yaml.contains(" web:\n")); + assert!(yaml.contains(" image: nginx:1.27\n")); + assert!(yaml.contains(" restart: always\n")); + assert!(yaml.contains(" - \"8080:80\"\n")); + } + + #[test] + fn minimal_container_omits_empty_sections() { + let yaml = compose_service(&Container::new("bare", "alpine")); + assert!(!yaml.contains("ports:")); + assert!(!yaml.contains("environment:")); + assert!(!yaml.contains("volumes:")); + } +} diff --git a/crates/modules/matilda/matilda-config/src/lib.rs b/crates/modules/matilda/matilda-config/src/lib.rs new file mode 100644 index 0000000..6682e08 --- /dev/null +++ b/crates/modules/matilda/matilda-config/src/lib.rs @@ -0,0 +1,63 @@ +//! `matilda-config` — del modelo declarativo a archivos de configuración. +//! +//! Funciones puras: toman un tipo de `matilda-core` y devuelven el texto +//! de configuración listo para escribir en el servidor. No tocan disco +//! ni Docker — sólo construyen strings, así que cada salida es testeable +//! y determinista. +//! +//! - [`docker`] — `Container` → `docker run` / servicio docker-compose. +//! - [`nginx`] — `VHost` → bloque `server` de nginx. + +#![forbid(unsafe_code)] + +pub mod docker; +pub mod nginx; + +pub use docker::{compose_service, docker_run_command}; +pub use nginx::nginx_server_block; + +use matilda_core::Inventory; + +/// Renderiza el `docker-compose.yml` completo de un inventario. +pub fn compose_file(inv: &Inventory) -> String { + let mut out = String::from("services:\n"); + for c in inv.containers() { + out.push_str(&compose_service(c)); + } + out +} + +/// Renderiza el archivo de sites de nginx — un bloque `server` por +/// vhost, separados por una línea en blanco. +pub fn nginx_sites(inv: &Inventory) -> String { + inv.vhosts() + .map(nginx_server_block) + .collect::>() + .join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use matilda_core::{Container, VHost}; + + #[test] + fn compose_file_lists_every_container() { + let mut inv = Inventory::new(); + inv.add_container(Container::new("web", "nginx")); + inv.add_container(Container::new("db", "postgres:16")); + let yaml = compose_file(&inv); + assert!(yaml.starts_with("services:\n")); + assert!(yaml.contains(" web:\n") && yaml.contains(" db:\n")); + } + + #[test] + fn nginx_sites_renders_every_vhost() { + let mut inv = Inventory::new(); + inv.add_vhost(VHost::to_container("a.com", "web", 80)); + inv.add_vhost(VHost::to_container("b.com", "web", 80)); + let conf = nginx_sites(&inv); + assert!(conf.contains("server_name a.com;")); + assert!(conf.contains("server_name b.com;")); + } +} diff --git a/crates/modules/matilda/matilda-config/src/nginx.rs b/crates/modules/matilda/matilda-config/src/nginx.rs new file mode 100644 index 0000000..e2a1425 --- /dev/null +++ b/crates/modules/matilda/matilda-config/src/nginx.rs @@ -0,0 +1,94 @@ +//! Renderizado de un [`VHost`] a un bloque `server` de nginx. + +use matilda_core::{Upstream, VHost}; + +/// URL de `proxy_pass` para un upstream. Un contenedor se referencia por +/// su nombre, que la red de Docker resuelve a su IP interna. +fn proxy_target(upstream: &Upstream) -> String { + match upstream { + Upstream::Address(addr) => format!("http://{addr}"), + Upstream::Container { name, port } => format!("http://{name}:{port}"), + } +} + +/// Renderiza el `server` de nginx de un vhost. Con TLS emite dos +/// bloques: el `:443 ssl` y un `:80` que redirige a HTTPS. +pub fn nginx_server_block(v: &VHost) -> String { + let names: Vec<&str> = std::iter::once(v.domain.as_str()) + .chain(v.aliases.iter().map(|s| s.as_str())) + .collect(); + let server_name = names.join(" "); + let target = proxy_target(&v.upstream); + + let mut out = String::new(); + if v.tls { + // Redirección :80 → :443. + out.push_str("server {\n"); + out.push_str(" listen 80;\n"); + out.push_str(&format!(" server_name {server_name};\n")); + out.push_str(" return 301 https://$host$request_uri;\n"); + out.push_str("}\n\n"); + + out.push_str("server {\n"); + out.push_str(" listen 443 ssl;\n"); + out.push_str(&format!(" server_name {server_name};\n")); + out.push_str(&format!( + " ssl_certificate /etc/letsencrypt/live/{}/fullchain.pem;\n", + v.domain + )); + out.push_str(&format!( + " ssl_certificate_key /etc/letsencrypt/live/{}/privkey.pem;\n", + v.domain + )); + } else { + out.push_str("server {\n"); + out.push_str(" listen 80;\n"); + out.push_str(&format!(" server_name {server_name};\n")); + } + + out.push_str(" location / {\n"); + out.push_str(&format!(" proxy_pass {target};\n")); + out.push_str(" proxy_set_header Host $host;\n"); + out.push_str(" proxy_set_header X-Real-IP $remote_addr;\n"); + out.push_str(" }\n"); + out.push_str("}\n"); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_vhost_listens_on_80() { + let block = nginx_server_block(&VHost::to_container("app.com", "web", 8080)); + assert!(block.contains("listen 80;")); + assert!(!block.contains("listen 443")); + assert!(block.contains("server_name app.com;")); + assert!(block.contains("proxy_pass http://web:8080;")); + } + + #[test] + fn tls_vhost_adds_443_and_redirect() { + let block = nginx_server_block(&VHost::to_address("secure.com", "10.0.0.5:80").with_tls()); + assert!(block.contains("listen 443 ssl;")); + assert!(block.contains("return 301 https://$host$request_uri;")); + assert!(block.contains("/etc/letsencrypt/live/secure.com/fullchain.pem")); + assert!(block.contains("proxy_pass http://10.0.0.5:80;")); + } + + #[test] + fn aliases_join_the_server_name() { + let v = VHost::to_address("main.com", "1.2.3.4:80") + .with_alias("www.main.com") + .with_alias("alt.com"); + let block = nginx_server_block(&v); + assert!(block.contains("server_name main.com www.main.com alt.com;")); + } + + #[test] + fn render_is_deterministic() { + let v = VHost::to_container("x.com", "c", 80).with_tls(); + assert_eq!(nginx_server_block(&v), nginx_server_block(&v)); + } +} diff --git a/crates/modules/matilda/matilda-core/Cargo.toml b/crates/modules/matilda/matilda-core/Cargo.toml new file mode 100644 index 0000000..2bd92c9 --- /dev/null +++ b/crates/modules/matilda/matilda-core/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "matilda-core" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "matilda — modelo de dominio de administración de servidores: Host, Container, VHost y el Inventory que los agrupa. Agnóstico de transporte y de Docker." + +[dependencies] +serde = { workspace = true } diff --git a/crates/modules/matilda/matilda-core/src/container.rs b/crates/modules/matilda/matilda-core/src/container.rs new file mode 100644 index 0000000..a731a25 --- /dev/null +++ b/crates/modules/matilda/matilda-core/src/container.rs @@ -0,0 +1,132 @@ +//! `Container` — la especificación declarativa de un contenedor Docker. +//! +//! Es sólo el *deseo*: qué imagen, qué puertos, qué entorno. Ejecutar +//! Docker es trabajo de capas superiores; aquí el contenedor es un dato +//! comparable (`PartialEq`) para que el plan detecte cambios. + +use serde::{Deserialize, Serialize}; + +/// Política de reinicio del contenedor. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum RestartPolicy { + /// Nunca reiniciar. + #[default] + No, + /// Reiniciar sólo si salió con error. + OnFailure, + /// Reiniciar siempre. + Always, + /// Reiniciar salvo que se haya detenido a mano. + UnlessStopped, +} + +impl RestartPolicy { + /// Valor tal como lo espera el flag `--restart` de Docker. + pub fn docker_flag(self) -> &'static str { + match self { + RestartPolicy::No => "no", + RestartPolicy::OnFailure => "on-failure", + RestartPolicy::Always => "always", + RestartPolicy::UnlessStopped => "unless-stopped", + } + } +} + +/// Un mapeo de puerto `host → contenedor`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct PortMap { + pub host: u16, + pub container: u16, +} + +impl PortMap { + pub fn new(host: u16, container: u16) -> Self { + Self { host, container } + } +} + +/// La especificación declarativa de un contenedor. Clave única: `name`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Container { + pub name: String, + /// Imagen con etiqueta — `"nginx:1.27"`, `"postgres:16"`. + pub image: String, + pub ports: Vec, + /// Variables de entorno, ordenadas por clave para comparación estable. + pub env: Vec<(String, String)>, + /// Volúmenes `ruta_host → ruta_contenedor`. + pub volumes: Vec<(String, String)>, + pub restart: RestartPolicy, +} + +impl Container { + /// Contenedor mínimo: nombre + imagen. + pub fn new(name: impl Into, image: impl Into) -> Self { + Self { + name: name.into(), + image: image.into(), + ports: Vec::new(), + env: Vec::new(), + volumes: Vec::new(), + restart: RestartPolicy::default(), + } + } + + /// Publica un puerto (encadenable). + pub fn with_port(mut self, host: u16, container: u16) -> Self { + self.ports.push(PortMap::new(host, container)); + self + } + + /// Define una variable de entorno (encadenable). El vector se + /// mantiene ordenado por clave para que dos contenedores con el + /// mismo entorno comparen iguales sin importar el orden de llamada. + pub fn with_env(mut self, key: impl Into, value: impl Into) -> Self { + let key = key.into(); + self.env.retain(|(k, _)| k != &key); + self.env.push((key, value.into())); + self.env.sort_by(|a, b| a.0.cmp(&b.0)); + self + } + + /// Monta un volumen (encadenable). + pub fn with_volume( + mut self, + host_path: impl Into, + container_path: impl Into, + ) -> Self { + self.volumes.push((host_path.into(), container_path.into())); + self + } + + /// Fija la política de reinicio (encadenable). + pub fn with_restart(mut self, restart: RestartPolicy) -> Self { + self.restart = restart; + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn env_order_does_not_affect_equality() { + let a = Container::new("c", "img").with_env("B", "2").with_env("A", "1"); + let b = Container::new("c", "img").with_env("A", "1").with_env("B", "2"); + assert_eq!(a, b); + } + + #[test] + fn with_env_overwrites_same_key() { + let c = Container::new("c", "img").with_env("K", "old").with_env("K", "new"); + assert_eq!(c.env, vec![("K".to_string(), "new".to_string())]); + } + + #[test] + fn restart_flags_match_docker() { + assert_eq!(RestartPolicy::UnlessStopped.docker_flag(), "unless-stopped"); + assert_eq!(RestartPolicy::default(), RestartPolicy::No); + } +} diff --git a/crates/modules/matilda/matilda-core/src/host.rs b/crates/modules/matilda/matilda-core/src/host.rs new file mode 100644 index 0000000..75bc095 --- /dev/null +++ b/crates/modules/matilda/matilda-core/src/host.rs @@ -0,0 +1,46 @@ +//! `Host` — un servidor administrado. + +use serde::{Deserialize, Serialize}; + +/// Un servidor bajo administración. La clave única es `name`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Host { + /// Nombre lógico — clave de inventario, no necesariamente el hostname. + pub name: String, + /// IP o nombre DNS por el que se alcanza. + pub address: String, + /// Etiquetas libres — `"prod"`, `"db"`, `"edge"`. + pub tags: Vec, +} + +impl Host { + pub fn new(name: impl Into, address: impl Into) -> Self { + Self { name: name.into(), address: address.into(), tags: Vec::new() } + } + + /// Añade una etiqueta (encadenable). No duplica. + pub fn with_tag(mut self, tag: impl Into) -> Self { + let tag = tag.into(); + if !self.tags.contains(&tag) { + self.tags.push(tag); + } + self + } + + /// `true` si el host lleva la etiqueta `tag`. + pub fn has_tag(&self, tag: &str) -> bool { + self.tags.iter().any(|t| t == tag) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn with_tag_dedups() { + let h = Host::new("edge-1", "10.0.0.1").with_tag("prod").with_tag("prod"); + assert_eq!(h.tags.len(), 1); + assert!(h.has_tag("prod")); + } +} diff --git a/crates/modules/matilda/matilda-core/src/inventory.rs b/crates/modules/matilda/matilda-core/src/inventory.rs new file mode 100644 index 0000000..01259a3 --- /dev/null +++ b/crates/modules/matilda/matilda-core/src/inventory.rs @@ -0,0 +1,131 @@ +//! `Inventory` — el estado declarado de la infraestructura. +//! +//! Reúne hosts, contenedores y vhosts. Cada colección es un `BTreeMap` +//! por nombre: toda iteración es determinista y el `diff` de +//! `matilda-plan` produce siempre el mismo orden de acciones. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::container::Container; +use crate::host::Host; +use crate::vhost::VHost; + +/// El inventario completo — la fuente de verdad declarativa. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Inventory { + hosts: BTreeMap, + containers: BTreeMap, + vhosts: BTreeMap, +} + +impl Inventory { + pub fn new() -> Self { + Self::default() + } + + // --- Hosts --- + + pub fn add_host(&mut self, host: Host) { + self.hosts.insert(host.name.clone(), host); + } + + pub fn host(&self, name: &str) -> Option<&Host> { + self.hosts.get(name) + } + + pub fn hosts(&self) -> impl Iterator { + self.hosts.values() + } + + // --- Contenedores --- + + pub fn add_container(&mut self, container: Container) { + self.containers.insert(container.name.clone(), container); + } + + pub fn container(&self, name: &str) -> Option<&Container> { + self.containers.get(name) + } + + pub fn containers(&self) -> impl Iterator { + self.containers.values() + } + + // --- VHosts --- + + pub fn add_vhost(&mut self, vhost: VHost) { + self.vhosts.insert(vhost.domain.clone(), vhost); + } + + pub fn vhost(&self, domain: &str) -> Option<&VHost> { + self.vhosts.get(domain) + } + + pub fn vhosts(&self) -> impl Iterator { + self.vhosts.values() + } + + // --- Consultas transversales --- + + /// `true` si el inventario no tiene nada declarado. + pub fn is_empty(&self) -> bool { + self.hosts.is_empty() && self.containers.is_empty() && self.vhosts.is_empty() + } + + /// VHosts cuyo upstream apunta a un contenedor inexistente — la + /// inconsistencia más común de un inventario. + pub fn broken_vhosts(&self) -> Vec<&VHost> { + self.vhosts + .values() + .filter(|v| { + v.depends_on_container() + .is_some_and(|c| !self.containers.contains_key(c)) + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn add_and_query_each_kind() { + let mut inv = Inventory::new(); + inv.add_host(Host::new("edge", "10.0.0.1")); + inv.add_container(Container::new("web", "nginx:1.27")); + inv.add_vhost(VHost::to_container("site.com", "web", 80)); + assert!(inv.host("edge").is_some()); + assert!(inv.container("web").is_some()); + assert!(inv.vhost("site.com").is_some()); + assert!(!inv.is_empty()); + } + + #[test] + fn broken_vhosts_point_to_missing_containers() { + let mut inv = Inventory::new(); + inv.add_vhost(VHost::to_container("site.com", "fantasma", 80)); + inv.add_vhost(VHost::to_address("static.com", "1.2.3.4:80")); + let broken: Vec<_> = inv.broken_vhosts().iter().map(|v| v.domain.clone()).collect(); + assert_eq!(broken, vec!["site.com"]); + } + + #[test] + fn vhost_with_present_container_is_not_broken() { + let mut inv = Inventory::new(); + inv.add_container(Container::new("web", "nginx:1.27")); + inv.add_vhost(VHost::to_container("site.com", "web", 80)); + assert!(inv.broken_vhosts().is_empty()); + } + + #[test] + fn iteration_is_ordered_by_name() { + let mut inv = Inventory::new(); + inv.add_container(Container::new("zeta", "img")); + inv.add_container(Container::new("alfa", "img")); + let names: Vec<_> = inv.containers().map(|c| c.name.as_str()).collect(); + assert_eq!(names, vec!["alfa", "zeta"]); + } +} diff --git a/crates/modules/matilda/matilda-core/src/lib.rs b/crates/modules/matilda/matilda-core/src/lib.rs new file mode 100644 index 0000000..5135abb --- /dev/null +++ b/crates/modules/matilda/matilda-core/src/lib.rs @@ -0,0 +1,26 @@ +//! `matilda-core` — el modelo de dominio de administración de servidores. +//! +//! matilda administra servidores, sus contenedores Docker y los hosts +//! virtuales de proxy inverso. Este crate es la parte declarativa y +//! pura: describe *qué* debe existir, sin tocar Docker, SSH ni archivos. +//! +//! - [`host`] — [`Host`], un servidor administrado. +//! - [`container`] — [`Container`], la spec declarativa de un contenedor. +//! - [`vhost`] — [`VHost`], un host virtual de proxy inverso. +//! - [`inventory`] — [`Inventory`], el estado declarado completo. +//! +//! El renderizado de configuración vive en `matilda-config`; la +//! reconciliación deseado-vs-actual, en `matilda-plan`; el transporte +//! (SSH «Linker», agente «Ghost»), en capas superiores. + +#![forbid(unsafe_code)] + +pub mod container; +pub mod host; +pub mod inventory; +pub mod vhost; + +pub use container::{Container, PortMap, RestartPolicy}; +pub use host::Host; +pub use inventory::Inventory; +pub use vhost::{Upstream, VHost}; diff --git a/crates/modules/matilda/matilda-core/src/vhost.rs b/crates/modules/matilda/matilda-core/src/vhost.rs new file mode 100644 index 0000000..4c48fb9 --- /dev/null +++ b/crates/modules/matilda/matilda-core/src/vhost.rs @@ -0,0 +1,88 @@ +//! `VHost` — un host virtual de proxy inverso. + +use serde::{Deserialize, Serialize}; + +/// El destino al que un `VHost` reenvía el tráfico. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Upstream { + /// Una dirección `host:puerto` literal. + Address(String), + /// Un contenedor del inventario, por nombre y puerto interno. + Container { name: String, port: u16 }, +} + +/// Un host virtual: un dominio que se reenvía a un upstream. Clave +/// única: `domain`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VHost { + pub domain: String, + pub upstream: Upstream, + /// Si se sirve sobre HTTPS. + pub tls: bool, + /// Dominios alternativos que resuelven al mismo upstream. + pub aliases: Vec, +} + +impl VHost { + /// VHost que apunta a una dirección literal. + pub fn to_address(domain: impl Into, address: impl Into) -> Self { + Self { + domain: domain.into(), + upstream: Upstream::Address(address.into()), + tls: false, + aliases: Vec::new(), + } + } + + /// VHost que apunta a un contenedor del inventario. + pub fn to_container( + domain: impl Into, + container: impl Into, + port: u16, + ) -> Self { + Self { + domain: domain.into(), + upstream: Upstream::Container { name: container.into(), port }, + tls: false, + aliases: Vec::new(), + } + } + + /// Activa TLS (encadenable). + pub fn with_tls(mut self) -> Self { + self.tls = true; + self + } + + /// Añade un alias de dominio (encadenable). + pub fn with_alias(mut self, alias: impl Into) -> Self { + self.aliases.push(alias.into()); + self + } + + /// Nombre del contenedor del que depende, si el upstream es uno. + pub fn depends_on_container(&self) -> Option<&str> { + match &self.upstream { + Upstream::Container { name, .. } => Some(name), + Upstream::Address(_) => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn container_upstream_reports_its_dependency() { + let v = VHost::to_container("app.example.com", "web", 8080).with_tls(); + assert_eq!(v.depends_on_container(), Some("web")); + assert!(v.tls); + } + + #[test] + fn address_upstream_has_no_container_dependency() { + let v = VHost::to_address("static.example.com", "10.0.0.9:80"); + assert_eq!(v.depends_on_container(), None); + } +} diff --git a/crates/modules/matilda/matilda-plan/Cargo.toml b/crates/modules/matilda/matilda-plan/Cargo.toml new file mode 100644 index 0000000..983b530 --- /dev/null +++ b/crates/modules/matilda/matilda-plan/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "matilda-plan" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "matilda — reconciliación de estado: compara el inventario deseado con el actual y produce una lista ordenada de acciones que respeta las dependencias." + +[dependencies] +matilda-core = { path = "../matilda-core" } +serde = { workspace = true } diff --git a/crates/modules/matilda/matilda-plan/src/lib.rs b/crates/modules/matilda/matilda-plan/src/lib.rs new file mode 100644 index 0000000..09de1ea --- /dev/null +++ b/crates/modules/matilda/matilda-plan/src/lib.rs @@ -0,0 +1,268 @@ +//! `matilda-plan` — reconciliación de estado deseado vs actual. +//! +//! Dado el inventario *actual* de un servidor y el inventario *deseado*, +//! produce la lista de [`Action`]s que lo lleva de uno al otro. El orden +//! respeta las dependencias: +//! +//! 1. crear/actualizar hosts; +//! 2. crear/actualizar contenedores (los vhosts dependen de ellos); +//! 3. crear/actualizar vhosts; +//! 4. eliminar vhosts (antes que sus contenedores); +//! 5. eliminar contenedores; +//! 6. eliminar hosts. +//! +//! Es una función pura y determinista — el mismo par de inventarios da +//! siempre el mismo plan. Aplicarlo (Docker, nginx, SSH) es trabajo de +//! capas superiores. + +#![forbid(unsafe_code)] + +use matilda_core::Inventory; +use serde::{Deserialize, Serialize}; + +/// El tipo de recurso sobre el que opera una acción. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Resource { + Host, + Container, + VHost, +} + +impl Resource { + fn label(self) -> &'static str { + match self { + Resource::Host => "host", + Resource::Container => "contenedor", + Resource::VHost => "vhost", + } + } +} + +/// La operación de una acción. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Op { + Create, + Update, + Remove, +} + +impl Op { + fn verb(self) -> &'static str { + match self { + Op::Create => "crear", + Op::Update => "actualizar", + Op::Remove => "eliminar", + } + } +} + +/// Una acción del plan: operar sobre un recurso identificado por nombre. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Action { + pub op: Op, + pub resource: Resource, + /// Nombre del recurso — `name` del host/contenedor, `domain` del vhost. + pub name: String, +} + +impl Action { + fn new(op: Op, resource: Resource, name: impl Into) -> Self { + Self { op, resource, name: name.into() } + } + + /// Descripción legible — `"crear contenedor «web»"`. + pub fn describe(&self) -> String { + format!("{} {} «{}»", self.op.verb(), self.resource.label(), self.name) + } +} + +/// El plan de reconciliación: acciones en orden de aplicación. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Plan { + pub actions: Vec, +} + +impl Plan { + /// `true` si no hay nada que cambiar — los inventarios ya coinciden. + pub fn is_empty(&self) -> bool { + self.actions.is_empty() + } + + /// Cantidad de acciones. + pub fn len(&self) -> usize { + self.actions.len() + } + + /// Cuenta las acciones de una operación dada. + pub fn count(&self, op: Op) -> usize { + self.actions.iter().filter(|a| a.op == op).count() + } +} + +/// Calcula el plan que lleva de `current` a `desired`. +pub fn plan(current: &Inventory, desired: &Inventory) -> Plan { + let mut actions: Vec = Vec::new(); + + // --- Fase 1: hosts a crear/actualizar --- + for h in desired.hosts() { + match current.host(&h.name) { + None => actions.push(Action::new(Op::Create, Resource::Host, &h.name)), + Some(cur) if cur != h => { + actions.push(Action::new(Op::Update, Resource::Host, &h.name)) + } + Some(_) => {} + } + } + + // --- Fase 2: contenedores a crear/actualizar --- + for c in desired.containers() { + match current.container(&c.name) { + None => actions.push(Action::new(Op::Create, Resource::Container, &c.name)), + Some(cur) if cur != c => { + actions.push(Action::new(Op::Update, Resource::Container, &c.name)) + } + Some(_) => {} + } + } + + // --- Fase 3: vhosts a crear/actualizar --- + for v in desired.vhosts() { + match current.vhost(&v.domain) { + None => actions.push(Action::new(Op::Create, Resource::VHost, &v.domain)), + Some(cur) if cur != v => { + actions.push(Action::new(Op::Update, Resource::VHost, &v.domain)) + } + Some(_) => {} + } + } + + // --- Fase 4: vhosts a eliminar (antes que sus contenedores) --- + for v in current.vhosts() { + if desired.vhost(&v.domain).is_none() { + actions.push(Action::new(Op::Remove, Resource::VHost, &v.domain)); + } + } + + // --- Fase 5: contenedores a eliminar --- + for c in current.containers() { + if desired.container(&c.name).is_none() { + actions.push(Action::new(Op::Remove, Resource::Container, &c.name)); + } + } + + // --- Fase 6: hosts a eliminar --- + for h in current.hosts() { + if desired.host(&h.name).is_none() { + actions.push(Action::new(Op::Remove, Resource::Host, &h.name)); + } + } + + Plan { actions } +} + +#[cfg(test)] +mod tests { + use super::*; + use matilda_core::{Container, Host, VHost}; + + #[test] + fn empty_to_empty_is_a_noop() { + let p = plan(&Inventory::new(), &Inventory::new()); + assert!(p.is_empty()); + } + + #[test] + fn fresh_inventory_is_all_creates() { + let mut desired = Inventory::new(); + desired.add_host(Host::new("edge", "10.0.0.1")); + desired.add_container(Container::new("web", "nginx")); + desired.add_vhost(VHost::to_container("site.com", "web", 80)); + let p = plan(&Inventory::new(), &desired); + assert_eq!(p.count(Op::Create), 3); + assert_eq!(p.count(Op::Remove), 0); + } + + #[test] + fn unchanged_inventory_yields_no_actions() { + let mut inv = Inventory::new(); + inv.add_container(Container::new("web", "nginx:1.27")); + let p = plan(&inv, &inv.clone()); + assert!(p.is_empty()); + } + + #[test] + fn changed_image_is_an_update() { + let mut current = Inventory::new(); + current.add_container(Container::new("web", "nginx:1.26")); + let mut desired = Inventory::new(); + desired.add_container(Container::new("web", "nginx:1.27")); + let p = plan(¤t, &desired); + assert_eq!(p.actions, vec![Action::new(Op::Update, Resource::Container, "web")]); + } + + #[test] + fn dropped_resources_become_removes() { + let mut current = Inventory::new(); + current.add_container(Container::new("old", "img")); + current.add_vhost(VHost::to_container("old.com", "old", 80)); + let p = plan(¤t, &Inventory::new()); + assert_eq!(p.count(Op::Remove), 2); + } + + #[test] + fn vhost_removal_precedes_container_removal() { + // Un vhost debe eliminarse antes que el contenedor que lo sirve. + let mut current = Inventory::new(); + current.add_container(Container::new("web", "nginx")); + current.add_vhost(VHost::to_container("site.com", "web", 80)); + let p = plan(¤t, &Inventory::new()); + let vhost_pos = p + .actions + .iter() + .position(|a| a.resource == Resource::VHost) + .unwrap(); + let cont_pos = p + .actions + .iter() + .position(|a| a.resource == Resource::Container) + .unwrap(); + assert!(vhost_pos < cont_pos); + } + + #[test] + fn container_creation_precedes_vhost_creation() { + let mut desired = Inventory::new(); + desired.add_container(Container::new("web", "nginx")); + desired.add_vhost(VHost::to_container("site.com", "web", 80)); + let p = plan(&Inventory::new(), &desired); + let cont_pos = p + .actions + .iter() + .position(|a| a.resource == Resource::Container) + .unwrap(); + let vhost_pos = p + .actions + .iter() + .position(|a| a.resource == Resource::VHost) + .unwrap(); + assert!(cont_pos < vhost_pos); + } + + #[test] + fn plan_is_deterministic() { + let mut current = Inventory::new(); + current.add_container(Container::new("a", "img:1")); + let mut desired = Inventory::new(); + desired.add_container(Container::new("a", "img:2")); + desired.add_container(Container::new("b", "img:1")); + assert_eq!(plan(¤t, &desired), plan(¤t, &desired)); + } + + #[test] + fn describe_is_human_readable() { + let a = Action::new(Op::Create, Resource::Container, "web"); + assert_eq!(a.describe(), "crear contenedor «web»"); + } +}