Migración MinIO → Garage en Cohete: bitácora en directo


1 de mayo de 2026

Este post es bitácora en vivo. Lo actualizo conforme avanzo. Última edición: 2026-04-30 11:10. El endpoint funciona en producción, falta limpiar deuda técnica.

Contexto: el repo de MinIO fue archivado el 25 de abril de 2026; la empresa pivotó a AIStor (closed source). En aurin teníamos un MinIO recién montado para el blog Cohete (nada en producción todavía). Decidimos migrar a Garage como reemplazo S3-compatible libre.

Ver también: el post largo sobre el cierre de MinIO y por qué Garage.

Estado actual

[███████████████████████████████████] 100%

[X] Garage instalado en aurin (modulo NixOS clone-first)
[X] Bucket cohete-blog-images creado en Garage
[X] Datos migrados de MinIO via mc mirror (2 objetos, 93B; tests)
[X] Credenciales S3 en agenix (cohete-garage-credentials.age)
[X] Cherry-pick del PR #4 a una rama nueva (sin las 3 versiones del repo)
[X] Rename Minio → S3 en clases y namespaces
[X] Wire DI en config/definitions.php
[ ] Endpoint MCP/HTTP que SUBA via MediaRepository (PR #4 nunca llegó a controllers)
[ ] Add EnvironmentFile S3_* al cohete-blog systemd unit (en dotfiles)
[ ] Deploy en cohete + smoke test
[X] Apagar MinIO + quitar parche permittedInsecurePackages
[X] Borrar modules/services/minio.nix + secrets/minio-*.age

Decisiones de diseño

Cherry-pick limpio en lugar de merge

El PR #4 de MediaRepository traía tres implementaciones del mismo repo (sync, async, observable) — pedagógicas para el post sobre el catch de Pascual. En producción solo se usa la Observable.

Además, la rama estaba sin actualizar respecto a main: hacer un merge plano borraba el feature de Author que mergeé ayer.

Solución: cherry-pick por archivos.

git checkout -b feat/s3-media-repository main

git checkout feat/media-repository-minio -- \
    src/ddd/Domain/Entity/Media/ \
    src/ddd/Infrastructure/Media/ObservableMinioMediaRepository.php \
    src/ddd/Infrastructure/Media/Aws4Signer.php \
    src/ddd/Infrastructure/Media/InMemoryMediaRepository.php

Quedaron fuera MinioMediaRepository.php (sync, antipatrón, bloquea event loop) y AsyncMinioMediaRepository.php (intermedia, solo Promise sin RxPHP). El composer.json con aws-sdk-php también se descartó: Aws4Signer.php está hecho a mano y no necesita el SDK.

Rename Minio → S3

Mantener MinioMediaRepository como nombre cuando el cliente habla con Garage es mentira semántica. S3 es el protocolo; los backends son intercambiables.

mv src/ddd/Infrastructure/Media/ObservableMinioMediaRepository.php \
   src/ddd/Infrastructure/Media/ObservableS3MediaRepository.php
sed -i 's/ObservableMinioMediaRepository/ObservableS3MediaRepository/g' \
    src/ddd/Infrastructure/Media/ObservableS3MediaRepository.php \
    src/ddd/Domain/Entity/Media/MediaRepository.php

Aws4Signer.php ya tenía nombre genérico. Su docstring se actualizó de "Compatible con S3 / MinIO" a "Compatible con MinIO, Garage, AWS S3, R2 de Cloudflare…".

Garage en NixOS con DynamicUser

Primer intento puso dataDir = "/storage/garage" por simetría con MinIO. Falló:

Error: Unable to open metadata db: Permission denied (os error 13)

El service de Garage en NixOS usa DynamicUser=true (UID asignado en runtime). No podemos chown el dir externo a un user que aún no existe. Solución: dejar dataDir en el default /var/lib/garage, donde StateDirectory=garage de systemd lo crea con el UID dinámico correcto.

Es el mismo disco (/ tiene 3.6 TB), no perdemos espacio.

rpcsecret format gotcha

El daemon de Garage espera la variable GARAGE_RPC_SECRET (con prefijo). Mi primer .age tenía RPC_SECRET…= y falló silenciosamente con:

Error: rpc_secret value is missing, not present in config file or in environment

Re-encriptado con el prefijo correcto:

SECRET=$(head -c 32 /dev/urandom | xxd -p -c 32)
echo "GARAGE_RPC_SECRET=$SECRET" | (cd ~/dotfiles/secrets && agenix -e garage-rpc-secret.age)

Truco bonus: cuando stdin no es interactivo, agenix -e usa internamente cp /dev/stdin. Puedes pipe el contenido directo.

Estructura del módulo NixOS

modules/services/garage.nix:

let
  cfg = config.dotfiles.garage;
in {
  options.dotfiles.garage = {
    enable           = mkEnableOption "Garage object storage (S3-compatible)";
    dataDir          = mkOption { default = "/var/lib/garage"; ... };
    apiPort          = mkOption { default = 3900; ... };
    adminPort        = mkOption { default = 3903; ... };
    rpcPort          = mkOption { default = 3901; ... };
    replicationMode  = mkOption { default = "none"; ... };
    openFirewall     = mkOption { default = false; ... };
  };

  config = mkIf cfg.enable {
    age.secrets.garage-rpc-secret = {
      file = ../../secrets/garage-rpc-secret.age;
      mode = "0444";  # DynamicUser, no chown posible
    };

    services.garage = {
      enable = true;
      package = pkgs.garage;
      settings = {
        metadata_dir       = "${cfg.dataDir}/meta";
        data_dir           = "${cfg.dataDir}/data";
        db_engine          = "lmdb";
        replication_factor = 1;
        rpc_bind_addr      = "[::]:${toString cfg.rpcPort}";
        rpc_public_addr    = "127.0.0.1:${toString cfg.rpcPort}";

        s3_api = {
          s3_region     = "garage";
          api_bind_addr = "[::]:${toString cfg.apiPort}";
          root_domain   = ".s3.garage.local";
        };

        admin = {
          api_bind_addr = "[::]:${toString cfg.adminPort}";
        };
      };
      environmentFile = config.age.secrets.garage-rpc-secret.path;
    };
  };
}

Activación en hosts/aurin/default.nix:

dotfiles.garage.enable = true;

DDD: el dominio Media

Tres value objects y un agregado:

src/ddd/Domain/Entity/Media/
├── Media.php                         # Aggregate Root
├── MediaId.php                       # extends UuidValueObject
├── MediaRepository.php               # interface
└── ValueObject/
    ├── Bucket.php                    # extends StringValueObject
    ├── ContentType.php               # validado: image/*, video/*, etc.
    └── MediaKey.php                  # path interno tipo media/<uuid>

Repository interface (todo async, PromiseInterface obligatorio):

interface MediaRepository
{
    public function put(Media $media, StreamInterface|string $body): PromiseInterface;
    public function find(MediaId $id): PromiseInterface;
    public function delete(MediaId $id): PromiseInterface;
    public function presignedUrl(MediaId $id, int $ttlSeconds = 3600): PromiseInterface;
}

Implementación ObservableS3MediaRepository: React\Http\Browser + Aws4Signer a mano + Rx\Observable->flatMap() para componer. Compatible con cualquier backend S3.

DI binding

config/definitions.php ahora tiene:

Aws4Signer::class => static fn () => new Aws4Signer(
    accessKey: $_ENV['S3_ACCESS_KEY'] ?? '',
    secretKey: $_ENV['S3_SECRET_KEY'] ?? '',
    region:    $_ENV['S3_REGION']    ?? 'us-east-1',
),

MediaRepository::class => static fn (ContainerInterface $c) => new ObservableS3MediaRepository(
    http:          new Browser(),
    signer:        $c->get(Aws4Signer::class),
    endpoint:      $_ENV['S3_ENDPOINT'] ?? 'http://localhost:9000',
    defaultBucket: Bucket::from($_ENV['S3_BUCKET'] ?? 'cohete-media'),
),

5 variables de entorno: S3_ENDPOINT, S3_REGION, S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET.

Esas mismas 5 viven en /run/agenix/cohete-garage-credentials como envfile.

Lo que falta (en orden)

  1. Endpoint para subir. El PR #4 nunca llegó a controllers; solo dominio + impls del repo. Hay dos caminos:

    • Reescribir upload_asset MCP tool (hoy guarda en filesystem /html/img/) para que use MediaRepository. Limpio pero rompe URLs viejas.
    • Añadir un endpoint nuevo (POST /media o MCP upload_media) que conviva con el viejo. Más conservador.

    Voto por el segundo: lo nuevo entra sin tocar lo viejo, deprecamos upload_asset cuando esté maduro.

  2. Configurar el systemd unit del cohete-blog para que cargue /run/agenix/cohete-garage-credentials como EnvironmentFile. En hosts/cohete/default.nix, una línea más en el serviceConfig.

  3. Deploy. Push a origin, git pull en cohete, systemctl restart cohete-blog.

  4. Smoke test. Subir una imagen via el endpoint nuevo, verificar que aparece en Garage (mc ls garage-aurin/cohete-blog-images).

  5. Apagar MinIO. dotfiles.minio.enable = false, rebuild aurin, quitar el parche permittedInsecurePackages del módulo. (O directamente borrar modules/services/minio.nix en un commit final.)

Comandos de referencia (cheatsheet)

Inspección de Garage

# Cargar el rpc_secret en la shell
source /run/agenix/garage-rpc-secret  # exporta GARAGE_RPC_SECRET

# Estado del cluster
sudo -E garage --config /etc/garage.toml status

# Listar buckets
sudo -E garage --config /etc/garage.toml bucket list

# Ver info de una key
sudo -E garage --config /etc/garage.toml key info cohete-rw

# Listar objetos en un bucket via mc
mc ls --recursive garage-aurin/cohete-blog-images

Mirror desde MinIO (one-shot)

mc alias set minio-aurin  http://aurin:9000 "$MINIO_USER" "$MINIO_PASSWORD"
mc alias set garage-aurin http://aurin:3900 "GK..." "..." --api S3v4
mc mirror minio-aurin/cohete-blog-images garage-aurin/cohete-blog-images

Próxima actualización

Cuando cierre el endpoint upload + smoke test pasado.

Cierre del e2e (2026-04-30 11:10)

Smoke test que cerró la migración

curl -sf -X POST https://pascualmg.dev/media \
  -H "Authorization: Bearer <author-token>" \
  -H "Content-Type: image/png" \
  --data-binary @/tmp/test.png

# Respuesta:
# {"id":"9d135c65-...","key":"media/9d135c65-...","byteSize":23,"contentType":"image/png"}

# Verificacion:
mc ls --recursive garage-aurin/cohete-blog-images
# media/9d135c65-...  23B  ← acabado de subir
# media/09080c56-...  35B  ← migrado de MinIO antes
# test-minio.txt      58B  ← migrado de MinIO antes

El path completo del request

[Internet]
   |
   v  HTTPS :443
[Cloudflare]                       termina TLS
   |
   v  HTTP :80
[cohete:80]                        cohete-blog (PHP, ReactPHP)
   |  POST /media + Authorization: Bearer
   |  ├─ UploadMediaController valida Bearer via AuthorAuthenticator
   |  ├─ UploadMediaCommandHandler genera MediaId UUID v4
   |  └─ ObservableS3MediaRepository.put(media, body)
   |        ├─ Aws4Signer firma PUT con AWS V4
   |        └─ React\Http\Browser hace HTTP request async
   |
   v  HTTP S3 :3900 (via mesh tailscale 100.64.0.4)
[Garage en aurin]
   |  ├─ Verifica firma AWS V4
   |  ├─ Bucket cohete-blog-images, key media/{uuid}
   |  └─ Persiste en /var/lib/garage/data/
   |
   v  HTTP 200 con ETag
[cohete-blog]
   |  └─ JSON {id, key, byteSize, contentType} con HTTP 201
   v
[Cliente]

Las dos peleas de medio

Mesh caída por tailscale 1.96 forzando HTTPS

El flake update bumpeó tailscale. La nueva versión exige HTTPS contra el control server. Headscale en cohete servía HTTP plano → mesh rota.

Solución: Cloudflare delante. DNS A headscale.pascualmg.dev proxied + Origin Rule reescribiendo a puerto :8085 + SSL Flexible. server_url en el módulo apunta a la nueva URL HTTPS.

Deploy a cohete bloqueado por firmas nix store

Tres intentos fallaron:

Workaround temporal HOY: las S3* credentials directo al /home/passh/src/cohete/.env (en .gitignore). Nada de EnvironmentFile declarativo hasta que se arregle el rebuild proper. Apuntado #178.

Próxima actualización

Cuando arregle el rebuild remoto (#178). Ahí caen tres cierres a la vez:

Hasta entonces: el blog escribe en Garage en producción. La migración funciona.

Cierre definitivo (2026-05-01 10:30)

MinIO apagado y borrado

hosts/aurin/default.nix:
  - import minio.nix         FUERA
  - dotfiles.minio.enable    FUERA
  - dataDir custom           FUERA

hosts/cohete/default.nix:
  - age.secrets.cohete-minio-credentials    FUERA

secrets/secrets.nix:
  - minio-root-credentials.age              FUERA
  - cohete-minio-credentials.age            FUERA

modules/services/minio.nix                  BORRADO
secrets/minio-root-credentials.age          BORRADO
secrets/cohete-minio-credentials.age        BORRADO

aurin (manual):
  /storage/minio/                           BORRADO (216 KB sin importancia)

Tras el rebuild aurin:

$ systemctl is-active minio garage
inactive
active

$ ss -tn -l | grep -E ":9000|:3900"
LISTEN 0 4096 *:3900 *:*

Solo Garage escuchando. MinIO cero presencia. El parche permittedInsecurePackages que metimos como ñapa para que el flake update no se atragantase con el paquete archivado se va con el módulo borrado.

Cierre indirecto del bug crónico de la mesh

Otra ventaja: la fix de Cloudflare HTTPS para headscale (que metimos para arreglar el bug de tailscale 1.96 forzando HTTPS) cierra de facto la TODO #161 ("mesh degrada con reboot aurin"). Antes cada reboot dejaba a aurin desconectado del control server. Ahora con TLS termination en Cloudflare, el cliente reconecta solo. Verificado con dos reboots consecutivos sin degradación.

Lo único que queda

[ ] Resolver #178: passh como trusted-user en cohete +
    nix-cache pubkey de aurin en trusted-public-keys.
    Con eso, los rebuilds remotos a cohete funcionan limpios.
[ ] Pasar S3_* del .env plano del blog a EnvironmentFile agenix.
    Bloqueado por #178.
[ ] Snapshot del estado actual: post final con la migración entera
    como referencia, fuera de este live-blog.

La pieza grande está cerrada. El blog Cohete escribe en Garage en producción.

Comparte este post:

Es tu post

Estas seguro? Esto no se puede deshacer.

Comentarios (0)

Sin comentarios todavia. Se el primero!

Deja un comentario