jfastframework
Documentación English PyPI GitHub

Deploy

Los artefactos de deploy se generan a partir del grafo de plugins, no se mantienen a mano. Deshabilitas un plugin y su contenedor desaparece del siguiente compose generado. Esa es toda la idea: la infraestructura no puede desviarse de lo que la aplicación realmente carga.

Bloques de puertos

Un servicio es dueño de diez puertos consecutivos a partir de su puerto base ([app].port). Cada plugin declara un offset dentro del bloque.

Offset Convención Lo declara
+0 HTTP API el servicio
+1 PostgreSQL database
+2 Kafka events
+3 Redis cache
+4 MongoDB mongo
+5 Prometheus metrics
+6 Grafana / RabbitMQ / MinIO metrics, queue, storage
+7 Qdrant HTTP qdrant
+8 Qdrant gRPC qdrant
+9 gRPC el servicio

Un servicio en 8010 obtiene PostgreSQL en 8011, Redis en 8013 y Qdrant en 8017. Un offset de 10 o más se rechaza — chocaría con el bloque del servicio siguiente. Un contenedor que necesita más de un puerto declara extra_ports.

Tres plugins comparten el +6 porque los tres son opt-in y no se espera que un servicio corra los tres. Si el tuyo lo hace, mueves uno: cada offset de arriba es un setting ([plugin.queue] rabbitmq_port_offset, y así).

Generar el compose

jfast deploy compose --stdout          # inspect
jfast deploy compose -o docker-compose.yml
jfast deploy compose --base-port 8020  # override the block base

Dado:

[app]
name = "billing"
port = 8010

[plugins]
enabled = ["observability", "metrics", "database", "cache"]

obtienes un servicio api más postgres (8011) y redis (8013), con volúmenes nombrados, healthchecks y condiciones depends_on conectadas a esos healthchecks para que la API no arranque contra una base de datos que todavía no acepta conexiones.

Con queue en la lista el archivo también trae un servicio worker: el mismo build, entorno y volúmenes que api, corriendo jfast worker --grace=25 en vez del servidor. No publica puertos, su healthcheck de imagen está desactivado (no responde /health), arranca cuando api está sano -- el entrypoint de la API es el que corre las migraciones que sus tasks necesitan -- y tiene un stop_grace_period de 30 s, así que con docker compose stop termina lo que puede y libera el resto a la cola antes de que compose lo mate. jfast workspace compose hace lo mismo por servicio, como <servicio>-worker. Escálalo con docker compose up --scale worker=3; ver Colas y eventos.

Prometheus y Grafana son opt-in incluso con metrics activado — la mayoría de los servicios hacen scrape desde un Prometheus central en vez de correr el suyo:

[plugin.metrics]
include_infra = true

Memoria compartida en PostgreSQL

El contenedor de PostgreSQL generado lleva shm_size: 1gb. El default de Docker es 64 MB, y /dev/shm es donde una query paralela guarda su memoria de trabajo — así que pasado el tamaño de tabla en el que el planner empieza a paralelizar, esa query falla con could not resize shared memory segment. Un 500 en exactamente las queries que importan y en ninguna otra, que es por qué parece aleatorio hasta que alguien lo correlaciona con la cantidad de filas.

Cualquier plugin puede pedir lo mismo: InfraService(..., shm_size="1gb").

Discos locales de storage

Un disco de storage con driver = "local" recibe un volumen nombrado, montado bajo el WORKDIR de la imagen. Sin él los archivos subidos viven en el filesystem del propio contenedor y el siguiente docker build los tira, mientras las filas que los referencian se quedan. Los dos generadores lo emiten y lo nombran igual, <service>_<disk>_data. El Dockerfile tiene que crear las mismas raíces para appuser, o Docker crea el punto de montaje como root y el disco no puede escribir: jfast deploy dockerfile las lee de [plugin.storage.disks] -- ver Storage.

Nombres de contenedor

Ningún generador emite container_name. Esa clave es global al daemon de Docker, así que dos proyectos que comparten el nombre de un servicio no pueden correr a la vez:

Conflict. The container name "/social-database" is already in use

