estado-servicio.sh ya distingue proceso, puerto y aplicación, pero se queda en la puerta: sabe que /salud devuelve un 200, no qué dice. La veloz-api responde JSON —estado de la base de datos, envíos en cola, versión desplegada, métricas—, y ahí el toolkit deja de leer ficheros que alguien dejó en el disco para integrarse con el resto del sistema. Esta lección cubre curl a fondo para hablar con APIs y jq para manejar JSON de verdad: extraerlo, filtrarlo, convertirlo en columnas y también construirlo de forma segura, porque al final informe-diario.sh publicará su resumen diario en JSON.

Contenido

  1. curl en scripts: el trío -sSf
  2. Métodos, cabeceras y cuerpos
  3. Ficheros, redirecciones y tiempos de espera
  4. Credenciales sin dejar rastro
  5. Códigos HTTP: qué debe distinguir un script
  6. Cuerpo y código de estado en una sola llamada
  7. Por qué NO se parsea JSON con grep o sed
  8. jq: el modelo de filtros
  9. Seleccionar, filtrar y agregar
  10. Controlar la salida: -r, -c, -e y @tsv
  11. Construir JSON de forma segura
  12. Leer JSON en Bash, y JSON como configuración
  13. Aplicación: el toolkit habla con la API y publica JSON

  1. curl en scripts: el trío -sSf

Por defecto curl está pensado para una persona en una terminal: escribe una barra de progreso y, si el servidor contesta un error, imprime la página de error y devuelve 0. En un script eso es exactamente lo contrario de lo que quieres. Tres opciones lo arreglan:

Opción Efecto Por qué
-s Silencioso: sin barra de progreso ni mensajes La barra ensucia la salida y los registros
-S Pero muestra los errores de curl Con -s a secas, un fallo de red sería mudo
-f Falla (código 22) ante respuestas HTTP 4xx y 5xx Sin esto, $? vale 0 aunque el servidor conteste 500
curl -sSf http://localhost:8080/salud || veloz_morir 1 "la API no contesta correctamente"

Memoriza -sSf como un bloque: silencioso, pero que se queje, y que falle de verdad. Es el error más extendido en scripts que hablan con APIs, y produce el peor de los fallos posibles: uno silencioso que hace creer que todo va bien.

  1. Métodos, cabeceras y cuerpos

Para APIs REST hacen falta cuatro opciones más:

curl -sSf -X POST -H 'Content-Type: application/json' -H "Authorization: Bearer $VELOZ_TOKEN" \
     -d '{"ciudad":"Madrid","estado":"incidencia"}' http://localhost:8080/envios

-X fija el método (GET, POST, PUT, DELETE); no hace falta con -d, que ya implica POST. -H añade una cabecera y se repite tantas veces como haga falta. -d envía el cuerpo —si empieza por @, lo lee de un fichero: -d @cuerpo.json—. Para parámetros con caracteres especiales existe --data-urlencode, que codifica el valor: curl -sSfG --data-urlencode "ciudad=Palma de Mallorca" http://localhost:8080/envios. Ese -G convierte los datos en parámetros de la URL en lugar de cuerpo, así que el resultado es /envios?ciudad=Palma%20de%20Mallorca. Escribir esa URL a mano con una variable de Bash es un fallo esperando ocurrir: un espacio, un & o un acento en el valor y la petición cambia de significado.

  1. Ficheros, redirecciones y tiempos de espera

Opción Qué hace
-o fichero Guarda el cuerpo en ese fichero (-o /dev/null para tirarlo)
-O Guarda con el nombre que tiene en la URL
-L Sigue las redirecciones 301/302 (no lo hace por defecto)
--connect-timeout N Máximo para establecer la conexión
--max-time N Máximo para toda la operación

Los dos tiempos de espera son distintos y hay que poner los dos. --connect-timeout 5 corta rápido cuando la máquina no está; --max-time 30 protege del servidor que acepta la conexión y luego se queda pensando indefinidamente. Un curl sin --max-time en un script automático es una bomba de relojería: el día que el servicio se atasque, tu tarea de cron se quedará colgada para siempre y bloqueará las siguientes (07-01).

  1. Credenciales sin dejar rastro

-u usuario:clave hace autenticación básica, pero escrita así la contraseña aparece en ps, en el historial (02-06) y en cualquier registro de la orden. Las dos formas correctas:

