De scripts a contratos
Lo que el sistema de tipos de Rust me enseñó sobre la composición de infraestructura — RustLab 2026, Bolonia
De scripts a contratos
Lo que el sistema de tipos de Rust me enseñó
sobre la composición de infraestructura
Jesús Pérez Lorenzo · Desarrollador Rust · Arquitecto de cloud libre
RustLab 2026 · Bolonia · Deep Space




- Infraestructura desde 1987 — Perl, Python, Bash, Chef, Ansible, Terraform, Go… y luego Rust.
- Construye y opera este stack — provisioning y ontoref, en varios proveedores de nube.
- Enseña y crea comunidad — talleres de cloud y sistemas con la Universidad de Las Palmas y la SPEGC; coorganizador de Rust Las Palmas.
- Precuela — Rustikon 2026: “Why I Needed Rust, Finally”. Hoy toca lo que vino después.
Este log ya lo habéis visto
$ ./deploy.sh production
[ok] network applied
[ok] secrets propagated
[ok] orchestrator updated
[ok] monitoring reloaded
[ok] deploy complete exit 0- Cada paso informó de éxito.
- El orden entre ellos era incorrecto — y nada sabía que hubiera un orden.
- El pipeline estaba en verde. Producción no.
Tres capas que yo confundía
| Capa | Pregunta | Quién lo comprueba |
|---|---|---|
| Forma | ¿Existe este campo, con el primitivo correcto? | serde, JSON Schema |
| Coherencia | ¿Concuerdan estos campos entre sí? | contratos, constructores validados |
| Composición | ¿Es legal el grafo entero de pasos? | … normalmente nadie |
- Rust me dio soltura en las dos primeras: newtypes, enums,
TryFrom. - De la tercera capa salieron todas las alertas de las 3 de la mañana que recuerdo.
- Esta charla es lo que pasó cuando convertí la capa tres en un contrato.
El stack, con honestidad
Nickel
coherencia — en tiempo de configuración
Contratos de componentes, DAGs de fórmulas, composición de workspaces. Rechaza antes de que nada se ejecute.
Nushell
orquestación
La CLI, el ejecutor real del DAG hoy, el pegamento con los proveedores. Datos estructurados, no strings.
Rust
la plataforma
Orquestador, traits de proveedor, servicios NATS, control center, vault post-cuántico.
Un componente es un contrato
# schemas/lib/contracts.ncl (extracto)
ComponentDef = {
name | String,
mode | [| 'taskserv, 'cluster, 'container |] | default = 'taskserv,
depends_on | Array {
name | String,
kind | [| 'Requires, 'PrefersBefore, 'ConflictsWith |] | default = 'Requires,
} | default = [],
on_error | [| 'Stop, 'Continue, 'Retry |] | default = 'Stop,
requires | {
storage | { size | String, persistent | Bool } | optional,
ports | Array { port | Number, exposure | [| 'public, 'private, 'internal |] },
} | default = {},
provides | {
service | String | optional,
port | Number | optional,
endpoints | Array String | default = [],
} | default = {},
# mandatory — never omit
concerns | ServiceConcerns,
..
}Concerns: tipar lo que nadie tipa
# schemas/lib/concerns.ncl
ConcernState = { kind | [| 'enabled, 'disabled, 'pending, 'inherited |], .. }
ServiceConcerns = {
tls | ConcernState, dns | ConcernState, certs | ConcernState,
backup | ConcernState, observability | ConcernState, security | ConcernState,
}
# un componente honesto sobre sus carencias
concerns = {
tls = c.disabled "stateless service, no TLS termination",
backup = c.pending "policy to be defined" "BACKUP-001",
..
}- No puedes omitir un concern. Solo puedes decir por qué está desactivado — o qué ticket lo cierra.
- "¿Qué componentes tienen
backup = 'pending?" pasa a ser una consulta, no una auditoría.
El grafo es un valor — y se comprueba
# schemas/lib/formula.ncl (condensado) — se ejecuta en tiempo de configuración
let _Formula = std.contract.custom (fun label value =>
let node_ids = value.nodes |> std.array.map (fun n => n.id) in
let bad_deps = value.nodes |> std.array.flat_map (fun node =>
node.depends_on
|> std.array.filter (fun dep => !(std.array.elem dep.node_id node_ids))
|> std.array.map (fun dep => "node '%{node.id}' depends_on unknown '%{dep.node_id}'")
) in
if bad_deps != [] then
std.contract.blame_with_message "invalid depends_on: %{std.string.join ", " bad_deps}" label
else 'Ok value
)- Ids de nodo duplicados,
depends_oncolgantes, extremos de arista inválidos → rechazados. - Una fórmula = DAG de componentes en un servidor.
max_parallelyon_errorson política sobre el grafo.
Grafos de grafos: la composición
# workspaces/libre-wuji/infra/libre-wuji/dag.ncl
composition.formulas = [
{ formula_id = "libre-wuji-cp-formula",
depends_on = [],
health_gate = {
check_cmd = "kubectl get nodes … | grep -v NotReady",
retries = 18,
} },
{ formula_id = "libre-wuji-wrk-0-formula",
depends_on = [{ formula_id = "libre-wuji-cp-formula",
condition = 'Healthy }],
parallel = true },
# wrk-1, wrk-2, strg-* …
]Exactamente uno de — la lección que no había aplicado
1 // lo que Rust quería desde el principio: dos estados, no cuatro
2 pub enum NodeKind {
3 TaskServ { taskserv: TaskServDef },
4 Component { component: ComponentDef },
5 }
6
7 pub struct FormulaNode {
8 pub id: String,
9 pub kind: NodeKind,
10 }
11
12 match &node.kind {
13 NodeKind::TaskServ { taskserv } => run_taskserv(taskserv),
14 NodeKind::Component { component } => run_component(component),
15 }
bail! desaparecen porque ese estado no se puede construir.Los proveedores cumplen un contrato — el núcleo no se mueve
// platform/crates/provisioning-compute/src/trait_def.rs
#[async_trait]
pub trait ComputeProvider: Send + Sync + Debug + 'static {
fn kind(&self) -> ProviderKind;
async fn spawn(&self, spec: InstanceSpec) -> Result<Instance, ComputeError>;
/// MUST be idempotent: destroying an unknown handle returns Ok(()).
/// MUST NOT return NotFound — that case maps to Ok(()).
async fn destroy(&self, handle: &InstanceHandle) -> Result<(), ComputeError>;
async fn status(&self, handle: &InstanceHandle) -> Result<InstanceStatus, ComputeError>;
async fn list(&self, label_selector: &str) -> Result<Vec<Instance>, ComputeError>;
async fn health(&self) -> Result<ProviderHealth, ComputeError>;
}- Añadir un proveedor = implementar un trait + un esquema Nickel. Sin tocar un núcleo — incluso tu propia infra local, o cualquier script Nushell detrás de un sobre JSON.
#[non_exhaustive] enum ComputeError— los modos de fallo forman parte del contrato.- Pero lee los doc comments: "MUST be idempotent" es un invariante que el tipo no puede sostener. Solo los tests pueden — así que todo proveedor, el tuyo incluido, pasa la misma suite de conformidad:
run_suite(provider).
En vivo: romper el contrato
let f = import "schemas/lib/formula.ncl" in
let Acyclic = import "acyclic.ncl" in
f.make_formula {
id = "rustlab-live",
description = "control plane, typed",
provider = "hetzner",
server = "libre-wuji-cp-0",
nodes = [
f.make_node { id = "os", taskserv = { name = "os" } },
f.make_node { id = "runc", taskserv = { name = "runc" }, depends_on = [{ node_id = "os" }] },
f.make_node { id = "crio", taskserv = { name = "crio" }, depends_on = [{ node_id = "runc" }] },
f.make_node { id = "kubernetes", taskserv = { name = "kubernetes" }, depends_on = [{ node_id = "crio" }] },
],
}Ownership, trasladado — como analogía, no como mecanismo
| Rust | Infraestructura |
|---|---|
| Un único dueño | Un componente es dueño de un recurso; los demás toman prestado su provides |
& vs &mut | Leer endpoints libremente; mutar el recurso de otro es un error de diseño |
| Lifetimes | "Esta credencial es válida para esta sesión de despliegue" |
TryFrom / constructores validados | Contratos que rechazan en la frontera |
enum frente a Option × Option | 'pending "reason" "BACKUP-001" en vez de un campo que falta |
| Trait bounds | "Cumple ComputeProvider y eres un proveedor" |
El estado mutable con alias es la raíz de la mayoría de las data races.
Dos sistemas que creen ser dueños del mismo recurso son la raíz de la mayoría de los incidentes de infra.
Por qué el rollback no es Drop
Yo quería esto:
{
let plan = Plan::apply(&ctx)?;
plan.verify()?;
plan.commit();
} // se descarta sin commit → rollbackDropes síncrono. Las APIs de la nube no.Dropno puede fallar. Borrar un volumen sí.- El orden de
Dropes léxico. El orden de desmontaje es un grafo.
Lo que tengo:
#[async_trait]
pub trait ProviderRollbackHandler: Send + Sync {
async fn rollback_resource(&self, r: &ResourceSnapshot)
-> Result<()>;
async fn cleanup_resources_after(&self, cp: &Checkpoint)
-> Result<Vec<String>>;
async fn validate_rollback(&self, cp: &Checkpoint)
-> Result<Vec<String>>;
}Explícito, con checkpoints, por proveedor — y validado antes de ejecutarse.
El bug que encontré preparando esta charla
use std::collections::{HashMap, HashSet, VecDeque};
#[derive(Default)]
struct DependencyGraph {
nodes: HashSet<String>,
edges: HashMap<String, Vec<String>>, // task -> its dependencies
reverse_edges: HashMap<String, Vec<String>>, // dependency -> its dependents
}
impl DependencyGraph {
fn add_dependency(&mut self, from: &str, depends_on: &str) {
self.nodes.extend([from.to_string(), depends_on.to_string()]);
self.edges.entry(from.into()).or_default().push(depends_on.into());
self.reverse_edges.entry(depends_on.into()).or_default().push(from.into());
}
// same logic as orchestrator/src/workflow.rs:174
fn topological_sort(&self) -> Result<Vec<String>, String> {
let mut in_degree: HashMap<&str, usize> =
self.nodes.iter().map(|n| (n.as_str(), 0)).collect();
for deps in self.edges.values() {
for dep in deps {
*in_degree.entry(dep).or_default() += 1;
}
}
let mut queue: VecDeque<&str> =
in_degree.iter().filter(|(_, d)| **d == 0).map(|(n, _)| *n).collect();
let mut result = Vec::new();
while let Some(node) = queue.pop_front() {
result.push(node.to_string());
for dependent in self.reverse_edges.get(node).into_iter().flatten() {
let d = in_degree.get_mut(dependent.as_str()).ok_or("unknown node")?;
*d -= 1;
if *d == 0 { queue.push_back(dependent); }
}
}
if result.len() != self.nodes.len() {
return Err("Circular dependency detected in workflow".into());
}
Ok(result)
}
}Los tipos comprueban forma. Los tests, significado.
Result<Vec<String>>lo satisface el orden correcto, el incorrecto y unErrque miente.- Los algoritmos de grafos necesitan propiedades, no ejemplos:
proptest! {
#[test]
fn topo_order_respects_every_edge(g in arb_dag(1..64)) {
let order = g.topological_sort().unwrap();
let pos: HashMap<_, _> = order.iter().enumerate().map(|(i, n)| (n, i)).collect();
for (task, deps) in &g.edges {
for dep in deps { prop_assert!(pos[dep] < pos[task]); }
}
}
}La frontera es donde los tipos se filtran
ComponentDef en Nickel — más de 20 campos
mode | [| 'taskserv, … |] | default = 'taskserv,
requires | { … },
provides | { … },
concerns | ServiceConcerns, # obligatorio
.. # record abiertoComponentDef en Rust — 7 campos
#[derive(Deserialize)]
pub struct ComponentDef {
pub name: String,
#[serde(default = "default_deploy_mode")] // ¡Cluster!
pub mode: DeployMode,
// requires, provides, concerns:
// serde los descarta en silencio
}- Dos sistemas de tipos, un concepto, dos valores por defecto.
serdeignora los campos desconocidos salvo que digas#[serde(deny_unknown_fields)].- Arreglo: una única fuente de verdad + un test de CI que exporta cada fixture de Nickel y la deserializa en modo estricto.
Un contrato solo protege donde se aplica
provides | {
service | String | optional,
port | Number | optional,
# Added 2026-08-18: the surrealdb catalog component has always declared
# provides.protocol = "http+ws" and this closed record rejected it, so the
# component could be listed in settings.ncl (which does not apply
# ComponentDef) but not used as a DAG component node, which does. The
# incompatibility stayed invisible until a workspace first tried to
# schedule it through the unified model.
protocol | String | optional,El tipado gradual es una virtud — y un sitio donde los bugs esperan. Ten claro dónde se ejecutan tus contratos.
El estado externo no tipa
condition = 'Healthyes un enum tipado. Lo que Healthy significa eskubectl get nodes | grep -v NotReady.- Un
200 OKde un control plane a menudo significa encolado, no hecho. - Consistencia eventual, límites de tasa, ventanas de idempotencia: nada de eso está en una firma.
- Puedo validar el plan. No puedo validar la nube.
Operacional, no estructural. Observado, no demostrado.
Qué haría distinto
- Tipos suma de extremo a extremo. Si el lenguaje de configuración no puede expresarlos, genera el lado de Rust — no copies a mano
Option × Option. - Errores tipados en la capa de composición. Todavía devuelve
anyhow. Los proveedores ya tienen enums#[non_exhaustive]; el grafo merece uno. - Un solo ejecutor. Nushell ejecuta el DAG en producción; a su lado había un motor en Rust sin tests en su ordenación. Dos motores = dos semánticas.
- Tests de propiedades en cada algoritmo de grafos. Desde el primer día — un
Result<Vec<String>>comprueba la forma de la respuesta, no su orden. - Comprobar la aciclicidad donde se declara el grafo. No donde se ejecuta — el ciclo pasó
dag.ncly solo Rust lo vio.
El mismo paradigma, otro ámbito
| Rust · el código | provisioning · infraestructura | ontoref · proyectos | |
|---|---|---|---|
| Declarar | tipos, enums | ComponentDef, fórmulas | axiomas, tensiones, prácticas — core.ncl |
| Estado | typestate | estado de la op, health gates | state.ncl: actual → deseado, con bloqueos |
| Comprobar antes de actuar | el compilador | contrato antes del despliegue | una decisión comprobada contra invariantes · act-gate en pre-commit |
| Qué cuenta como hecho | Result, no exit 0 | el health gate, no "applied" | el veredicto de un paso es el exit code de su check, no su informe |
| Traza | — | ops firmadas → ledger en Radicle | orden de trabajo firmada + recibo |
forma · el plan
flujo · la nube
Qué cambia esto para los agentes de IA
Ayuda
- El contexto se consulta, no se recuerda. El agente pregunta al proyecto qué es cierto (
ontoref describe … --actor agent) en vez de adivinarlo a partir de ficheros. - El agente propone, el contrato dispone. Los mismos validadores por MCP que en la CLI; las propuestas llegan sin firmar a
ops.pending.<ws>, y solo los comandos firmados alcanzanops.cmd.<ws>. - Su palabra no es el veredicto. El estado de un paso es el exit code de su check; un autoinforme que lo contradiga se rechaza.
- Los actos humanos siguen siendo humanos. Solo una persona ratifica o acepta; un agente firma solo como sí mismo. Su alcance es un eje declarado:
'AgentBoundary.
No resuelve
- Bien tipado y erróneo. El
topological_sortcompilaba. - Declarado ≠ leído. Un agente cita un mecanismo que nada ejecuta — igual que hacen las personas.
- "No hay comprobación" también es una afirmación. La ausencia necesita la misma prueba que la presencia.
- Cada contrato es coste de adopción. Más forma, más fricción.
La misma disciplina, un nivel más arriba
# un workspace → su dominio (libre-daoshi/.domains-ontoref/…)
{ id = "libre-daoshi-to-provisioning",
from = "libre-daoshi", to = "provisioning",
axis = 'Governance, state = 'Node, resolution = 'Merge }
# proyecto → gov, un sentido (provisioning/.domains-ontoref/…)
{ id = "provisioning-to-gov",
from = "provisioning", to = "gov",
axis = 'Dependency, state = 'Linked,
dep_kind = 'Conformist, mutual = false }'Governancees un DAG.bond validatelo recorre e informa de un ciclo como error.'Dependencyes un plano. No se recorre buscando ciclos: un par mutuo sería legal — por tipo, no por excepción.'Merge: el nodo gana por id; el resto se hereda del dominio.
Conclusiones
- Tipa la composición, no solo los recursos. En las aristas viven los incidentes.
- La ausencia tiene que ser explícita.
'pending "why" "TICKET-42"es mejor que un campo que falta. - La disciplina es recursiva. Componentes → fórmulas → infraestructura → proyectos y sus bonds.
- Las costuras necesitan sus propios tests. Config ↔ Rust, plan ↔ nube, tipo ↔ significado.
- Los agentes proponen; los contratos, los checks y las personas disponen. Ten claro dónde acaba cada garantía.
Y si tipa, todavía tiene que ser verdad.
Cifras
| Rust | 28 crates en el workspace · ~270k LOC |
| Nickel | 984 ficheros · ~71k LOC |
| Nushell | 895 ficheros · ~199k LOC |
| Workspace de producción | libre-wuji · Hetzner fsn1 · 71 definiciones de componentes |
| Proveedores de cómputo | Hetzner · UpCloud · AWS · DigitalOcean · local |
| Bus | NATS JetStream · payloads JSON · structs serde |
| Auditoría | ops.audit.* → historial firmado en Radicle |
¿Por qué no Terraform / Pulumi / CUE / KCL / Crossplane?
| Terraform OpenTofu | Pulumi | CUE | KCL | Crossplane | provisioning | |
|---|---|---|---|---|---|---|
| Recursos tipados | ● esquemas de proveedor | ● tipos del lenguaje anfitrión | ● unificación | ● esquemas + check | ● CRD / OpenAPI | ● contratos Nickel |
| La composición es un valor declarado | ◐ implícita por referencias | ○ construida ejecutando código | ◐ datos, sin ejecutor | ◐ datos; la ejecuta KusionStack | ◐ Compositions, orden eventual | ● DAG de fórmula + composición |
| Política sobre el grafo política de error · paralelismo · health gates | ○ -parallelism global | ◐ escrita en código | ○ — | ○ no está en el lenguaje | ◐ readiness checks | ● on_error · max_parallel · health_gate por nodo |
| Aspectos transversales explícitos | ○ tags, convención | ○ convención | ◐ codificable | ◐ codificable | ○ convención | ● 6 concerns tipados, 'pending + ticket |
| Grafo ilegal rechazado antes de ejecutar nada | ● refs + ciclos en validate | ◐ preview ejecuta el programa | ● lo que codifiques | ● lo que codifiques | ○ se descubre al reconciliar | ◐ refs en tiempo de config · ciclos solo en Rust |
| Drift: reconciliación continua | ◐ plan muestra el drift | ◐ refresh | ○ — | ○ — | ● es el modelo | ◐ gates + eventos · sin bucle de reconciliación |
| Ecosistema · proveedores | ● miles | ● grande | ◐ creciendo | ◐ CNCF sandbox | ● grande | ◐ 5 en el catálogo · cualquiera por contrato — un script, tu propia infra — comprobado por una suite de conformidad · un solo mantenedor |
"Esto se puede hacer en cualquier lenguaje"
Sí — la disciplina viaja
- La composición se tipa en Nickel y la ejecuta Nushell. Porta el ejecutor a Go y los contratos siguen valiendo.
- Ese es el sentido de la charla: un hábito de Rust, exportado a la parte del sistema que no es Rust.
- Y Rust no me salvó de un
topological_sorterróneo. Lo hicieron los tests.
Lo que aporta Rust — todo en un mismo compilador
Tipos suma + match exhaustivo | estados ilegales imposibles de construir — NodeKind, no Option × Option |
Result + #[non_exhaustive] | el fallo forma parte de la firma — ComputeError |
Send + Sync + 'static | compartir un proveedor entre tareas, comprobado en compilación |
| Sin GC · un único binario estático | daemons que no pueden atascarse: ops-controller, ncl-sync, un vault post-cuántico |
serde en la frontera | la costura tipada en ambos sentidos — y ves por dónde se filtra |
Federación (ADR-042)
- Los proyectos declaran Integration Modes; los contratos se distribuyen como artefactos OCI (
application/vnd.ontoref.mode.v1) víaoras. - Los workspaces los consumen como imports tipados.
- El impacto entre proyectos es un BFS acotado:
impact_graph(slug, node, max_depth), profundidad ≤ 5.
Demo de respaldo — salidas reales
nickel: as written
✓ contracts satisfied — nothing touched a server
os ← ∅ · runc ← os · crio ← runc · kubernetes ← crio
nickel: runc → rnuc
✗ contract broken
Formula 'rustlab-live' has invalid depends_on:
node 'crio' depends_on unknown 'rnuc'
nickel: os depends_on kubernetes
✓ contracts satisfied ← the cycle passes
nickel: … } | Acyclic
✗ contract broken
Formula 'rustlab-live' has a dependency cycle
through: os, runc, crio, kubernetesrust: as in workflow.rs
error: Circular dependency detected in workflow
property FAILED — case 0: acyclic graph of
4 nodes rejected: Circular dependency detected
rust: in-degree counted on the task
order: runc → crio → kubernetes → cilium
property: 1000 random DAGs, every edge respected ✓Enlaces
Infraestructura
Comunidad
Protocolo · los dominios gov y custody, parte de ontoref
· lecturas