Los puertos se asignan por workspace justamente para evitar eso; los nombres de contenedor no. El caso en el que muerde es el que más vale la pena soportar: una versión vieja y una nueva lado a lado mientras evalúas una actualización. Compose deriva el nombre del proyecto — el directorio, o lo que diga -p:

docker compose -p old up -d     # old-social-database-1
docker compose -p new up -d     # new-social-database-1

Qué rompe: un script que se dirige a un contenedor por el nombre fijo anterior. Usa el servicio de compose, que no cambió:

docker logs social-database                      # antes
docker compose logs social-database              # ahora

docker exec social-database psql -U app          # antes
docker compose exec social-database psql -U app  # ahora

Los nombres de servicio en la red de compose quedan intactos, así que cada DSN generado, cada arista de depends_on y cada upstream de Caddy sigue andando.

Dos generadores

Hay dos, y no son intercambiables:

jfast deploy compose jfast workspace compose
Cubre un servicio cada servicio de jfast.workspace.toml
Nombra un datastore por el plugin que lo pide: postgres por el recurso: billing-database
Variable de contraseña POSTGRES_PASSWORD BILLING_DATABASE_PASSWORD, una por recurso
Volumen postgres_data billing_database_data
Lee jfast.toml el archivo del workspace y el jfast.toml de cada servicio

La diferencia de nombres se queda, porque ninguno de los dos sirve en el lugar del otro. Un servicio solo tiene un PostgreSQL, así que postgres no es ambiguo. Un workspace tiene los que sean, así que cada uno se nombra — y cada uno lleva su propia contraseña, porque una sola POSTGRES_PASSWORD compartida haría que una filtración en cualquier lado fuera una filtración en todos.

Todo lo que no es topología es código compartido y no puede divergir: la forma de un contenedor, el mapeo de puertos y los volúmenes de storage de arriba los emite la misma función en los dos casos.

Mover un servicio de uno al otro

Adoptar un servicio suelto dentro de un workspace le cambia el nombre del contenedor del datastore, el de la variable de contraseña y el del volumen, así que un .env escrito a mano deja de coincidir y el contenedor nuevo arranca vacío. El .env se regenera:

jfast workspace migrate-resources   # datastores become named resources
jfast workspace env                 # rewrite each service's .env from them

De ahí en adelante el DSN se deriva, así que la variable que antes se escribía a mano no se vuelve a escribir. Quedan dos cosas manuales: copiar la contraseña de POSTGRES_PASSWORD al .env del workspace bajo <RESOURCE>_PASSWORD, y los datos — docker volume no tiene rename, así que es un pg_dump del volumen viejo y un restore en el nuevo, o un docker run que copie de uno al otro. Ese costo es la razón por la que no se renombró uno para que coincidiera con el otro: se lo cobraría a todos, una vez por despliegue existente, a cambio de una consistencia que ninguno de los dos casos necesita.

Generar un Dockerfile

jfast deploy dockerfile

Produce una imagen liviana que corre como usuario no-root (uid 10001), instala las dependencias en una capa cacheada antes de copiar el código, y trae un HEALTHCHECK que pega a /health.

Correr contenedores como root es un hallazgo en toda revisión de seguridad, y arreglarlo después implica reconstruir capas de imagen en toda la flota. El Dockerfile generado arranca correcto.

Workers

Un worker de uvicorn es un proceso de Python en un core. Un request pasa la mayor parte de su vida fuera de la base de datos — serializando, validando, renderizando — así que un servicio puede estar lejísimos del límite de su base y estar igual saturado: medido sobre una página de feed, 6.5 ms en PostgreSQL contra 79 ms punta a punta con concurrencia 16.

Por eso el entrypoint deriva la cantidad de workers cuando arranca el contenedor:

JFAST_WORKERS=4 docker run …    # explicit wins
                                # otherwise: the container's CPU quota, capped at 8

Se deriva al arrancar y no se hornea en la imagen porque la imagen no sabe cuánto CPU le van a dar. nproc solo es la respuesta equivocada: reporta los cores del host, así que un contenedor limitado a medio core arrancaría tantos workers como cores tenga la máquina. cgroup v2 publica la cuota real (/sys/fs/cgroup/cpu.max), así que eso se lee primero y nproc queda de fallback. El tope está porque pasado cierto punto los workers sólo compiten por el mismo core, y cada uno cuesta una copia entera de la memoria de la app.

