El otro día @PeladoNerd confesaba en este vídeo SADSERVERS / Kihei, Unimak Island, Ivujivik (MEDIUM) que no entendía como funcionaba Jq. Inmediatamente empaticé con su frustación; es, sin duda, un sentimiento generalizado.
Jq es una herramienta realmente potente; pero “con un gran poder”, llega una curva de aprendizaje muy dura.
Por eso me he decidido a escribir este artículo: no porque sea un super gurú de Jq, que no lo soy; pero creo que puedo aportar algo de claridad sobre la manera de aproximarse a Jq evitando los problemas más habituales.
TL;DR: Al final de la entrada hay un resumen en formato bullet points Resumen: manual de supervivencia para Jq
Qué es Jq
Jq es una herramienta con la manipular ficheros JSON.
Una gran cantidad de APIs aceptan y devuelven datos en forma de ficheros JSON. Algunas bases de datos NoSQL también usan ficheros JSON… Así que antes o después, es muy probable que te encuentres en una situación en la que tengas que manipular ficheros JSON…
@PeladoNerd es un SRE con experiencia en Kubernetes, por lo que en vez de usar un fichero JSON “inventado”, he decidido usar un manifest de un Pod (con dos contenedores) en formato JSON para los ejemplos.
El fichero en cuestión lo he copiado de la página de la documentación de Kubernetes Creating a Pod that runs two Containers y lo he convertido a JSON usando éste conversor online.
Cómo ejecutar Jq
Jq obtiene el documento JSON como entrada, lo “procesa” y produce una salida.
Aquí sólo voy a comentar la capacidad de Jq para filtrar un fichero y obtener, a la salida, un subconjunto del objeto inicial. Jq puede hace muchas más cosas a parte de filtrar.
Para procesar el fichero pod.json
, los dos comandos mostrados a continuación son equivalentes:
-
Usar como “entrada” la salida de otro comando (usando una pipe
|
):$ cat pod.json | jq { "apiVersion": "v1", "kind": "Pod", "metadata": { "name": "two-containers", ...
-
Indicar a Jq el fichero a procesar:
$ jq . pod.json { "apiVersion": "v1", "kind": "Pod", "metadata": { "name": "two-containers", ...
Personalmente, prefiero la segunda forma porque indica claramente los tres componentes necesarios para usar Jq:
jq
: 😉.
: un filtro (cuando el filtro incluye espacios, debe ir entrecomillado)pod.json
: el fichero que Jq usa como input
Primer gotcha!
@PeladoNerd se topa con uno de los primero gotchas de Jq en este momento del vídeo: ¡completame el jq!.
Jq requiere un filtro; esto no es evidente usándolo de la forma cat ${nombre-fichero} | jq
, porque Jq asume como filtro por defecto .
, que representa “todo el documento”.
Si embargo, en la forma jq . ${nombre-fichero}
, Jq está esperando como primer parámetro un filtro, no un nombre de fichero; y eso hace que no funcione el autocompletar de la shell.
Para evitar tropezar con este comportamiento de Jq, recomiendo usar la forma jq '{filtro}' ${nombre-fichero}
.
Si aceptas como entrada la salida de otro comando, incluye el .
(aunque no sea necesario) para recordarte que Jq siempre requiere un filtro: cat ${nombre-fichero} | jq .
La forma jq '<filtro>' ${nombre-fichero}
usa únicamente Jq, mientras que usando echo
o cat
para enviar contenido a Jq dependes de la implementación de estas herramientas en el sistema operativo, así como el uso del pipe (|
) de la shell. echo
está implementado como un builtin, por lo que la implementación puede variar de un shell a otro.
Yo me topé con una de estas “pequeñas” diferencias con mktemp, que por algún motivo, no funciona igual en Bash que en Zsh; en particular, la opción de usar
-t
para generar ficheros temporales con un prefijo. Así que aunque creas que esas “pequeñas diferencias” no te afectarán nunca, confía en mí si te digo que aparecen cuando (y donde) menos te las esperas.
Qué es un objeto JSON
De la página de JSON:
JSON (JavaScript Object Notation) es un formato ligero de intercmbio de datos. Es fácil para los humanos de leer y de escribir. Es fácil para las máquinas analizarlo (parse) y generarlo.
El objeto JSON más simple es {}
. Todos los objetos JSON son colecciones de pares de clave y valor.
Como en mi día a día trabajo con objetos JSON de una base de datos NoSQL “documental”, suelo llamar “documento” a los “objetos” JSON delimitados por
{
y}
.
[]
es una lista (vacía). Una lista es una colección ordenada de valores cuyos valores están separados por ,
. Esta lista de objetos JSON también se llama array.
Jq proporciona el Array/Object Iterator, que tiene la forma
.[]
; aunque está relacionados con las listas de JSON, son cosas diferentes.
JSONPath
Quizás has usado JSONPath Support en Kubernetes. La sintaxis es muy parecida a la de Jq, pero no son lo mismo.
El siguiente comando obtiene el valor de status.capacity
en todos los pods; fíjate que usa el asterisco (*
) en .items[*]
para obtener la propiedad seleccionada de todos los pods.
# Sintaxis de JSONPath
kubectl get pods -o=jsonpath="{.items[*]['metadata.name', 'status.capacity']}"
En Jq el asterisco *
es el operador para la multiplicación y no tiene sentido usarlo en un array.
# Sintaxis de Jq
$ jq '.spec.containers[*]' pod.json
jq: error: syntax error, unexpected '*' (Unix shell quoting issues?) at <top-level>, line 1:
.spec.containers[*]
jq: 1 compile error
Usando Jq
El filtro identidad
El filtro más sencillo es la identidad: .
hace que la salida sea igual a la entrada.
Jq formatea la salida por defecto; esto hace que el objeto JSON sea más fácil de “leer”:
$ echo '{"foo":"xxx","bar":"yyy"}' | jq . { "foo": "xxx", "bar": "yyy" }
Claves y valores
En el objeto del ejemplo anterior, foo
es una key y xxx
es el valor asociado a la key foo
.
Las keys en JSON siempre son string
, mientras que los valores pueden ser string
, number
, otros objetos JSON (delimitados por {
y }
), arrays
(listas de objetos), true
, false
o null
.
Podemos obtener las keys
de un objeto mediante la función keys
:
$ jq '. | keys' pod.json
[
"apiVersion",
"kind",
"metadata",
"spec"
]
Estos son los campos requeridos para que un manifest de Kubernetes sea válido: Required fields.
Para enlazar la salida de un filtro con una función o la salida de una función al siguiente filtro, usamos la pipe |
(como en la shell).
En el ejemplo, pasamos la salida del filtro identidad .
a la función de Jq keys
, que produce un array con las claves del documento de entrada.
Filtrando el valor de una clave
Empezando desde el primer nivel .
, podemos filtrar el valor de la clave como metadata
mediante jq '.metadata' pod.json
.
Si el valor de una determinada clave es otro objeto JSON, podemos ir descendiendo por las claves del objeto concatenando las keys en el filtro, como en jq '.spec.containers[].name' pod.json
.
Imagina que el objeto JSON es un árbol donde cada una de las claves es una rama.
Si queremos obtener el valor asociado a la clave apiVersion
tenemos que trazar un camino desde la raíz del documento (.
) hasta la clave; como apiVersion
“cuelga” directamente de la raíz, filtramos su valor mediante .apiVersion
:
$ jq '.apiVersion' pod.json
"v1"
Usaré esta analogía del “árbol” más adelante con imágenes generadas en el sitio web https://jsoncrack.com/editor
Nota: https://jsoncrack.com/editor agrupa las keys que no tienen descendientes (como
apiVersion
ykind
) y las separa de aquellas que sí los tienen (metadata
yspec
) en la vista gráfica lo que rompe un poco la analogía del “árbol”.
Comillas
En el ejemplo anterior he usado comillas simples ('
) para delimitar el filtro. También se pueden usar comillas dobles "
. Sin embargo, como las keys de los objetos JSON son cadenas delimitadas por comillas, es más cómodo usar comillas simples y no tener que escapar las comillas:
# Usando comillas simples (recomendado)
echo '{"foo":"xxx","bar":"yyy"}' | jq .
# Usando comillas dobles
echo "{\"foo\":\"xxx\",\"bar\":\"yyy\"}" | jq .
Gotcha! con las comillas de los valores
El valor de .apiVersion
es "v1"
, con las comillas incluidas.
Yo llamo a estas comillas en Jq comillas duras, ya que no se comportan como unas comillas “normales”.
En Bash, puedo entrecomillar los valores de una variable, pero las comillas sólo son “delimitadores” (para evitar problemas con espacios en blanco y otros caracteres), pero no forman parte del valor:
# Comillas normales
$ EJEMPLO="Hola mundo"; echo "$EJEMPLO"
Hola mundo
Aunque tanto el valor de la variable EJEMPLO
(Hola mundo
) está entrecomillado y que también la variable está entrecomillada en el comando echo
, la salida no muestra las comillas.
En Jq esto no es así y suele provocar problemas.
Considera el siguiente ejemplo:
$ jq '.apiVersion' pod.json
"v1"
$ [[ $(jq '.apiVersion' pod.json) == "v1" ]] || echo "no son iguales"
no son iguales
Para que el test se verifique, necesito incluir las comillas como parte del valor de la key apiVersion
:
$ [[ $(jq '.apiVersion' pod.json) == "\"v1\"" ]] || echo "no son iguales"
$
O usando comillas simples:
$ [[ $(jq '.apiVersion' pod.json) == '"v1"' ]] || echo "no son iguales"
$
En Bash, las comillas simples preservan el valor literal de los caracteres dentro de las comillas. Pero eso hace que no se susituyan las variables por su valor, que es lo que quieres habitualmente:
# Se compara el resultado del comando Jq ("v1") con el valor de la variable `$EXPECTED_VALUE` (v1) (sin comillas) $ EXPECTED_VALUE="v1" ; [[ $(jq '.apiVersion' pod.json) == "$EXPECTED_VALUE" ]] || echo "no son iguales" no son iguales # Se compara el resultado del comando Jq ("v1") con el literal `$EXPECTED_VALUE` $ EXPECTED_VALUE="v1" ; [[ $(jq '.apiVersion' pod.json) == '$EXPECTED_VALUE' ]] || echo "no son iguales" no son iguales # Usando comillas dobles, debemos escapar las comillas en el valor esperado $ EXPECTED_VALUE="\"v1\"" ; [[ $(jq '.apiVersion' pod.json) == "$EXPECTED_VALUE" ]] || echo "no son iguales" $
Estas comillas duras siguen confundiéndome y haciendo que los scripts fallen más a menudo de lo que creerías; afortunadamente, Jq incluye la opción de eliminar las comillas en la salida; para ello, usa el flag --raw-output
o -r
en versión corta:
$ jq -r '.apiVersion' pod.json
v1
Observa como la salida no incluye las comillas.
Filtros más complejos
Si queremos el valor de campo name
del Pod, tenemos que descender “dos niveles”:
- la clave
metadata
(primer nivel) - la clave
name
del objeto JSON resultante de filtrar la entrada inicial (todo el documento leído desdepod.json
) y aplicar el filtro anterior (.metadata
).
Así, el filtro resultante es:
# (El primer filtro `. |` es opcional y normalmente, se omite)
$ jq -r '. | .metadata.name' pod.json
two-containers
Pipe interna de Jq
Jq filtra una entrada y produce una salida; la salida de un filtro puede encauzarse a través de una pipe (|
) y utilizarse como entrada del siguiente filtro.
Podemos enlazar filtros dentro de Jq o usar el |
de la shell; comprueba como los dos comandos siguientes son equivalentes:
Podemos concatenar los filtros uno tras otro con
.
, enlazando las keys por las que descendemos en el objeto JSON (como en.metadata.name
). Sólo es necesario usar una pipe interna cuando queremos pasar el resultado de un filtro a una función de Jq o viceversa.
# Pipe interna de Jq (en este caso no es necesario usar la *pipe*)
$ jq -r '.metadata | .name' pod.json
two-containers
# (Opción recomendada)
$ jq -r '.metadata.name' pod.json
two-containers
# Pipe "externa" a Jq
$ jq '.metadata' | jq -r '.name'
two-containers
Observa que para que el resultado final no contenga “comillas duras” es necesario usar
-r
en el último comando Jq.
Mi recomendación es concatenar los filtros siempre que sea necesario; si usamos funciones de Jq, usar la pipe interna; no sólo es más compacto, sino que además mantiene la estructura de jq '{filtro}' ${nombre_fichero}
.
Errores
Parte de la frustación de usar Jq es que, en mi humilde opinión, los mensajes de error son increíblemente crípticos.
Intenta adivinar qué puede haber causado el siguiente mensaje de error:
jq: parse error: Invalid numeric literal at line 2, column 0
Y ahora la solución:
$ echo '<html></html>' | jq .
jq: parse error: Invalid numeric literal at line 2, column 0
Creo que el problema lo causa que Jq interpreta <
como un operador de comparación entre números (x es menor que y). Antes de <
no hay nada, con lo que quizás lo interpreta como 0, pero h
(el caracter que aparece después de <
) no es un valor numérico, lo que causa el error de Invalid numeric literal
…
Como ves, (si mi interpretación es correcta), el mensaje de error tiene sentido; el problema es que no es aparente a primera vista.
Como muchas veces el JSON que se procesa por Jq se obtiene desde una API, es tentador usar algo como:
# Se espera un JSON como `{"foo" : "bar"}` value=$(curl -s https://$ENDPOINT/api/ | jq -r '.foo')
Si el token para acceder al endpoint ha caducado, o hay un problema cualquiera con la petición, el endpoint puede devolver HTML (por ejemplo, para mostrar un error
<h1>401 - > Unauthorized</h1>
). El problema es que el mensaje de error de Jq se “guarda” en$value
y el script fallará de forma inesperada más adelante, cuando vayas a usar el valor de$value
pensando que esbar
.La recomendación es guardar la respuesta en un fichero; verifica el código HTTP devuelto por el servidor e inspecciona el contenido del fichero para validar que contiene lo que esperas y no otra cosa.
Seleccionando valores de una lista
He elegido el JSON de un Pod con dos contenedores para poder ilustrar un escenario común: el valor que nos interesa obtener está en un objeto JSON dentro de un array.
En el siguiente ejemplo queremos obtener la propiedad name
de cada uno de los contenedores en el pod; como hemos visto, tenemos que ir descendiendo por las ramas del “árbol” del objeto JSON; hasta ahora a cada key le correspondía un valor o un objeto, con sus propias keys.
En este caso, cuando llegamos a .spec.containers
tenemos una lista de objetos, cada uno con la clave name
(en la que estamos interesados).
Para obtener la clave name
de cada uno de los elementos del array, tenemos que iterar sobre todos ellos; Jq proporciona el operador .[]
. Para cada uno de los elementos, seleccionamos la key name
. El filtro resultante es:
$ jq -r '.spec.containers.[].name' pod.json
nginx-container
debian-container
Traduciendo el filtro a castellano: selecciona la key spec
; del resultado, selecciona la key containers
, itera sobre todos sus elementos (con .[]
) y selecciona para cada uno de ellos la key name
. Como vemos, la salida son los dos nombres de los contenedores que contiene el manifest del Pod.
En este caso, es equivalente usar
jq -r '.spec.containers[].name' pod.json
; en vez de iterar sobre cada elemento de la listacontainers
.
Seleccionando un valor específico de una lista
Usando el índice del array
Si por algún motivo sabes en qué posición se encuentra el objeto en el que estás interesado en la lista (que en JSON está ordenada), puedes acceder al elemento específico indicando su posición en el array:
jq -r '.spec.containers.[0].name' pod.json
nginx-container
De esta forma puedes seleccionar más de un elemento, indicando la posición de cada elemento que quieres obtener.
En el siguiente ejemplo, obtendo el primer y segundo elemento del arrray:
$ jq -r '.spec.containers.[0,1].name' pod.json
nginx-container
debian-container
Filtrando los valores del array mediante select()
Sin embargo, no es habitual saber en qué posición se encuentra el elemento, sino el valor de alguna de sus keys
.
Imagina que quieres obtener el mountPath
del contenedor cuyo nombre es debian-container
. Esta frase recuerda a una query SQL, ¿no?:
SELECT 'mountPath' FROM pod.json WHERE name='debian-container';
Jq ofrece la función select()
que permite algo así.
Quizás lo primero que se te ocurre probar es algo como:
jq 'select( .spec.containers[].name == "debian-container" )' pod.json
El resultado es todo el documento… ¿?
Una de las cosas más importantes a recordar cuando trabajas con Jq es que lo único que hace Jq es filtrar la entrada (para producir una salida).
El filtro en el comando anterior es equivalente a '. | select( .spec.containers[].name == "debian-container" )'
; traduciendo al castellano: usando como entrada todo el objeto JSON, pasa hacia el output los elementos que validen la condición especificada en el select
. Como el select
es true
, porque el objeto de entrada valida la condición indicada en el select
, todo el input es mostrado en la salida.
El enfoque que uso es filtrar el documento inicial hasta llegar a la clave en la que hay más de una opción disponible; en este caso, la key containers
.
Como containers
es una lista (array), explicitamos que lo que queremos pasar al siguiente filtro es cada uno de sus elementos de manera independiente, aceptando únicamente aquellos para los que se valide una condición que especifiquemos.
Podemos conseguirlo mediante el filtro .spec.containers[]
o usando el operador de iteración sobre la clave containers
, que es un array, mediante .spec.containers.[]
.
Observa la salida de jq '.spec.containers[]' pod.json
comparándola con la de jq '.spec.containers' pod.json
:
# La salida de .spec.containers[] son dos objetos JSON independientes: '{1}' y '{2}'
$ jq '.spec.containers[]' pod.json
{
"name": "nginx-container",
...
}
{
"name": "debian-container",
...
}
# La salida de .spec.containers es un solo elemento, un array '[{1}, {2}]'
$ jq '.spec.containers' pod.json
[
{
"name": "nginx-container",
...
},
{
"name": "debian-container",
...
}
]
La función select()
puede actuar sobre listas de elementos individuales, como en el primer ejemplo de la documentación de Jq (algo como [1,2,3]
) o con elmentos por parejas de clave=valor
(que es lo que tenemos en nuestro caso).
@PeladoNerd se encuentra con problemas al intentar hacer un
select
en este momento.
select
filtra la entrada que recibe y sólo “deja pasar” lo que resulta en true
en la condición:
En nuestro caso, tras el filtro de select()
sólo tenemos el manifest correspondiente al contenedor que valida la condición name=="debian-container"
:
$ jq '.spec.containers[] | select( .name == "debian-container" )' pod.json
{
"name": "debian-container",
"image": "debian",
"volumeMounts": [
{
"name": "shared-data",
"mountPath": "/pod-data"
}
],
"command": [
"/bin/sh"
],
"args": [
"-c",
"echo Hello from the debian container > /pod-data/index.html"
]
}
De forma gráfica (pulsa sobre la imagen para ampliarla):
A continuación, podemos seguir añadiendo filtros a la salida de select()
; el siguiente sería usando la clave .volumeMounts
(que vuelve a ser una lista).
En este caso concreto, la lista sólo contiene un elemento, por lo que los dos comandos siguientes producen el mismo resultado:
$ jq -r '.spec.containers[] | select ( .name == "debian-container" ) | .volumeMounts[].name' pod.json
shared-data
$ jq -r '.spec.containers[] | select ( .name == "debian-container" ) | .volumeMounts.[].name' pod.json
"shared-data"
De nuevo, la representación visual del filtro sería (pulsa sobre la imagen para ampliarla):
Si queremos estar seguros de que obtenemos el valor de la key mountPath
con nombre shared-data
y no el valor de otra key que pueda incluir la lista volumeMounts
, podemos usar select()
de nuevo:
$ jq '.spec.containers[] | select( .name == "debian-container" ) | .volumeMounts[] | select( .name == "shared-data" ) | .mountPath' pod.json
"/pod-data"
Es decir, de todos los elementos contenidos en el array volumeMounts
, seleccionamos el que verifica .name == "shared-data"
; a continuación, filtramos el contenido para obtener únicamente el valor de la key mountPath
.
select()
puede contener cualquier expresión que se evalúe comotrue
ofalse
; en la solución al reto, vemos que se usa una condición en la que se evalúa el valor dos keys usando el operadorand
.
Resumen: manual de supervivencia para Jq
-
Recuerda que Jq procesa una entrada mediante un filtro para producir una salida.
Siempre que puedas, usa
jq '<filtro>' ${nombre-fichero}
. Si usas como entrada contenido que llega a través de una pipe, incluye siempre el filtro (incluso cuando no sea necesario, como en el siguiente ejemplo) para reforzar la idea de que Jq siempre usa un filtro:cat ${nombre-fichero} | jq .
. -
Usa siempre comillas (simples) para delimitar el filtro (incluso cuando no sea necesario):
jq '.name.containers[].name' pod.json
. Si te acostumbras, nunca tendrás problemas si el filtro incluye espacios.Jq no tiene en cuenta el espacio en blanco, lo que te puede ayudar a formatear filtros complejos para facilitar su visualización (especialmente en scripts):
jq -r '.spec.containers[] | select ( .name == "debian-container" ) | .volumeMounts[].name ' pod.json
-
Cuidado con las comillas en la salida del comando Jq.
Recuerda el flag
-r
; si no te preocupan las comillas, usa-r
siempre para evitar problemas en aquellas situaciones en las que sí sean relevantes (como en scripts). -
Concatena las keys y usa la pipe interna de Jq para enlazar filtros.
-
Recuerda que cuando enlazas filtros, la salida de un filtro se convierte en la entrada del siguiente.
-
select
filtra los valores de la entrada para los que la condición de evalúa comotrue
y sólo esos pasan hacia la salida (o el siguiente filtro).