gioser

tawasuyu/brahman

Rust 476TOML 190Markdown 50JSON 176602 Definitions

⎇ 3f8a3ea4b6d3de529e42ae418d486bc81cca308e crates/modules/matilda/matilda-plan/src/lib.rs Rust · 268 lines · 8.3 KB History Who touched what raw

feat(matilda): administración de servidores — core + config + plan 3f8a3ea4b · sergio ·

1//! `matilda-plan` — reconciliación de estado deseado vs actual.
2//!
3//! Dado el inventario *actual* de un servidor y el inventario *deseado*,
4//! produce la lista de [`Action`]s que lo lleva de uno al otro. El orden
5//! respeta las dependencias:
6//!
7//! 1. crear/actualizar hosts;
8//! 2. crear/actualizar contenedores (los vhosts dependen de ellos);
9//! 3. crear/actualizar vhosts;
10//! 4. eliminar vhosts (antes que sus contenedores);
11//! 5. eliminar contenedores;
12//! 6. eliminar hosts.
13//!
14//! Es una función pura y determinista — el mismo par de inventarios da
15//! siempre el mismo plan. Aplicarlo (Docker, nginx, SSH) es trabajo de
16//! capas superiores.
17
18#![forbid(unsafe_code)]
19
20use matilda_core::Inventory;
21use serde::{Deserialize, Serialize};
22
23/// El tipo de recurso sobre el que opera una acción.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "lowercase")]
26pub enum Resource {
27    Host,
28    Container,
29    VHost,
30}
31
32impl Resource {
33    fn label(self) -> &'static str {
34        match self {
35            Resource::Host => "host",
36            Resource::Container => "contenedor",
37            Resource::VHost => "vhost",
38        }
39    }
40}
41
42/// La operación de una acción.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "lowercase")]
45pub enum Op {
46    Create,
47    Update,
48    Remove,
49}
50
51impl Op {
52    fn verb(self) -> &'static str {
53        match self {
54            Op::Create => "crear",
55            Op::Update => "actualizar",
56            Op::Remove => "eliminar",
57        }
58    }
59}
60
61/// Una acción del plan: operar sobre un recurso identificado por nombre.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct Action {
64    pub op: Op,
65    pub resource: Resource,
66    /// Nombre del recurso — `name` del host/contenedor, `domain` del vhost.
67    pub name: String,
68}
69
70impl Action {
71    fn new(op: Op, resource: Resource, name: impl Into<String>) -> Self {
72        Self { op, resource, name: name.into() }
73    }
74
75    /// Descripción legible — `"crear contenedor «web»"`.
76    pub fn describe(&self) -> String {
77        format!("{} {} «{}»", self.op.verb(), self.resource.label(), self.name)
78    }
79}
80
81/// El plan de reconciliación: acciones en orden de aplicación.
82#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
83pub struct Plan {
84    pub actions: Vec<Action>,
85}
86
87impl Plan {
88    /// `true` si no hay nada que cambiar — los inventarios ya coinciden.
89    pub fn is_empty(&self) -> bool {
90        self.actions.is_empty()
91    }
92
93    /// Cantidad de acciones.
94    pub fn len(&self) -> usize {
95        self.actions.len()
96    }
97
98    /// Cuenta las acciones de una operación dada.
99    pub fn count(&self, op: Op) -> usize {
100        self.actions.iter().filter(|a| a.op == op).count()
101    }
102}
103
104/// Calcula el plan que lleva de `current` a `desired`.
105pub fn plan(current: &Inventory, desired: &Inventory) -> Plan {
106    let mut actions: Vec<Action> = Vec::new();
107
108    // --- Fase 1: hosts a crear/actualizar ---
109    for h in desired.hosts() {
110        match current.host(&h.name) {
111            None => actions.push(Action::new(Op::Create, Resource::Host, &h.name)),
112            Some(cur) if cur != h => {
113                actions.push(Action::new(Op::Update, Resource::Host, &h.name))
114            }
115            Some(_) => {}
116        }
117    }
118
119    // --- Fase 2: contenedores a crear/actualizar ---
120    for c in desired.containers() {
121        match current.container(&c.name) {
122            None => actions.push(Action::new(Op::Create, Resource::Container, &c.name)),
123            Some(cur) if cur != c => {
124                actions.push(Action::new(Op::Update, Resource::Container, &c.name))
125            }
126            Some(_) => {}
127        }
128    }
129
130    // --- Fase 3: vhosts a crear/actualizar ---
131    for v in desired.vhosts() {
132        match current.vhost(&v.domain) {
133            None => actions.push(Action::new(Op::Create, Resource::VHost, &v.domain)),
134            Some(cur) if cur != v => {
135                actions.push(Action::new(Op::Update, Resource::VHost, &v.domain))
136            }
137            Some(_) => {}
138        }
139    }
140
141    // --- Fase 4: vhosts a eliminar (antes que sus contenedores) ---
142    for v in current.vhosts() {
143        if desired.vhost(&v.domain).is_none() {
144            actions.push(Action::new(Op::Remove, Resource::VHost, &v.domain));
145        }
146    }
147
148    // --- Fase 5: contenedores a eliminar ---
149    for c in current.containers() {
150        if desired.container(&c.name).is_none() {
151            actions.push(Action::new(Op::Remove, Resource::Container, &c.name));
152        }
153    }
154
155    // --- Fase 6: hosts a eliminar ---
156    for h in current.hosts() {
157        if desired.host(&h.name).is_none() {
158            actions.push(Action::new(Op::Remove, Resource::Host, &h.name));
159        }
160    }
161
162    Plan { actions }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use matilda_core::{Container, Host, VHost};
169
170    #[test]
171    fn empty_to_empty_is_a_noop() {
172        let p = plan(&Inventory::new(), &Inventory::new());
173        assert!(p.is_empty());
174    }
175
176    #[test]
177    fn fresh_inventory_is_all_creates() {
178        let mut desired = Inventory::new();
179        desired.add_host(Host::new("edge", "10.0.0.1"));
180        desired.add_container(Container::new("web", "nginx"));
181        desired.add_vhost(VHost::to_container("site.com", "web", 80));
182        let p = plan(&Inventory::new(), &desired);
183        assert_eq!(p.count(Op::Create), 3);
184        assert_eq!(p.count(Op::Remove), 0);
185    }
186
187    #[test]
188    fn unchanged_inventory_yields_no_actions() {
189        let mut inv = Inventory::new();
190        inv.add_container(Container::new("web", "nginx:1.27"));
191        let p = plan(&inv, &inv.clone());
192        assert!(p.is_empty());
193    }
194
195    #[test]
196    fn changed_image_is_an_update() {
197        let mut current = Inventory::new();
198        current.add_container(Container::new("web", "nginx:1.26"));
199        let mut desired = Inventory::new();
200        desired.add_container(Container::new("web", "nginx:1.27"));
201        let p = plan(&current, &desired);
202        assert_eq!(p.actions, vec![Action::new(Op::Update, Resource::Container, "web")]);
203    }
204
205    #[test]
206    fn dropped_resources_become_removes() {
207        let mut current = Inventory::new();
208        current.add_container(Container::new("old", "img"));
209        current.add_vhost(VHost::to_container("old.com", "old", 80));
210        let p = plan(&current, &Inventory::new());
211        assert_eq!(p.count(Op::Remove), 2);
212    }
213
214    #[test]
215    fn vhost_removal_precedes_container_removal() {
216        // Un vhost debe eliminarse antes que el contenedor que lo sirve.
217        let mut current = Inventory::new();
218        current.add_container(Container::new("web", "nginx"));
219        current.add_vhost(VHost::to_container("site.com", "web", 80));
220        let p = plan(&current, &Inventory::new());
221        let vhost_pos = p
222            .actions
223            .iter()
224            .position(|a| a.resource == Resource::VHost)
225            .unwrap();
226        let cont_pos = p
227            .actions
228            .iter()
229            .position(|a| a.resource == Resource::Container)
230            .unwrap();
231        assert!(vhost_pos < cont_pos);
232    }
233
234    #[test]
235    fn container_creation_precedes_vhost_creation() {
236        let mut desired = Inventory::new();
237        desired.add_container(Container::new("web", "nginx"));
238        desired.add_vhost(VHost::to_container("site.com", "web", 80));
239        let p = plan(&Inventory::new(), &desired);
240        let cont_pos = p
241            .actions
242            .iter()
243            .position(|a| a.resource == Resource::Container)
244            .unwrap();
245        let vhost_pos = p
246            .actions
247            .iter()
248            .position(|a| a.resource == Resource::VHost)
249            .unwrap();
250        assert!(cont_pos < vhost_pos);
251    }
252
253    #[test]
254    fn plan_is_deterministic() {
255        let mut current = Inventory::new();
256        current.add_container(Container::new("a", "img:1"));
257        let mut desired = Inventory::new();
258        desired.add_container(Container::new("a", "img:2"));
259        desired.add_container(Container::new("b", "img:1"));
260        assert_eq!(plan(&current, &desired), plan(&current, &desired));
261    }
262
263    #[test]
264    fn describe_is_human_readable() {
265        let a = Action::new(Op::Create, Resource::Container, "web");
266        assert_eq!(a.describe(), "crear contenedor «web»");
267    }
268}