Para fijar un número en la imagen, render_dockerfile(workers=4).

Secretos

El compose generado referencia variables de entorno en vez de embeber los valores:

POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}

La forma :? hace que compose falle con un mensaje claro en vez de arrancar PostgreSQL en silencio con una contraseña vacía.

Nunca pongas un DSN, una key o una contraseña en jfast.toml — se commitea. Los settings que guardan secretos son de tipo SecretStr, lo que además los mantiene fuera de jfast describe y de /info.

Quién gana: jfast.toml o el entorno

Dos reglas, una para cada tipo de ajuste:

El segundo tipo es una sola tabla en el código, jfastframework.deployment_keys.DEPLOYMENT_KEYS, y esta es esa tabla. Todo lo que no está en ella sigue la primera regla.

Tabla Llave Variable
[app] env, debug JFAST_ENV, JFAST_DEBUG
[app] cors_origins, cors_origin_regex JFAST_CORS_ORIGINS, JFAST_CORS_ORIGIN_REGEX
[app] trusted_hosts, trusted_proxies, root_path JFAST_TRUSTED_HOSTS, JFAST_TRUSTED_PROXIES, JFAST_ROOT_PATH
[plugin.observability] level, json_logs JFAST_LOG_LEVEL, JFAST_LOG_JSON_LOGS
[plugin.mail] backend, host, port, username*, password*, from_email, use_starttls, use_ssl JFAST_MAIL_<LLAVE>
[plugin.notifications] backend, project_id, credentials_json* JFAST_NOTIFICATIONS_<LLAVE>
[plugin.llm] api_key*, base_url, chat_model, embedding_model, budget_usd, tenant_budget_usd JFAST_LLM_<LLAVE>
[plugin.storage] serve_local, signing_key* JFAST_STORAGE_<LLAVE>
[plugin.auth] issuer, audience, jwks_url, secret*, public_key* JFAST_AUTH_<LLAVE>
[plugin.accounts] frontend_url, bootstrap_admin_password* JFAST_ACCOUNTS_<LLAVE>
[plugin.tenancy] base_domain JFAST_TENANCY_BASE_DOMAIN
[plugin.database] dsn* JFAST_DB_DSN
[plugin.cache] url* JFAST_CACHE_URL
[plugin.mongo] dsn* JFAST_MONGO_DSN
[plugin.qdrant] url, api_key* JFAST_QDRANT_URL, JFAST_QDRANT_API_KEY
[plugin.queue] rabbitmq_url* JFAST_QUEUE_RABBITMQ_URL
[plugin.events] bootstrap_servers JFAST_EVENTS_BOOTSTRAP_SERVERS
[plugin.rag] ollama_url JFAST_RAG_OLLAMA_URL
[plugin.sentry] dsn* JFAST_SENTRY_DSN
[plugin.telemetry] endpoint, traces_endpoint, headers* JFAST_TELEMETRY_<LLAVE>, o OTEL_EXPORTER_OTLP_ENDPOINT / OTEL_EXPORTER_OTLP_TRACES_ENDPOINT
[plugin.http] upstreams.<nombre>.base_url JFAST_HTTP_UPSTREAMS__<NOMBRE>__BASE_URL

* Una credencial. Nunca va en jfast.toml; está en la tabla para que una commiteada por error igual pierda contra la del despliegue, y se enmascara en todo lugar donde se imprime un desacuerdo.

Algunas candidatas quedan fuera a propósito. El bucket, endpoint_url y region de un disco de storage viven en una tabla por disco que hoy ninguna variable sola direcciona (JFAST_STORAGE_DISKS reemplaza el mapa entero, que es la forma del servicio), el target de una ruta del gateway es un elemento de una lista, y [app] port es el bloque de puertos del que se derivan el compose, el Caddyfile y el .env del frontend generados. El timeout del correo, el pool de la base y cualquier otro número de ajuste son la forma del servicio: ajústalos en el archivo.

