Metadata-Version: 2.4
Name: deployless
Version: 0.1.0
Summary: Compile Flask/FastAPI apps to AWS SAM serverless
Author-email: Antonio Rodriguez <contact@antoniorodriguez.dev>
License-Expression: Apache-2.0
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Requires-Dist: build>=1.4.0
Requires-Dist: click>=8.0
Requires-Dist: pyyaml>=6.0
Requires-Dist: twine>=6.2.0
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"
Requires-Dist: flask; extra == "dev"

# deployless

**deployless** es un compilador que convierte aplicaciones Flask (y en el futuro FastAPI) en templates de AWS SAM listos para desplegar como funciones Lambda serverless. No requiere reescribir tu app: simplemente añades anotaciones de configuración en tus `routes.py` y ejecutas `deployless build`.

---

## Indice

1. [Qué es deployless](#qué-es-deployless)
2. [Instalación](#instalación)
3. [Referencia de deployless.yaml](#referencia-de-deploylessyaml)
4. [pc.configure() en routes.py](#pcconfigure-en-routespy)
5. [Recursos AWS](#recursos-aws)
   - [DynamoDB](#dynamodb)
   - [S3](#s3)
   - [SQS](#sqs)
   - [KMS](#kms)
   - [SSM Parameter Store](#ssm-parameter-store)
6. [@pc.cron() — Lambdas programadas](#pccron--lambdas-programadas)
7. [@pc.route() — Split Lambdas por ruta](#pcroute--split-lambdas-por-ruta)
8. [@pc.lambda_function() — Lambdas standalone](#pclambda_function--lambdas-standalone)
9. [pc.shared_resource() — Recursos globales](#pcshared_resource--recursos-globales)
10. [Fichero .env y secrets](#fichero-env-y-secrets)
11. [Comandos CLI](#comandos-cli)
12. [Estructura de proyecto](#estructura-de-proyecto)
13. [Ejemplo completo](#ejemplo-completo)

---

## Qué es deployless

deployless toma tu proyecto Flask estructurado por features y genera:

- Un `template.yaml` de AWS SAM con una función Lambda por feature (y opcionalmente una por ruta específica).
- Una carpeta `.dist/` con el código empaquetado para cada Lambda, incluyendo un `bootstrap.py` generado automáticamente y un `requirements.txt` fusionado.
- Log Groups de CloudWatch con retención configurable para cada función.

### Modelo mental

```
app/features/users/routes.py   →   UsersFunction (Lambda)
app/features/auth/routes.py    →   AuthFunction  (Lambda)
app/features/tenant/routes.py  →   TenantFunction (Lambda)
```

Cada feature vive en su propia Lambda. Si un endpoint específico necesita configuración distinta (más memoria, timeout mayor), puedes "splittearlo" en su propia Lambda con `@pc.route()`.

### Flujo de compilación

```
deployless build
  │
  ├── 1. Lee deployless.yaml
  ├── 2. Descubre app/features/*/routes.py
  ├── 3. Importa cada routes.py (extrae Blueprints y rutas)
  ├── 4. Lee metadata de pc.configure(), @pc.cron(), @pc.route()
  ├── 5. Valida (memoria, timeout, rutas duplicadas, schedules, etc.)
  ├── 6. Genera .dist/{Feature}Function/ para cada Lambda
  └── 7. Escribe template.yaml
```

---

## Instalación

### Desde el repositorio (desarrollo local)

```bash
# Desde la raíz del proyecto
pip install -e ./deployless

# O con uv
uv add --editable ./deployless
```

### Dependencias de deployless

```
pyyaml
click
flask         # ya debes tenerlo instalado
```

### Dependencia de runtime en cada Lambda

Cada Lambda generada necesita `aws-wsgi` para adaptar Flask al formato de eventos de API Gateway. deployless la añade automáticamente al `requirements.txt` de cada paquete `.dist/`.

```bash
pip install aws-wsgi
```

---

## Referencia de deployless.yaml

Crea este fichero en la raíz del proyecto (al mismo nivel que `requirements.txt`). Todos los campos son opcionales; los valores por defecto están indicados.

```yaml
# Nombre del proyecto
name: mi-app

# Proveedor cloud — solo "aws" soportado por ahora
provider: aws

# Stage de despliegue. Se puede sobreescribir con --stage en el CLI.
stage: dev

# Tags aplicados a todos los recursos de CloudFormation
tags:
  Project: mi-app
  Environment: production

# Rutas a los directorios clave del proyecto
paths:
  features: app/features    # Directorio donde viven las features
  shared: app/shared         # Código compartido (se copia en cada Lambda)

# Config global para todas las funciones Lambda
globals:
  runtime: python3.13        # Runtime de Lambda
  memory: 256                # MB (128–10240)
  timeout: 30                # Segundos (1–900)
  log_retention: 14          # Retención en CloudWatch (días)
                             # Valores válidos: 1,3,5,7,14,30,60,90,120,
                             # 150,180,365,400,545,731,1096,1827,3653

# Configuración del API Gateway
api:
  endpoint_type: REGIONAL    # REGIONAL | EDGE | PRIVATE

  # CORS
  cors:
    allow_origin: "*"          # O lista: ["https://mi-app.com"]
    allow_methods: [GET, POST, PUT, DELETE, OPTIONS]
    allow_headers: [Content-Type, Authorization, X-API-Key]
    max_age: 3600              # Segundos que el browser cachea el preflight
    # allow_credentials: true  # No compatible con allow_origin: "*"

  # Autenticación global del API Gateway
  # (ver sección "Autenticación del API Gateway" para detalles)
  auth:
    type: cognito              # cognito | lambda | iam
    user_pool_arn: "arn:aws:cognito-idp:us-east-1:123456789:userpool/us-east-1_ABC"
    name: CognitoAuthorizer    # Opcional
    scopes: []                 # Opcional

  # API Keys
  api_keys: true               # true = generar nueva key | "key-id" = usar existente

  # Rate limiting (requiere api_keys)
  usage_plan:
    rate: 10000                # Requests/segundo
    burst: 2000                # Pico máximo
    quota: 1000000             # Opcional — total de requests
    period: DAY                # DAY | WEEK | MONTH (requerido si hay quota)

  # Dominio personalizado
  domain:
    domain_name: api.mi-app.com
    certificate_arn: "arn:aws:acm:us-east-1:123456789:certificate/abc-123"
    base_path: /v1             # Opcional
    route53:                   # Opcional — configura DNS automáticamente
      hosted_zone_id: Z1234567890ABC

  # Tipos MIME que API Gateway trata como binario (no UTF-8)
  binary_media_types:
    - image/png
    - image/jpeg
    - application/octet-stream

  # Comprimir respuestas mayores a N bytes
  minimum_compression_size: 1024

# Variables de entorno globales inyectadas en TODAS las funciones
env:
  APP_ENV: production
  LOG_LEVEL: INFO

# Fichero .env — variables de entorno y secrets
# Las variables normales se inyectan como env vars en todas las Lambdas.
# Las variables con prefijo SECRET_ se pushean a SSM Parameter Store como SecureString
# y se inyectan como dynamic references {{resolve:ssm-secure:...}}.
env_file: .env.production

# Clave KMS para cifrar los secrets en SSM (opcional).
# Si no se especifica, SSM usa la clave gestionada por AWS (aws/ssm).
# Acepta alias ("mi-app/secrets") o key ID / ARN.
secrets_kms: mi-app/secrets
```

---

## Autenticación del API Gateway

### Cognito User Pool

```yaml
api:
  auth:
    type: cognito
    user_pool_arn: "arn:aws:cognito-idp:us-east-1:123456789:userpool/us-east-1_ABC"
    name: CognitoAuthorizer    # Opcional, default: "CognitoAuthorizer"
    scopes:                    # Opcional — scopes OAuth2 requeridos
      - email
      - profile
```

### Lambda Authorizer (función personalizada)

```yaml
api:
  auth:
    type: lambda
    function_arn: "arn:aws:lambda:us-east-1:123456789:function:my-authorizer"
    name: LambdaAuthorizer     # Opcional, default: "LambdaAuthorizer"
    ttl: 300                   # Segundos antes de re-autorizar (0 = sin cache)
    identity:
      header: Authorization    # Header donde está el token
```

### IAM

```yaml
api:
  auth:
    type: iam
```

### Sobreescribir auth por feature

Desde `routes.py`, puedes sobreescribir la auth global para una feature completa:

```python
import deployless as pc

# Todos los endpoints de esta feature son públicos (sin auth)
pc.configure(auth=None)

# Todos los endpoints de esta feature requieren API key
pc.configure(auth="api_key")
```

### Sobreescribir auth por ruta individual (split Lambda)

```python
@pc.route(memory=512, auth=None)      # Este endpoint es público
@bp.route('/health', methods=['GET'])
def health_check():
    return {"status": "ok"}

@pc.route(memory=1024, auth="api_key")  # Este endpoint requiere API key
@bp.route('/export', methods=['POST'])
def export_data():
    ...
```

### Jerarquía de auth (mayor prioridad primero)

```
@pc.route(auth=...)        ← Ruta individual (solo split lambdas)
pc.configure(auth=...)     ← Feature completa
api.auth en deployless.yaml   ← Global
```

---

## API Keys y Rate Limiting

```yaml
api:
  api_keys: true        # Genera una nueva API key
  usage_plan:
    rate: 10000         # 10k requests/segundo
    burst: 2000         # Pico de 2k simultáneos
    quota: 1000000      # Máximo 1M requests por día
    period: DAY
```

El ID de la API Key generada aparece en los Outputs del stack:

```bash
# Ver el valor de la key (no se muestra en Outputs por seguridad)
aws apigateway get-api-key --api-key <ApiKeyId> --include-value
```

Para usar una key existente en lugar de crear una nueva:

```yaml
api:
  api_keys: "abc123existingkeyid"
```

---

### Reglas de validación

| Código | Regla |
|--------|-------|
| E00 | Validaciones de recursos: DynamoDB (key types, GSI, projection INCLUDE), S3 (bucket name DNS-compliant, 3–63 chars, sin underscores), SQS (queue name, visibility_timeout, message_retention, max_receive_count), KMS (alias format, key_usage/key_spec válidos, incompatibilidades ECC/SIGN_VERIFY), SSMParameter (name starts with /, chars válidos, type válido, value no vacío) |
| E01 | `stage` solo puede contener caracteres alfanuméricos |
| E02 | `api.endpoint_type` debe ser REGIONAL, EDGE o PRIVATE |
| E03 | `globals.log_retention` debe ser un valor válido de CloudWatch |
| E04 | `allow_credentials: true` no es compatible con `allow_origin: "*"` |
| E11 | `api.auth.type` debe ser cognito, lambda o iam |
| E12 | `api.auth` (cognito): `user_pool_arn` es obligatorio |
| E13 | `api.auth` (lambda): `function_arn` es obligatorio |
| E14 | `api.usage_plan`: `rate` y `burst` son obligatorios |
| E15 | `api.usage_plan`: `period` es obligatorio si hay `quota` |
| E16 | `api.usage_plan.period` debe ser DAY, WEEK o MONTH |
| E17 | `api.domain`: `domain_name` y `certificate_arn` son obligatorios |
| E18 | `api.minimum_compression_size` debe ser un entero >= 0 |
| E19 | `ephemeral_storage` fuera de rango (512–10240 MB) |
| E20 | `reserved_concurrency` debe ser >= 0 |
| E21 | `provisioned_concurrency` debe ser >= 1 |
| E22 | `log_retention` por feature debe ser un valor válido de CloudWatch |
| E23 | `alarms.sns_topic_arn` debe ser un ARN válido (empieza con `arn:`) |
| E24 | `alarms.duration.threshold_pct` debe estar entre 1 y 100 |
| E25 | `lambda_function` memory fuera de rango (128–10240 MB) |
| E26 | `lambda_function` timeout fuera de rango (1–900 s) |
| E27 | `env_file` especificado no existe |
| E28 | Variable `SECRET_` con valor vacío |
| E29 | Formato inválido de `secrets_kms` |

---

## pc.configure() en routes.py

`pc.configure()` se llama al nivel de módulo en `routes.py` para registrar la configuración Lambda de esa feature. Es un **no-op en runtime**: cuando tu app Flask arranca normalmente, esta llamada no hace nada visible. Solo el compilador de deployless la lee.

deployless detecta automáticamente en qué feature está siendo llamado inspeccionando el call stack.

### Referencia completa de parámetros

```python
import deployless as pc

pc.configure(
    # ── Básico ──────────────────────────────────────────────────────────────
    memory=512,                  # int — MB. Sobreescribe globals.memory (128–10240)
    timeout=30,                  # int — Segundos. Sobreescribe globals.timeout (1–900)
    description="Mi feature",    # str — Descripción visible en CloudFormation

    # ── Entorno ─────────────────────────────────────────────────────────────
    env={"FLAG": "true"},        # dict — Env vars adicionales para esta Lambda
    layers=["arn:aws:lambda:..."],# list — ARNs de Lambda Layers

    # ── IAM ─────────────────────────────────────────────────────────────────
    policies=[                   # list — IAM policies inline (formato SAM)
        "AmazonDynamoDBReadOnlyAccess",          # Policy gestionada por nombre
        {"DynamoDBCrudPolicy": {"TableName": pc.Ref(mi_tabla)}},  # SAM policy
        {"Version": "2012-10-17", "Statement": [...]},            # Inline policy
    ],

    # ── Recursos AWS ─────────────────────────────────────────────────────────
    resources={                  # dict — Recursos que esta feature usa
        "users": pc.DynamoDB("users-table", pk="id"),
        "files": pc.S3("uploads-bucket"),
        "jobs":  pc.SQS("jobs-queue", dlq=True),
    },

    # ── Arquitectura ─────────────────────────────────────────────────────────
    architectures=["arm64"],     # list — ["x86_64"] o ["arm64"] (Graviton, ~20% más barato)
    tracing=True,                # bool — Activa AWS X-Ray distributed tracing

    # ── Concurrencia ─────────────────────────────────────────────────────────
    reserved_concurrency=10,     # int >= 0 — Límite máximo de ejecuciones simultáneas.
                                 #   0 = throttle completo (útil para deshabilitar temporalmente)
    provisioned_concurrency=3,   # int >= 1 — Instancias pre-calentadas (elimina cold starts).
                                 #   Implica AutoPublishAlias: live en el template.

    # ── Almacenamiento temporal ───────────────────────────────────────────────
    ephemeral_storage=1024,      # int — Tamaño de /tmp en MB (512–10240, default 512)

    # ── Fiabilidad ────────────────────────────────────────────────────────────
    dlq=True,                    # bool — Crea una SQS Dead Letter Queue para
                                 #   invocaciones fallidas asíncronas

    # ── Observabilidad ────────────────────────────────────────────────────────
    log_retention=30,            # int — Días de retención en CloudWatch (sobreescribe global)

    alarms=True,                 # Habilita CloudWatch Alarms con umbrales por defecto
    # alarms=False,              # Deshabilita alarms para esta feature
    # alarms={...},              # Config personalizada (ver sección Alarms)

    # ── Auth (API Gateway) ────────────────────────────────────────────────────
    auth=None,                   # None = rutas públicas | "api_key" = requiere API key
                                 # (no especificado = hereda auth global de deployless.yaml)
)
```

### Ejemplo completo

```python
# app/features/user/routes.py
from flask import Blueprint
import deployless as pc

users_table = pc.DynamoDB(
    "users-table",
    pk="tenant_id",
    sk="user_id",
    gsi=[{"name": "EmailIndex", "pk": "email"}],
    ttl_attribute="expires_at",
    deletion_policy="Retain",
)

pc.configure(
    memory=512,
    timeout=30,
    description="User Management API",
    resources={"users": users_table},
    policies=[{"DynamoDBCrudPolicy": {"TableName": pc.Ref(users_table)}}],
    architectures=["arm64"],
    dlq=True,
    alarms=True,
    log_retention=30,
)

user_bp = Blueprint("user_bp", __name__, url_prefix="/users")

@user_bp.route("", methods=["GET"])
def list_users():
    ...
```

---

## Recursos AWS

Los recursos se declaran dentro de `pc.configure(resources={...})` en el `routes.py` de cada feature. deployless los añade al `template.yaml` y les asigna variables de entorno automáticamente.

### DynamoDB

```python
pc.DynamoDB(
    table_name: str,                      # Nombre de la tabla en AWS
    pk: str = "id",                       # Partition key (clave de partición)
    pk_type: str = "S",                   # "S" (String) | "N" (Number) | "B" (Binary)
    sk: str = None,                       # Sort key opcional. Si se define → AWS::DynamoDB::Table
    sk_type: str = "S",                   # "S" | "N" | "B"
    gsi: list = None,                     # Global Secondary Indexes (ver formato abajo)
    billing_mode: str = "PAY_PER_REQUEST",# "PAY_PER_REQUEST" | "PROVISIONED"
    read_capacity: int = None,            # Solo para billing_mode="PROVISIONED" (default: 5)
    write_capacity: int = None,           # Solo para billing_mode="PROVISIONED" (default: 5)
    ttl_attribute: str = None,            # Atributo de Time-To-Live (DynamoDB lo expira automáticamente)
    stream: str = None,                   # "NEW_IMAGE" | "OLD_IMAGE" | "NEW_AND_OLD_IMAGES" | "KEYS_ONLY"
    point_in_time_recovery: bool = False, # Habilita PITR (restauración punto en el tiempo)
    sse_enabled: bool = True,             # Encriptación en reposo con KMS gestionado por AWS
    deletion_policy: str = "Delete",      # "Delete" | "Retain" | "Snapshot"
    existing: bool = False,               # True = tabla ya existe, no crear (solo inyecta env var)
)
```

#### Lógica de tipo CloudFormation

| Condición | Tipo generado | Notas |
|---|---|---|
| Solo `pk` (sin `sk` ni `gsi`) | `AWS::Serverless::SimpleTable` | Más simple, sin SSE configurable |
| Con `sk` o `gsi` | `AWS::DynamoDB::Table` | Control total, SSE habilitado por defecto |

#### Variable de entorno generada automáticamente

El sufijo `-table` / `_table` se elimina para evitar redundancia:

| `table_name` | Variable de entorno |
|---|---|
| `users-table` | `USERS_TABLE` |
| `orders_table` | `ORDERS_TABLE` |
| `sessions` | `SESSIONS_TABLE` |

#### Formato de GSI

Cada elemento de la lista `gsi` acepta:

```python
{
    "name": "StatusIndex",           # Requerido — nombre del índice
    "pk": "status",                  # Requerido — partition key del índice
    "pk_type": "S",                  # Opcional, default "S"
    "sk": "created_at",              # Opcional — sort key del índice
    "sk_type": "S",                  # Opcional, default "S"
    "projection": "ALL",             # "ALL" | "KEYS_ONLY" | "INCLUDE" (default "ALL")
    "non_key_attributes": ["email"], # Requerido solo si projection="INCLUDE"
}
```

#### Ejemplos

**Tabla simple (solo PK):**
```python
pc.DynamoDB("sessions-table", pk="session_id", ttl_attribute="expires_at")
# → AWS::Serverless::SimpleTable
# → Variable: SESSIONS_TABLE
```

**Tabla con SK y múltiples GSI:**
```python
pc.DynamoDB(
    "orders-table",
    pk="tenant_id",
    sk="order_id",
    gsi=[
        {
            "name": "StatusIndex",
            "pk": "status",
            "sk": "created_at",
        },
        {
            "name": "CustomerIndex",
            "pk": "customer_id",
            "projection": "INCLUDE",
            "non_key_attributes": ["total", "status"],
        },
    ],
    ttl_attribute="expires_at",
    point_in_time_recovery=True,
    deletion_policy="Retain",
)
# → AWS::DynamoDB::Table con SSEEnabled=True
# → Variable: ORDERS_TABLE
```

**Tabla con capacidad provisionada:**
```python
pc.DynamoDB(
    "high-traffic-table",
    pk="pk",
    sk="sk",
    billing_mode="PROVISIONED",
    read_capacity=100,
    write_capacity=50,
)
```

**Tabla con DynamoDB Streams:**
```python
pc.DynamoDB(
    "events-table",
    pk="event_id",
    stream="NEW_AND_OLD_IMAGES",  # Dispara Lambda en cada cambio
)
```

**Tabla existente (no crear, solo inyectar env var):**
```python
pc.DynamoDB("prod-users-table", existing=True)
# No genera recurso CloudFormation
# Inyecta: PROD_USERS_TABLE = "prod-users-table" (string literal)
```

---

### S3

```python
pc.S3(
    bucket_name: str,
    versioning: bool = False,
    encryption: bool = True,        # SSE-S3 (AES256) habilitado por defecto
    cors: list = None,              # Lista de reglas CORS (formato CloudFormation CorsRule)
    lifecycle_rules: list = None,   # Lista de reglas de lifecycle (formato CloudFormation)
    public_access_block: bool = True,  # Bloquea acceso público por defecto
    deletion_policy: str = "Delete",
    existing: bool = False,
)
```

**Variable de entorno generada:**
- `uploads-bucket` → `UPLOADS_BUCKET`
- `my_files_bucket` → `MY_FILES_BUCKET` (el sufijo `-bucket` / `_bucket` se elimina)

**Validaciones en tiempo de compilación (E00):**
- `bucket_name` no puede estar vacío
- Longitud entre 3 y 63 caracteres
- No puede contener underscores (S3 es DNS-compliant)
- Solo minúsculas, dígitos, guiones y puntos — empieza y termina en alfanumérico

**Ejemplo básico:**

```python
pc.S3("user-uploads")
# → SSE-S3 AES256 habilitado, acceso público bloqueado
# → Variable: UPLOADS_BUCKET
```

**Ejemplo con todas las opciones:**

```python
pc.S3(
    "user-uploads",
    versioning=True,
    encryption=True,           # AES256 por defecto — pasar False solo si usas KMS externo
    public_access_block=True,
    deletion_policy="Retain",
    cors=[
        {
            "AllowedOrigins": ["https://mi-app.com"],
            "AllowedMethods": ["GET", "PUT"],
            "AllowedHeaders": ["*"],
            "MaxAge": 3600,
        }
    ],
    lifecycle_rules=[
        {
            "Id": "expire-tmp",
            "Status": "Enabled",
            "ExpirationInDays": 7,
            "Prefix": "tmp/",
        }
    ],
)
```

---

### SQS

```python
pc.SQS(
    queue_name: str,
    fifo: bool = False,               # True = cola FIFO. Añade .fifo al nombre automáticamente.
    dlq: bool = False,                # True = crea también una Dead Letter Queue
    visibility_timeout: int = 30,     # segundos (0–43200)
    message_retention: int = 345600,  # segundos (60–1209600, por defecto 4 días)
    max_receive_count: int = 3,       # Intentos antes de mandar a DLQ (1–1000)
    encryption: bool = True,          # SqsManagedSseEnabled — SSE-SQS habilitado por defecto
    deletion_policy: str = "Delete",
    existing: bool = False,
)
```

**Nota:** SQS y KMS devuelven **múltiples recursos** de CloudFormation (la cola principal + la DLQ, o la clave + el alias). deployless los inserta todos correctamente en el template.

**Variable de entorno generada:**
- `notifications-queue` → `NOTIFICATIONS_QUEUE_URL`

**Validaciones en tiempo de compilación (E00):**
- `queue_name` no puede estar vacío ni exceder 80 caracteres
- Solo alfanumérico, `-` y `_` (el sufijo `.fifo` se excluye de la validación)
- `visibility_timeout` debe estar en rango `[0, 43200]`
- `message_retention` debe estar en rango `[60, 1209600]`
- `max_receive_count` debe estar en rango `[1, 1000]`

**Ejemplo básico:**

```python
pc.SQS("email-notifications")
# → SSE-SQS habilitado, 4 días retención, visibility 30s
# → Variable: EMAIL_NOTIFICATIONS_QUEUE_URL
```

**Ejemplo con DLQ:**

```python
pc.SQS(
    "email-notifications",
    dlq=True,
    visibility_timeout=60,
    message_retention=86400,   # 1 día
    max_receive_count=5,
)
# → Cola principal + DLQ con 14 días de retención
# → Ambas con SSE-SQS habilitado
```

**Ejemplo FIFO:**

```python
pc.SQS(
    "orders",
    fifo=True,       # → queue_name se convierte en "orders.fifo" automáticamente
    dlq=True,        # → DLQ también será FIFO: "orders-dlq.fifo"
)
```

---

### KMS

```python
pc.KMS(
    alias: str = None,                      # e.g. "alias/mi-app" o simplemente "mi-app"
    description: str = None,
    key_usage: str = "ENCRYPT_DECRYPT",     # "ENCRYPT_DECRYPT" | "SIGN_VERIFY" | "GENERATE_VERIFY_MAC"
    key_spec: str = "SYMMETRIC_DEFAULT",    # "SYMMETRIC_DEFAULT" | "RSA_2048/3072/4096"
                                            # | "ECC_NIST_P256/P384/P521" | "ECC_SECG_P256K1"
                                            # | "HMAC_224/256/384/512"
    enable_rotation: bool = None,           # None → auto: True para SYMMETRIC_DEFAULT, False si no
    deletion_policy: str = "Retain",        # KMS usa Retain por defecto (seguridad)
    existing_key_id: str = None,            # ID o ARN de una clave existente (no crea recurso)
    env_var: str = None,                    # Fuerza el nombre del env var generado
)
```

**Variable de entorno generada:**
- `env_var="MY_KEY"` → `MY_KEY` (tiene prioridad sobre cualquier derivación automática)
- `alias="myapp/encryption"` → `MYAPP_ENCRYPTION_KEY_ID`
- Sin alias ni env_var → `KMS_KEY_ID`

**Recursos CloudFormation generados:**
- `AWS::KMS::Key` — con `Enabled: True`, `KeyUsage`, `KeySpec` y key policy básica (root account)
- `AWS::KMS::Alias` — alias opcional para identificar la clave por nombre
- `EnableKeyRotation` solo se añade cuando `key_spec="SYMMETRIC_DEFAULT"` (claves asimétricas no soportan rotación automática)

**Validaciones en tiempo de compilación (E00):**
- `alias` solo puede contener alfanumérico, `-`, `_`, `/`
- `key_usage` debe ser uno de los valores válidos
- `key_spec` debe ser uno de los valores válidos
- `enable_rotation=True` no es válido para claves asimétricas (RSA, ECC, HMAC)
- `key_spec` ECC no es compatible con `key_usage="ENCRYPT_DECRYPT"`
- `key_spec="SYMMETRIC_DEFAULT"` no es compatible con `key_usage="SIGN_VERIFY"`

**Nota:** La Lambda NO tiene permisos para usar la clave por defecto. Debes añadir la policy IAM explícitamente con `pc.configure(policies=[...])`.

#### Ejemplo con permisos IAM

```python
kms_key = pc.KMS(
    alias="mi-app/datos",
    description="Clave de cifrado para datos sensibles",
    enable_rotation=True,
    deletion_policy="Retain",
)

pc.configure(
    resources={"datos_key": kms_key},
    policies=[
        {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Action": ["kms:Encrypt", "kms:Decrypt", "kms:GenerateDataKey"],
                    "Resource": pc.Ref(kms_key),
                }
            ],
        }
    ],
)
```

#### Cómo usar la clave en el código de la app

La variable de entorno `KMS_KEY_ID` (o `{ALIAS}_KEY_ID` si usas alias) se inyecta automáticamente en la Lambda. Úsala en tus servicios de cifrado:

```python
# app/features/tenant/services/kms_service.py
import boto3
import base64
import os
from botocore.exceptions import ClientError

kms_client = boto3.client('kms')

def encrypt_with_kms(plaintext: str) -> str:
    """Cifra un string y devuelve el ciphertext en base64."""
    response = kms_client.encrypt(
        KeyId=os.getenv('KMS_KEY_ID'),
        Plaintext=plaintext.encode('utf-8'),
    )
    return base64.b64encode(response['CiphertextBlob']).decode('utf-8')

def decrypt_with_kms(ciphertext_b64: str) -> str:
    """Descifra un ciphertext en base64 y devuelve el plaintext."""
    ciphertext_blob = base64.b64decode(ciphertext_b64)
    response = kms_client.decrypt(CiphertextBlob=ciphertext_blob)
    return response['Plaintext'].decode('utf-8')
```

> `kms:Decrypt` no necesita especificar `KeyId` porque el ciphertext ya lleva embebido el ID de la clave que lo cifró.

#### Ejemplo completo — cifrado de claves RSA por tenant

Patrón real usado en la app de referencia: el tenant feature cifra la clave privada RSA al crear el tenant y el auth feature la descifra en cada login.

```python
# app/features/tenant/routes.py
import deployless as pc

tenant_key = pc.KMS(
    alias="ums/tenant-keys",
    description="Cifrado de claves privadas RSA por tenant",
    enable_rotation=True,
    deletion_policy="Retain",
)

tenants_table = pc.DynamoDB("ums-tenants", pk="tenant_id", deletion_policy="Retain")

pc.configure(
    resources={
        "tenants": tenants_table,
        "tenant_key": tenant_key,
    },
    policies=[
        {"DynamoDBCrudPolicy": {"TableName": pc.Ref(tenants_table)}},
        {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Action": ["kms:Encrypt"],          # tenant solo cifra
                    "Resource": pc.Ref(tenant_key),
                }
            ],
        },
    ],
)
```

```python
# app/features/auth/routes.py
import deployless as pc

# Reutiliza la misma clave existente (no la vuelve a crear)
tenant_key = pc.KMS(existing_key_id=os.getenv("UMS_TENANT_KEYS_KEY_ID"))

pc.configure(
    resources={"tenant_key": tenant_key},
    policies=[
        {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Action": ["kms:Decrypt"],          # auth solo descifra
                    "Resource": os.getenv("UMS_TENANT_KEYS_KEY_ID"),
                }
            ],
        }
    ],
)
```

**Variables de entorno auto-inyectadas:**

| Alias | Variable |
|---|---|
| `ums/tenant-keys` | `UMS_TENANT_KEYS_KEY_ID` |
| `mi-app` | `MI_APP_KEY_ID` |
| Sin alias | `KMS_KEY_ID` |

#### Clave asimétrica para firma digital (RSA)

```python
signing_key = pc.KMS(
    alias="mi-app/signing",
    description="Clave RSA para firmar JWT o documentos",
    key_usage="SIGN_VERIFY",
    key_spec="RSA_2048",
    # enable_rotation no aplica — se ignora automáticamente para claves asimétricas
)

pc.configure(
    resources={"signing_key": signing_key},
    policies=[
        {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Action": ["kms:Sign", "kms:Verify", "kms:GetPublicKey"],
                    "Resource": pc.Ref(signing_key),
                }
            ],
        }
    ],
)
```

#### Clave existente (no crear, solo inyectar env var)

```python
pc.KMS(existing_key_id="arn:aws:kms:us-east-1:123456789:key/abc-123")
# No genera recurso CloudFormation
# KMS_KEY_ID = "arn:aws:kms:us-east-1:123456789:key/abc-123"
```

---

### SSM Parameter Store

deployless ofrece dos herramientas para SSM: `pc.SSMParameter` para **crear** un parámetro como recurso CloudFormation, y `pc.SSMParam` para **referenciar** un parámetro existente como dynamic reference en env vars.

#### pc.SSMParameter — crear un parámetro

```python
pc.SSMParameter(
    name: str,                  # Path del parámetro, debe comenzar con "/"
    value: str,                 # Valor del parámetro
    type: str = "String",       # "String" | "StringList" | "SecureString"
    description: str = None,
    existing: bool = False,     # True = no crear, solo inyectar env var
)
```

**Variable de entorno generada** — último segmento del path:
- `/myapp/db/host` → `HOST`
- `/myapp/api/secret-key` → `SECRET_KEY`

**Validaciones en tiempo de compilación (E00):**
- `name` debe comenzar con `/`
- Solo alfanumérico, `.`, `-`, `_`, `/`
- `type` debe ser `String`, `StringList` o `SecureString`
- `value` no puede estar vacío (salvo `SecureString`)

**Ejemplo:**

```python
db_host = pc.SSMParameter(
    "/myapp/db/host",
    value="db.example.com",
    description="RDS endpoint",
)

pc.configure(
    resources={"db_host": db_host},
    policies=["SSMParameterReadPolicy": {"ParameterName": "/myapp/db/host"}],
)
# → Variable: HOST = {"Ref": "MyappDbHostParameter"}
```

#### pc.SSMParam — referenciar un parámetro existente

No genera recurso CloudFormation. Produce una **dynamic reference** de CloudFormation directamente en el valor de la env var.

```python
pc.SSMParam(
    name: str,              # Path del parámetro existente
    secure: bool = False,   # True → "{{resolve:ssm-secure:/path}}" (SecureString)
    version: int = None,    # Opcional — anclar a una versión específica
)
```

**Uso en env vars:**

```python
pc.configure(
    env={
        "DB_HOST":   pc.SSMParam("/prod/db/host"),
        "API_KEY":   pc.SSMParam("/prod/api/key", secure=True),
        "DB_PASS":   pc.SSMParam("/prod/db/password", secure=True, version=3),
    }
)
```

Esto genera en el template:

```yaml
Environment:
  Variables:
    DB_HOST:  "{{resolve:ssm:/prod/db/host}}"
    API_KEY:  "{{resolve:ssm-secure:/prod/api/key}}"
    DB_PASS:  "{{resolve:ssm-secure:/prod/db/password:3}}"
```

> `{{resolve:ssm-secure:...}}` solo funciona con parámetros de tipo `SecureString` y requiere que la Lambda tenga permiso `ssm:GetParameter` + `kms:Decrypt` sobre la clave KMS del parámetro.

---

---

## CloudWatch Alarms

deployless puede generar automáticamente 3 alarmas por Lambda: errores, throttles y duración.

### Activación

```python
# En routes.py — habilita alarms con umbrales por defecto
pc.configure(alarms=True)

# Con umbrales personalizados
pc.configure(alarms={
    "errors": {
        "threshold": 1,      # Disparar cuando Errors >= 1 en el periodo
        "period": 300,        # Periodo de evaluación en segundos
    },
    "throttles": {
        "threshold": 1,
        "period": 300,
    },
    "duration": {
        "threshold_pct": 80,  # Disparar cuando Duration > 80% del timeout configurado
        "period": 300,        # (si timeout=30s → alarma a los 24000ms)
    },
    "sns_topic_arn": "arn:aws:sns:us-east-1:123456789:my-alerts",  # Opcional
})

# Deshabilitar alarms para esta feature aunque estén activos globalmente
pc.configure(alarms=False)
```

### Alarms globales (para todas las features)

En `deployless.yaml`, puedes activar alarms para todo el proyecto:

```yaml
alarms:
  errors:
    threshold: 1
    period: 300
  throttles:
    threshold: 1
    period: 300
  duration:
    threshold_pct: 80
    period: 300
  sns_topic_arn: "arn:aws:sns:us-east-1:123456789:my-alerts"
```

### Recursos generados

Para cada feature con `alarms` activo, deployless genera en el template:

```yaml
UserFunctionErrorsAlarm:
  Type: AWS::CloudWatch::Alarm
  Properties:
    MetricName: Errors
    Namespace: AWS/Lambda
    Statistic: Sum
    Period: 300
    Threshold: 1
    ComparisonOperator: GreaterThanOrEqualToThreshold
    TreatMissingData: notBreaching

UserFunctionThrottlesAlarm:
  Type: AWS::CloudWatch::Alarm
  Properties:
    MetricName: Throttles
    # ...

UserFunctionDurationAlarm:
  Type: AWS::CloudWatch::Alarm
  Properties:
    MetricName: Duration
    Statistic: Maximum
    Threshold: 24000    # 80% de 30s = 24000ms
    # ...
```

---

### pc.Ref() y pc.GetAtt() — Referenciar recursos

Usa `pc.Ref(resource)` para obtener el ID lógico de un recurso (genera `{"Ref": "LogicalId"}`), y `pc.GetAtt(resource, attr)` para obtener un atributo específico (genera `{"Fn::GetAtt": ["LogicalId", "Attr"]}`).

```python
tabla = pc.DynamoDB("users-table")
bucket = pc.S3("uploads")

pc.configure(
    resources={"users": tabla, "uploads": bucket},
    policies=[
        {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Action": ["dynamodb:GetItem", "dynamodb:PutItem"],
                    "Resource": pc.GetAtt(tabla, "Arn"),
                },
                {
                    "Effect": "Allow",
                    "Action": ["s3:GetObject", "s3:PutObject"],
                    "Resource": pc.GetAtt(bucket, "Arn"),
                },
            ]
        }
    ],
)
```

`pc.Ref()` y `pc.GetAtt()` aceptan tanto un objeto recurso como un string con el logical ID de CloudFormation.

---

## @pc.cron() — Lambdas programadas

Decora cualquier función con `@pc.cron()` para que deployless la despliegue como una Lambda separada disparada por EventBridge (CloudWatch Events) en el schedule indicado.

```python
@pc.cron(
    schedule: str,          # Expresión de schedule (requerido)
    memory: int = None,     # MB. Si None, usa globals.memory
    timeout: int = None,    # Segundos. Si None, usa globals.timeout
    env: dict = None,       # Variables de entorno adicionales
    description: str = None,
)
```

**Formatos de schedule:**
- `"rate(5 minutes)"` — cada 5 minutos
- `"rate(1 hour)"` — cada hora
- `"rate(24 hours)"` — diario
- `"cron(0 9 * * ? *)"` — todos los días a las 9:00 UTC

**La función debe tener la firma `(event, context)` de Lambda.**

**Ejemplo:**

```python
# app/features/user/routes.py
import deployless as pc

@pc.cron(
    schedule="rate(24 hours)",
    memory=128,
    timeout=300,
    description="Limpieza diaria de usuarios expirados",
)
def cleanup_expired_users(event, context):
    # Tu lógica aquí
    deleted = delete_expired_users()
    return {"status": "ok", "deleted": deleted}
```

Esto genera en `template.yaml`:

```yaml
CleanupExpiredUsersFunction:
  Type: AWS::Serverless::Function
  Properties:
    CodeUri: .dist/CleanupExpiredUsersFunction/
    Handler: bootstrap.handler
    MemorySize: 128
    Timeout: 300
    Description: Limpieza diaria de usuarios expirados
    Events:
      Schedule:
        Type: Schedule
        Properties:
          Schedule: rate(24 hours)
```

---

## @pc.route() — Split Lambdas por ruta

Por defecto, todas las rutas de una feature comparten una sola Lambda. Con `@pc.route()` puedes aislar un endpoint específico en su propia Lambda (útil para endpoints que consumen muchos recursos o tienen timeouts distintos).

```python
@pc.route(
    memory: int = None,
    timeout: int = None,
    description: str = None,
    auth = <no especificado>,   # None = público | "api_key" = requiere API key
                                # (no especificado = hereda auth de la feature o global)
)
```

**El decorador `@pc.route()` debe ir por encima del decorador de Flask.**

```python
# app/features/user/routes.py
import deployless as pc
from flask import Blueprint

user_bp = Blueprint("user_bp", __name__, url_prefix="/users")

@pc.route(memory=1024, timeout=120, description="Exportación pesada de datos")
@user_bp.route("/export", methods=["POST"])
def export_users():
    # Este endpoint tendrá su propia Lambda con 1 GB y 2 minutos de timeout
    ...

@user_bp.route("", methods=["GET"])
def list_users():
    # Este endpoint va en la Lambda compartida de la feature
    ...
```

Esto genera dos funciones Lambda separadas:
- `UserFunction` — contiene `GET /users` (y todos los demás endpoints sin `@pc.route()`)
- `ExportUsersFunction` — contiene solo `POST /users/export`

---

## @pc.lambda_function() — Lambdas standalone

Para funciones Lambda que no tienen rutas HTTP ni schedule — por ejemplo, consumers de SQS, handlers de eventos S3, o pasos de Step Functions — usa `@pc.lambda_function()`.

```python
@pc.lambda_function(
    memory: int = None,       # MB. Si None, usa globals.memory
    timeout: int = None,      # Segundos. Si None, usa globals.timeout
    env: dict = None,         # Variables de entorno adicionales
    description: str = None,
)
```

**La función debe tener la firma `(event, context)` de Lambda.**

**Ejemplo:**

```python
# app/features/orders/routes.py
import deployless as pc

@pc.lambda_function(memory=512, timeout=60, description="Procesa mensajes de la cola de pedidos")
def process_order_queue(event, context):
    for record in event.get("Records", []):
        body = record["body"]
        print(f"Procesando pedido: {body}")
    return {"processed": len(event.get("Records", []))}
```

Esto genera en `template.yaml`:

```yaml
ProcessOrderQueueFunction:
  Type: AWS::Serverless::Function
  Properties:
    CodeUri: .dist/ProcessOrderQueueFunction/
    Handler: bootstrap.handler
    MemorySize: 512
    Timeout: 60
    Description: Procesa mensajes de la cola de pedidos
```

> **Nota:** A diferencia de las features HTTP, las lambdas standalone no tienen eventos de API Gateway. Puedes conectarlas a SQS, S3, DynamoDB Streams, etc. manualmente en el template o mediante event source mappings.

---

## pc.shared_resource() — Recursos globales

Si un recurso (tabla DynamoDB, bucket S3, cola SQS, etc.) debe estar disponible para **todas** las features, usa `pc.shared_resource()` en lugar de declararlo dentro del `resources` de una feature individual.

```python
pc.shared_resource(key: str, resource)
```

- El recurso se incluye **una sola vez** en el template de CloudFormation.
- Las variables de entorno del recurso se inyectan en **todas** las Lambdas del proyecto.
- Se puede referenciar con `pc.Ref()` y `pc.GetAtt()` desde cualquier feature.

**Ejemplo:**

```python
# app/features/events/routes.py (o cualquier routes.py)
import deployless as pc

# Tabla compartida por todas las features
pc.shared_resource("audit_log", pc.DynamoDB("audit-log", pk="event_id", sk="timestamp"))

# Bucket compartido
pc.shared_resource("shared_assets", pc.S3("app-shared-assets"))
```

Desde cualquier feature puedes usar las variables de entorno generadas:

```python
import os

audit_table = os.getenv("AUDIT_LOG_TABLE")       # Inyectado en TODAS las Lambdas
assets_bucket = os.getenv("APP_SHARED_ASSETS_BUCKET")
```

---

## Fichero .env y secrets

deployless puede leer un fichero `.env` para inyectar variables de entorno y gestionar secrets automáticamente.

### Configuración en deployless.yaml

```yaml
env_file: .env.production       # Ruta al fichero .env

# Opcional — clave KMS para cifrar los secrets en SSM
secrets_kms: mi-app/secrets     # Alias, key ID, o ARN
```

### Formato del fichero .env

```env
# Variables normales — se inyectan directamente como env vars en todas las Lambdas
APP_ENV=production
LOG_FORMAT=json

# Secrets — prefijo SECRET_ indica que se pushean a SSM Parameter Store
SECRET_DB_PASSWORD=mysecretpassword
SECRET_API_KEY=sk_live_xxxx
```

### Comportamiento

| Tipo | Ejemplo | Destino | Valor en Lambda |
|------|---------|---------|-----------------|
| Normal | `APP_ENV=production` | Env var directa | `production` |
| Secret | `SECRET_DB_PASSWORD=xxx` | SSM Parameter Store | `{{resolve:ssm:/mi-app/SECRET_DB_PASSWORD}}` |

**Para las variables `SECRET_`:**

1. El nombre se mantiene completo con el prefijo: `SECRET_DB_PASSWORD` → `/mi-app/SECRET_DB_PASSWORD`
2. El valor se almacena como `String` en SSM Parameter Store bajo el path `/{app_name}/{VAR_NAME}`
3. La Lambda recibe una **dynamic reference** `{{resolve:ssm:...}}` que CloudFormation resuelve al crear/actualizar el stack
4. El env var en la Lambda también mantiene el nombre completo: `SECRET_DB_PASSWORD`

> **Nota:** Se usa `String` (no `SecureString`) porque CloudFormation no soporta `{{resolve:ssm-secure:...}}` en Lambda environment variables. El valor sigue protegido por IAM — solo los roles con permiso `ssm:GetParameter` pueden leerlo.

### Validaciones

| Código | Regla |
|--------|-------|
| E27 | El fichero `env_file` especificado no existe |
| E28 | Variable `SECRET_` con valor vacío |
| E29 | Formato inválido de `secrets_kms` (alias solo puede contener alfanumérico, `-`, `_`, `/`) |

---

## Comandos CLI

### `deployless build`

Genera el `template.yaml` y construye los paquetes `.dist/`.

```bash
deployless build

# Opciones:
deployless build --stage prod          # Sobreescribe el stage
deployless build -o infra/template.yaml  # Ruta de salida del template
deployless build --dry-run             # Valida sin escribir ficheros
deployless build --verbose             # Output detallado
```

### `deployless validate`

Valida el proyecto sin generar ningún fichero. Equivalente a `build --dry-run` pero con output más limpio.

```bash
deployless validate
deployless validate --stage prod
deployless validate --check-existing   # Verifica que los recursos con existing=True existen en AWS
deployless validate --verbose
```

### `deployless deploy`

Encadena `deployless build` + `sam build` + `sam deploy`. Requiere tener el [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) instalado.

```bash
deployless deploy
deployless deploy --stage prod
deployless deploy --guided     # Lanza el wizard de sam deploy (primera vez)
```

### `deployless clean`

Elimina los ficheros generados (`.dist/` y `template.yaml`).

```bash
deployless clean
deployless clean -o infra/template.yaml  # Si usaste una ruta de salida distinta
```

### `deployless info`

Muestra el resumen del proyecto detectado.

```bash
deployless info
```

Salida de ejemplo:

```
Project  : mi-ums-api
Provider : aws
Stage    : dev
Runtime  : python3.13

Features (3):
  - auth    (app/features/auth/routes.py)
  - tenant  (app/features/tenant/routes.py)
  - user    (app/features/user/routes.py)
```

### `deployless secrets push`

Pushea las variables `SECRET_*` del fichero `.env` a AWS SSM Parameter Store.

```bash
deployless secrets push
deployless secrets push --stage prod
deployless secrets push --env-file .env.prod   # Sobreescribe la ruta del env_file de deployless.yaml
deployless secrets push --verbose
```

**Proceso:**
1. Lee el fichero `.env` (de `deployless.yaml` o `--env-file`)
2. Filtra las variables con prefijo `SECRET_`
3. Crea/actualiza parámetros SSM: `/{app_name}/{VAR_NAME}` (tipo `String`)

> **Nota:** `deployless build` también ejecuta este paso automáticamente cuando `env_file` está configurado en `deployless.yaml`.

**Ejemplo:**

```env
# .env.prod
SECRET_DB_PASSWORD=mysecretpassword
SECRET_API_KEY=sk_live_xxx
```

```bash
deployless secrets push --env-file .env.prod
# Crea en SSM:
#   /mi-app/SECRET_DB_PASSWORD  (String)
#   /mi-app/SECRET_API_KEY      (String)
```

### `deployless secrets sync`

Push + elimina parámetros huérfanos en SSM. Útil para mantener SSM en sync cuando se eliminan secrets del `.env`.

```bash
deployless secrets sync
deployless secrets sync --stage prod
deployless secrets sync --env-file .env.prod
deployless secrets sync --yes              # Auto-confirma eliminación de huérfanos
deployless secrets sync --verbose
```

**Comportamiento:**
1. Pushea todas las variables `SECRET_*` (igual que `secrets push`)
2. Lista los parámetros existentes bajo `/{app_name}/` en SSM
3. Detecta parámetros que ya no están en el `.env`
4. Pide confirmación antes de eliminarlos (salvo con `--yes`)

---

## Estructura de proyecto

deployless espera la siguiente estructura de directorios (configurable en `deployless.yaml`):

```
mi-proyecto/
├── deployless.yaml             # Configuración de deployless
├── requirements.txt         # Dependencias globales del proyecto
├── app/
│   ├── features/            # Una carpeta por feature
│   │   ├── auth/
│   │   │   ├── routes.py    # REQUERIDO — Blueprint Flask + pc.configure()
│   │   │   ├── use_cases/
│   │   │   ├── repositories/
│   │   │   └── schemas/
│   │   ├── user/
│   │   │   ├── routes.py
│   │   │   ├── requirements.txt  # OPCIONAL — dependencias extra para esta feature
│   │   │   └── ...
│   │   └── tenant/
│   │       └── routes.py
│   └── shared/              # Código compartido — se copia en TODAS las Lambdas
│       ├── decorators/
│       ├── errors/
│       └── config.py
└── .dist/                   # Generado por deployless build (no subir a git)
    ├── AuthFunction/
    │   ├── app/
    │   │   ├── __init__.py
    │   │   ├── features/
    │   │   │   ├── __init__.py
    │   │   │   └── auth/        # Solo el código de este feature
    │   │   │       ├── routes.py
    │   │   │       ├── use_cases/
    │   │   │       └── ...
    │   │   └── shared/          # Copia de app/shared/
    │   ├── bootstrap.py         # Generado automáticamente
    │   ├── deployless.py           # Stub runtime (no-ops)
    │   └── requirements.txt     # requirements.txt global + feature + aws-wsgi
    ├── UserFunction/
    └── TenantFunction/
```

### Reglas de descubrimiento

- deployless escanea `app/features/` buscando subdirectorios que contengan un fichero `routes.py`.
- Los directorios que empiezan por `_` (e.g. `__pycache__`) se ignoran.
- Se procesan en orden alfabético.
- Cada `routes.py` debe definir al menos un Blueprint Flask con al menos una ruta.

### El bootstrap generado

Para cada Lambda se genera un `bootstrap.py` que:

1. Registra todos los Blueprints Flask encontrados en `routes.py`.
2. Crea una app Flask temporal.
3. Envuelve la app con `aws_wsgi.response()` para convertir eventos de API Gateway en requests WSGI.

```python
# .dist/UserFunction/bootstrap.py — generado automáticamente, no editar
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from flask import Flask
import app.features.user.routes as _routes_module  # namespace app/ completo
import inspect

flask_app = Flask(__name__)
for _name, _obj in inspect.getmembers(_routes_module):
    _klass = type(_obj)
    if _klass.__name__ == "Blueprint" and "flask" in _klass.__module__:
        flask_app.register_blueprint(_obj)

import awsgi
def handler(event, context):
    return awsgi.response(flask_app, event, context, base64_content_types={"image/png", "image/jpeg"})
```

Cada Lambda incluye además un `deployless.py` con implementaciones no-op de todas las funciones de deployless (`configure`, `KMS`, `DynamoDB`, etc.), para que los `import deployless as pc` en `routes.py` no fallen en runtime sin necesidad de instalar el paquete completo.

---

## Ejemplo completo

Este ejemplo usa la app real de este repositorio (`app/features/auth`, `user`, `tenant`).

### 1. deployless.yaml

```yaml
name: ums-api
provider: aws
stage: dev

paths:
  features: app/features
  shared: app/shared

globals:
  runtime: python3.13
  memory: 256
  timeout: 30
  log_retention: 14

api:
  endpoint_type: REGIONAL
  cors:
    allow_origin: "*"
    allow_methods: [GET, POST, PUT, DELETE, OPTIONS]
    allow_headers: [Content-Type, Authorization, X-Api-Key]

env:
  LOG_LEVEL: INFO
```

### 2. app/features/user/routes.py

```python
from flask import Blueprint, request, g, jsonify
import deployless as pc

from app.features.user.schemas import CreateUserRequest, UpdateUserRequest
from app.features.user.use_cases import create_user, list_users, get_user, update_user, delete_user
from app.shared.decorators import require_auth, require_scopes

# ---- Configuración Lambda para la feature "user" ----
pc.configure(
    memory=512,
    timeout=30,
    description="User Management Service",
    resources={
        "users": pc.DynamoDB(
            "ums-users",
            pk="tenant_id",
            pk_type="S",
            sk="user_id",
            sk_type="S",
            gsi=[
                {
                    "name": "EmailIndex",
                    "pk": "email",
                    "pk_type": "S",
                }
            ],
            ttl_attribute="expires_at",
            deletion_policy="Retain",
        ),
        "sessions": pc.DynamoDB(
            "ums-sessions",
            pk="session_id",
            ttl_attribute="expires_at",
        ),
    },
    env={
        "TOKEN_EXPIRY": "3600",
    },
)

# ---- Cron: limpieza diaria de sesiones expiradas ----
@pc.cron(
    schedule="rate(24 hours)",
    memory=128,
    timeout=60,
    description="Limpieza de sesiones expiradas",
)
def cleanup_sessions(event, context):
    # Lógica de limpieza
    return {"status": "ok"}

# ---- Blueprint Flask ----
user_bp = Blueprint("user_bp", __name__, url_prefix="/users")

@user_bp.route("", methods=["POST"])
@require_auth
@require_scopes(["ums:users:create"])
def create_user_route():
    data = request.get_json()
    req = CreateUserRequest(
        email=data.get("email"),
        password=data.get("password"),
        scopes=data.get("scopes", []),
    )
    response = create_user(req, g.user["tenant_id"])
    return jsonify(response.to_dict()), 201

@user_bp.route("", methods=["GET"])
@require_auth
@require_scopes(["ums:users:read"])
def list_users_route():
    response = list_users(g.user["tenant_id"])
    return jsonify(response.to_dict()), 200

@user_bp.route("/<user_id>", methods=["GET"])
@require_auth
@require_scopes(["ums:users:read"])
def get_user_route(user_id):
    response = get_user(g.user["tenant_id"], user_id)
    return jsonify(response.to_dict()), 200

@user_bp.route("/<user_id>", methods=["PUT"])
@require_auth
@require_scopes(["ums:users:update"])
def update_user_route(user_id):
    data = request.get_json()
    req = UpdateUserRequest(
        email=data.get("email"),
        password=data.get("password"),
        scopes=data.get("scopes"),
    )
    response = update_user(g.user["tenant_id"], user_id, req)
    return jsonify(response.to_dict()), 200

@user_bp.route("/<user_id>", methods=["DELETE"])
@require_auth
@require_scopes(["ums:users:delete"])
def delete_user_route(user_id):
    delete_user(g.user["tenant_id"], user_id)
    return "", 204

# ---- Split Lambda: exportación pesada ----
@pc.route(memory=1024, timeout=120, description="Exportación masiva de usuarios")
@user_bp.route("/export", methods=["POST"])
@require_auth
@require_scopes(["ums:users:export"])
def export_users_route():
    # Este endpoint tendrá su propia Lambda
    ...
    return jsonify({"url": "https://..."}), 200
```

### 3. Ejecutar el build

```bash
deployless build --verbose
```

Salida esperada:

```
[deployless] Project: ums-api | Stage: dev | Provider: aws
[deployless] Features found: ['auth', 'tenant', 'user']
[deployless]   auth: 3 routes, 0 split
[deployless]   tenant: 2 routes, 0 split
[deployless]   user: 5 routes, 1 split
[deployless] Crons: ['cleanup_sessions']
[deployless] Validation passed.
[deployless]   Built: .dist/AuthFunction
[deployless]   Built: .dist/TenantFunction
[deployless]   Built: .dist/UserFunction
[deployless]   Built split route: .dist/ExportUsersRouteFunction
[deployless]   Built cron: .dist/CleanupSessionsFunction
[deployless] Template generated: /path/to/project/template.yaml
```

### 4. Desplegar

```bash
# Primera vez (wizard interactivo de SAM)
deployless deploy --guided --stage prod

# Despliegues posteriores
deployless deploy --stage prod
```

### 5. template.yaml generado (resumen)

```yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: ums-api — Generated by deployless

Globals:
  Function:
    Runtime: python3.13
    MemorySize: 256
    Timeout: 30
    Environment:
      Variables:
        LOG_LEVEL: INFO
        APP_STAGE: dev

Resources:
  Api:
    Type: AWS::Serverless::Api
    Properties:
      StageName: dev
      EndpointConfiguration: REGIONAL
      Cors:
        AllowOrigin: "'*'"
        AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'"
        AllowHeaders: "'Content-Type,Authorization,X-Api-Key'"

  UserFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: .dist/UserFunction/
      Handler: bootstrap.handler
      MemorySize: 512
      Timeout: 30
      Description: User Management Service
      Environment:
        Variables:
          UMS_USERS_TABLE:
            Ref: UmsUsersTable
          UMS_SESSIONS_TABLE:
            Ref: UmsSessionsTable
          TOKEN_EXPIRY: '3600'
      Events:
        UserPostGet:
          Type: Api
          Properties:
            RestApiId:
              Ref: Api
            Path: /users
            Method: get
        # ... más eventos

  UmsUsersTable:
    Type: AWS::DynamoDB::Table
    DeletionPolicy: Retain
    Properties:
      TableName: ums-users
      BillingMode: PAY_PER_REQUEST
      # ... atributos, GSI, TTL

  CleanupSessionsFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: .dist/CleanupSessionsFunction/
      Handler: bootstrap.handler
      MemorySize: 128
      Timeout: 60
      Events:
        Schedule:
          Type: Schedule
          Properties:
            Schedule: rate(24 hours)

Outputs:
  ApiUrl:
    Description: API Gateway endpoint URL
    Value:
      Fn::Sub: https://${Api}.execute-api.${AWS::Region}.amazonaws.com/dev
  UserFunctionArn:
    Value:
      Fn::GetAtt: [UserFunction, Arn]
  # ...
```

---

## Notas y limitaciones conocidas

- **Solo Flask está soportado** por ahora. El soporte para FastAPI está previsto (adaptador en `deployless/adapters/fastapi.py`).
- **El código de la feature se copia flat**: solo los ficheros `.py` del directorio raíz de la feature se incluyen. Los subdirectorios (use_cases, repositories, etc.) **no se copian**. Si tu `routes.py` importa de subdirectorios propios, deberás adaptar la estructura o ampliar el packager.
- **`app/shared/` se copia completo** en cada Lambda bajo el nombre `shared/`. Las importaciones del tipo `from app.shared.x import y` deberán cambiarse a `from shared.x import y` en el código de producción Lambda.
- **No se instalan las dependencias** durante `deployless build`. `sam build` (ejecutado por `deployless deploy`) es quien instala el `requirements.txt` de cada paquete.
- **Los recursos SQS y KMS** devuelven múltiples entradas de CloudFormation (cola + DLQ, clave + alias). deployless los inserta correctamente todos en el template.