curl -sSf --netrc-file ~/.netrc https://api.veloz.example/envios     # credenciales en fichero 600
curl -sSf -H "Authorization: Bearer $VELOZ_TOKEN" http://localhost:8080/metricas

--netrc-file lee máquina, usuario y clave de un fichero con permisos 600, igual que veloz-ops.conf (05-06). La segunda opción usa una variable de entorno cargada desde ese fichero de configuración: no queda en el historial, aunque sí es visible en /proc/<pid>/environ para el propio usuario y root. Lo que nunca debe hacerse es incrustar la credencial en la URL, como ya se vio en 06-04.

  1. Códigos HTTP: qué debe distinguir un script

Familia Significado Qué debe hacer el script
2xx Correcto Continuar
3xx Redirección Seguirla con -L, o tratarla como configuración incorrecta
4xx te equivocaste (401, 403, 404, 422) Fallar y no reintentar: reintentar no lo va a arreglar
5xx El servidor falló (500, 502, 503) Reintentar con retroceso (06-04); puede ser temporal
Sin respuesta Red, DNS, tiempo agotado Distinguirlo de un 5xx en el mensaje

Esa distinción entre 4xx y 5xx es la que hace útil un script de integración: reintentar un 404 es perder el tiempo, y no reintentar un 503 es dar por caído un servicio que se estaba reiniciando. Y recuerda el punto de partida: sin -f, curl devuelve 0 con cualquier código, así que sin él ni siquiera te enteras.

  1. Cuerpo y código de estado en una sola llamada

-f te dice que hubo error, pero descarta el cuerpo, que suele contener la explicación. Para tener ambos sin hacer dos peticiones, se pide el código con -w al final y se separa después:

respuesta=$(curl -sS -w '\n%{http_code}' --max-time 10 "http://localhost:8080/envios?ciudad=Madrid")
codigo="${respuesta##*$'\n'}"        # ultima linea: el codigo
cuerpo="${respuesta%$'\n'*}"         # todo lo anterior: el cuerpo
case "$codigo" in
    2??) veloz_log_info "consulta correcta" ;;
    4??) veloz_morir 1 "peticion incorrecta ($codigo): $cuerpo" ;;
    5??) veloz_log_error "error del servidor ($codigo), reintentando"; return 1 ;;
    *)   veloz_morir 1 "sin respuesta de la API" ;;
esac