Solo cuenta el entorno del proceso, no un archivo .env que lean los settings. El .env.example generado trae valores de desarrollo (JFAST_ENV=local entre ellos), y un .env copiado no debe poder devolver un env = "prod" -- o un backend = "smtp" -- commiteado a la configuración de una laptop sin que nadie lo decida. El env_file: de compose sí mete el .env al entorno del proceso, y también jfast serve y jfast dev cuando lo traducen para el host; entonces cuenta como cualquier otra variable. Por eso el override que pone JFAST_ENV: prod va en environment:, que compose pone por encima de env_file:.

Así, JFAST_ENV=prod enciende producción aunque el jfast.toml del proyecto siga diciendo env = "local" -- lo que jfast start escribía antes de 0.1.0a12 --, y JFAST_MAIL_BACKEND=smtp manda correo aunque el archivo diga console.

Todo desacuerdo se ve

Un valor que el entorno reemplaza por otro distinto nunca es silencioso:

Los proyectos generados desde 0.1.0a12 no escriben env ni issuer, y cada llave del entorno que sí escriben -- el formato de logs, serve_local, base_domain, los topes del LLM -- lleva un comentario con la variable que le gana.

El archivo generado es generado

docker-compose.generated.yml lleva un encabezado que lo dice. Las ediciones a mano se pierden en la siguiente corrida. Si necesitas algo que el generador no emite:

Kubernetes

No implementado. La fase 4 en PLAN.md cubre la generación de Deployment, Service, ConfigMap, Secret y HPA a partir de las mismas declaraciones infra(). Hasta entonces, escribe los manifiestos a mano — y no asumas que la convención de bloques de puertos mapea limpio sobre la red de un cluster, donde los servicios se direccionan por nombre DNS y las colisiones de puertos no son un problema.

Protecciones de borde

Cuatro cosas vienen encendidas antes de que configures nada, porque jfast deploy function pone un servicio en Lambda sin nada adelante, y uvicorn main:app en una laptop tampoco tiene nada.

Setting Default Se apaga con
max_body_bytes 2097152 (2 MiB), o 26214400 con storage 0
request_timeout 30.0, o 120.0 con storage 0
security_headers true false
trusted_proxies loopback + rangos privados []

TOML no tiene null, así que 0 es la forma en que un archivo de config dice "sin límite". 2 MiB está muy por encima de cualquier body JSON para el que el framework genere un handler, y muy por debajo de lo que cuesta bufferear uno; 30s es el [plugin.gateway] timeout del propio gateway generado, así que el servicio se rinde en el mismo momento que lo hace lo que tiene adelante, en vez de retener un worker por una respuesta que ya nadie espera.

storage sube los dos

Un servicio con el plugin storage habilitado resuelve 25 MiB y 120s en su lugar. Eso lo aplica el kernel, no el scaffold: un servicio de uploads necesita ambos, una conexión móvil lenta enviando 25 MiB no termina el body en 30 segundos, y un proyecto que habilita storage un año después de jfast new service tiene exactamente la misma necesidad que uno generado con él. Antes de esta release la subida existía solo donde el scaffold la había escrito, así que un servicio que encendía storage después rechazaba sus propios uploads:

{"status": 413, "detail": "Request body of 3000196 bytes exceeds the 2097152 byte limit."}

jfast new service --with storage sigue escribiendo el par en jfast.toml, así que los números quedan visibles donde se editan. Borrar esas dos líneas no cambia nada mientras storage esté habilitado.

Un valor explícito siempre gana, incluido un 0 explícito: la subida llena un setting que nadie eligió, nunca pisa uno que alguien sí eligió. [plugins] disabled también gana — un servicio que deshabilita storage vuelve al par normal.

CORS

Apagado hasta que se lista un origen. cors_origins es una lista de orígenes exactos; cors_origin_regex es un patrón para los que una lista no puede escribir -- uno por subdominio de tenant, cuando el SPA en app.example.com inicia sesión en acme.example.com/api/auth/login:

[app]
cors_origins = ["https://app.example.com"]
# Cadena literal de TOML: las barras invertidas llegan a la regex tal cual.
cors_origin_regex = 'https://[a-z0-9-]+\.example\.com'
cors_allow_credentials = true

Un origen se permite si está en la lista o cumple el patrón. El patrón se compara contra el origen completo (allow_origin_regex de Starlette, fullmatch), así que https://acme.example.com.evil.com no pasa por su prefijo; escapa los puntos, o . también acepta el - de https://evil-example.com. Un patrón que no compila detiene el arranque. JFAST_CORS_ORIGIN_REGEX lo pone desde el entorno.

