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:
@@ -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 |
|
||||
@@ -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" }
|
||||
@@ -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:"));
|
||||
}
|
||||
}
|
||||
@@ -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;"));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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(¤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»");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user