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,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