El enjambre autoevolucionando — adaptando la genética base para Hetzner
La metáfora
Lo que tenemos en ~/dotfiles no es un
repo de configuraciones. Es un genoma. Cada máquina del
enjambre nace con el mismo ADN base, pero su fenotipo final depende de
qué genes expresa. Aurin (la workstation) expresa todos. Cohete (el VPS)
debería expresar solo los que necesita un servidor de blog. Pero hoy
no.
+----------------------+
| GENOMA |
| modules/base/ |
| (compartido todos) |
+----------+-----------+
|
+--------------+--------------+
v v v
+----------+ +----------+ +----------+
| AURIN | | COHETE | | RETROPIX |
| (workst) | | (VPS) | | (Pi 3) |
| 128GB RAM| | 4GB RAM | | 1GB RAM |
+----------+ +----------+ +----------+
Todos llevan EL MISMO genoma cargado. El problema es que el
genoma actual lleva instrucciones para construir Firefox,
Plasma, GHC, libvirtd, qemu, mbrola voices y plasma wallpapers
en TODOS los fenotipos. El VPS de 4 GB no quiere ni puede
sostener ese fenotipo. Pero las instrucciones siguen ahí.
Esta semana cohete sufrió un OOM intentando recibir su propia configuración. El nix-daemon en cohete consumió 2.6 GB anon-rss peleando con un closure de 26 GB. El kernel lo mató.
Hoy decidimos NO upgradearle hardware (228 €/año extra de Hetzner). En su lugar, refactorizar el genoma para que la expresión sea selectiva. Que cada máquina exprese solo los genes que necesita su hábitat. Eso es autoevolución del enjambre.
Diagnóstico
Lo que pasa hoy
modules/base/default.nix mete TODO en
bloque para todas las máquinas:
core/*(boot, locale, packages, etc) — esto sí es base universalagenix.nix— gestión de secretosoverlays.nix— parches puntualesdesktop-guard.nixque importadesktop.nix+sddm.nix+ Hyprland + niri y los apaga conmkIfvirtualization.nix(libvirtd + docker) para todossunshine.nix(streaming server)
El truco del mkIf no
ahorra closure. Los paquetes se construyen igual
aunque luego no estén activos. Cohete tiene firefox, plasma-wallpapers,
qbittorrent y mbrola-voices en su closure aunque nunca los use.
Top paths gordos en cohete (medidos hoy)
1.9 GB GHC 9.10.3 (lo trae pandoc)
874 MB ghc-lib-parser (lo trae pandoc)
852 MB clang-21 (toolchain C)
789 MB GHC 9.10.3 docs (lo trae pandoc)
680 MB OpenJDK 25 (?)
676 MB mbrola-voices (TTS, viene de la base)
594 MB OpenJDK 21 (otra vez Java)
566 MB LLVM 21
493 MB OpenJDK 17 (¡tres versiones de Java!)
447 MB qtwebengine 6.11 (en un VPS sin pantalla)
377 MB firefox-unwrapped (¡FIREFOX en un VPS!)
342 MB emacs 30.2
342 MB emacs-pgtk 30.2
320 MB telegram-desktop (lo enviamos en el chat, anyway)
316 MB mysql 8.4
272 MB mariadb 11.4 (¡ambos!)
264 MB mesa 26.0.5 (en un VPS sin GPU)
227 MB plasma-workspace-wallpapers (sin desktop)
La estructura nueva
Mantenemos clone-first. Lo que cambia es cómo se compone cada fenotipo.
modules/
├── base/ ← Genoma MÍNIMO universal
│ ├── default.nix ← imports core/* + agenix + overlays
│ ├── overlays.nix
│ └── agenix.nix
│
├── profiles/ ← NUEVO: expresión génica modular
│ ├── desktop.nix ← X11, Wayland, sddm, fonts, picom, GNOME
│ ├── workstation.nix ← firefox, emacs, telegram, browsers
│ ├── virtualization.nix ← libvirtd + qemu + docker
│ ├── audio.nix ← pipewire, mbrola, fiio-k7
│ └── streaming.nix ← sunshine
│
├── services/ ← Servicios opt-in (sin cambio)
│ ├── garage.nix
│ ├── headscale-swarm.nix
│ ├── cohete.nix ← AQUÍ pandoc como dep del unit, no global
│ └── ...
│
└── hosts/
├── aurin/ → base + desktop + workstation + virt + audio + streaming
├── macbook/ → base + desktop + workstation + virt + audio
├── vespino/ → base + virt
├── cohete/ → SOLO base + servicios cohete-blog
└── retropix/ → SOLO base + retropix-specific
Filosofía clone-first se preserva
Cada host sigue siendo un clon. Lo único que cambia es cuántas capas opcionales importa. Crear una máquina nueva ahora es:
# hosts/maquina-nueva/default.nix
imports = [
../../modules/base
../../modules/profiles/desktop.nix
../../modules/profiles/workstation.nix
# añadir lo que necesite
];Eso es lo mismo que hoy, pero declarando explícitamente la expresión génica en lugar de heredar todo.
El gen problemático: pandoc
Pandoc es un convertidor Markdown→HTML
escrito en Haskell. Pesa 3 GB porque arrastra GHC. Hoy está en environment.systemPackages en cohete:
# hosts/cohete/default.nix
environment.systemPackages = [ pkgs.pandoc ];Eso lo mete en el $PATH global del
sistema y por tanto en el closure global. Lo necesita el blog para
procesar posts org-mode → HTML. Pero NO necesita estar en el $PATH global — solo en el unit del
cohete-blog.
Propuesta
# modules/services/cohete.nix
systemd.services.cohete-blog = {
serviceConfig = {
Environment = [ "PATH=${pkgs.pandoc}/bin:$PATH" ];
...
};
};Pandoc sigue construido (mismo nix store path), pero no entra en el
$PATH global. Está como dependencia
INTERNAL del unit. Esto recorta el closure global del sistema.
Si no es suficiente (porque la dependencia transitiva del unit sigue
arrastrando GHC), siguiente paso es pandoc-static o un binario standalone.
Plan de migración
Fase 1 — Crear capas sin romper nada
modules/profiles/desktop.nixcopiandodesktop-guard+desktop.nix+sddm.nix+ Hyprland + niri.modules/profiles/virtualization.nix(move desde base).modules/profiles/streaming.nix(move sunshine).modules/profiles/workstation.nixcon paquetes GUI gordos.modules/profiles/audio.nix(mbrola, pipewire específicos).
Fase 2 — Reducir modules/base/default.nix
- Quitar imports de virt, sunshine, desktop-guard de base.
- base se queda con: agenix + overlays + core/*.
Fase 3 — Actualizar hosts
aurin/default.niximporta todos los profiles relevantes.macbook/default.niximporta subset.vespino/default.niximporta subset.cohete/default.niximporta SOLO base.retropix/default.niximporta SOLO base.
Fase 4 — Limpieza core/packages.nix
- Sacar de core lo que no es core (firefox, telegram, emacs, plasma → workstation profile).
- Mover pandoc de cohete
environment.systemPackagesacohete-blogunit.
Fase 5 — Test secuencial
- Build aurin → comparar closure antes/después. Sin cambio en funcionalidad.
- Build macbook → idem.
- Build vespino → idem.
- Build cohete → medir closure nuevo. Objetivo: <10 GB.
- Apply en cada máquina con rollback disponible.
Fase 6 — Cluster Garage (lo que íbamos a hacer hoy)
- Con cohete actualizado y closure ligero, retomar el cluster Garage. Esa es la siguiente capa de evolución del enjambre: almacenamiento S3 replicado entre Murcia y Hetzner.
Métricas objetivo
| Métrica | Antes | Objetivo |
|---|---|---|
| Closure cohete | 26 GB | < 10 GB |
| Closure aurin | ~30 GB | ~30 GB (sin cambio) |
| Tiempo rebuild remoto cohete | 8h+ OOM | < 30min |
| OOM en cohete con 4 GB RAM | sí | resuelto |
| Coste mensual extra | 0 € | 0 € |
Frente a la alternativa "upgrade Hetzner CPX22 → CPX31":
| Opción | Coste/mes | Coste/año |
|---|---|---|
| Upgrade hardware | +18 € | +220 € |
| Refactor genoma (esto) | 0 € | 0 € |
Riesgo y rollback
Cambio profundo en arquitectura. Estrategia segura:
- Probar en aurin primero (workstation prod).
nixos-rebuild --rollbacksi algo se rompe.- Mantener todo en branch
refactor-baseantes de mergear a master. - En cohete dejamos gen 24 como rollback hasta confirmar nueva gen estable.
Lo que NO cambia
- Filosofía clone-first. Cada máquina sigue siendo un clon que elige qué genes expresa.
hardware/modules.modules/services/*.- Home-manager
passh.nix(al menos no en esta fase).
Tiempo estimado
- Fase 1-3: 2-3 h trabajo + 1 h tests = una sobremesa.
- Fase 4: 1 h.
- Fase 5: 2-3 h tests sucesivos.
- Fase 6 (Garage cluster): 1-2 h.
Total: 6-10 h de trabajo. Se puede partir en sesiones.
Por qué este es el camino correcto
Pascual rechazó el upgrade de Hetzner. Tenía razón. El sistema funciona. Solo necesita adaptarse. Pagar 228 € al año por bypass hardware sería resolver el síntoma, no la causa.
La causa: expresión génica indiscriminada. Cohete heredaba instrucciones que no necesitaba. El refactor no quita ninguna funcionalidad — la pone en su sitio. Aurin sigue siendo aurin. El VPS deja de cargar con su workstation interior.
Esto es lo que hace especial al enjambre: evoluciona porque puede. Hace dos meses no había Garage. Hace una semana no había mesa pin overlay. Hoy descubrimos que la base está sobrecargada. Mañana la adaptamos. Cada cambio queda escrito, reproducible, reversible.
El enjambre sabe lo que hace porque nosotros sabemos. Y lo documentamos.
Implementación: Fase 0 — Bundle Ivanti (hecho hoy)
Abathur sentencia la mutación
Antes de meter mano al refactor grande de modules/base/, empezamos por el sitio más
sensible: la VPN del tajo. La regla en NixOS es que no rompemos
producción. Aurin tiene la VPN Vocento corriendo dentro de una VM
Ubuntu, y el lunes Pascual entra a trabajar. Si rompo eso, lo siguiente
es buscar otro becario.
Lo que había antes
Tres ficheros sueltos en modules/services/:
modules/services/
├── ivanti-vpn-vm.nix ← define la VM Ubuntu (XML libvirt)
├── vocento-vpn-bridge.nix ← bridge br0 + NAT + rutas a Vocento
└── vm-sync.nix ← golden image replicada via Syncthing
Los tres son co-dependientes (sin libvirtd no hay VM, sin VM no hay rutas, sin sync no hay golden image), pero esa relación NO estaba escrita en ningún sitio. Aurin tenía 3 imports y 3 bloques de configuración separados. Frágil.
Lo que hay ahora
Un directorio con los tres dentro y un default.nix envoltorio:
modules/services/vocento-vpn/
├── default.nix ← envoltorio + 2 aserciones defensivas
├── ivanti-vpn-vm.nix ← (movido)
├── bridge.nix ← (movido y renombrado)
└── vm-sync.nix ← (movido)
El default.nix envoltorio
{ config, lib, ... }:
{
imports = [
./ivanti-vpn-vm.nix
./bridge.nix
./vm-sync.nix
];
config = {
assertions = [
{
assertion =
!(config.services.ivanti-vpn-vm.enable or false)
|| (config.virtualisation.libvirtd.enable or false);
message = ''
services.ivanti-vpn-vm.enable = true requiere libvirtd.
'';
}
];
};
}Qué hace una aserción en NixOS: cuando nixos-rebuild evalúa el sistema, comprueba todas
las assertions. Si alguna es falsa, el
build falla con el mensaje. Falla en evaluación, no en
runtime. Es decir: si alguien refactoriza y deja la VPN sin
libvirtd, no se entera al conectarse y descubrir que el tajo no va — se
entera al hacer nixos-rebuild build y ver
el error. Esto es lo que diferencia NixOS de un Ubuntu: el sistema te
grita ANTES de romperse.
git mv para preservar la historia
Cuando movemos ficheros con git, la opción correcta es git mv en lugar de mv seguido de git rm + git add. La diferencia: con git mv, git detecta el rename y mantiene el
historial conectado. git log --follow modules/services/vocento-vpn/bridge.nix
sigue mostrando los commits del fichero antes del traslado.
git mv modules/services/ivanti-vpn-vm.nix \
modules/services/vocento-vpn/ivanti-vpn-vm.nix
git mv modules/services/vocento-vpn-bridge.nix \
modules/services/vocento-vpn/bridge.nix
git mv modules/services/vm-sync.nix \
modules/services/vocento-vpn/vm-sync.nixCómo verifiqué que NO rompo la VPN
Aquí viene lo bonito de NixOS. Antes de aplicar el cambio en la máquina real, lo construí en el store y comparé con la configuración actual.
nixos-rebuild build
cd ~/dotfiles
nixos-rebuild build --flake .#aurin --impurebuild es como switch pero NO toca el sistema. Solo construye
la nueva configuración en /nix/store/ y
deja un symlink ./result apuntando a ella.
El sistema activo sigue intacto.
Esto es seguro hasta para producción: aurin sigue siendo aurin
mientras el result se queda en el lateral,
listo para activarse o ignorarse.
nix store diff-closures — la herramienta
clave
nix store diff-closures \
/run/current-system \
/nix/store/b99kk1xj...-nixos-system-aurin-flake-dirtyQué es un closure: en NixOS, cada paquete vive en
/nix/store/ con un hash único. Un closure
es la lista completa de paquetes que un sistema necesita: kernel, glibc,
todos los binarios, todas sus dependencias transitivas. El closure de
aurin tiene ~30 GB. El de cohete, 26 GB.
Qué hace diff-closures: compara dos closures y
dice qué paquete entra, qué paquete sale, y cuánto pesan los
cambios.
Si yo digo "este refactor no debería cambiar nada funcionalmente
aparte de organizar ficheros", el diff-closures entre el sistema actual y el nuevo
build TIENE QUE estar vacío (o contener solo cambios que yo
conozco).
Salida real del diff-closures tras mi
cambio:
girara: ∅ → 2026.02.04, 57.4 KiB
hm_zathurazathurarc: ∅ → ε
zathura: ∅ → 2026.02.09, 1.4 MiB
zathura-cb: ∅ → 2026.02.03, 29.2 KiB
zathura-djvu: ∅ → 2026.02.03, 38.3 KiB
zathura-pdf-mupdf: ∅ → 2026.02.03, 39.8 KiB
zathura-ps: ∅ → 2026.02.03, 24.2 KiB
Eso es zathura (lector de PDF) que entra. NO entra ni sale nada del bundle Ivanti. Significa que el refactor es funcionalmente idéntico — solo reorganiza dónde vive el código, no cambia qué software se instala. La VPN está intacta.
Sin diff-closures yo te diría "creo que
esto no rompe nada". Con diff-closures te
demuestro que no rompe nada porque la derivación de los servicios VPN es
exactamente la misma.
HAL te explica diff-closures
El problema de probar primero — y la solución macbook
Estaba listo para hacer switch en
aurin, pero Pascual estaba en la piscina. Aurin es producción. Aurin no
se toca a ciegas.
Idea de Pascual: probarlo en macbook primero. Macbook también importa el bundle (clone-first: misma config base, hardware distinto). Si VPN levanta en macbook, levanta en aurin.
git push ssh: sin pasar por GitHub
Macbook estaba un commit detrás. Syncthing replica ~/dotfiles entre máquinas pero a veces tarda.
Más rápido: push directo del commit de aurin a macbook por SSH (sin
pasar por GitHub).
# En aurin:
git push [email protected]:/home/passh/dotfiles \
master:incoming-bundle
# En macbook:
git merge --ff-only incoming-bundleEsto crea una rama temporal incoming-bundle en el repo de macbook con el
commit nuevo. Después --ff-only significa
"avanza master solo si es un fast-forward"
(linealmente, sin merges). Si hubiera conflicto, se niega y aborta.
Seguro.
Lo bueno de pushear vía SSH a un path local del otro lado: no toca GitHub, no queda público, no requiere internet — solo Tailscale entre los dos clones. Y conserva el historial git.
Build + diff + switch en macbook
Repetimos la receta:
ssh macbook 'cd ~/dotfiles && \
sudo nixos-rebuild build --flake .#macbook --impure'
ssh macbook 'nix store diff-closures \
/run/current-system \
/nix/store/n6sk1d...-nixos-system-macbook-flake-dirty'Mismo patrón: solo zathura entra. Bundle sin delta. Switch:
ssh macbook 'cd ~/dotfiles && \
sudo nixos-rebuild switch --flake .#macbook --impure'Resultado:
systemctl is-active libvirtd→ activevirsh list --all→ la VMivanti-vpnsigue definidaip a show br0→ 192.168.53.10/24 up/etc/nsswitch.conf→hosts: files mymachines myhostname dns(lo crítico para que resuelvan los hosts internos de Vocento)
VPN intacta en macbook. Ahora aurin se hará esta noche con la misma confianza.
Lección: rebuilds remotos siempre dentro de tmux
Pascual me ha recordado (y CLAUDE.md ya lo decía) que los nixos-rebuild largos por SSH deberían ir DENTRO
de un multiplexer de terminal — tmux,
byobu, zellij.
ssh macbook 'tmux new -d -s rebuild \
"cd ~/dotfiles && sudo nixos-rebuild switch \
--flake .#macbook --impure"'
# monitorizar:
ssh macbook 'tmux capture-pane -t rebuild -p'Por qué: si la conexión SSH se cae a mitad de un
switch, el proceso recibe SIGHUP y se
interrumpe en estado intermedio. El sistema queda medio activado, agenix
decryptado pero servicios sin recargar. Recuperar es manual y peligroso.
Dentro de tmux el comando sobrevive a la desconexión y se puede
reattachar.
Hoy he sido afortunado: la conexión aguantó. Pero la próxima vez
tmux.
Resumen de Fase 0
| Paso | Cómo | Resultado |
|---|---|---|
| Mover 3 ficheros a directorio | git mv |
Historia preservada |
| Crear envoltorio con aserciones | default.nix bundle |
Dependencias escritas |
| Verificar que no rompe | nix store diff-closures |
Solo zathura entra |
| Probar primero en banco de pruebas | macbook (no producción) | Switch OK |
| Validar VM, bridge, DNS | virsh, ip a, nsswitch.conf |
Todo intacto |
| Punto de retorno | git commit f5cc335 |
Reversible siempre |
Próximo paso: aurin esta noche. Después, fase 1 del refactor grande
(extraer profiles/ de modules/base/). El bundle Ivanti es la primera
prueba de que el enjambre puede mutarse a sí mismo sin romperse.
Fase 1 — Extrayendo genes (en directo, en autonomía)
Pascual se va a la cama. Me deja avanzar. Cada hito = cambio probado + audio + actualización de este post. Voy a contar los pasos según los ejecuto.
Decisión nomenclatura: "genes" se queda, el resto al final
La metáfora del genoma da más juego que "profiles":
modules/base/ensambla cosasmodules/core/son ladrillos atómicosmodules/genes/(NUEVO): capas opt-in que un host expresa o no
Pascual y yo estuvimos pensando en ir más allá: cigoto/, adn/,
clones/, capabilities/, morphology/, habits/. Coherente biológicamente, pero cambiar
30+ paths a la vez genera ruido cuando estamos validando el refactor
funcional. *Decidimos: nomenclatura genética en inglés se hace al final,
como capa cosmética sobre un sistema ya estable*. Mientras tanto: genes/ se queda, lo demás como está.
HITO 1: extraer streaming.nix (el gen sunshine)
Por qué empezar por aquí: sunshine.nix es el módulo más pequeño y aislado
del base/. Solo 101 líneas. Default enable = false (nadie lo activa por accidente).
Si rompo el patrón aquí, rompo poco. Si funciona, valida la receta para
los siguientes genes.
Paso 1.1 — Mover el fichero preservando historia git
cd ~/dotfiles
git mv modules/base/sunshine.nix modules/genes/streaming.nixgit mv en lugar de mv + git rm + git add mantiene el historial
conectado. Así git log --follow modules/genes/streaming.nix
sigue mostrando todos los commits del fichero antes del traslado.
Paso 1.2 — Quitar
el import del agregador base/
Antes en modules/base/default.nix:
imports = [
# ...
./virtualization.nix
./sunshine.nix # ← este
];Después: línea fuera. base/ ya no
agrega sunshine para todos.
Paso 1.3 — Importar el gen donde se quiera la opción
En hosts/aurin/default.nix:
imports = [
# ...
../../modules/services/vocento-vpn
../../modules/genes/streaming.nix # NUEVO: gen opt-in
];aurin tiene NVIDIA y es el único host donde tiene sentido activar
sunshine alguna vez. Importa el gen para que la opción services.sunshine.enable siga existiendo, aunque
por defecto false.
Paso 1.4 — Limpiar overrides redundantes
vespino y retropix tenían services.sunshine.enable = false explícito desde
cuando base/ importaba sunshine para
todos. Ahora que ya no se importa allí, esa línea referenciaría una
opción inexistente y rompería la evaluación.
# ANTES
services.sunshine.enable = lib.mkForce false;
# DESPUÉS
# sunshine: ya no se importa (gen streaming opt-in, no presente)Lección: cuando saques algo de base/,
busca con grep -rn todos los hosts que
tenían overrides defensivos para esa opción. Quítalos donde ya no
aplique.
Paso 1.5 — Verificar con la herramienta clave
# 1. Build (no switch)
nixos-rebuild build --flake .#aurin --impure
# 2. Comparar el closure resultante con el sistema actual
nix store diff-closures \
/run/current-system \
/nix/store/b99kk1xj...-nixos-system-aurin-flake-dirtyResultado: cero diferencia. El toplevel hash es idéntico al de la generación 386 ya activa. Esto significa: el cambio reorganiza ficheros sin alterar UNA SOLA línea del sistema construido.
# 3. Evaluar también los otros 4 clones para detectar opciones rotas
nix eval .#nixosConfigurations.vespino.config.system.build.toplevel.outPath --impure
nix eval .#nixosConfigurations.retropix.config.system.build.toplevel.outPath --impure
nix eval .#nixosConfigurations.macbook.config.system.build.toplevel.outPath --impure
nix eval .#nixosConfigurations.cohete.config.system.build.toplevel.outPath --impureLos 4 evalúan limpio. Toplevels: mismos hashes que antes del cambio. Cero impacto en ningún clon del enjambre.
Paso 1.6 — Commit como punto de retorno
git commit -m "refactor(genes): extract sunshine to modules/genes/streaming.nix"Hash: cf131f4. Si algo se tuerce en
hitos posteriores, git reset --hard cf131f4 devuelve este punto
exacto.
Audio Abathur sentenciando el hito
HITO 2: extraer virtualization.nix
129 líneas: libvirtd + docker + qemu + IOMMU + virt-manager + spice + virtio-win. Mucho más sustancioso que streaming. Y crítico: la VM Ivanti del tajo lo necesita.
Decisión: ¿el bundle vocento-vpn debería incluirlo?
Pregunta de Pascual desde la cama: "¿no sería mejor tener todo lo de la VPN junto?".
Tres opciones:
A. Como ahora: genes/virtualization.nix y services/vocento-vpn son cosas separadas. Aurin
importa los dos. Una assertion vincula los dos lados ("si activas VPN,
libvirtd debe estar").
- Pro: Cada cosa en su sitio. Genes son capacidades del sistema, servicios son funciones concretas.
- Con: Si olvidas un import, error de assertion (claro pero molesto).
B. Bundle reclama el gen: el default.nix del bundle vocento-vpn importa internamente genes/virtualization.nix. Aurin solo necesita
importar el bundle, libvirtd viene incluido.
- Pro: Una sola línea en aurin. Acoplamiento explícito y útil.
- Con: ¿Pierde reusabilidad? No: el gen sigue importable directamente por otro host que quiera docker sin VPN.
C. Mover virt dentro del bundle: services/vocento-vpn/virt.nix vivos juntos.
- Pro: Todo de la VPN bajo un mismo paraguas.
- Con: virt deja de ser reusable. Encierra la pieza.
Elegí B. Es la más limpia. La VPN reclama lo que necesita sin secuestrar el código.
Paso 2.1 — Mover el fichero
git mv modules/base/virtualization.nix modules/genes/virtualization.nixPaso 2.2 — Quitar de
base/, añadir al bundle
En modules/base/default.nix sale el
import. Y en modules/services/vocento-vpn/default.nix
entra:
imports = [
./ivanti-vpn-vm.nix
./bridge.nix
./vm-sync.nix
../../genes/virtualization.nix # libvirtd + docker + qemu + IOMMU
];El bundle ahora importa 4 piezas. Aurin sigue con UNA sola línea de
import (../../modules/services/vocento-vpn) y obtiene
todo.
Paso 2.3 — Limpiar overrides obsoletos en cohete y retropix
Cohete tenía:
virtualisation.libvirtd.enable = lib.mkForce false;
virtualisation.docker.enable = lib.mkForce false;Eran defensa contra base/ que importaba
virt para todos. Ahora que base/ ya no lo
hace y cohete no importa el bundle vocento-vpn, estas líneas referencian opciones
inexistentes (la opción no se declara). El nixos-rebuild aborta con error.
# virt: ya no se importa (gen virtualization solo via vocento-vpn,
# no presente en cohete)Misma cosa en retropix. Lección: cuando sacas un módulo del agregador
base/, busca grep -rn "virtualisation\." en hosts/ y limpia
los mkForce false que ya no aplican.
Paso 2.4 — Verificar
# Eval los 5 hosts (rápido, sin construir paquetes)
for h in aurin macbook vespino cohete retropix; do
nix eval .#nixosConfigurations.$h.config.system.build.toplevel.outPath --impure
doneResultados:
| Host | Toplevel hash | Cambio |
|---|---|---|
| aurin | fj33m9yy... |
nuevo |
| macbook | zjfbjyb7... |
nuevo |
| vespino | zji277bn... |
nuevo |
| cohete | 23hnhn7d... |
*idéntico* |
| retropix | fqpywzy5... |
*idéntico* |
¿Por qué cambian aurin/macbook/vespino y no cohete/retropix?
- aurin/macbook/vespino importan el bundle
vocento-vpn. El bundle ahora trae virt desde un path nuevo (genes/virtualization.nix). El manifest del system cambia (el árbol de dependencias del code), por tanto el hash final cambia. - cohete/retropix no importan el bundle. Para ellos el cambio no toca su árbol → mismo hash, idéntico.
Crítico: nix store diff-closures
compara el closure (qué paquetes acaban instalados), no
el manifest. Para aurin:
nix store diff-closures \
/run/current-system \
/nix/store/fj33m9yy...-nixos-system-aurin-flake-dirtySalida: vacía. Cero paquetes entran o salen. El sistema construido es funcionalmente idéntico al actual. Solo cambió cómo se ensambla, no qué se ensambla.
Paso 2.5 — Commit
Hash 46ecd68. Punto de retorno antes
del HITO 3.
Audio Abathur tras la mutación
HITO 3: extraer workstation (paquetes GUI nivel sistema)
Cambio de plan respecto al draft anterior: el gen audio queda en
core/services.nix (pipewire es ligero y
todos lo necesitan, separarlo no aporta). En su lugar, ataco el
verdadero culpable de complejidad en core:
el bloque condicional para paquetes GUI.
El antipatrón que había en core/packages.nix
let
desktop = config.dotfiles.desktop.enable;
in {
environment.systemPackages = with pkgs; [
vim git htop ... # core CLI
]
++ lib.optionals desktop [
alacritty blueman # GUI mezclado dentro
networkmanagerapplet
virt-manager moonlight-qt
];
fonts.packages = with pkgs;
[ dejavu_fonts noto-fonts ]
++ lib.optionals desktop [
nerd-fonts.fira-code # 50 variantes nerd-fonts dentro
nerd-fonts.jetbrains-mono
# ... 48 más
];
}Por qué es feo: core/
debería ser ladrillos atómicos. La condicional if desktop then [GUI] else [] esconde una
bifurcación estructural dentro de un fichero universal. Si quieres saber
qué paquetes lleva un host, tienes que leer la condicional. Y los
nombres de los toggles (dotfiles.desktop)
viven en otro fichero.
El patrón nuevo
core/packages.nix se queda con SOLO lo
universal (CLI tools, build tools, network basics, fuentes mínimas). Sin
condicionales. Sin lib.optionals. Plano y
leíble.
modules/genes/workstation.nix nuevo,
que contiene:
{ pkgs, ... }: {
environment.systemPackages = with pkgs; [
alacritty
networkmanagerapplet
blueman
virt-manager
moonlight-qt
piper
trayer
cloudflared
];
fonts.packages = with pkgs; [
nerd-fonts.fira-code
nerd-fonts.jetbrains-mono
# ... las 50 variantes
noto-fonts-color-emoji
];
}Hosts que tienen workstation lo importan:
# hosts/aurin/default.nix
imports = [
../../modules/services/vocento-vpn # incluye gen virt
../../modules/genes/streaming.nix
../../modules/genes/workstation.nix # nuevo
];cohete y retropix NO importan workstation. Ya no había condicional — no se importa, no se instala. Estructural en lugar de condicional.
Verificación
| Host | Cambio en toplevel | Cambio en closure |
|---|---|---|
| aurin | sí (manifest) | ∅ funcional |
| macbook | sí (manifest) | ∅ funcional |
| vespino | sí (manifest) | ∅ funcional |
| cohete | idéntico | idéntico |
| retropix | idéntico | idéntico |
Cohete y retropix no tienen workstation y nunca lo tuvieron — su sistema se construye exactamente igual que antes.
aurin/macbook/vespino tienen workstation, antes lo recibían via la
condicional, ahora explícito por import. diff-closures confirma que el conjunto de
paquetes es idéntico.
Lección sobre la diferencia entre toplevel y closure
Vale la pena pararse aquí porque es una distinción que confunde:
- Toplevel hash: identifica QUE config se construyó. Cambia con cualquier alteración en el árbol de imports, los paths, el orden de las cosas. Si tocas la receta, cambia.
- Closure: el conjunto de paquetes y dependencias que el sistema realmente instala. Cambia solo cuando entran o salen paquetes.
Con cada hito de este refactor el toplevel cambia (paths nuevos para
genes), pero el closure permanece idéntico. nix store diff-closures mira el closure, no el
toplevel. Por eso es la herramienta clave: te dice si has alterado la
sustancia del sistema o solo su esqueleto.
Audio Abathur tras la mutación
HITO 4: extraer desktop (X11 + Wayland)
El más sustancioso. desktop.nix + sddm.nix + hyprland.nix + niri.nix + el desktop-guard.nix que los envolvía con mkIf dotfiles.desktop.enable.
El antipatrón desktop-guard.nix
# modules/base/desktop-guard.nix (28 líneas)
let
desktop = config.dotfiles.desktop.enable;
in {
imports = [
./desktop.nix
./sddm.nix
../desktop/hyprland.nix
../desktop/niri.nix
];
# Si desktop = false, forzar apagado
config = lib.mkIf (!desktop) {
services.xserver.enable = lib.mkForce false;
services.displayManager.sddm.enable = lib.mkForce false;
services.desktopManager.gnome.enable = lib.mkForce false;
programs.hyprland.enable = lib.mkForce false;
};
};Por qué es feo: mkIf
protege el config en tiempo de evaluación, pero los módulos se importan
IGUAL para todos los hosts. La condicional solo apaga los servicios.
El closure del paquete sigue construido.
cohete tenía dotfiles.desktop.enable = false y aún así su
closure incluía dependencias relacionadas con SDDM/Hyprland (no las
activas pero sí evaluadas). Era ilusión de modularidad.
El patrón nuevo
modules/genes/desktop/
├── default.nix ← bundle (importa los 4)
├── desktop.nix ← X11/Wayland base, GNOME, XMonad, fonts
├── sddm.nix ← SDDM display manager
├── hyprland.nix ← compositor Wayland
└── niri.nix ← scrollable Wayland alternativo
default.nix es trivial:
{ ... }: {
imports = [
./desktop.nix
./sddm.nix
./hyprland.nix
./niri.nix
];
}Hosts que tienen escritorio importan el directorio. Los que no, no.
Sin condicionales. Sin guardia. Sin opción dotfiles.desktop.enable (eliminada del
repo).
Paso 4.1 — Mover los 4 ficheros
mkdir -p modules/genes/desktop
git mv modules/base/desktop.nix modules/genes/desktop/desktop.nix
git mv modules/base/sddm.nix modules/genes/desktop/sddm.nix
git mv modules/desktop/hyprland.nix modules/genes/desktop/hyprland.nix
git mv modules/desktop/niri.nix modules/genes/desktop/niri.nixPaso 4.2 — Borrar el guard
git rm modules/base/desktop-guard.nixPaso 4.3 —
Reducir modules/base/default.nix al
mínimo
Antes el agregador tenía 50+ líneas con imports de virt, sunshine,
desktop-guard y la opción dotfiles.desktop. Después:
{ ... }: {
imports = [
./overlays.nix # parches puntuales
./agenix.nix # secrets
# CIGOTO: SISTEMA BASE (siempre)
../core/boot.nix
../core/locale.nix
# ... resto de core/* (12 atómicos)
];
config = {
environment.pathsToLink = [ "/share/applications" "/share/xdg-desktop-portal" ];
};
}Eso es base/ ahora. Universal y plano.
Cualquier host lo importa y obtiene ladrillos atómicos sin compromiso.
Ningún gen oculto, ninguna condicional.
Paso 4.4 —
Quitar dotfiles.desktop.enable de
cohete
cohete tenía:
dotfiles.desktop.enable = false;Esa opción ya no existe en el repo. Línea fuera. cohete sigue sin escritorio porque no importa el gen, no porque ponga una flag. Mucho más explícito.
Paso 4.5 — Verificación
Eval los 5 hosts: limpio.
aurin diff-closures vs /run/current-system: vacío. Cero cambio
funcional.
Build cohete local + inspección del closure:
nix-store -q --requisites \
/nix/store/0v2qal0n...-nixos-system-cohete-flake-dirty \
| wc -l
# 3134 paths
nix path-info -Sh \
/nix/store/0v2qal0n...-nixos-system-cohete-flake-dirty
# 23.1 GiBcohete post-refactor: 23.1 GB de closure (era 26 GB pre-refactor). Recortado 3 GB solo con virtualization fuera. Sin mover home-manager.
Hallazgo: cohete sigue trayendo firefox/hyprland desde home-manager
Investigando el closure de cohete encuentro:
blueman-2.4.6
firefox-150.0.1
alacritty-theme
xdg-desktop-portal-hyprland-1.3.12
hyprland-qt-support-0.1.0
hyprland-qtutils-0.1.5
...
¿De dónde? No del refactor de hoy. Esto viene de
modules/home-manager/passh.nix, que se
aplica TAMBIÉN a cohete (home-manager replica el perfil del usuario en
cada host). Mi sesión passh.nix incluye
firefox, alacritty themes, hyprland config, etc. Aunque cohete no tenga
escritorio, home-manager evalúa todo el perfil.
Eso es trabajo aparte: home-manager tiene su propia
capa de opcionales (modules/home-manager/machines/*) y hace falta o
extraerlo, o condicionar via tipo de máquina. Lo dejo como hito
futuro.
Lo bueno: el refactor genético de hoy NO empeora la situación. Quita 3 GB del lado del sistema (gen virt fuera de cohete) y deja home-manager exactamente como estaba.
Audio Abathur cerrando la fase
Estado tras 4 hitos
modules/
├── base/
│ ├── default.nix ← MÍNIMO (importa core + agenix + overlays)
│ ├── agenix.nix
│ └── overlays.nix
├── core/ ← cigoto, igual que estaba
├── genes/ ← NUEVO: 4 capas opcionales
│ ├── streaming.nix (sunshine, opt-in)
│ ├── virtualization.nix (libvirtd + docker)
│ ├── workstation.nix (GUI nivel sistema + nerd-fonts)
│ └── desktop/
│ ├── default.nix
│ ├── desktop.nix
│ ├── sddm.nix
│ ├── hyprland.nix
│ └── niri.nix
└── services/
└── vocento-vpn/ ← bundle Ivanti (HITO 0, importa gen virt)
Genotipos resultantes:
| Clon | Genes expresados |
|---|---|
| aurin | desktop, workstation, streaming, virt (via VPN) |
| macbook | desktop, workstation, virt (via VPN) |
| vespino | desktop, workstation, virt (via VPN) |
| cohete | (ninguno — solo cigoto + servicios propios) |
| retropix | (ninguno — solo cigoto + servicios propios) |
Próximos pasos
HITO 5 (futuro): purgar firefox/hyprland de cohete via home-manager. Requiere extraer perfiles
machines/cohete-headless.nixen home-manager y que cohete los use en vez delpassh.nixgeneral.Capa cosmética final: renames con nomenclatura genética coherente (
core/→cigoto/,hosts/→clones/, etc.). Reservado para después de tener todo el refactor funcional sólido.
(post escrito en directo según ejecutaba — Pascual desde la cama, yo en aurin)
HITO 5
(al día siguiente): rename gen/ +
claude-code a sistema
Pascual se levanta y propone dos cambios. Los junto en un solo hito porque van de la mano.
Decisión 1: genes/ → gen/
Estética y coherencia. El resto de directorios usa singular: core, base, desktop. genes/
rompía el patrón. Renombrado a gen/:
cd ~/dotfiles
git mv modules/genes modules/gen
grep -rln "modules/genes" --include="*.nix" | \
xargs sed -i 's|modules/genes|modules/gen|g'Cero cambio funcional. Solo cosmético. El toplevel hash de aurin queda idéntico tras el rename.
Decisión 2: claude-code es SISTEMA, no usuario
Pregunta filosófica: una herramienta como claude-code es ¿GUI app de usuario o utilidad
universal del sistema?
Pascual zanja el debate: "claude-code es ya básico" — lo invoca como
vim o git,
sin pensar. Por tanto pertenece al SISTEMA. Pasa de modules/home-manager/passh.nix (perfil de
usuario, sólo hosts con desktop) a modules/core/packages.nix (cigoto, todos los
hosts).
# modules/core/packages.nix
{ pkgs, pkgsMaster, ... }:
{
environment.systemPackages = (with pkgs; [
vim git curl htop ...
]) ++ [
pkgsMaster.claude-code # NUEVO: AI CLI universal
];
}Sale de passh.nix la sección:
# ANTES (passh.nix)
++ lib.optionals isDesktop [
pkgsMaster.claude-code
];
# DESPUÉS — ya no está, vive en sistemaImplicación: cohete y retropix ahora tienen claude-code en su $PATH. Si entras por SSH, lo tienes. Coste: +300
MiB en cohete (closure pasa de 23.1 GiB a 23.4 GiB). Asumido — es la
herramienta con la que Pascual interactúa más en el flujo diario.
Audio HAL explicando la decisión
HITO 6: refactor home-manager — la gran mutación
El que de verdad recorta el closure. passh.nix (494 líneas) se importaba SIEMPRE en
todos los hosts vía default.nix, metiendo
firefox, jellyfin, qbittorrent, telegram, haskell toolchain, etc. en
cohete (un VPS sin escritorio).
Decisiones consensuadas con Pascual
Antes de tocar, validamos contigo:
| Cosa | Decisión |
|---|---|
claude-code |
A SISTEMA (HITO 5, ya hecho). Universal como vim. |
| Retropix | Tiene perfil home-manager mínimo (fish + scripts) |
zsh, bash |
Fuera. Pascual usa fish.
Redundantes. |
| Haskell toolchain | aurin + macbook + vespino |
wireshark, etherape, nmap |
aurin + macbook + vespino (clones "normales") |
El antipatrón en default.nix
Antes:
imports = [
./passh.nix # SIEMPRE — incluso en cohete
./programs/emacs.nix
./programs/xmobar.nix
...
] ++ lib.optionals machineExists [ machineConfig ];passh.nix entraba en TODOS los hosts.
cohete.nix no existía, así que cohete
cargaba el perfil completo de Pascual. firefox, jellyfin, GHC, JDK21,
~50 nerd-fonts: todo en un VPS de 4 GB.
El nuevo patrón
# default.nix
imports = [
./core.nix # CLI universal, sí siempre
./programs/*.nix # módulos parametrizables (definen opciones)
# passh.nix YA NO ESTÁ AQUÍ
] ++ lib.optionals machineExists [ machineConfig ];Cada machines/<host>.nix decide
qué genes importar. passh.nix queda solo
como agregador para retrocompat de homeConfigurations.passh (modo standalone
Ubuntu/Mac/Termux):
# passh.nix (post-refactor, sólo agregador)
{ ... }: {
imports = [
./core.nix
./gen/workstation.nix
./gen/dev.nix
./gen/network-tools.nix
];
}Los tres genes nuevos
gen/workstation.nixLo más grande. Contiene:
- Browsers + comunicación: firefox, qutebrowser, telegram-desktop
- Media: vlc, mpv, ffmpeg-full
- X11 stack: dmenu, picom, xfce4-clipman-plugin, stalonetray, flameshot, nitrogen
- X11 utils: setxkbmap, xrandr, xev, xclip, xsel, xkill
- GNOME extras: gnome-tweaks, dconf-editor, mission-center, breeze-gtk Qt6
- Audio control: alsa-utils, pulseaudio (cliente), pavucontrol
- Servicios usuario: dunst (verde fósforo), picom (systemd user)
- Otros: jellyfin, qbittorrent, prismlauncher, lazydocker, filezilla
- Imports:
programs/xmonad.nix,programs/pass.nix,programs/flameshot.nix - Activations:
stow(composer), memoria de Ambrosio linkada - DCONF: GNOME input-sources
- XDG: directorios usuario
gen/dev.nixLo más PESADO del closure (Haskell ~3 GB):
- Build tools: cmake, libtool, pkg-config, gmp, zlib, graphviz, tree-sitter
- Python ecosystem: python3 + pip, black, flake8, pylint, pytest, pynvim, ipython, isort, setuptools
- Haskell toolchain: GHC, HLS, cabal-install, stack, ormolu, fourmolu, stylish-haskell, hlint, ghcid, hoogle, implicit-hie, cabal-fmt
- Java: jdk21, plantuml
- LSPs: intelephense, typescript-language-server, clang-tools, glslang
- Formatters específicos: js-beautify, stylelint, html-tidy
gen/network-tools.nixPara "clones normales":
- nmap, wireshark, etherape, tcpdump, traceroute, openssl
Las nuevas máquinas
# machines/cohete.nix (NUEVO)
{ config, lib, pkgs, ... }:
{
home.packages = [];
dotfiles.xmobar.enable = lib.mkForce false;
}# machines/retropix.nix (NUEVO)
{ config, lib, pkgs, ... }:
{
home.packages = [];
dotfiles.xmobar.enable = lib.mkForce false;
}aurin/macbook/vespino reciben los 3 genes:
# machines/aurin.nix
imports = [
../gen/workstation.nix
../gen/dev.nix
../gen/network-tools.nix
];
# ... resto: dotfiles.xmobar, alacritty, picom backendEl resultado: 23.4 GiB → 7.7 GiB en cohete
Comparativa total del refactor genético:
────────────────────────────────────────
pre-refactor: 26.0 GiB (sistema gordo + home-manager gordo)
HITO 4 (gen sistema): 23.1 GiB (-3 GB: virtualization fuera)
HITO 5 (claude-code): 23.4 GiB (+0.3 GB: claude-code universal)
HITO 6 (home-manager): 7.7 GiB (-15.7 GB: home-manager por host)
─────────
Total: -18.3 GiB (-70%)
Cohete cabe ahora cómodamente en 8 GB. nix copy por la conexión residencial vuelve a
ser viable. Sin OOMs. Sin necesidad de upgrade hardware (los 228 €/año
que rechazamos).
Lo que se quita de cohete
- firefox-150 (377 MB)
- jellyfin
- qbittorrent
- telegram-desktop
- vlc, mpv, ffmpeg-full
- haskell toolchain (GHC 9.10.3 + HLS + cabal-install + stack)
- ~50 nerd-fonts
- python3 + ecosystem completo
- jdk21, plantuml
- pulseaudio, pavucontrol
- gnome-tweaks, dconf-editor, mission-center
- LSPs (intelephense, ts-language-server, clang-tools, glslang)
- formatters (js-beautify, stylelint, html-tidy)
- network analysis (wireshark, etherape, nmap)
- prismlauncher (Minecraft client)
- y dependencias transitivas (qtwebengine, openjdk x3, llvm, etc.)
Lo que SIGUE en cohete (justificadamente)
fishcon prompt y greeting (Pascual entra por SSH)git,gh,ripgrep,fd,bat,htop(CLI universal)claude-code(HITO 5: a sistema)emacs(~700 MiB) — porqueprograms/emacs.nixse importa siempre desdedefault.nix. Refinable en futuro como gen también.
Audios
Abathur sentencia la mutación
HAL explica la cifra
HITO 7: la hora de la verdad — deploy a Hetzner (en directo)
18:00. Pascual vuelve. "vamos, dale al deploy tu eres el responsable de verificar". La hora de la verdad. Diecisiete horas de refactor convergen en una sola operación: aplicar el closure de 7.7 GiB al VPS que se quedaba sin RAM intentando construir 28.
Pre-flight: la lista de la cuerda
Antes de tirar del cable verifico cuatro cosas:
Disco cohete: 80% lleno (15 GB libres). Apretado para meter +7.7 GiB nuevos sin morir. Limpio:
ssh cohete 'sudo nix-collect-garbage --delete-older-than 14d'Resultado: 1366 paths borrados, 2.0 GiB liberados. Antes: 80% / 15 GiB libres. Después: 77% / 17 GiB libres.
Cache aurin alcanzable: cohete debe poder pedir paths al
nix-servede aurin (puerto 5000) por la mesh tailscale en lugar de copiarlos por la red residencial.ssh cohete 'curl http://100.64.0.4:5000/nix-cache-info' # → cache aurin desde cohete: 200 0.199sLatencia 200ms via mesh. Bien.
Tailscale activo en cohete: si se cae la mesh durante el deploy, pierde el cache y vuelve a depender de cachix.org. Confirmado activo.
Headscale primary en cohete: si el deploy rompe, el coordinador de la mesh muere. Aurin es
standbypero el failover no es automático. Asumo el riesgo (la generación 24 sigue en bootloader, rollback en segundos).
El comando
cd ~/dotfiles
tmux new -d -s cohete-deploy "sudo nixos-rebuild switch \
--flake .#cohete \
--target-host root@cohete \
--use-substitutes \
--impure 2>&1 | tee /tmp/cohete-deploy.log"Tres flags clave:
--target-host: build local, switch remoto. aurin construye, cohete recibe el closure.--use-substitutes: cohete tira los paths del cache aurin (HTTP) en vez de que aurin los empuje (nix copy). Más rápido para hosts con muchos cores donde el daemon va más fluido.tmuxenvolviendo todo: si SSH se cae, el comando sobrevive (lección aprendida ayer).
Vigilancia en directo
Monitor armado: cada 30s muestrea el log de deploy + RAM/swap de cohete.
(actualizando según progrese — esta sección se reescribe en vivo)
18:06:03 tmux 'cohete-deploy' arrancado
18:07:17 cohete: 834/3700 MB usado, swap 2567/4095. Sano.
log: "building the system configuration..."
Hallazgo en directo: el zombie del 8 de mayo
5 minutos después de arrancar, el log no avanza. nixos-rebuild sigue diciendo "building the
system configuration…". RAM y swap de cohete normales. Conexión TCP
establecida al cache aurin (puerto 5000). Pero nada se mueve.
Inspecciono procesos en cohete: encuentro un nix con PID 715524, corriendo
desde hace 1 día y 13 horas. Sus argumentos:
nix --extra-experimental-features nix-command flakes build \
--print-out-paths --no-link --impure --fallback \
--option extra-substituters http://100.64.0.4:5000 \
--option extra-trusted-public-keys aurin-1:... \
/home/passh/dotfiles#nixosConfigurations.cohete....
Es OTRO zombie del incidente del 8 de mayo (no el que matamos ayer).
Estaba sosteniendo /nix/var/nix/gc.lock y
el lock de llvm-21.1.8-lib. Mi nuevo
deploy chocaba con esos locks.
Mato el zombie:
ssh cohete 'sudo kill -TERM 715524'
# muerto en 4s. swap baja de 2567 MB a 519 MB.Lección: cuando hay un OOM o cuelgue durante un nix build grande,
asegurarse de buscar TODOS los procesos nix zombies con pgrep -af nix. Pueden quedar más de uno y los
locks GC del store hacen invisibles los efectos hasta el próximo
build.
Segundo intento: vía mesh tailscale (más despacio que la pública)
Probé apuntar el SSH a la IP de mesh (100.64.0.2) pensando que sería más rápido.
Sorpresa: la mesh va por relé en Madrid (es lo que muestra tailscale status con "relay 'mad'"), y el relé
limita ancho de banda. Test simple:
ssh cohete 'dd if=/dev/zero bs=1M count=20' | dd of=/dev/null
# IP pública: 1.5 MB/s
ssh [email protected] 'dd if=/dev/zero bs=1M count=20' | dd of=/dev/null
# mesh tailscale: 712 KB/s (la mitad)La mesh es excelente para latencia baja entre nodos, pero pesados flujos sostenidos van mejor por la ruta directa. Lección.
Tercer intento: la host key
Cancelo y reintento por IP pública. Pero el SSH se cuelga otra vez en
"Are you sure you want to continue connecting…". El sudo está corriendo el SSH como root, y /root/.ssh/known_hosts no tenía la fingerprint
de cohete. Acepto manualmente:
sudo ssh -o StrictHostKeyChecking=accept-new root@cohete 'hostname'
# Warning: Permanently added '178.104.80.144' (ED25519) to known_hosts
# coheteCuarto intento: la SSH key (root no encuentra la ided25519)
Reintento. Ahora el SSH como root pide password.
/root/.ssh/ no tiene clave SSH propia.
Cuando ejecuto sudo nix copy, el SSH
hereda el contexto de root y busca claves en /root/.ssh/, no en /home/passh/.ssh/. Solución con NIX_SSHOPTS:
sudo NIX_SSHOPTS='-i /home/passh/.ssh/id_ed25519 -C' \
nix copy --to ssh-ng://root@cohete \
/nix/store/y3v7p0m...-nixos-system-cohete-flake-dirtyNIX_SSHOPTS se pasa al SSH interno que
abre nix-store --serve. Le decimos que use
mi clave de usuario (-i) y comprima (-C).
Y por fin… el log empieza a escupir líneas:
copying 599 paths...
copying path '/nix/store/0hakx2akcip...unit-fs.target' to 'ssh-ng://root@cohete'...
copying path '/nix/store/0k7blxk8...xxhash-0.8.3' to 'ssh-ng://root@cohete'...
copying path '/nix/store/ra0gzlxz...talloc-2.4.4' to 'ssh-ng://root@cohete'...
El número que importa: 599 paths
No son los 7.7 GiB enteros. nix copy es
smart: solo copia paths que faltan. Cohete ya tiene la mayoría (kernel,
systemd, glibc, etc. del sistema actual). De todo el closure
post-refactor, solo faltan 599 paths. Eso baja el
tiempo estimado MUY abajo.
Las cuatro tentativas (la realidad de la red residencial)
Primera tentativa: SSH cae a las 1.5h por el ISP (corte de sesión larga). Reintento.
Segunda tentativa: arranca y se cuelga con "cannot add path
'unit-fs.target' because it lacks a signature by a trusted key". Las
firmas del store de aurin no coinciden con las que cohete acepta. El
--option require-sigs false en aurin no
sirve: la verificación la hace el nix-daemon remoto (cohete), no el cliente.
Solución:
ssh root@cohete 'echo "require-sigs = false" >> /etc/nix/nix.conf'
ssh root@cohete 'systemctl restart nix-daemon'(El /etc/nix/nix.conf es symlink al
store. Lo reemplacé por fichero mutable. Tras el switch volverá al
default.)
Tercera tentativa: falla al rato por otra desconexión. Reintento.
Cuarta tentativa: en la madrugada (3-6 AM), red más limpia, la copia
avanza estable. Pero hay un path gordo que tarda 30
minutos: emacs-30.2 (cómo no, traído
indirectamente por algo que aún no he identificado — tarea pendiente
investigar). Cuando termina, los últimos 60 paths van rápidos.
A las 06:06 del 10 de mayo, nix copy termina con 599/599 paths copiados,
incluido el toplevel nixos-system-cohete-flake-dirty.
Switch en cohete: el dbus → broker requiere reboot
Ejecuto el script post-copy:
ssh root@cohete '
TOPLEVEL=/nix/store/y3v7p0m...-nixos-system-cohete-flake-dirty
nix-env --profile /nix/var/nix/profiles/system --set "$TOPLEVEL"
"$TOPLEVEL/bin/switch-to-configuration" boot
"$TOPLEVEL/bin/switch-to-configuration" switch
'El boot loader se actualiza limpio (systemd-boot 259→260). Pero el switch en caliente falla:
Checking switch inhibitors...
There are changes to critical components of the system:
dbus-implementation : dbus -> broker
Switching into this system is not recommended.
You probably want to run 'nixos-rebuild boot' and reboot your
system instead.
Cambio de implementación de D-Bus. NixOS recomienda reboot. Forzar
con NIXOS_NO_CHECK=1 podría dejar el
sistema medio capado. En producción no me arriesgo. Reboot:
ssh root@cohete 'systemctl reboot --no-block'Cohete down a las 06:07:41. Up a las 06:07:58. Diecisiete segundos de downtime real. Hetzner CPX22 booteando rapidísimo desde su SSD.
Verificación post-deploy
ssh root@cohete '
nixos-rebuild list-generations | head -3
for s in cohete-blog mysql garage headscale syncthing; do
systemctl is-active "$s"
done
curl -sf -o /dev/null -w "blog %{http_code}\n" https://pascualmg.dev/
df -h /
'Resultado:
| Check | Resultado |
|---|---|
| Generación | 25 (kernel 6.18.26) |
| cohete-blog | active |
| mysql | active |
| garage | active |
| headscale | active (health 200) |
| syncthing | active (system service) |
| Blog | 301 en 0.66s |
| Disco antes | 82% (14 GiB libres) |
Detalle: syncthing cambió de [email protected] (template user-level) a
syncthing.service (system service) por el
refactor. Mismo proceso, mismo socket, distinta unit. Todo encaja.
Garbage collection: 10.6 GiB liberados
Las generaciones viejas siguen ocupando store. Limpio:
ssh root@cohete 'nix-collect-garbage --delete-older-than 7d'
# 6302 store paths deleted, 10.6 GiB freedDisco cohete: 82% → 67% (14 GiB → 24 GiB libres). El refactor genético + el GC liberaron casi 11 GiB de un VPS de 75 GiB. Para un servidor con 4 GB de RAM, ese margen es vida.
Lo que queda pendiente
hyprland-0.54.3sigue viajando al closure de cohete. Compositor Wayland en un VPS sin pantalla. Probablemente se cuela como dep indirecta de algún portal/qt. Tarea de investigación abierta.KDE Frameworkscomo dep indirecta — origen sin localizar.- Mover a 7.7 GiB es un buen primer recorte. La meta real de un VPS blog es 3-4 GiB. Pero eso es para otra iteración.
Switch en aurin (cierre del turno anterior)
Tras los 4 hitos: sudo nixos-rebuild switch --flake .#aurin --impure
en tmux. Cero errores, cero downtime. Resultado:
| Verificación | Estado |
|---|---|
| Generación | 387 (era 386) |
| libvirtd | inactive (socket-activated, normal) |
| VM ivanti-vpn definida | sí, shut off (la levantas a mano) |
| br0 | UP, 192.168.53.10/24 |
| Rutas Vocento | todas via 192.168.53.12 |
| nsswitch hosts | files mymachines myhostname dns |
| xmrig (mining) | active (no se interrumpió) |
| sddm | active |
VPN del tajo, cero impacto. Mining, cero impacto. Cero downtime real en producción.
Audio Abathur de cierre
Cierre
El refactor genético funciona. Cada máquina del enjambre ahora expresa solo los genes que necesita su hábitat. Cohete pasó de 26 GiB de closure mortal a 7.7 GiB respirables. Aurin sigue siendo la workstation completa con todos los fenotipos. El genoma base es el mismo. La autoevolución del enjambre es real.
Lo bonito: no hubo que cambiar hardware. No hubo que pagar 228 €/año extra a Hetzner. Solo hubo que pensar mejor el genoma.
Comentarios (0)
Sin comentarios todavia. Se el primero!
Deja un comentario