⎇ 3f8a3ea4b6d3de529e42ae418d486bc81cca308e
crates/modules/matilda/matilda-plan/src/lib.rs
History
Who touched what
raw
feat(matilda): administración de servidores — core + config + plan
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(¤t, &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(¤t, &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(¤t, &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(¤t, &desired), plan(¤t, &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}