Aquí se juntan tres cosas de módulos anteriores: -w '\n%{http_code}' añade el código en una línea nueva al final, las expansiones ${var##*} y ${var%*} de 04-04 lo separan del cuerpo sin lanzar procesos, y el case con globs de 04-05 clasifica por familia con 2??. Fíjate en que aquí no se usa -f: queremos el cuerpo del error, así que la decisión la toma el case.

  1. Por qué NO se parsea JSON con grep o sed

La tentación es enorme y el resultado siempre acaba mal. Para sacar el estado de /salud, lo que todo el mundo escribe la primera vez es curl -s $API/salud | grep -o '"estado":"[^"]*"' | cut -d'"' -f4. Funciona hasta que deja de hacerlo, y las razones no son rebuscadas: el JSON puede venir compacto o con saltos de línea (tu regex depende del formato, que el servidor puede cambiar sin avisar); el orden de las claves no está garantizado; puede aparecer un objeto anidado con otra clave estado y tu grep cogerá la primera que encuentre; un valor puede contener comillas escapadas ("mensaje":"error \"grave\"") que destrozan [^"]*; y null, los números y los booleanos no llevan comillas, así que el patrón ni los ve. En resumen: JSON no es un formato de líneas, y las herramientas de líneas no pueden entenderlo con fiabilidad. Lo que hace falta es un analizador, y ese es jq.

  1. jq: el modelo de filtros

jq es un lenguaje de filtros: recibe un documento JSON, le aplica una expresión y emite JSON. La idea central es que todo filtro transforma una entrada en cero, una o varias salidas, y se encadenan con | como las tuberías del shell.

Supongamos que /salud devuelve {"estado":"ok","version":"2.4.1","bd":{"conectada":true,"latencia_ms":12},"cola":[3,7,2]}:

Filtro Resultado Qué hace
. Todo el documento formateado Identidad; útil para leerlo
.estado "ok" Accede a una clave
.bd.latencia_ms 12 Ruta anidada
.cola[0] 3 Índice de un array
.cola[1:] [7,2] Rebanada
.cola[] 3, 7, 2 Itera: emite tres salidas separadas
.cola | length 3 Longitud de array, objeto o cadena
keys ["bd","cola","estado","version"] Claves ordenadas
has("estado") true ¿Existe esa clave?
.faltante null Una clave inexistente no es un error
.estado // "desconocido" "ok" Valor por defecto si es null o false
.bd.x? (nada) ? suprime el error si el tipo no encaja

Las dos últimas filas son las que evitan la mitad de los sustos: // da un valor por defecto cuando la API omite un campo, y ? impide que un error de tipo aborte todo el filtro. La diferencia entre .cola (un array) y .cola[] (tres salidas) es el concepto que hay que interiorizar: la mayoría de los filtros interesantes trabajan sobre flujos de valores, no sobre uno solo.

  1. Seleccionar, filtrar y agregar

Sobre una respuesta de /envios?ciudad=Madrid, que devuelve un array de objetos con id, estado e importe:

curl -sSf "$API/envios?ciudad=Madrid" | jq '.[] | select(.estado == "incidencia")'
curl -sSf "$API/envios?ciudad=Madrid" | jq '[.[] | .importe] | add'
Filtro Qué hace
select(cond) Deja pasar solo los elementos que cumplen la condición
map(f) Aplica f a cada elemento de un array ([.[] | f] abreviado)
add Suma los elementos de un array (o concatena)
length Número de elementos, claves o caracteres
sort_by(.campo) Ordena un array por ese campo
min / max / min_by(.c) Extremos
group_by(.campo) Agrupa en un array de arrays (requiere ordenar por el mismo campo)
to_entries Convierte {"a":1} en [{"key":"a","value":1}] para poder iterar objetos

Los corchetes de [.[] | .importe] son importantes: .[] | .importe emite tres valores sueltos, y add necesita un array, así que hay que recogerlos. Confundir "flujo de valores" con "array" es el error número uno con jq, y se arregla siempre con [ ] o con map(). Verás que group_by + map es la misma agregación que hacías con arrays asociativos en awk (06-01), solo que sobre JSON.

  1. Controlar la salida: -r, -c, -e y @tsv

Por defecto jq emite JSON, así que una cadena sale entrecomillada: "ok", no ok. Al asignarla a una variable de Bash, esas comillas viajan con ella y estropean cualquier comparación posterior.

Opción Efecto
-r Raw: emite las cadenas sin comillas
-c Compacto: cada resultado en una sola línea
-e El código de salida refleja el resultado: 1 si fue null o false
-n No lee entrada; construye desde cero (sección 11)
estado=$(curl -sSf "$API/salud" | jq -r '.estado')     # "ok" -> ok
[[ "$estado" == ok ]] || veloz_log_error "API en estado $estado"
curl -sSf "$API/salud" | jq -e '.bd.conectada' >/dev/null || veloz_log_error "BD desconectada"

-e convierte una consulta JSON en una condición de shell, sin variables intermedias. Y para volver al terreno de awk, @tsv y @csv convierten arrays en columnas:

curl -sSf "$API/envios?ciudad=Madrid" | jq -r '.[] | [.id, .estado, .importe] | @tsv' |
    awk -F'\t' '{ s[$2] += $3 } END { for (e in s) printf "%-12s %8.2f\n", e, s[e] }'

Ese encadenamiento es el puente entre los dos mundos: jq entiende la estructura y la aplana a columnas; awk agrega. @csv entrecomilla los campos según las reglas de CSV, y ambos requieren -r para que no salga todo como una cadena JSON escapada.

  1. Construir JSON de forma segura

Generar JSON a mano —printf '{"ciudad":"%s","nota":"%s"}\n' "$ciudad" "$nota"— funciona hasta el primer valor con una comilla, una barra invertida o un salto de línea, y entonces produce JSON inválido, o peor, JSON válido con el contenido cambiado. La forma correcta es jq -n, que construye desde cero, con --arg para cadenas y --argjson para valores que ya son JSON (números, booleanos, objetos):

jq -n --arg fecha "$FECHA" --arg host "$(hostname)" \
      --argjson total "$total" --argjson tasa "$tasa" \
      '{fecha: $fecha, host: $host, total: $total, tasa_entrega: $tasa}'
# -> {"fecha":"2026-08-03","host":"srv-veloz-01","total":1001,"tasa_entrega":87.3}

jq se encarga del escapado: una comilla dentro de $nota sale como \", un salto de línea como \n y los acentos se codifican correctamente. La distinción entre --arg y --argjson es la trampa habitual: --arg total 1001 produce "1001" (cadena) y --argjson total 1001 produce 1001 (número). Si el valor puede venir vacío, --argjson fallará —jq no acepta JSON inválido—, así que conviene un ${total:-0}. Y como con awk -v en 06-01, el principio es el mismo: los valores de Bash entran como parámetros, nunca interpolados dentro del filtro, porque interpolarlos es inyección de código.

  1. Leer JSON en Bash, y JSON como configuración

Para recorrer una respuesta en Bash, la combinación es jq -r '.[] | @tsv' con el bucle de 04-01:

while IFS=$'\t' read -r id estado importe; do
    [[ "$estado" == incidencia ]] && veloz_log_error "envio $id con incidencia ($importe EUR)"
done < <(curl -sSf "$API/envios?ciudad=Madrid" | jq -r '.[] | [.id, .estado, .importe] | @tsv')

@tsv es preferible a separar por espacios porque los valores pueden contenerlos, y también escapa los tabuladores y saltos de línea que hubiera dentro de un campo. La sustitución de procesos < <( ) de 05-05 mantiene el bucle en el shell actual. Cuando lo que necesitas es un array de Bash con una sola columna, mapfile -t ids < <(jq -r '.[].id' <<< "$json") (04-03) es más directo.

JSON también sirve como formato de configuración, y jq lo lee con las mismas reglas: jq -r '.umbrales.disco // 85' etc/veloz.json devuelve el valor o el defecto si falta. Frente al clave=valor de veloz-ops.conf (05-06), gana en estructura anidada y tipos, y pierde en que no se puede cargar con source y no admite comentarios. Para YAML —el formato de los ficheros de CI y de Kubernetes— existe yq, que replica la sintaxis de jq sobre YAML e incluso convierte entre ambos con yq -o=json. Si ya sabes jq, sabes yq.

  1. Aplicación: el toolkit habla con la API y publica JSON

Primero, estado-servicio.sh consulta la API de verdad y distingue caída de error de aplicación, que es la diferencia entre reiniciar el servicio y avisar al equipo de desarrollo:

comprobar_api_json() {
    local api="http://localhost:8080" cuerpo codigo salida
    salida=$(curl -sS -w '\n%{http_code}' --connect-timeout 3 --max-time 10 "$api/salud") || {
        veloz_log_error "veloz-api: sin respuesta (red, DNS o servicio caido)"; return 1; }
    codigo="${salida##*$'\n'}"; cuerpo="${salida%$'\n'*}"
    [[ "$codigo" == 2?? ]] || {
        veloz_log_error "veloz-api: HTTP $codigo — $(jq -r '.mensaje // "sin detalle"' <<< "$cuerpo")"
        return 1; }
    jq -e '.estado == "ok"' <<< "$cuerpo" >/dev/null ||
        veloz_log_error "veloz-api: viva pero degradada ($(jq -r '.estado' <<< "$cuerpo"))"
    veloz_log_info "veloz-api $(jq -r '.version' <<< "$cuerpo") — BD $(jq -r 'if .bd.conectada then "ok" else "KO" end' <<< "$cuerpo")"
}

Los tres desenlaces son distintos a propósito: sin respuesta (el curl falla), respuesta con error HTTP (se extrae .mensaje del cuerpo para el aviso) y respuesta correcta pero con estado degradado. jq -e usa el JSON como condición y el if ... then ... else ... end de jq formatea el booleano para el mensaje. Nótese <<< "$cuerpo", el here-string de 05-05: evita releer la respuesta de la red cada vez.

Y informe-diario.sh cierra el círculo publicando su resumen como JSON, construido con jq -n:

publicar_resumen() {
    local destino="$BASE_DIR/logs/resumen-$(date +%F).json" tmp
    tmp=$(mktemp) && trap 'rm -f "$tmp"' RETURN
    jq -n --arg fecha "$(date +%F)" --arg host "$(hostname -f)" \
          --argjson total "${total:-0}" --argjson tasa "${tasa:-0}" \
          '{generado: (now | todate), fecha: $fecha, host: $host,
            resumen: {envios: $total, tasa_entrega: $tasa}}' > "$tmp" && mv "$tmp" "$destino"
    veloz_log_info "resumen publicado en $destino"
}

El mktemp con trap de 05-01 y el mv final garantizan que nadie lea nunca un fichero a medio escribir: o existe el resumen completo o existe el del día anterior. now | todate genera la marca de tiempo en UTC ISO-8601 sin llamar a date. Y ahora el informe ya no es solo un texto para leer: es un dato que otro programa —un panel, una alerta, el proyecto 09-05— puede consumir sin volver a parsear nada.

Errores Comunes y Consejos

  • curl sin -f (o sin comprobar el código). Devuelve 0 con un 500 y el script continúa como si nada. Usa -sSf, o -w '%{http_code}' y un case.
  • curl sin --max-time. Un servicio atascado cuelga la tarea de cron indefinidamente.
  • Parsear JSON con grep/sed. Falla con el formato compacto, el orden de claves, los anidados y las comillas escapadas. Usa jq.
  • Olvidar -r. estado=$(jq '.estado') guarda "ok" con comillas y [[ $estado == ok ]] falla.
  • Confundir flujo con array. .[] | .importe emite valores sueltos; add necesita [.[] | .importe].
  • --arg para números. Produce "1001" en vez de 1001; para números y booleanos, --argjson.
  • Interpolar variables de Bash dentro del filtro jq. Mismo riesgo de inyección que en awk: usa --arg.
  • Reintentar un 4xx. No se va a arreglar solo. Reintenta los 5xx y los fallos de red, con retroceso (06-04).
  • Consejo: construye los filtros por pasos (jq '.', luego jq '.campo'…) contra una respuesta de ejemplo guardada en un fichero: es más rápido que llamar a la API, funciona sin conexión y jq se depura mucho mejor en incrementos que de un tirón.

Ejercicios

Ejercicio 1. Escribe una función veloz_api_get para lib/comun.sh que haga GET a una ruta de la API, devuelva el cuerpo por la salida estándar, distinga red / 4xx / 5xx con mensajes distintos y códigos de salida diferentes, y no pueda colgarse.

Ejercicio 2. Con /metricas devolviendo {"peticiones":48210,"errores":37,"latencia_p95_ms":210,"envios_cola":14}, escribe una comprobación que avise si la tasa de errores supera el 0,1 % o si la latencia p95 pasa de 500 ms, tratando los campos que falten como 0.

Ejercicio 3. Convierte la salida de /envios?ciudad=Madrid (array de objetos con id, repartidor, estado, importe) en un resumen por repartidor con número de envíos e importe total, ordenado de mayor a menor, usando solo jq.

Soluciones

Solución 1.

# veloz_api_get — GET a la API. Uso: veloz_api_get <ruta>. Devuelve el cuerpo por stdout.
veloz_api_get() {
    local ruta="${1:?falta la ruta}" base="${VELOZ_API:-http://localhost:8080}" salida codigo
    salida=$(curl -sS -w '\n%{http_code}' --connect-timeout 3 --max-time 15 "$base$ruta") ||
        { veloz_log_error "API: sin respuesta en $ruta"; return 69; }
    codigo="${salida##*$'\n'}"
    case "$codigo" in
        2??) printf '%s\n' "${salida%$'\n'*}" ;;
        4??) veloz_log_error "API: peticion incorrecta ($codigo) en $ruta"; return 64 ;;
        *)   veloz_log_error "API: error del servidor ($codigo) en $ruta"; return 75 ;;
    esac
}

