gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Definitions

feat(matilda): administración de servidores — core + config + plan

matilda-core: modelo declarativo (Host, Container, VHost, Inventory).
matilda-config: renderiza Container→docker-compose/docker run y
VHost→bloque server nginx (con TLS + redirección :80→:443).
matilda-plan: reconciliación pura actual→deseado con acciones
ordenadas por dependencia (contenedores antes que vhosts, removes
en orden inverso). Demo CLI en apps/matilda.

29 tests. Funciones puras, cero Docker/SSH/disco.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

3f8a3ea4b6d3de529e42ae418d486bc81cca308e · by sergio · (5 months ago) · parent 71f6cf130 · browse the tree at this commit

Semantic delta · 100

crates/apps/matilda/src/main.rs

crates/modules/matilda/matilda-config/src/docker.rs

crates/modules/matilda/matilda-config/src/lib.rs

crates/modules/matilda/matilda-config/src/nginx.rs

crates/modules/matilda/matilda-core/src/container.rs

crates/modules/matilda/matilda-core/src/host.rs

crates/modules/matilda/matilda-core/src/inventory.rs

crates/modules/matilda/matilda-core/src/lib.rs

crates/modules/matilda/matilda-core/src/vhost.rs

crates/modules/matilda/matilda-plan/src/lib.rs

18 files · +1190 −0

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(&current, &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<String> = 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::<Vec<_>>()
+        .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<PortMap>,
+    /// 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<String>, image: impl Into<String>) -> 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<String>, value: impl Into<String>) -> 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<String>,
+        container_path: impl Into<String>,
+    ) -> 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<String>,
+}
+
+impl Host {
+    pub fn new(name: impl Into<String>, address: impl Into<String>) -> 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<String>) -> 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<String, Host>,
+    containers: BTreeMap<String, Container>,
+    vhosts: BTreeMap<String, VHost>,
+}
+
+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<Item = &Host> {
+        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<Item = &Container> {
+        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<Item = &VHost> {
+        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<String>,
+}
+
+impl VHost {
+    /// VHost que apunta a una dirección literal.
+    pub fn to_address(domain: impl Into<String>, address: impl Into<String>) -> 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<String>,
+        container: impl Into<String>,
+        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<String>) -> 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<String>) -> 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<Action>,
+}
+
+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<Action> = 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(&current, &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(&current, &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(&current, &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(&current, &desired), plan(&current, &desired));
+    }
+
+    #[test]
+    fn describe_is_human_readable() {
+        let a = Action::new(Op::Create, Resource::Container, "web");
+        assert_eq!(a.describe(), "crear contenedor «web»");
+    }
+}