Medios, almacenamiento de objetos y git sobre object storage

Por: Artiko
buzznostrs3minioblossomgitalmacenamiento

Medios, almacenamiento de objetos y git sobre object storage

Hasta ahora el relay guardaba eventos en Postgres y estado efímero en Redis. Este capítulo cubre la tercera pata: el almacenamiento de objetos, que en Buzz sirve para dos cosas distintas sobre el mismo bucket: medios (imágenes, GIF, vídeo, archivos genéricos) vía el protocolo Blossom implementado por el crate buzz-media, y repositorios git alojados por el relay, cuyo estado autoritativo vive entero en el object store, sin sistema de ficheros persistente por repositorio.

El README pone tanto “media” como “Git hosting backend” en la columna Works today, así que las dos superficies son funcionales, no promesas. La autorización de medios se apoya en la membresía de relay que configuraste en el capítulo sobre membresía, roles y moderación.

Por qué object storage y no disco

flowchart LR
    cli["Cliente Buzz"] -->|"PUT /upload"| relay["buzz-relay"]
    cli -->|"git push"| relay
    relay --> media["buzz-media<br/>pipeline Blossom"]
    relay --> git["api::git<br/>manifest + CAS"]
    media --> s3[("Bucket S3 o MinIO")]
    git --> s3
    relay --> pg[("Postgres<br/>metadata y eventos")]

La decisión de diseño es la misma en ambos caminos: el proceso del relay no es dueño de estado durable en disco local, lo que permite correr varias réplicas sin coordinar nada entre ellas. El bucket es la fuente de verdad; Postgres guarda metadata y eventos; el disco local sólo aloja cachés y directorios de trabajo desechables.

El crate buzz-media

buzz-media es una librería sin dependencia de Axum: los handlers HTTP viven en buzz-relay y el crate expone sólo la lógica. Sus módulos, según crates/buzz-media/src/lib.rs:

MóduloResponsabilidad
authVerificación de eventos Blossom kind:24242 (BUD-11)
bucket_indexClasificador de claves del bucket y fold puro para el barrido de almacenamiento
configMediaConfig, S3AddressingStyle y su validación de arranque
storageCliente S3: put, put_file, get, get_range, get_stream, head, sidecars
thumbnailMiniatura y blurhash, síncronos y CPU-bound
uploadPipeline de subida: validar, hashear, almacenar, sidecar
upload_recordRegistros por evento de subida para moderación
validationMagic bytes, allowlist de MIME, límites de tamaño, protección de image bombs, metadata de vídeo

bucket_index tiene cero I/O de S3: trabaja sobre pares (clave, tamaño), lo que hace testeable la agregación contra listados sintéticos.

Blossom: los endpoints reales

El router (crates/buzz-relay/src/router.rs) monta exactamente estas rutas de medios:

MétodoRutaHandler
PUT/uploadapi::media::upload_blob
PUT/media/uploadapi::media::upload_blob (alias legado)
GET/media/{sha256_ext}api::media::get_blob
HEAD/media/{sha256_ext}api::media::head_blob

La autenticación no es NIP-98 sino Blossom: una cabecera Authorization: Nostr <base64(evento kind:24242)>. El decodificador acepta base64url sin padding (lo que dice BUD-11) y base64 estándar, por compatibilidad con nostr-tools.

verify_blossom_auth_event_for_verb comprueba, en orden: firma Schnorr válida; kind == 24242; tag t igual al verbo (upload o get, los dos únicos que Buzz acepta); contenido no vacío (BUD-11 pide un string legible por humanos); tag expiration presente y en el futuro; created_at no en el futuro, con 5 segundos de tolerancia de reloj y no más antiguo que la ventana max_age_secs; y, si hay tags server, que el host al que se ligó la petición aparezca en alguno. Ese último punto importa en multi-tenant: el host contra el que se valida es el host de la petición resuelto por TenantContext::host(), no un dominio global del proceso. Un relay sirve muchos hosts de tenant, y validar contra uno solo daría 401 a todos los clientes de los demás.

Flujo de una subida

sequenceDiagram
    participant C as Cliente
    participant E as Extractor AuthenticatedUpload
    participant P as Pipeline buzz-media
    participant S as S3 o MinIO

    C->>E: PUT /upload con Authorization Nostr
    Note over E: Valida Blossom antes de leer el body
    E->>E: enforce_relay_membership
    E->>E: Adquiere permiso de subida
    C->>P: bytes del cuerpo
    P->>P: validate_content sobre magic bytes, MIME y tamaño
    P->>P: sha256 y reverificación Blossom ligada al hash
    P->>S: HEAD del blob y HEAD del sidecar
    alt ambos existen
        P->>C: BlobDescriptor idempotente
    else falta alguno
        P->>S: PUT del blob sha.ext
        P->>S: PUT de la miniatura sha.thumb.jpg
        P->>S: PUT del sidecar en _meta
        P->>C: BlobDescriptor
    end

Tres detalles operativos:

  • El extractor AuthenticatedUpload es un FromRequestParts, y Axum los procesa antes que los de cuerpo: un cliente no autenticado no puede obligar al servidor a bufferizar decenas de megas.
  • La verificación Blossom ocurre dos veces: el extractor valida el evento con ventana de 3600 segundos, y el pipeline bufferizado lo revalida ligado al sha256 calculado con ventana de 600. El token tiene que ser fresco y hablar de estos bytes concretos.
  • La idempotencia hace corto circuito sólo si existen a la vez el blob y su sidecar. El sidecar es la puerta de servicio de un blob y no se publica hasta que el resto del trabajo terminó.

La respuesta es un BlobDescriptor BUD-02 con url, sha256, size, type, uploaded y, cuando aplica, dim, blurhash, thumb y duration. La URL se construye sobre BUZZ_MEDIA_BASE_URL, que por eso tiene validación estricta de arranque en MediaConfig::validate: debe terminar en /media y no debe terminar en /. Con https://buzz.example.com/media/ el relay no arranca. La miniatura se deriva del mismo prefijo: {public_base_url}/{sha256}.thumb.jpg. Un cliente adjunta el blob poniendo esa URL en el contenido o en tags del evento; el relay no reescribe eventos para inyectar medios.

Tipos permitidos y límites de tamaño

buzz-media tiene tres caminos de subida y cada uno valida distinto. Imagen. Allowlist cerrada de cuatro MIME en crates/buzz-media/src/validation.rs: image/jpeg, image/png, image/gif, image/webp. video/mp4 está excluido a propósito: si alguien sube un MP4 por este camino falsificando el Content-Type, infer::get() lo detecta y validate_content() lo rechaza.

Vídeo. Pipeline aparte (process_video_upload) que nunca bufferiza en RAM: escribe a disco temporal y sube con put_file, que usa un BufReader de 8 MiB. La detección se hace sobre la caja ftyp ISO-BMFF y una lista de marcas (isom, mp41, mp42, avc1, dash, entre otras).

