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>
This commit is contained in:
sergio
2026-05-20 17:06:36 +00:00
parent 71f6cf1306
commit 3f8a3ea4b6
18 changed files with 1190 additions and 0 deletions
@@ -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);
}
}
@@ -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"));
}
}
@@ -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"]);
}
}
@@ -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};
@@ -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);
}
}