Proxies de confianza

X-Forwarded-For lo escribe quien haya mandado el request, así que solo se le cree cuando el peer está en trusted_proxies. La cadena se recorre entonces desde la derecha, y se detiene en el primer hop que no es uno de tus proxies — todo lo que está más a la izquierda lo agregó alguien sin derecho a tu confianza, incluido el cliente.

La lista por defecto es loopback más los rangos privados (10/8, 172.16/12, 192.168/16, fc00::/7), que es donde está el borde en todo lo que este framework genera: Caddy en un bridge de compose, un ingress en una red de pods, un sidecar en localhost. Un request que llega desde una dirección pública es un cliente hablándote directo, y su X-Forwarded-For es una sugerencia.

Recórtala al rango real del balanceador en cualquier despliegue donde un cliente pueda alcanzar el servicio desde dentro de la red privada:

[app]
trusted_proxies = ["10.0.4.0/24"]     # solo la subred del ingress

["*"] confía en todos los peers. Es la única respuesta viable en una plataforma cuyo front end no tiene dirección estable, y es equivocada en cualquier lugar donde el servicio también sea alcanzable directamente.

La dirección resuelta reemplaza scope["client"], así que request.client.host ya es la correcta. El código que prefiera ser explícito lee request.state.client_ip, o:

from jfastframework.middleware import client_ip

key = f"ip:{client_ip(request)}"

X-Forwarded-Proto desde un peer de confianza fija request.url.scheme de la misma forma, que es lo que permite que HSTS sepa si el request llegó de verdad sobre TLS.

Una sola cosa puede resolver la dirección del cliente

uvicorn trae su propio manejo de X-Forwarded-For, activado por defecto, y con su propia lista que resuelve a 127.0.0.1. Reescribe scope["client"] y scope["scheme"] antes de que corra cualquier middleware de la aplicación, así que trusted_proxies estaría decidiendo sobre una dirección que el request proporcionó y no sobre la que está en el socket. En un servicio alcanzable desde su propio host — un sidecar de Kubernetes, cualquier proceso local — eso es un bypass completo de todo rate limit, registro de auditoría y línea de log que dependa del cliente.

Por eso todos los lanzadores de este framework lo apagan: jfast serve y jfast dev pasan proxy_headers=False, y el entrypoint del Dockerfile generado pasa --no-proxy-headers. No hay flag para volver a activarlo. La política vive en trusted_proxies, y una segunda copia de ella en la capa del servidor que gana en silencio es el bug, no la funcionalidad.

Si arrancas uvicorn tú mismo, pasa el flag:

uvicorn main:app --host 0.0.0.0 --port 8000 --no-proxy-headers

Sin él, el framework detecta la sustitución — una dirección de peer que además aparece en la cadena X-Forwarded-For que supuestamente está retransmitiendo no fue leída de un socket —, registra un error que nombra el arreglo, y trata a la conexión como si no tuviera cliente. client_ip() devuelve entonces "unknown": un único bucket compartido, que es la respuesta de la que un atacante no puede rotar. Degradado, ruidoso, y no evadible.

Headers de seguridad

Toda respuesta lleva X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy y una Content-Security-Policy. Un header que la aplicación puso ella misma nunca se sobrescribe, así que una ruta que necesita una política más laxa pone la suya y el resto del servicio sigue estricto.

X-Frame-Options: DENY sale junto con frame-ancestors 'none' de la CSP, no en su lugar. La directiva de CSP lo reemplaza en un navegador actual, pero se ignora en una política report-only y en los WebViews embebidos y frames en modo IE que siguen siendo la razón por la que el clickjacking se sigue reportando.

La política por defecto es enforced, y está escrita alrededor de lo que carga la salida del propio framework:

default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com;
style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:;
connect-src 'self'; frame-ancestors 'none'; base-uri 'self';
form-action 'self'; object-src 'none'