Los tres códigos son deliberados y siguen la tabla de 05-03: 69 (EX_UNAVAILABLE) cuando no hay respuesta, 64 (EX_USAGE) para un 4xx —el error es nuestro— y 75 (EX_TEMPFAIL) para un 5xx, que es precisamente el código que le dice al llamante "esto es temporal, puedes reintentar". El cuerpo sale limpio por stdout y los diagnósticos por stderr (02-04), de modo que datos=$(veloz_api_get /salud) captura solo lo que interesa.

Solución 2.

metricas=$(veloz_api_get /metricas) || exit $?
read -r peticiones errores p95 < <(jq -r '[.peticiones // 0, .errores // 0,
    .latencia_p95_ms // 0] | @tsv' <<< "$metricas")
awk -v e="$errores" -v p="$peticiones" 'BEGIN { exit !(p > 0 && 100*e/p > 0.1) }' &&
    veloz_log_error "tasa de errores por encima del 0.1% ($errores de $peticiones)"
(( p95 > 500 )) && veloz_log_error "latencia p95 de ${p95}ms por encima del umbral"

El // 0 de cada campo evita que una métrica ausente se convierta en null y rompa las cuentas. Los tres valores se extraen en una sola invocación de jq con @tsv y un read, en vez de llamar tres veces. La tasa de errores es decimal, así que la comparación va en awk con el idioma exit !(...) de 06-03; la latencia es entera y le basta (( )).

Solución 3.

veloz_api_get "/envios?ciudad=Madrid" |
    jq -r 'group_by(.repartidor)
           | map({repartidor: .[0].repartidor, envios: length, total: (map(.importe) | add)})
           | sort_by(-.total)
           | .[] | [.repartidor, .envios, (.total | tostring)] | @tsv' |
    column -t

group_by produce un array de arrays —uno por repartidor—, y map lo convierte en objetos: .[0].repartidor toma el nombre de cualquier elemento del grupo, length cuenta y map(.importe) | add suma. sort_by(-.total) ordena descendente negando el valor, más limpio que ordenar y luego reverse. Al final, @tsv aplana a columnas y column -t (02-02) las alinea. Es exactamente la agregación de la solución 1 de 06-01, pero partiendo de JSON en vez de CSV.

Conclusión

Hablar con una API desde un script son dos herramientas y unas cuantas reglas. De curl: -sSf como bloque indivisible —silencioso, quejica ante fallos de red y fallando de verdad ante un error HTTP, porque sin -f devuelve 0 con un 500—; -X, -H, -d y --data-urlencode para construir la petición; -L, -o/-O para el cuerpo; --connect-timeout y --max-time siempre, o el día que el servicio se atasque colgarás la tarea de cron; credenciales por --netrc o cabecera desde un fichero 600, nunca en la URL; y -w '\n%{http_code}' para quedarte con cuerpo y código en una sola llamada y clasificar con un case que distinga 4xx (no reintentar) de 5xx (reintentar con retroceso). De jq: un lenguaje de filtros encadenados con | donde .campo, .a.b, .[], select, map, group_by, add, sort_by, length, to_entries, // y ? cubren casi todo; -r para sacar cadenas sin comillas, -e para usar JSON como condición, @tsv/@csv para volver al terreno de awk; y jq -n --arg/--argjson para construir JSON con el escapado resuelto, pasando los valores de Bash como parámetros y nunca interpolados. Y la regla que engloba todo: JSON no es un formato de líneas, así que grep y sed no sirven para leerlo.

Con esto se cierra el Módulo 6 y el toolkit de Veloz Envíos cambia de naturaleza. informe-diario.sh agrega en una sola pasada con awk y publica su resumen en JSON; estado-servicio.sh reúne el contexto del sistema, comprueba red y puerto, interroga a la veloz-api y distingue una caída de un error de aplicación. Ya no es un lector de ficheros: es una pieza integrada con el resto del sistema. Pero fíjate en lo que tienen en común todas las ejecuciones de este módulo: has sido tú quien ha escrito el comando. El informe diario solo existe si alguien se acuerda de lanzarlo, y una comprobación de estado que se ejecuta cuando ya sospechas que algo va mal llega tarde por definición. En el Módulo 7 eso se acaba: cron para que las tareas se ejecuten solas (07-01), el diseño de tareas verdaderamente desatendidas (07-02), respaldos automáticos (07-03), monitorización y registro continuos (07-04), servicios y temporizadores de systemd (07-05) y automatización remota con ssh (07-06). El toolkit deja de esperar tus órdenes.

Curso de Programación en Bash

Módulo 1: Introducción a Bash

Módulo 2: Comandos Básicos de Bash

Módulo 3: Fundamentos de Scripting

Módulo 4: Scripting Intermedio

Módulo 5: Técnicas Avanzadas de Scripting

Módulo 6: Trabajando con Herramientas Externas

Módulo 7: Automatización y Programación

Módulo 8: Mejores Prácticas y Optimización

Módulo 9: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados