Medios, almacenamiento de objetos y git sobre object storage
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ódulo | Responsabilidad |
|---|---|
auth | Verificación de eventos Blossom kind:24242 (BUD-11) |
bucket_index | Clasificador de claves del bucket y fold puro para el barrido de almacenamiento |
config | MediaConfig, S3AddressingStyle y su validación de arranque |
storage | Cliente S3: put, put_file, get, get_range, get_stream, head, sidecars |
thumbnail | Miniatura y blurhash, síncronos y CPU-bound |
upload | Pipeline de subida: validar, hashear, almacenar, sidecar |
upload_record | Registros por evento de subida para moderación |
validation | Magic 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étodo | Ruta | Handler |
|---|---|---|
| PUT | /upload | api::media::upload_blob |
| PUT | /media/upload | api::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
AuthenticatedUploades unFromRequestParts, 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
sha256calculado 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:
| Variable | Default | Aplica a |
|---|---|---|
BUZZ_MAX_IMAGE_BYTES | 50 MiB | Camino de imagen |
BUZZ_MAX_GIF_BYTES | 10 MiB | GIF animados |
BUZZ_MAX_VIDEO_BYTES | 500 MiB | Camino de vídeo |
BUZZ_MAX_FILE_BYTES | 100 MiB | Archivos 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 ejemplocf-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:
| Clase | Forma 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 |
| unknown | todo 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:
| Valor | Forma de la petición | Cuándo |
|---|---|---|
path (default) | https://endpoint/bucket/key | MinIO empaquetado y endpoints cuyo DNS no resuelve subdominios de bucket |
virtual | https://bucket.endpoint/key | Proveedores 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:
| Constante | Valor | Significado |
|---|---|---|
MANIFEST_VERSION | 1 | Versión de esquema |
MAX_MANIFEST_PACKS | 128 | Máximo de packs por manifiesto |
PACK_COMPACTION_THRESHOLD | 96 (tres cuartos de 128) | Umbral que dispara compactación proactiva |
MAX_MANIFEST_REFS | 10000 | Má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:
| Axioma | Enunciado |
|---|---|
| A1 | Escritura 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 |
| A2 | Read-after-write fuerte. Una lectura posterior a un PUT exitoso observa esa escritura |
| A3 | Escritura 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:
| Variable | Default | Efecto |
|---|---|---|
BUZZ_GIT_CONFORMANCE_PROBE | true (cualquier valor distinto de "false" la activa) | Ejecuta la sonda A1/A3 contra el backend configurado |
BUZZ_GIT_PROBE_WRITERS | 32 | Escritores concurrentes de la carrera |
BUZZ_GIT_PROBE_ROUNDS | 3 | Rondas 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étodo | Ruta | Para qué |
|---|---|---|
| GET | /git/{owner}/{repo}/info/refs | Anuncio de refs smart HTTP |
| POST | /git/{owner}/{repo}/git-upload-pack | Fetch y clone |
| POST | /git/{owner}/{repo}/git-receive-pack | Push |
| POST | /internal/git/policy | Chequeo 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:
| Variable | Default | Qué es |
|---|---|---|
BUZZ_GIT_REPO_PATH | ./repos; /data/git en Compose de producción; /var/lib/buzz/git en Helm | Raíz de workspaces git efímeros |
BUZZ_GIT_PACK_CACHE_PATH | ./repos/.pack-cache; /var/cache/buzz/git-packs en Helm | Caché local de pares pack/index inmutables |
BUZZ_GIT_PACK_CACHE_MAX_BYTES | 5368709120 (5 GiB, cinco veces max_repo_bytes) | Tamaño de la caché; 0 desactiva retención |
BUZZ_GIT_PACK_CACHE_MAX_CONCURRENT_POPULATIONS | 2 | Poblaciones 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
| Variable | Default | Qué acota |
|---|---|---|
BUZZ_GIT_MAX_PACK_BYTES | 524288000 (500 MiB) | Tamaño máximo de un pack |
BUZZ_GIT_MAX_REPO_BYTES | 1048576000 (1 GiB; dos veces max_pack_bytes) | Tamaño máximo de un repositorio |
BUZZ_GIT_MAX_REPOS_PER_PUBKEY | 100 | Repositorios por pubkey |
BUZZ_GIT_MAX_CONCURRENT_OPS | 20 | Operaciones 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:
| Variable | Default | Efecto |
|---|---|---|
BUZZ_STORAGE_SWEEP_INTERVAL_SECS | 3600, con piso de 60 | Tiempo mínimo entre barridos exitosos |
BUZZ_STORAGE_SWEEP_TIMEOUT_SECS | 120 | Timeout de un intento completo |
BUZZ_STORAGE_SWEEP_MAX_OBJECTS | 1000000 | Tope acumulado de objetos listados; superarlo falla el intento en vez de crecer memoria sin límite |
BUZZ_STORAGE_METRICS | Habilitado salvo off | Kill 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étrica | Qué mide |
|---|---|
buzz_total_storage_bytes / buzz_total_storage_objects | Con etiqueta kind en physical o logical |
buzz_storage_orphan_blob_bytes / buzz_storage_orphan_blobs / buzz_storage_orphan_sidecars | Blobs sin sidecar y sidecars sin blob |
buzz_storage_multi_variant_shas / buzz_storage_multi_variant_bytes | Mismo sha con varias extensiones |
buzz_storage_unknown_key_bytes / buzz_storage_unknown_key_objects | Claves fuera de la taxonomía |
buzz_community_storage_bytes / buzz_community_storage_objects | Por comunidad, con etiqueta community |
buzz_storage_unmapped_community_bytes | Sidecars que apuntan a una comunidad sin fila en base de datos |
buzz_storage_sweep_ok / _failures / _duration_seconds / _age_seconds | Salud 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
- Bucket creado y sin acceso anónimo; en el bundle lo hace
minio-init. - Credenciales estáticas, o ambas vacías para usar la cadena AWS / IRSA; una sola definida es error de configuración al arrancar.
BUZZ_S3_ADDRESSING_STYLEcoherente con el proveedor yBUZZ_S3_REGIONigual a la región del endpoint en AWS real.BUZZ_MEDIA_BASE_URLtermina en/mediay no termina en/.- Deja
BUZZ_GIT_CONFORMANCE_PROBE=truey respalda bucket y volumen de git en la misma ventana que Postgres. - Saca
BUZZ_REQUIRE_MEDIA_GET_AUTHyBUZZ_REQUIRE_MEDIA_READ_AUTHde tu.env.
Resumen
buzz-mediaes una librería sin Axum que implementa Blossom sobre S3/MinIO; las rutas sonPUT /upload,PUT /media/uploadyGET/HEAD /media/{sha256_ext}, y la auth es un eventokind:24242enAuthorization: Nostr <base64>con verbosuploadyget.- 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 Blossomt=getmás membresía. - En producción se configuran
BUZZ_S3_ENDPOINT, credenciales,BUZZ_S3_BUCKET,BUZZ_S3_REGIONyBUZZ_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}/pointerque 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 elgitdel 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