'unsafe-inline' está ahí porque lo exige la salida que no puede romper: los dos templates base de HTMX traen un handler htmx:responseError inline y el scaffolder deja en paz una copia existente, y la página /docs de FastAPI es una llamada SwaggerUIBundle inline. Una política que tira un 500 en la primera página de un servicio nuevo se apaga dentro de la hora. Lo que sí compra el default es todo lo que no cuesta nada: nada de framing, nada de <base> inyectado, nada de form post fuera de origen, nada de plugin embebido, y nada de fetch ni <img> fuera de origen por donde exfiltrar.

Fuera de producción la política también permite https://cdn.jsdelivr.net, https://fonts.googleapis.com, https://fonts.gstatic.com y https://fastapi.tiangolo.com — los CDNs desde los que cargan /docs y /redoc. Salen solos cuando se cierra el esquema OpenAPI, que es lo que hace JFAST_ENV=prod por defecto. La política más estricta llega con el entorno, no con una edición que alguien tenga que recordar.

Para ir más lejos, en este orden:

  1. [plugin.web] htmx_cdn = false y guarda htmx.min.js en static/. Eso saca https://unpkg.com de lo que la política tiene que permitir.
  2. Pon csp_report_only = true con tu propia csp más estricta y un report-uri, y observa qué se rompe durante un release.
  3. Pon esa política en csp sin 'unsafe-inline' y apaga report-only. Cualquier <script> inline que quede en tus templates tiene que pasar a ser un archivo primero.

HSTS

Apagado en local y dev, un año en producción, y omitido en cualquier request que no haya llegado por HTTPS — un navegador ignora HSTS sobre texto plano de todos modos (RFC 6797), y quien lea curl -I http://localhost no debería ver al servicio reclamar una garantía que nada está aplicando.

La condición es real porque el error no se puede deshacer desde la aplicación: el navegador recuerda HSTS durante todo el max-age, así que encenderlo por accidente envenena http://localhost por un año y ninguna limpieza del caché de la app lo arregla.

[app]
hsts_seconds = 600            # pídelo donde sea, corto, para probar
hsts_preload = false          # entrar a la preload list es ~irreversible

Rendimiento

Lo que cuesta una petición encima de FastAPI, medido para 0.1.0a10 con ab -k -n 20000 -c 50 contra un worker de uvicorn, en un /ping que no toca base de datos (mediana de tres corridas, laptop Apple serie M):

0.1.0a9 0.1.0a10
FastAPI solo 25,500 req/s 25,500 req/s
JFast, plugins por defecto, logs en INFO (una línea por petición) 4,374 9,937
JFast, plugins por defecto, logs en WARNING 4,228 12,443
FastAPI + JWT y tenant a mano, dependencia def 9,494 9,494
JFast + auth + tenancy + métricas, logs en WARNING 2,411 8,581
FastAPI + JWT y tenant a mano, dependencia async def 15,116 15,116

Dos cosas explicaban casi toda la diferencia, y las dos se fueron:

Lo que queda -- unos 25 us por petición -- es el trabajo en sí: verificar el token, una muestra de métricas, los headers de seguridad, el request id. En un endpoint que consulta PostgreSQL (1-5 ms) es ruido; en uno que llama a un modelo ni se nota.

Las reglas que lo mantienen así en tu propio código:

Desde 0.1.0a11 lo sostiene un presupuesto en CI. scripts/bench_overhead.py mide los mismos escenarios en proceso, en tiempo de CPU, como proporción contra FastAPI solo en la misma máquina -- JFast por defecto 2.48x, auth + tenancy + métricas 5.43x en la laptop de referencia -- y tests/test_performance_budget.py falla cuando una proporción crece más de 20 %. Las rutas propias del framework (/health, /ready, /info, /metrics, la documentación) ahora se prueban después de las de la aplicación, lo que ahorra de 2 a 4 us de CPU en cada petición a la aplicación. La tabla de ab de arriba no se volvió a medir para 0.1.0a11: la máquina estaba compartida con otras suites de pruebas, y las proporciones en proceso, que sí se midieron, no se movieron. El método, los números y el resto del trabajo de escala -- jfast bench, varias réplicas, MinIO, RAG y agregados -- están en Escalar.

Para medir tu propio servicio, arráncalo con un worker y sin access log, y apunta jfast bench (o ab, wrk, oha) a un endpoint real con un token real; compara con el mismo endpoint en una app de FastAPI sin nada antes de creerle a cualquier número, incluidos los de esta página.

Checklist antes de producción