Archivo genérico. Aquí la lógica se invierte: es una lista de bloqueo. Se rechazan los portadores clásicos de stored-XSS (text/html, application/xhtml+xml, image/svg+xml, application/javascript, text/javascript) y los ejecutables nativos (application/x-msdownload, application/x-executable, application/x-mach-binary, application/x-msi, application/vnd.android.package-archive). Estos archivos ya se sirven con Content-Disposition: attachment, X-Content-Type-Options: nosniff y CSP: default-src 'none', así que la lista es defensa en profundidad ante una regresión de cabeceras; sólo image/* y video/* se sirven inline (serve_inline).

Los límites y sus defaults reales, en crates/buzz-relay/src/config.rs:

VariableDefaultAplica a
BUZZ_MAX_IMAGE_BYTES50 MiBCamino de imagen
BUZZ_MAX_GIF_BYTES10 MiBGIF animados
BUZZ_MAX_VIDEO_BYTES500 MiBCamino de vídeo
BUZZ_MAX_FILE_BYTES100 MiBArchivos genéricos

MediaConfig::validate exige además que todos sean mayores que cero y que max_gif_bytes <= max_image_bytes; el límite del cuerpo HTTP que monta el router es max(max_image_bytes, max_video_bytes) vía RequestBodyLimitLayer. Y tres perillas más acotan el trabajo de parseo y almacenamiento que un proceso admite: BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS (default 8, subidas concurrentes por proceso), BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS_PER_PUBKEY (default 2) y BUZZ_MEDIA_UPLOADS_PER_MINUTE (default 30). El valor por pubkey se recorta automáticamente para no superar el global: con MAX_CONCURRENT_UPLOADS=4 y PER_PUBKEY=10, el efectivo por pubkey es 4.

Las lecturas siempre están autenticadas

GET y HEAD sobre /media/* exigen siempre un token Blossom con t=get ligado al sha256, más membresía de relay. No hay flag que reabra las lecturas anónimas: dos variables antiguas quedaron inertes (BUZZ_REQUIRE_MEDIA_GET_AUTH y BUZZ_REQUIRE_MEDIA_READ_AUTH) y el relay avisa por log que ponerlas —incluso a false— no cambia nada. Queda un hueco documentado y diferido: una lectura autorizada no está gated por el ACL del canal al que se adjuntó el blob, así que membresía de relay más un hash conocido basta para leer.

Registros de subida para moderación

Opcionalmente el relay escribe un registro por evento de subida bajo _uploads/{community}/{sha256}/{event_id}.json, activado con BUZZ_MEDIA_UPLOAD_RECORDS=true (default false). Se escribe para toda subida aceptada, incluido el corto circuito idempotente: una re-subida de bytes conocidos no hace PUT de blob, así que sin el registro quien la subió sería invisible para moderación.

Hay dos variables asociadas cuya coherencia se valida al arrancar:

  • BUZZ_MEDIA_UPLOAD_IP_HEADER — cabecera del edge de confianza con la IP pública, por ejemplo cf-connecting-ip. Definirla sin activar los registros falla el arranque.
  • BUZZ_MEDIA_UPLOAD_PORT_HEADER — cabecera con el puerto. Falla el arranque si no hay también cabecera de IP, porque el puerto sólo se registra junto a una IP válida.

El parseo de IP es fail-empty: sólo acepta una IP pública sintácticamente válida. Listas con comas, rangos privados, loopback, CGNAT, ULA y basura se descartan sin registrar nada, y nunca cae de vuelta a la dirección del socket.

El layout de claves del bucket

bucket_index::classify_key define cinco clases, y esto es de hecho el mapa completo de lo que verás si listas el bucket:

ClaseForma de la clave
thumb{sha256}.thumb.jpg
blob{sha256}.{ext} con ext de 1 a 8 alfanuméricos
sidecar_meta/{community-uuid}/{sha256}.json
auxiliary_uploads/{community-uuid}/{sha256}/{ulid}.json
unknowntodo lo demás

Unknown es deliberadamente el cajón de sastre: una variante malformada de un prefijo conocido cae ahí en vez de colarse como Auxiliary, para que los gauges se queden ruidosos en lugar de silenciosamente equivocados.

Fíjate en la asimetría: los bytes del blob no llevan comunidad en la clave, el sidecar sí. Ésa es la frontera multi-tenant: los bytes pueden ser almacenamiento compartido a nivel de operador, pero metadata, autorización, cuotas, auditoría y visibilidad son por comunidad. Un blob subido en una comunidad no se vuelve observable en otra porque no existe el sidecar _meta/{otra-comunidad}/{sha}.json. Súmale las claves de git en el mismo bucket: packs/<hex>, los objetos de manifiesto y repos/{community}/{owner}/{repo}/pointer.

Configurar MinIO en local

El docker-compose.yml de la raíz (infraestructura de desarrollo, sin el relay) trae MinIO como imagen minio/minio:latest, contenedor buzz-minio, puertos 9000:9000 (API) y 9001:9001 (consola), healthcheck contra /minio/health/live, límite de memoria 256m y volumen buzz-minio-data. Las credenciales de desarrollo son buzz_dev / buzz_dev_secret, y un servicio one-shot minio-init con minio/mc:latest crea el bucket buzz-media y le aplica mc anonymous set none.

El bloque correspondiente de .env.example:

# S3-Compatible Object Storage (media + Git/CAS)
BUZZ_S3_ENDPOINT=http://localhost:9000
BUZZ_S3_ACCESS_KEY=buzz_dev
BUZZ_S3_SECRET_KEY=buzz_dev_secret
BUZZ_S3_BUCKET=buzz-media
BUZZ_S3_REGION=us-east-1
BUZZ_S3_ADDRESSING_STYLE=path

El path está ahí porque el MinIO local es alcanzable desde procesos del host en localhost:9000 y el estilo de ruta mantiene el bucket en el path de la URL. Verifica el bucket desde la consola en http://localhost:9001 o con mc.

Configurar S3 real en producción

S3AddressingStyle sólo acepta dos valores, y un valor inválido falla el arranque:

ValorForma de la peticiónCuándo
path (default)https://endpoint/bucket/keyMinIO empaquetado y endpoints cuyo DNS no resuelve subdominios de bucket
virtualhttps://bucket.endpoint/keyProveedores estilo AWS y los buckets nuevos de Railway Storage

La región importa de verdad en AWS: MediaConfig documenta que debe coincidir con la del endpoint, porque si no las peticiones se firman con el scope de credencial equivocado y AWS las rechaza. El default es us-east-1 para preservar el comportamiento con MinIO. El relay lee BUZZ_S3_REGION y, si no está, cae a AWS_REGION.

Credenciales: estáticas o cadena AWS

MediaStorage::new tiene tres ramas explícitas: si ambas claves son no vacías usa credenciales estáticas; si ambas están vacías cae a Credentials::default(), es decir la cadena de credenciales de AWS (entorno, perfil compartido, token de identidad web — IRSA en EKS vía AssumeRoleWithWebIdentity —, contenedor y metadata de instancia, en ese orden); y si defines una sola devuelve error de configuración inmediato. En EKS puedes dejar ambas vacías y usar el rol IAM del pod, sin llaves estáticas de larga vida.

El bundle de Compose de producción fija el endpoint

Esto es una trampa que conviene conocer antes de perder una tarde. deploy/compose/compose.yml fija dos valores en el bloque environment del relay:

BUZZ_S3_ENDPOINT: http://minio:9000
# Docker DNS resolves `minio`, not arbitrary `<bucket>.minio` hosts.
BUZZ_S3_ADDRESSING_STYLE: path

Como están en environment, ganan sobre lo que pongas en el .env. El bundle sólo deja configurables BUZZ_S3_ACCESS_KEY, BUZZ_S3_SECRET_KEY (ambas obligatorias, con :?set …) y BUZZ_S3_BUCKET. Esas mismas claves alimentan MINIO_ROOT_USER y MINIO_ROOT_PASSWORD del contenedor MinIO, con imagen pinneada en minio/minio:RELEASE.2025-09-07T16-13-09Z. Para un proveedor S3 externo tienes dos caminos —el chart de Helm o una configuración Compose propia—, y recuerda que el relay del bundle depende de minio-init: service_completed_successfully, así que sacar MinIO obliga a rehacer esas dependencias.

En Kubernetes

El chart expone s3.endpoint, s3.bucket (default buzz-media), s3.region, s3.addressingStyle (default path), s3.accessKey y s3.secretKey, más un bloque minio con enabled: false reservado al perfil quickstart. s3.endpoint es obligatoria en producción cuando el MinIO empaquetado está deshabilitado, que es el default; s3.region se renderiza como BUZZ_S3_REGION sólo si la fijas, para preservar el fallback a AWS_REGION en upgrades. Para un Railway Storage Bucket el chart documenta addressingStyle: virtual.

Git sobre object storage

La parte conceptualmente más densa del capítulo. docs/git-on-object-storage.md es una especificación formal con estado draft que describe cómo alojar repositorios git sin sistema de ficheros persistente.

El modelo de datos

Un repositorio R tiene exactamente dos cosas en el object store: un conjunto P_R de objetos pack direccionados por contenido —su clave es el digest de sus bytes, se escriben create-only con If-None-Match: * y los lectores los verifican contra el digest— y un único puntero de manifiesto M_R, un objeto mutable que guarda el digest del manifiesto actual.

El manifiesto es a su vez inmutable y direccionado por contenido. En crates/buzz-relay/src/api/git/manifest.rs es esta estructura, cuyo orden de campos es significativo porque la serialización canónica debe cumplir key == sha256(bytes):

pub struct Manifest {
    pub version: u32,
    pub head: String,                       // ref simbólica, p.ej. "refs/heads/main"
    pub refs: BTreeMap<String, String>,     // refname -> oid hex
    pub packs: Vec<String>,                 // claves "packs/<hex>", ordenadas
    pub parent: Option<String>,             // digest del manifiesto al que sucede
}

Constantes del mismo archivo:

ConstanteValorSignificado
MANIFEST_VERSION1Versión de esquema
MAX_MANIFEST_PACKS128Máximo de packs por manifiesto
PACK_COMPACTION_THRESHOLD96 (tres cuartos de 128)Umbral que dispara compactación proactiva
MAX_MANIFEST_REFS10000Máximo de refs anunciadas

HEAD está publicado en el manifiesto, no derivado en lectura: derivarlo con una regla tipo “por defecto main, si no la primera cabeza” dejaría que un clon anunciara una rama por defecto distinta de la que el escritor quiso.

La clave del puntero, pointer_key, es la fuente única compartida entre escritura (cas_publish) y lectura (hydrate), y tiene esta forma exacta —quitando un .git final si el llamador lo pasó—: repos/{community}/{owner}/{repo}/pointer. La comunidad está dentro de la clave; los objetos pack y manifiesto, compartidos y direccionados por contenido, viven fuera de ese namespace scopeado.

El protocolo de push

sequenceDiagram
    participant G as git del cliente
    participant R as buzz-relay
    participant S as Object store

    G->>R: POST git-receive-pack
    R->>S: hydrate_for_write lee pointer y manifiesto padre
    Note over R: ParentState lleva ETag, digest y Manifest
    R->>R: receive-pack indexa el pack y deriva los objetos nuevos
    R->>S: PUT de cada pack en modo create-only
    R->>R: Valida el delta contra las refs del padre
    R->>S: PUT del manifiesto nuevo direccionado por contenido
    R->>S: PUT del pointer con If-Match del ETag del padre
    alt CAS ganado
        S-->>R: 200 con ETag nuevo
        R->>R: Emite kind 30618 derivado
        R->>G: Éxito construido por finalize_push
    else CAS perdido
        S-->>R: 412
        R->>G: 409 terminal y el cliente re-pushea
    end

La valla. La respuesta de éxito no se construye hasta que el CAS del puntero devuelve éxito, y eso garantiza que un cliente nunca observa éxito para un cambio de refs que aún no es durable. Hay una única costura que construye respuestas de push, finalize_push(PushContext) -> Response, y es estructural, no convención: sin PushContext no hay respuesta de push.

El punto de commit es el CAS, no el evento. Un push exitoso publica además un evento kind:30618 (KIND_GIT_REPO_STATE de NIP-34) firmado por el relay, con la pubkey del que pusheó en un tag p (extensión de Buzz; NIP-34 no define uno). Ese evento es una notificación derivada: un push ha ocurrido si y sólo si M_R fue CAS-swapped, sin importar si —o cuándo— se publica algún evento. Los suscriptores deben tratarlo sólo como señal para releer el puntero, nunca como estado de refs. Un fallo al insertar el 30618 no es fatal: el push sigue siendo durable.

412 se traduce a 409 y es terminal. No hay reintento en el handler: la salida de receive-pack del perdedor se derivó contra un padre ya superado, y reusarla rompería la invariante de que cada instalación deriva del puntero que efectivamente leyó. El cliente re-pushea y git conduce ese reintento.

Sin lock consultivo. La serialización de escritores es el CAS y nada más. El tradeoff v1 está nombrado: bajo pushes concurrentes al mismo repo cada contendiente hidrata y corre receive-pack, y el trabajo de los perdedores se descarta — “CPU/IO desperdiciado bajo contención, no un bug”.

Nada se borra bajo el protocolo. Packs y manifiestos nunca se borran, y eso es lo que hace que una lectura sea un snapshot consistente incluso con escritores concurrentes: un lector con un digest viejo siempre puede hacer GET de todos los packs que nombra. La poda física es retención del backend, y cualquier barrido debe respetar a los lectores en vuelo.

Los tres axiomas y la sonda de conformidad

La seguridad del protocolo se prueba relativa a tres propiedades del object store:

AxiomaEnunciado
A1Escritura durable. Un PUT que devuelve éxito es durable. La inmutabilidad no se asume del store: se impone por disciplina de protocolo con claves direccionadas por contenido y escritura create-only
A2Read-after-write fuerte. Una lectura posterior a un PUT exitoso observa esa escritura
A3Escritura condicional linealizable. PUT M_R If-Match: e tiene éxito si y sólo si el ETag actual es e; entre varios PUT condicionales sobre el mismo e, a lo sumo uno tiene éxito

A3 es la única suposición de backend que carga peso: reemplaza la atomicidad del rename() de POSIX en la que se apoya reftable. Para AWS S3 está documentada; para MinIO, Ceph RGW o cualquier otro es una afirmación empírica, y por eso el relay corre una sonda de conformidad al arrancar:

VariableDefaultEfecto
BUZZ_GIT_CONFORMANCE_PROBEtrue (cualquier valor distinto de "false" la activa)Ejecuta la sonda A1/A3 contra el backend configurado
BUZZ_GIT_PROBE_WRITERS32Escritores concurrentes de la carrera
BUZZ_GIT_PROBE_ROUNDS3Rondas de la carrera

La sonda tiene una mitad secuencial (create-if-absent funciona, un If-None-Match: * duplicado falla, un If-Match obsoleto falla, un If-Match sobre clave inexistente no crea, read-after-write devuelve lo escrito) y una mitad concurrente, que es la que carga el peso: N escrituras condicionales en paralelo sobre el mismo ETag, y pasa sólo si exactamente una gana, las otras N−1 clasifican como precondición fallida y el cuerpo final es el del ganador. Hay además un chequeo de consistencia del token de ETag entre el camino HEAD y el GET, porque If-Match compara literalmente y un desajuste de comillas probaría silenciosamente otra cosa.

El fallo es fatal: el relay no abre el listener, porque un backend que no puede satisfacer el CAS del puntero invalida el protocolo entero. Es una compuerta de admisión de despliegue, no una prueba, y el bundle de producción la deja en true. En desarrollo, just dev la baja a 8 escritores y 2 rondas.

Endpoints git y control de acceso

MétodoRutaPara qué
GET/git/{owner}/{repo}/info/refsAnuncio de refs smart HTTP
POST/git/{owner}/{repo}/git-upload-packFetch y clone
POST/git/{owner}/{repo}/git-receive-packPush
POST/internal/git/policyChequeo interno de política de hook

El ACL de git es el tag buzz-channel del anuncio kind:30617: tanto la puerta de lectura como la política de push autorizan contra la membresía del canal ligado. La resolución es fail-closed y sólo considera el primer tag buzz-channel: si ése está malformado el binding queda Broken y se deniega, aunque un duplicado posterior sea válido — si fuese “el primero que parsee”, cualquiera capaz de añadir un segundo tag elegiría el canal. Además, el anuncio siembra un puntero con manifiesto vacío antes de publicar el evento: un repo anunciado siempre es clonable, y la ausencia de puntero significa exactamente “nunca fue anunciado” → 404.

Implicancias operativas

El disco local sigue existiendo, pero sólo para cosas desechables:

VariableDefaultQué es
BUZZ_GIT_REPO_PATH./repos; /data/git en Compose de producción; /var/lib/buzz/git en HelmRaíz de workspaces git efímeros
BUZZ_GIT_PACK_CACHE_PATH./repos/.pack-cache; /var/cache/buzz/git-packs en HelmCaché local de pares pack/index inmutables
BUZZ_GIT_PACK_CACHE_MAX_BYTES5368709120 (5 GiB, cinco veces max_repo_bytes)Tamaño de la caché; 0 desactiva retención
BUZZ_GIT_PACK_CACHE_MAX_CONCURRENT_POPULATIONS2Poblaciones concurrentes de caché

La caché está indexada por digest verificado y es de vida-del-proceso. Fallos de caché, reinicios y desalojos afectan sólo al rendimiento: el object storage sigue siendo la fuente de verdad y refs y HEAD se materializan por petición desde el manifiesto actual. En Kubernetes el chart la monta en un volumen efímero por pod (git.packCacheVolumeSize: 7Gi), y en Compose el volumen buzz-git-data va en /data/git; la checklist de respaldo lo incluye junto con el bucket, y los snapshots de Postgres y los de objeto/git deben venir de la misma ventana de mantenimiento. Dos perillas más: BUZZ_GIT_HOOK_HMAC_SECRET, que el relay genera aleatoria si falta y rechaza si mide menos de 32 caracteres —con más de una réplica es obligatoria y estable—, y BUZZ_SERVE_GIT_WEB_GUI, opt-in, que habilita el navegador de repositorios en la UI web.

Cuotas y costos

VariableDefaultQué acota
BUZZ_GIT_MAX_PACK_BYTES524288000 (500 MiB)Tamaño máximo de un pack
BUZZ_GIT_MAX_REPO_BYTES1048576000 (1 GiB; dos veces max_pack_bytes)Tamaño máximo de un repositorio
BUZZ_GIT_MAX_REPOS_PER_PUBKEY100Repositorios por pubkey
BUZZ_GIT_MAX_CONCURRENT_OPS20Operaciones git concurrentes

Los defaults son encadenados: si subes BUZZ_GIT_MAX_PACK_BYTES y no tocas los otros dos, max_repo_bytes pasa a ser el doble y pack_cache_max_bytes diez veces el nuevo valor de pack. Fíjalos los tres explícitamente si te importa el tamaño del disco del pod.

No hay tabla de precios en el repositorio y no vamos a inventarla, pero el diseño sí dice qué crece. El bucket sólo crece: nada se borra bajo el protocolo de git, y un force-push o un borrado de rama deja objetos inalcanzables pero todavía nombrados en manifiestos previos. La compactación proactiva reduce los packs de un manifiesto al acercarse al umbral de 96, pero no borra objetos viejos. Los medios tampoco se borran solos, y las llamadas LIST del barrido horario también cuestan.

El barrido de almacenamiento

crates/buzz-relay/src/storage_sweep.rs implementa una tarea de fondo single-flight con snapshot cacheado:

VariableDefaultEfecto
BUZZ_STORAGE_SWEEP_INTERVAL_SECS3600, con piso de 60Tiempo mínimo entre barridos exitosos
BUZZ_STORAGE_SWEEP_TIMEOUT_SECS120Timeout de un intento completo
BUZZ_STORAGE_SWEEP_MAX_OBJECTS1000000Tope acumulado de objetos listados; superarlo falla el intento en vez de crecer memoria sin límite
BUZZ_STORAGE_METRICSHabilitado salvo offKill switch total

El kill switch existe por una razón concreta: un despliegue cuyo rol IAM no tenga s3:ListBucket puede apagar la funcionalidad entera; con off no se emite ningún gauge, ni siquiera los de salud. Las métricas que publica, leídas del snapshot cacheado en cada tick del poller de uso:

MétricaQué mide
buzz_total_storage_bytes / buzz_total_storage_objectsCon etiqueta kind en physical o logical
buzz_storage_orphan_blob_bytes / buzz_storage_orphan_blobs / buzz_storage_orphan_sidecarsBlobs sin sidecar y sidecars sin blob
buzz_storage_multi_variant_shas / buzz_storage_multi_variant_bytesMismo sha con varias extensiones
buzz_storage_unknown_key_bytes / buzz_storage_unknown_key_objectsClaves fuera de la taxonomía
buzz_community_storage_bytes / buzz_community_storage_objectsPor comunidad, con etiqueta community
buzz_storage_unmapped_community_bytesSidecars que apuntan a una comunidad sin fila en base de datos
buzz_storage_sweep_ok / _failures / _duration_seconds / _age_secondsSalud del propio barrido

Un detalle que evita falsos pánicos: una caché caliente republica el último snapshot bueno mientras el intento más reciente falla, así que un hipo de S3 no blanquea los dashboards; una caché fría publica sólo los gauges de salud. Cómo alertar sobre esto se ve en observabilidad.

Checklist de puesta en producción

  1. Bucket creado y sin acceso anónimo; en el bundle lo hace minio-init.
  2. Credenciales estáticas, o ambas vacías para usar la cadena AWS / IRSA; una sola definida es error de configuración al arrancar.
  3. BUZZ_S3_ADDRESSING_STYLE coherente con el proveedor y BUZZ_S3_REGION igual a la región del endpoint en AWS real.
  4. BUZZ_MEDIA_BASE_URL termina en /media y no termina en /.
  5. Deja BUZZ_GIT_CONFORMANCE_PROBE=true y respalda bucket y volumen de git en la misma ventana que Postgres.
  6. Saca BUZZ_REQUIRE_MEDIA_GET_AUTH y BUZZ_REQUIRE_MEDIA_READ_AUTH de tu .env.

Resumen

  • buzz-media es una librería sin Axum que implementa Blossom sobre S3/MinIO; las rutas son PUT /upload, PUT /media/upload y GET/HEAD /media/{sha256_ext}, y la auth es un evento kind:24242 en Authorization: Nostr <base64> con verbos upload y get.
  • Los límites por defecto son 50 MiB imagen, 10 MiB GIF, 500 MiB vídeo y 100 MiB archivo genérico, con allowlist de cuatro MIME para imagen y lista de bloqueo para archivo genérico.
  • El bucket tiene cinco clases de clave: blob, thumb, sidecar _meta/{community}/, auxiliar _uploads/{community}/ y unknown. Los bytes no llevan comunidad; el sidecar sí, y es lo que hace visible un blob a una comunidad. Las lecturas siempre exigen auth Blossom t=get más membresía.
  • En producción se configuran BUZZ_S3_ENDPOINT, credenciales, BUZZ_S3_BUCKET, BUZZ_S3_REGION y BUZZ_S3_ADDRESSING_STYLE; dejar ambas credenciales vacías activa la cadena de credenciales de AWS, incluido IRSA. El bundle de Compose fija endpoint y estilo, y no permite un S3 externo sin modificarlo.
  • Git sobre object storage no tiene estado autoritativo en disco: packs y manifiestos inmutables direccionados por contenido, más un puntero repos/{community}/{owner}/{repo}/pointer que avanza por compare-and-swap.
  • El punto de commit de un push es el CAS del puntero, nunca el evento kind:30618. Un 412 se traduce a 409 terminal y el reintento lo conduce el git del cliente.
  • La sonda de conformidad A1/A3 corre al arrancar y es fatal si falla. Nada se borra bajo el protocolo de git: el bucket crece de forma monótona y el barrido horario publica gauges buzz_storage_* para vigilar huérfanos, claves desconocidas y bytes por comunidad.

Siguiente: Búsqueda y registro de auditoría