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,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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user