24 diciembre 2023

"Godot 4 Game Development Cookbook" por Jeff Johnson

Este libro formaba parte de un paquete que sacó hace algún tiempo Humble Bundle, con libros para desarrollar juegos con Unity y con Godot.

El libro ofrece una serie de tutoriales sobre casos de uso específicos de Godot. Hay que reconocer que el índice es sugerente y cubre temas de nivel intermedios, ideales si tienes ya cierta experiencia con otros engines y acabas de terminar los tutoriales iniciales de Godot.

Hay una introducción a las funcionalidades avanzadas recién introducidas en GDScript 2.0. Se explica cómo introducir niebla volumétrica en los escenarios, sistemas de partículas decals, búsqueda de caminos, modelos deformables, tiles, emisores de sonido y se ofrece una aproximación inicial a las funcionalidades multijugador de Godot.

Ofrece también recetas sobre shaders, pero son tas superficiales y el tema tan extenso que esas recetas me han parecido prácticamente inútiles.

Las diferentes recetas siguen una estructura común. Primero se ofrece una descripción muy resumida de lo que se quiere conseguir con la receta. Luego hay una sección "How to do it" donde se enumeran los pasos a realizar a través del editor de Godot. Por últimos hay una sección de "How it works..." que se supone que debería explicar con profundidad los pasos que se han realizado en la sección anterior.

La selección de temáticas para las recetas me parece muy adecuada y sugerente. La descripción de las tareas a realizar es clara y no he tenido problemas para seguirlas. Aunque en alguna faltaba alguna cosilla para que funcionase y he tenido que completarla de mi cosecha. En general, he disfrutado las siguientes prácticas porque me han permitido descubrir lo lejos que puede llegar Godot. 

Sin embargo, donde el libro hace aguas es en las secciones "How it works...". Vale que la sección "How to do it" se limite a enumerar pasos, pero en la de "How it works" se supone que es donde debería explicar con detalle por qué se han llevado a cabo los pasos anteriores y así profundizar en las funcionalidades del engine. En vez de eso, lo que hace el autor es volver a contar las tareas realizadas, pero de manera literaria en vez de enumerada. Vamos, que la sección "How it works" no aporta absolutamente a la de "How to do it". Si más o menos has ido entendiendo los pasos de la sección "How to do it" te puedes ahorrar la de "How it works" porque no te va a aportar nada. Eso limita enormemente el valor aportado por el libro. Mi consejo, si lees este libro, es que consultes la documentación de Godot de los diferentes componentes conforme vayas avanzando por los pasos de los diferentes tutoriales. Así podrás suplir parcialmente la falta de explicaciones del autor.

En mi opinión, se trata de un nuevo ejemplo de la nula labor editorial de Packt. Creo que el problema del libro no es que su autor desconozca el tema sino de que la editorial no repasó el libro y no le alertó de que estaba enfocando mal las secciones "How it works". Packt sigue siendo una máquina de sacar libros donde prima sobre todo la cantidad sobre la calidad.

No me arrepiento de haber leído este libro. Realmente me ha permitido cacharrear con Godot y convencerme de que es un engine viable para hacer juegos de todo tipo. Tampoco es que haya muchos libros sobre Godot 4 en el mercado. La mayoría son de hecho los de Packt, así que hay poca alternativa. Lo que pasa es que no me parece que el libro valga su precio completo. Rebajado, o incluido en un paquete como los de Humble Bundle, puede ser, pero no creo que merezca la pena el precio habitual que tiene. Antes que eso hay múltiples tutoriales en Youtube mucho mejores y más asequibles.  

11 noviembre 2023

"Godot 4 Game Development Projects" por Chris Bradfield

Ya he dicho en alguna ocasión que no me gustan los libros de la editorial Packt, Siempre me ha parecido que priman la cantidad sobre la calidad. La culpa no suele ser de los autores, que en Packt los hay malos y también buenos, sino de que la editorial no hace su trabajo de pulir los contenidos para que cumplan un estándar mínimo de calidad antes de llegar al público. Por eso, hay demasiados libros de Packt que desprenden un tufillo a tutorial recién copia-pegado de Internet, con incoherencias, repeticiones y fallos de contenido y formato. La mayor parte de las veces, si la editorial hubiera ejercido de tal y hubiese guiado mínimamente a los autores, el resultado podría haber sido mucho más decente. Hay cosas que puedes admitirle a un libro autoeditado, pero cuando hay detrás una editorial que te pone los libros al mismo precio que un O'Reilly creo que el nivel de exigencia tiene que ser mayor. En la parte positiva de la balanza, y por lo que creo que aún salvan el negocio, está que esa enorme cantidad de publicaciones que sacan abarca temas muy novedosos y variados que otras editoriales tardarán al menos un año y medio más en cubrir y con mucha menor cantidad de libros. No es raro que busques un libro sobre un tema candente y que la única editorial con libros sobre el tema sea Packt. Otra cosa es su novel de calidad, como ya he dicho. Por eso suelo evitar los libros de Packt, a no ser que me encuentre un pack de sus libros a buen precio. En este caso, el libro que le da nombre a este artículo estaba incluido en un pack de libros de desarrollo de videojuegos que se lanzó a través de Humble Bundle. Por casi 20€ me compre un par de decenas de libros de Packt, así que por ese preció sí que me mereció la pena.

Sobre la base de lo anterior, este libro tiene a su favor que hay muy pocas obras que hablen sobre el desarrollo de juegos con Godot y menos sobre su recién estrenada versión 4, la cual incorpora suficientes novedades como para hacerla muy diferente de las versiones anteriores.

Se estructura en una serie de proyectos que hacen de tutoriales. Hay tres proyectos 2D, de dificultad creciente, y dos proyectos 3D. Esa estructura hace que el libro sea práctico y entretenido, pero no profundiza en las particularidades de diseño y enfoque que hacen tan especiales a Godot frente a, por ejemplo, Unreal o Unity. Aún así, si los tutoriales de la documentación oficial de Godot te han sabido a poco, este libro puede ser una buena continuación de ellos ya que tanto por forma como por contenido se parece mucho a ellos, pero los lleva un par de pasos más allá. De hecho, me gustó mucho el último capítulo en el que habla sobre cómo crear niveles proceduralmente en tiempo real.

Los tutoriales son detallados y claros de seguir. El código que utiliza es GDScript y diría que es un código limpio y bien estructurado. Creo que en general, la manera de trabajar con Godot que se ve en el libro no está lejos de las buenas prácticas oficiales. Así que es una buena obra introductoria a Godot.

En el plano negativo está el típico descuido de un libro de Packt. Al menos en la versión digital, que es la que yo he leído, las primeras líneas de los listados de código están cortadas a la izquierda. Es como si hubieran cortado todo lo que estuviera a la izquierda del primer o segundo nivel de indentado. Eso hace que el cuerpo de muchas funciones se salve pero que tengas que adivinar el nombre de la función y de las variables globales a partir del fragmento incompleto que te sale. A mi me ha resultado muy molesto. No tengo ni idea de cómo ha podido pasar el libro el filtro de calidad así, suponiendo que en Packt lo hagan.

¿Me ha merecido la pena? Teniendo en cuenta que compré el libro en un pack y por tanto me salió a un precio irrisorio, que no hay muchos libros de Godot (y menos de Godot 4) y de que ya me había terminado los tutoriales oficiales de Godot, pues sí que me mereció la pena. Tener todos los tutoriales recogidos en un libro es muy cómodo, aunque puedes encontrar otros igual de buenos a poco que indagues por internet. Si me lo hubiese comprado por el precio al que está en Amazon, o en el misma Packt, ya no sé si me habría merecido la pena.

30 octubre 2023

Robust Python: Write Clean and Maintainable Code by Patrick Viafore


Cuando no te ganas la vida con la programación sino que simplemente eres un aficionado al que le gusta programar, lo difícil no es aprender múltiples lenguajes sino mantenerlos vivos sin que se te olviden. Cuando sólo puedes dedicar un rato al día, te centras en un proyecto con un determinado lenguaje y el resto de ellos se van oxidando. Es cierto que hay libros con los que repasar, pero con el tiempo superas el nivel básico y ya no te vale cualquier libro para mantener fresco un lenguaje. Yo me temo que he llegado a ese punto con Python y andaba un poco desesperado por encontrar un libro que realmente me aportase algo nuevo. Hasta que tuve la fortuna de adquirir este libro en un pack (bendito Humble Bundle).

"Robust Python: Write Clean and Maintainable Code" de Patrick Viafore no es un libro para novatos, ni siquiera para desarrolladores intermedios, sino más bien para desarrolladores experimentados que son los que más apreciarán las propuestas de este libro para desarrollar aplicaciones complejas que puedan ser mantenibles con el paso del tiempo.

El libro empieza explicando el sistema de tipado opcional de Python y lo lleva mucho más lejos que los tutoriales de Internet hasta un nivel de sofisticación nada habitual. ¿Sabías que puedes definir interfaces en Python? ¿sabías que puedes definir tus propios tipos? Dataclasses, Enums y otros tipos se exponen aquí con funcionalidades interesantísimas que ni se vislumbran en los textos introductorios.

Después de eso, se profundiza en algunos de los principios SOLID y en arquitecturas, como la de eventos, para cumplirlos. Todo ello, siguiendo unos ejemplos sencillos y amenos de entender.

Finalmente se trata extensamente, el ecosistema de Python para hacer pruebas automatizadas, explicando múltiples estrategias.

El libro me ha gustado mucho. Va al grano, es ameno y realmente aporta herramientas nuevas que no suelen aparecer en el resto de libros de este nivel. En resumen: una muy buena compra si lo que quieres es pulir tu Python para empezar a ser experto.

27 octubre 2023

Curso "Texturizado estilizado (Hand Painted) para videojuegos"


Ayer acabé el curso "Texturizado estilizado (Hand Painted) para videojuegos" de Udemy y la verdad es que me ha gustado bastante.

Se trata de un curso en español de casi 25 horas en las que se explica cómo hacer las texturas artísticas para nuestros modelos 3D. Se enfoca a Blender, lo que me parece un acierto ya que es un paquete perfectamente open source y gratuito capaz de modelar cualquier cosa que podamos necesitar en un juego indie.

Se basa en el texturizado de 6 proyectos: unos barriles, unas rocas, un tile de hierba, una roseta, un suelo de piedra y un personaje de fantasía. En mi opinión no se sigue un gradiente de dificultad, sino que en cada proyecto se cuentan técnicas diferentes. El de los barriles y el de la rocas sirve de introducción para las técnicas que se verán en el resto del curso. El de la hierba, la roseta y el suelo, tienen en común que sirven para explicar cómo preparar texturas repetibles (tileables). En cuanto al texturizado del personaje de fantasía, se trata del proyecto más complejo, pero que viene a recopilar casi todas las técnicas que se han visto en el resto del curso.

Las técnicas de texturizado que se ven son muchas. Para pintar se explica tanto el pintado directo sobre el modelo usando Blender, como el unwrapping de la textura y pintado usando Photoshop. También se explica cómo generar otros mapas, como el de Ambiento Occlusion o el mapa Normal.

El curso está bien explicado y el profesor se explaya a la hora de mostrar cómo pintar artísticamente las texturas usando las distintas técnicas. De hecho, el ritmo del curso te permite ir pintando al mismo tiempo que el profesor. En mi caso, que sólo me interesaba conocer los conceptos y las técnicas, pero no practicarlas, opté por ponerme la velocidad a 2x y ver el curso en mis sesiones de bicicleta elíptica y se entendía bastante bien. Si lo que quieres es practicar, puedes ponértelo en 1x e ir pintando, para lo que te bastará con un ratón, aunque sospecho el sector más artístico de la audiencia usará una tableta (el profesor no lo llega a decir explícitamente, pero sospecho que también usaba tableta).

Al final, te quedas con una idea bastante clara de cómo se texturizan modelos 3D y el trabajo que supone. Está claro que es un trabajo que tiene mucho mérito y el profesor consigue transmitirlo.

Por ponerle un pero al curso, aunque me gusta que se haya basado en Blender, en muchas ocasiones usa también Photoshop y ZBrush que, a diferencia del primero, no son ni gratuitos ni open source. No sé para el caso de ZBrush, pero todo lo que hace durante el curso con Photoshop lo podría haber hecho con Gimp, que sí que es gratuito y open source. Creo que el que usa Blender es porque prefiere el software open source, por lo que habría sido más coherente usar Gimp que Photoshop. En el caso de ZBrush, creo que la alternativa habría sido el mismo Blender, así que no entiendo por qué no lo utiliza para realizar los polígonos en alto detalle, para luego hacer los bakes, en vez de utilizar ZBrush. Al nivel que usa ZBrush, creo que podría haber usado perfectamente Blender y el curso habría quedado mejor.

En todo caso, me alegro de haber hecho el curso y lo recomiendo a cualquiera que quiera introducirse en las vertientes más artísticas del desarrollo de videojuegos. Eso sí, te recomiendo que esperes a que salga de oferta en Udemy, por debajo de 20 € (lo cual ocurre a menudo). Ni se te ocurra comprarlo por el precio desorbitado por el que lo ponen de vez en cuando.

19 octubre 2023

Game AI Pro 360 - Guide to Tactics and Strategy


Hay gran cantidad de libros relativos a la Inteligencia Artificial (IA), pero la mayoría o son muy académicos, o asumen un enfoque meramente divulgativo o se centran en temas más aplicados como el machine learning y el análisis de datos. La bibliografía sobre IA enfocada a los videojuegos es muy escasa, sobre todo si la buscas en formato digital (en mi caso Kindle).

Sin embargo, uno de los grandes autores en este campo es Steve Ravin, no tanto por sus aportaciones propias sino por su labor compendiando las de otros en sus obras. Suya es la serie de libros AI Game Programming Wisdom de la que este es uno de sus últimos exponentes. Como los anteriores, cada capítulo de este libro es un artículo de un autor diferente, desarrollando un concepto relativo a la IA en videojuegos. A diferencia de los libros anteriores, que tenía artículos que cubrían áreas de lo más variopintas, en este todos los artículos se centran en la faceta del análisis y la toma de decisiones tácticas y estratégicas.

El valor principal del libro es que los autores de los distintos capítulos son profesionales especializados en este campo que realizan su labor en reputados estudios de videojuegos. Así que los problemas, matices y enfoques que proponen están respaldados por su gran experiencia. La solvencia de los autores se nota también en el aire académico que desprende cada una de sus páginas. 

Sin embargo, el principal handicap del libro es precisamente ese: la procedencia laboral de sus autores. Al venir de grandes estudios deben estar sujetos a clausulas de confidencialidad y supongo que esa es la razón por la que no entran al detalle sobre las implementaciones concretas, dado que podrían comprometer la propiedad intelectual de sus empresas. Esos autores pueden explicar los conceptos generales adoptados en sus juegos, pero no pueden desvelar el detalle de cómo los han puesto en práctica sin comprometer el core de esos juegos.

Por eso, el libro se mueve en un nivel muy conceptual en el que plantean cosas realmente muy interesantes, pero que se queda muy corto a la hora de explicar cómo lo han implementado. Los fragmentos de código brillan por su ausencia y cuando aparecen no pasan de mero pseudocódigo que en realidad no acaba de explicar por completo los conceptos planteados. Ha habido capítulos completos en los que he perdido el hilo a las pocas páginas y no he sabido completar los huecos que iban dejando los autores. En otros sí que he entendido todo lo que planteaban, pero me quedaba con las ganas de un ejemplo de implementación real.

Probablemente me falte el nivel que tienen los autores, pero mi conclusión es que desde luego no es un libro para introducirse en el género de la IA para videojuegos. Para ello, antes que este libro recomiendo el de AI for Games de Ian Millington. Este último libro, sí que va desde un nivel de iniciación a uno medio-alto y con todo lujo de detalles. Pensaba que este libro ya me dejaba preparado para otros, ya de nivel intermedio, pero se ve que no para este de Steve Ravin.

¿Lamento haberlo leído? No. Cuando he conseguido cogerle el hilo a los capítulos los he disfrutado y he sacado un par de ideas que aunque no sepa aún cómo implementarlas sí que me han resultado lo suficientemente sugerentes para intentarlo a la primer oportunidad.

Veredicto del libro: no sirve para iniciarse, pero es una lectura interesante para que te vayan sonando los conceptos que mueven la IA de los grandes videojuegos.

09 agosto 2023

Unity Turn-Based Strategy Game: Intermediate C# Coding

Portada del curso
Hace unas semanas terminé el curso "Unity Turn-Based Strategy Game: Intermediate C# Coding", disponible en Udemy

Se trata de un curso creado por el equipo de GameDev.tv y, sobre todo, por un autor ya clásico de tutoriales de Unity: Code Monkey.

El curso es de pago pero, aunque a veces lo ponen a precios escandalosos, le pasa lo que al resto de los cursos de Udemy: que a poco que esperes unos días, con el curso en tu wishlist, te lo acaban ofertando a unos 12 €. 

Ten en cuenta que es en inglés, así que debes estar cómodo en ese idioma para entender las explicaciones. No tiene subtítulos en español, pero sí en inglés así que yo los he dejado activados para cuando la pronunciación de alguna palabra se me ha resistido.

El contenido es un tutorial muy detallado sobre cómo crear en Unity algunas de las mecánicas habituales en juegos tácticos por turnos, del tipo X-Com. Si bien es cierto que a nivel gráfico el juego desarrollado no deja de ser una maqueta, es cierto que sí que se explican, entre otras, las principales mecánicas:

  • Selección de objetos con ratón.
  • Gestión de rejillas para el escenario.
  • Movimiento y búsqueda de caminos sobre la rejilla.
  • Gestión de cámaras.
  • HUD del juego.
  • Sistema de turnos.
  • Animación de caracteres.
  • Sistema de vida y daños.
  • Efectos visuales, como estelas o explosiones.
En general, la calidad de los contenidos es muy alta y la programación utilizada es limpia, aunque en el apartado de las rejillas me queda la duda de por qué ha creado su propia rejilla desde cero en vez de usar el componente que incluye Unity. También me ha dejado un poco frío la parte de la implementación de la IA de los enemigos, que es mínima. Aún así, hay otros apartados que no he visto en otros tutoriales o libros, como el de los objetos destruibles o la búsqueda de caminos en entornos con múltiples plantas.

Así que el curso me ha encantado. Me parece muy adecuado para alguien que quiere pasar del nivel básico de Unity a temas más intermedios, sobre todo si planeas que tu próximo juego sea de tablero o por turnos. Me ha dejado tan buen sabor de boca que probablemente acabe comprando también el curso que tiene el mismo autor sobre cómo hacer un juego de Tower Defense en Unity. Si finalmente lo hago, diré lo que me ha parecido por aquí.

24 mayo 2023

Cómo parsear los parámetros de entrada de tu aplicación de consola en Rust usando Clap

En un artículo anterior, expliqué  cómo usar el módulo ArgParse module para leer los argumentos de consola de las aplicaciones desarrolladas en Python. Rust cuenta con crates especializados en el parseo de argumentos de consola. En este artículo vamos a ver cómo usar uno de esos crates: clap.

Una de las cosas maravillosas de Rust es que dentro de ese caparazón rustaceo, duro y árpero hay un corazón pythonista. Al usar clap encontrarás muchas de las funcionalidades de ArgParse. En realidad, los conceptos entre ambos son muy similares: mientras que los verbos recibían el nombre de subparser en ArgParse, en clap reciben el nombre de subcomandos, mientras que ArgParse llama argumentos a los que clap denomina simplemente arg. Por lo tanto, si estás acostumbrado a usar ArgParser, lo más probable es que te sientas en casa usando clap. Por todo lo anterior, voy a asumir que ya has leído mi artículo anterior sobre ArgParse y no voy a repetirme volviendo a explicar los mismos conceptos.

Como cualquier otro crate de Rust, para usar clap hay que incluirlo primero en el fichero Cargo.toml:

Después, ya podrás usar clap en tu código fuente. Para ilustrar las explicaciones, voy a usar como ejemplo el parseo que se realiza en mi proyecto cifra-rust

Como puedes ver allí, puedes usar clap directamente en tu función main(), pero yo prefiero abstraer ese proceso en una función aparte que retorne un struct Configuration específicamente desarrollado por mí. De esa manera, si en el futuro decidiese cambiar y pasar a utilizar otro crate de parseo de argumentos, el proceso sería mucho más fácil al haber reducido el acoplamiento. Por eso, tal y como también hago en Python, he definido una función llamada parse_arguments() que retorna un tipo Configuration. 

En el código se puede ver que el parser raíz se define en clap usando App::new(). Como en otros crates de Rust, clap hace un uso muy intenso del patrón constructor para configurar su parseo. De esa manera, puedes configurar la versión del comando, su autor y la descripción larga ("long_about"), entre otras opciones, tal y como se puede ver entre las líneas 277 y 280:


El comportamiento de clap se puede personalizar usando el método setting(). Un parámetro típico con el que llamar a ese método es AppSettings::ArgRequiredElseHelp, para mostrar la ayuda si se llama al comando sin argumento alguno:


Un subparser se crea llamando al método subcommand() y pasándole una nueva instancia de App:


Usando el método about() se puede definir una descripción corta, sobre el comando o sus argumentos, la cual aparecerá cuando se llame al comando principal con la opción --help.

Lo normal es que tanto el parser, como sus subparser, requieran argumentos. Los argumentos se definen a través del método arg(), en el parser al que pertenezca. Aquí tienes un ejemplo:


En el último ejemplo se puede ver que el subparser create (línea 285) tiene dos argumentos: dictionary_name (línea 287) e initial_words_file (línea 292). Fíjate que todas las llamadas a arg() usan como argumento una instancia de Arg, mientras que la configuración del argumento se hace usando el patrón constructor sobre las instancias de Arg.

El argumento dictionary_name es necesario porque está configurado como required(true) en la línea 288. Ten en cuenta que aunque aquí se utilice un flag, en general se suele recomendar que no se haga. Por definición, todos los argumentos obligatorios deberían ser posicionales (es decir, no deberían usar un flag), el único caso en el que el uso de un flag está bien visto con un argumento obligatorio es cuando ese argumento llama a una operación destructiva y pedirle el flag al usuario es una manera de asegurar que el usuario sabe lo que hace al tener que tomarse ese trabajo extra. Cuando se usa un argumento posicional, se puede usar el método index() para especificar la posición de ese argumento en relación a otros. En realidad, después me he enterado de que puedes dejar el método index() y en ese caso el índice se asignará según el orden de evaluación dentro del código. Precisamente lo que te permite index() e poder saltarte ese orden que sale de la evaluación del código.

Cuando se usa takes_value(true) oen un argumento, el valor facilitado desde consola se guarda en una clave llamada como el argumento. Por ejemplo, el  takes_value(true) de la línea 290 hace que el valor facilitado por el usuario se guarde en una clave llamada dictionary_name. Si estás usando un flag opcional sin valor (es decir un flag booleano), puedes usar takes_value(false) o sencillamente omitirlo.

La llamada al método value_name() es equivalente al parámetro metavar del ArgParse de Python. Te permite definir la cadena que representará al parámetro cuando se llame a la ayuda con el parámetro --help.

Lo raro es que los argumentos no usan el método about() para definir sus cadenas de ayuda, sino el método help().

Puedes ver cómo se define un argumento opcional de la línea 292 a la 298. Allí, la versión larga de flag se define con el método long() y la corta con el método short(). En este ejemplo, el argumento puede llamarse tanto como "--initial_words_file <PATH_TO_FILE_WITH_WORDS>" como "-i <PATH_TO_FILE_WITH_WORDS>".

Puede resultarte útil llamar al método validator(), ya que define una función que se aplicará sobre los argumentos facilitados para asegurar que cumplen las condiciones de lo que se espera recibir. La función que se utilice como validador deberá ser capaz de recibir un argumento string y deberá retornar un Result<(), String>. Si haces tus comprobaciones y encuentras correcto el argumento entonces debes retornar un Ok(()) o, en caso contario, un Err("Here you write your error message").

Encadenando métodos en argumentos anidados y en subcommandos puedes definir un árbol entero de comandos. Una vez que hayas acabado tienes que hacer una última llamada al método get_matches_from() del parser raíz. Para ello, debes pasarle al método un vector de con los strings de cada uno de los argumentos del comando:



Normalmente le pasarás el vector de strings que devuelve la función args(), el cual retorna un vector con cada argumento dado por el usuario al llamar a la aplicación desde la consola: 



Fíjate en que mi función main() está casi vacía. Eso es porque de esa manera puedo llamar a _main() (fíjate en el guión bajo) desde mis tests de integración, metiendo mi propio vector de argumentos para simular a un usuario llamando a la aplicación desde la consola.

los que get_matches_from() devuelve es un tipo ArgMatches. Ese tipo devuelve el valor de cada argumento si se le especifica el nombre de una clave. De las líneas 149 a 245 he implementado un método para crear un tipo Configuration usando el contenido del ArgMatches. 

Ten en cuenta que cuando sólo tienes un parser puedes obtener los valores usando el método value_of(). Pero si tienes subcomandos perimero tienes que conseguir el ArgMatches específico para esa rama de subcomando, haciendo una llamada al método subcommand_matches():


En el último ejemplo puede ver la manera de trabajar normal:
  • Profundizas obteniendo el ArgMatches de la rama en la que estás interesado. (líneas 160-161)
  • Una vez que tienes el ArgMatches que quieres, utilizas value_of() para conseguir el valor de un argumento concreto. (línea 164)
  • Para parámetros opcionales, puedes usar el método is_present() para comprobar si se facilitó el parámetro o no. (línea 165). 
Mediante estos métodos, puedes recuperar los valores de los argumentos facilitados por el usuario y construir la configuración necesaria para ejecutar tu aplicación.

Como puedes ver, puedes hacer un parser realmente potente con clap, con la misma funcionalidad que ofrecía ArgParse en Python, pero en este caso en un entorno de desarrollo con Rust.

27 abril 2023

Cómo crear tus propias imágenes de Docker

 


En un artículo anterior ya vimos las bases de cómo usar imágenes de Docker. Pero en aquel caso usamos imágenes construidas por otros. Eso está muy bien si encuentras la imagen que estás buscando, pero qué pasa si ninguna cubre tus necesidades?

En este artículo te voy a explicar cómo crear tus propias imágenes y cómo subirlas a Docker Hub de manera que puedas descargarlas fácilmente para tus proyectos, así como compartirlas con otros desarrolladores. 


Para cocinar necesitas una receta

Partiendo de que hayas seguido el tutorial que enlazaba al principio, ya tendrás Docker instalado en tu Linux. Con eso, necesitarás una receta para decirle a Docker los pasos para "cocinar" tu imagen. Esa receta es un fichero llamado Dockerfile que hay que crear en la carpeta que más te convenga en la que habrás de incluir cualquier fichero que quiera meter en tu imagen.

¿Qué cocinas con esa receta? una imagen, ¿pero qué es una imagen de Docker?. Una imagen de docker es un sistema operativo virtual con una configuración específica y un conjunto concreto de dependencias instaladas. Cómo utilices esa imagen depende de ti. Algunas imágenes están pensadas para ser usadas como base en las que instalar nuestra aplicación para que se ejecute dentro. Otras imágenes son más específicas y ya traen una aplicación instalada para ser ejecutada en cuanto se arranque la imagen. Haciendo una analogía con la Programación Orientada a Objetos (OOP en inglés), una imagen de Docker sería una clase, mientras que un contenedor sería una instancia de esa clase (también denominada "objeto" en OOP). No es raro encontrar múltiples contenedores ejecutándose simultáneamente después de ser iniciados de la misma imagen.

Para entender cómo funciona un Dockerfile, vamos a analizar algunos ejemplos.

Vamos a empezar con este Dockerfile del  proyecto vdist. La imagen que se crea con ese Dockerfile está pensada para compilar una distribución de Python y ejecutar una aplicación llamada fpm sobre una carpeta creada sobre la marcha cl arrancar el contenedor basado en la imagen. Por lo tanto, este Dockerfile se encarga de instalar todas las dependencias necesarias para que la imagen quede preparada para que sus contenedores puedan compilar la distribución de Python y ejecutar fpm.

El primer paso en todos los Dockerfile es definir qué imagen vamos a usar como punto de partida. Continuando la metáfora de la OOP, si nuestra imagen es una clase, en este punto definimos de qué otra clase hereda la nuestra.

En este caso, nuestra imagen se deriva de la imagen ubuntu:latest, pero para tus imágenes puedes usar cualquier otra imagen disponible en Docker Hub. Sólo asegúrate de echarle un ojo a la página de la imagen en Docker Hub para averiguar cual es la etiqueta que más se ajuste a lo que necesites. Una vez que subas tu imagen a Docker Hub otros podrán usarla como punto de partida para sus respectivas imágenes.

Todos las obras de arte deberían firmarse. A tu imagen le pasa lo mismo, por lo que debemos definir metadatos para hacerlo.

La miga empieza realmente con los comandos RUN. Esos son lo que usas para configurar tu imagen.

Algunas personas que crean una imagen por primera vez confunden lo que hacen realmente los comandos RUN. Estos comandos no se ejecutan cuando se crea un contenedor de una imagen, con el comando "docker run". En vez de eso, se ejecutan sólo una vez por parte del comando "docker build", para crear una imagen de un Dockerfile.

El conjunto exacto de comandos RUN para una imagen dependerá de los que necesite el proyecto respectivo. En este ejemplo, los comando RUN comprueban que la lista de fuentes de apt son las correctas, actualizan la base de datos de apt e instalan un  conjunto de dependencias usando tanto "apt-get" como "gem install". 

Te recomiendo que comiences el apartado de los comandos RUN con un "RUN set -e" de manera que el proceso de construcción finalice con un error si cualquiera de los comandos RUN devuelve un error. Esto puede parecer una medida extrema, pero es la mejor para asegurar que no subas una imagen con un error inadvertido.

Además de eso, cuando revises Dockerfiles de otros proyectos te darás cuenta que muchos de ellos incluyen varios comandos de shell dentro del mismo comando RUN, como pasa en las líneas 14 a 16 de nuestro ejemplo. La gente de Docker recomienda juntar dentro de un mismo comando RUN aquellos comandos de shell que están relacionados entre si (es decir, si dos comandos no tienen sentido separados, siendo ejecutados sin el otro, deberían ser incluidos dentro del mismo comando RUN). Esto se debe a la estructura en capas con la que se construyen las imágenes de Docker, en las que cada comando RUN conforma una capa separada. Si sigues el consejo de la gente de Docker, tus imágenes serán más rápidas de reconstruir cuando realices un cambio sobre su Dockerfile. Para incluir varios comandos de shell en el mismo comando RUN, recuerda separar esos comandos en una línea por cada comando de shell y pon al final de cada línea un "&& \" para encadenar las sucesivas líneas (excepto en la última, como se puede ver en el ejemplo).

Aparte de los comandos RUN, hay otros que deberías conocer. Vamos a analizar el Dockerfile de mi proyecto markdown2man. El propósito de esa imagen es ejecutar un script de Python para hacer que Pandoc realice la conversión de un fichero usando los argumentos pasados por el usuario al arrancar el contenedor con "docker run". Para ello, verás que algunos de los comandos empleados en el Dockerfile ya resultan familiares:


Pero a partir de ahí, aparecen nuevos comando que vuelven interesante la cosa.

Con los comandos ENV puedes crear variables de entorno para ser usadas en los comandos del Dockerfile, de manera que no tengas que repetir una y otra vez las mismas cadenas y además facilites las modificaciones.


Sin embargo, ten en cuenta que las variables de entorno creadas con los comandos ENV van más allá de la fase de construcción y persisten en el contenedor que se cree de esa imagen. Eso puede provocar colisiones con otras variables de entorno creadas al ejecutar el contenedor de la imagen. Si sólo necesitas una variable de entorno durante la fase de construcción y quieres que se borre cuando se ejecute el contenedor, entonces usa el comando ARG en vez de los comandos ENV.

Para incluir los ficheros de tu aplicación dentro de la imagen, usa el comando COPY:


Estos comandos copian un fichero de origen, relativos a la ubicación del Dockerfile, desde el equipo donde estés construyendo la imagen, a una ruta dentro de esa imagen. En este ejemplo, copiamos un fichero requirements.txt, que está ubicado en la misma carpeta que el Dockerfile, hacia una carpeta llamada /script (tal y como se define en la variable de entorno SCRIPT_PATH) dentro de la imagen.

Por último, pero no por ello menos importante, tenemos otro comando nuevo: ENTRYPOINT.


Un ENTRYPOINT define qué comando ejecutar cuando se inicie una contenedor a partir de esta imagen, de manera que los comandos que se pasan a "docker run" en realidad se pasan al comando definido en ENTRYPOINT. El contenedor se mantendrá en marcha hasta que el comando ejecutado en el ENTRYPOINT finalice.

Los ENTRYPOINT son la clave para poder usar contenedores de docker como medio para ejecutar programas sin necesitar de instalarlos en nuestro equipos y "contaminar" nuestro sistema de paquetes con las dependencias de ese programa.

Hora de cocinar

Una vez que nuestra receta esté lista, será hora de cocinar algo con ella.

Cuando nuestro Dockerfile esté listo, será el momento de usar el comando "docker build" para creau una imagen. Partiendo de que estemos en la misma carpeta que nuestro Dockerfile:

dante@Camelot:~/Projects/markdown2man$ docker build -t dantesignal31/markdown2man:latest .
Sending build context to Docker daemon  20.42MB
Step 1/14 : FROM python:3.8
 ---> 67ec76d9f73b
Step 2/14 : LABEL maintainer="dante-signal31 (dante.signal31@gmail.com)"
 ---> Using cache
 ---> ca94c01e56af
Step 3/14 : LABEL description="Image to run markdown2man GitHub Action."
 ---> Using cache
 ---> b749bd5d4bab
Step 4/14 : LABEL homepage="https://github.com/dante-signal31/markdown2man"
 ---> Using cache
 ---> 0869d30775e0
Step 5/14 : RUN set -e
 ---> Using cache
 ---> 381750ae4a4f
Step 6/14 : RUN apt-get update     && apt-get install pandoc -y
 ---> Using cache
 ---> 8538fe6f0c06
Step 7/14 : ENV SCRIPT_PATH /script
 ---> Using cache
 ---> 25b4b27451c6
Step 8/14 : COPY requirements.txt $SCRIPT_PATH/
 ---> Using cache
 ---> 03c97cc6fce4
Step 9/14 : RUN pip install --no-cache-dir -r $SCRIPT_PATH/requirements.txt
 ---> Using cache
 ---> ccb0ee22664d
Step 10/14 : COPY src/lib/* $SCRIPT_PATH/lib/
 ---> d447ceaa00db
Step 11/14 : COPY src/markdown2man.py $SCRIPT_PATH/
 ---> 923dd9c2c1d0
Step 12/14 : RUN chmod 755 $SCRIPT_PATH/markdown2man.py
 ---> Running in 30d8cf7e0586
Removing intermediate container 30d8cf7e0586
 ---> f8386844eab5
Step 13/14 : RUN ln -s $SCRIPT_PATH/markdown2man.py /usr/bin/markdown2man
 ---> Running in aa612bf91a2a
Removing intermediate container aa612bf91a2a
 ---> 40da567a99b9
Step 14/14 : ENTRYPOINT ["markdown2man"]
 ---> Running in aaa4774f9a1a
Removing intermediate container aaa4774f9a1a
 ---> 16baba45e7aa
Successfully built 16baba45e7aa
Successfully tagged dantesignal31/markdown2man:latest

dante@Camelot:~$

Si no estuviésemos en la misma carpeta que en el Dockerfile, deberíamos reemplazar ese "." al final del comando con una ruta hacia la carpeta del Dockerfile.

El  parámetro "-t" se usa para darle un nombre adecuado (también llamado "tag") a nuestra imagen. Si quieres subir tu imagen a Docker Hub te aconsejo que intentes seguir sus convenciones de nombrado. Para que una imagen pueda ser subida a Docker su nombre se debe estructurar de la siguiente forma: <docker-hub-user>/<repository>:<version>. Como puedes ver en el último ejemplo, el parámetro docker-hub-user era dantesignal31 mientras que el repositorio era markdown2man y la versión era latest.

Subir tu imagen a Docker Hub

Si el proceso de construcción finaliza correctamente debería ver cómo se registra tu imagen en el sistema:

dante@Camelot:~/Projects/markdown2man$ docker images
REPOSITORY                   TAG                 IMAGE ID       CREATED          SIZE
dantesignal31/markdown2man   latest              16baba45e7aa   15 minutes ago   1.11GB


dante@Camelot:~$

Pero una imagen que sólo esté disponible localmente tiene una utilidad limitada. Para que esté globalmente disponible deberíassubirla a Docker Hub, pero para hacerlo primero necesitas una cuenta en Docker Hub. El proceso para registrar una nueva cuenta es similar al de cualquier otro servicio online.

Hecho el registro, accede con tu nueva cuenta a Docker Hub. Una vez dentro, crea un nuevo repositorio. Recuerda que, sea cual sea el nombre que le des al repositorio, se le pondrá un prefijo con tu nombre de usuario para conformar el nombre completo del repositorio.


En este ejemplo, el nombre completo del repositorio sería mobythewhale/my-private-repo. A menos que estés usando una cuenta de pago, probablemente seleccionarás un repositorio "Public".

Recuerda nombrar a tu imagen con un nombre de repositorio que concuerde con el que creaste en Docker Hub.

Antes de que puedas subir tu imagen, tendrás que hacer login a Docker Hub desde la consola con "docker login":

dante@Camelot:~/Projects/markdown2man$ docker login
Authenticating with existing credentials...
WARNING! Your password will be stored unencrypted in /home/dante/.docker/config.json.
Configure a credential helper to remove this warning. See
https://docs.docker.com/engine/reference/commandline/login/#credentials-store

Login Succeeded
dante@Camelot:~$

La primera vez que hagas login se te pedirá tu usuario y contraseña.

Una vez logado ya puedes subir tu imagen con "docker push":

dante@Camelot:~/Projects/markdown2man$ docker push dantesignal31/markdown2man:latest
The push refers to repository [docker.io/dantesignal31/markdown2man]
05271d7777b6: Pushed 
67b7e520b6c7: Pushed 
90ccec97ca8c: Pushed 
f8ffd19ea9bf: Pushed 
d637246b9604: Pushed 
16c591e22029: Pushed 
1a4ca4e12765: Pushed 
e9df9d3bdd45: Mounted from library/python 
1271cc224a6b: Mounted from library/python 
740ef99eafe1: Mounted from library/python 
b7b662b31e70: Mounted from library/python 
6f5234c0aacd: Mounted from library/python 
8a5844586fdb: Mounted from library/python 
a4aba4e59b40: Mounted from library/python 
5499f2905579: Mounted from library/python 
a36ba9e322f7: Mounted from library/debian 
latest: digest: sha256:3e2a65f043306cc33e4504a5acb2728f675713c2c24de0b1a4bc970ae79b9ec8 size: 3680

dante@Camelot:~$

A partir de ahí tu imagen estará disponible en Docker Hub y lista para ser usada por cualquiera.

Conclusión

Hemos revisado los fundamentos básicos de como construir una imagen de Docker. A partir de aquí, ya sólo te queda practicar creando imágenes cada vez más complejas, a partir de otras más sencillas. Afortunadamente Docker tiene una gran documentación por lo que debería resultarte fácil resolver cualquier problema con el que puedas encontrarte.

Comprendido lo básico, puedes sacarle mucho partido a herramientas como Docker Desktop que pueden simplificarte enormemente el proceso.

06 abril 2023

Cómo crear tus propias Actions para GitHub Actions

En  mi artículo sobre GitHub Actions vimos que se pueden crear flujos de trabajo completo simplemente usando unidades ya hechas, denominadas Actions, que se pueden encontrar en el GitHub Marketplace

En ese marketplace puedes encontrar muchísimas Actions listas para ser usadas, pero probablemente en algún momento te veas en la necesidad de hacer algo para lo que no haya aún Action alguna en el marketplace. En esos caso podrías usar tus propios scripts. De hecho, en aquel artículo ya utilicé un script (./ci_scripts/get_version.py) en la sección "Compartir datos entre pasos y flujos". El problema con ese enfoque es que si quieres hacer las mismas tareas en otro proyecto tendrás que copiar los scripts entre proyectos y adaptarlo. Lo suyo en realidad es transformar los scripts en Actios que puedan ser fácilmente reutilizables no sólo en tus proyectos sino también en los de otros desarrolladores.

Todas las Actions que se pueden encontrar en el marketplace se han desarrollado usando uno de los métodos que voy a explicar aquí. En realidad, si entrar en la página del marketplace de cualquier Action encontrarás un enlace, a mano derecha, al repositorio de git de esa Action, por lo que puedes analizar su código y ver cómo funciona.

Hay tres métodos principales para crear tu propio Action para GitHub:

  • Composite Actions: Son las más sencillas y rápidas de desarrollar, pero tienen que cumplir la condición de que se basen en un script autosuficiente que no requiera que se instalen dependencias adicionales. Es decir, deben ser capaces de funcionar con lo que un Linux estándar ofrezca de base.
  • Docker Actions: Si tu Action va a necesitar la instalación de alguna dependencia para funcionar, entonces tendrás que usar esta opción.
  • Javascript Actions: Bueno... es cierto que también puedes desarrollar tus Actions usando javascript, pero aborrezco ese lenguaje, así que no voy a desarrollar esta opción en este artículo.
El problema con las dependencias de las Actions es que pueden contaminar el entorno donde se vaya a usar la Action. Si no tienes cuidado, y optas por una Composite Action instalando dependencias, estas pueden colisionar con la de la app que quieras compilar con la Action. Esa es la razón por la que resulta imprescindible encapsular nuestra Action y sus dependencias para que sean independientes del entorno en el que compilemos la app. Por supuesto, esto no será un problema si el propósito explícito de la Action es precisamente instalar algo para preparar el entorno. Hay Actions, por ejemplo, para instalar y configurar Pandoc para usarlo en el workflow. El problema surge cuando se supone que el propósito explícito de tu Action es algo ajeno a la instalación de paquete alguno (por ejemplo, copiando ficheros) y sin embargo acaba instalando algo de manera inadvertida comprometiendo el entorno del flujo de trabajo. Por eso, lo mejor es que cuando necesites instalar algo para tu Action lo hagas en un contenedor de Docker y hagas que el script de tu Action se ejecute en ese contenedor de Docker, de manera que sus dependencias sean completamente independientes de los paquetes instalados en el entorno del flujo.  

Composite Actions

Si a tu Action le basta con un puñado de comandos de Bash o con un script de Python que utilice exclusivamente la librería estándar, entonces las Composite Actions son tu opción.

Como ejemplo de una Composite Action, vamos a revisar cómo funciona mi Action rust-app-version. Esa Action busca un fichero de configuración Cargo.toml y lee qué versión consta en él para la aplicación de Rust. La cadena de esa versión es precisamente la salida del Action, de manera que puedas usar esa cadena en otros puntos de tu flujo, por ejemplo para etiquetar una nueva release en GitHub. Esta Action en concreto sólo usa módulos disponibles en la distribución estándar de Python. Es decir, que no tiene que instalar nada en la máquina virtual de GitHub Actions (el runner). Es cierto que si analizas el repositorio de rust-app-version verás un fichero requierements.txt pero este sólo es para instalar paquetes necesarios para el testeo unitario de la Action (recuerda que una Action no deja de ser una app más).

Para tener tu propia Action, lo primero es crear un repositorio de GitHub para albergarlo. Allí podrás ubicar los poquitos ficheros que se necesitan en realidad para hacer funcionar tu Action.

En la raíz del repositorio debes crear un fichero que se llame "action.yml". Este fichero es realmente importante ya que es el que le da forma a tu Action. Debes conseguir que tus usuarios puedan pensar en tu Action como si fuera una caja negra, como si fuera un módulo en el que metes entradas (inputs) y recibes salidas (outputs). Precisamente esos inputs y outputs se definen en el fichero action.yml.

Si lees el fichero action.yml file de rust-app-version verás que esa Action sólo necesita un input denominado "cargo_toml_folder" y que en realidad el input es opcional, ya que si lo dejamos vacío asumirá el valor de ".":



Los outputs son algo diferentes, dado a que deben referirse al output de un caso específico de tu Action:


En la última sección especificamos que esta Action tendrá un único output llamado "app_version" y que ese output será el output llamado "version" de un paso con el valor de id "get-version".

Esos inputs y outputs definen lo que tu Action consume y ofrece, es decir, lo que tu Action hace. El cómo lo hace tu Action se define bajo la etiqueta "runs:". Ahí es donde defines que tu Action es una composite, en la que vas a llamar a una secuencia de pasos. Este ejemplo en particular sólo tiene un paso, pero puedes tener todos los que necesites:



Fíjate que en la línea 22 es donde el paso recibe el id "get-version". Ese nombre es importante para poder referirse a este paso en particular desde la configuración de los outputs.

La línea 24 es donde se ejecuta el comando. Aquí sólo he ejecutado un comando, pero si necesitases ejecutar varios comandos dentro del mismo paso bastaría con usar una barra después de la etiqueta run: "run: |". De esa manera, señalas que las siguientes líneas, que se indenten bajo la etiqueta "run:", son líneas separadas de comandos que deben ser ejecutadas secuencialmente.

El comando de la línea 24 también es interesante por 3 cosas:
  • Llama a un script situado en el repositorio de nuestro Action. Para referirse a la raíz del repositorio del Action hay que usar la variable de entorno github.action_path. Lo bueno es que aunque nuestro script esté en el repositorio del Action GitHub lo ejecuta de manera que tenga visibilidad del repositorio del flujo de trabajo desde el que se llama a la Action. Nuestro script (el del Action) verá los ficheros del repositorio del flujo como si hubiese sido ejecutado desde su raíz.
  • Al final de la línea se puede ver cómo se pueden utilizar los inputs, a través del contexto inputs.
  • Lo más raro de esa línea es probablemente como configuras el output del paso. Para fijar el output de un paso de bash debes hacer echo "::set-output name=<ouput_name>::<output_value>". En este caso, name es version y su valor es lo que  get_version.py imprime en la consola. Ten en cuenta que output_name se usa para recuperar el output, una vez que el paso finaliza, mediante ${{ steps.<id>.outputs.<output_name> }}, en este caso ${{ steps.get_version.outputs.version }}
Aparte de eso, no hay más que configurar los metadatos del Action. Para eso, no se necesitan más que unas pocas líneas:


Ten en cuenta que el valor de "name:" es el nombre que tendrá tu Action en el marketplace. El otro parámetro "description:" es la explicación corta que se mostrará junto al nombre en los resultados de las búsquedas del marketplace. Por último, la etiqueta "branding:" sirve para configurar el icono (de los disponibles en la suite Feather icon) y el color de este que representarán a nuestra Action en el marketplace.

Con esas 24 líneas en el fichero action.yml y tu script en su ruta respectiva ( en este caso la subcarpeta 
 rust_app_version/ ), ya puedes usar tu Action. Sólo necesitas pulsar el botón que aparecerá en tu repositorio para publicar el Action en el marketplace. De todos modos, antes de lanzarte a hacerlo te recomiendo que te leas este artículo hasta el final porque tengo algunos consejos que sospecho que te serán útiles.

Una vez publicada, tu Action ya será visible para el resto de los usuarios de GitHub. Además, se creará automáticamente la página de tu Action en el marketplace. Para usar una Action como esta lo único que tienes que hacer es incluir en tu flujo una configuración como la siguiente:



Docker actions

Si tu Action necesitase instalar alguna dependencia entonces deberías empaquetarla dentro de un contenedor de docker. De esa manera las dependencias de la Action no se mezclarán con las dependencias del flujo desde el que se llame a la Action.

Como ejemplo de una Docker Action, vamos a revisar cómo funciona mi Action markdown2man. Esta Action coge un fichero README.md y lo convierte en un manpage. Con esta Action no necesitas mantener dos fuentes diferentes para la documentación de cómo usar tu aplicación de consola. En vez de eso, puedes usar un único fichero README.md para documentar el uso de tu aplicación y generar automáticamente a partir de él el manpage.

Para hacer esa conversión markdwon2man necesita que se instale el paquete Pandoc. Además, Pandoc tiene también sus respectivas dependencias por lo que instalar todo eso en el runner del usuario puede romper su flujo de trabajo. En vez de eso, lo que haremos será instalar esas dependencias en una imagen de docker y ejecutar nuestro script desde esa imagen. Recuerda que docker te permite ejecutar scripts desde el contenedor interactuando con los ficheros del anfitrión del contenedor.

Igual que en el caso de las Composite Action, tendremos que crear un fichero action.yml en la raiz del repositorio del Action. Allí configuraremos nuestros metadatos, los inputs y los outputs tal y como hicimos para las Composite Actions. La diferencia en este caso es que concretamente markdown2man Action no emite output alguno, por lo que esa sección se omite. La sección para "runs:" también es diferente:


En esa sección definimos que esta Action es del tipo docker (en "using:"). Hay dos maneras de usar una imagen de docker en tu Action: 
  • Generar una imagen específicamente para esa Action y guardarla en el registro de docker de GitHub. En ese caso usaríamos la etiqueta "image: Dockerfile".
  • Usar una imagen del registro de DockerHub registry. Para hacer eso hay que usar la etiqueta "image: <dockerhub_user>:<docker-image-tag>".
Si la imagen que vamos a construir estuviera pensada para ser usada sólo en una Action de GitHub yo iría por el primer enfoque (el del Dockerfile). En el caso de markdwon2man elegí la opción del Dockerfile. Cada vez que actualicemos nuestro Dockerfile, GitHub se encargará de de construir una nueva imagen y guardarla en su registro para poder ofrecerla rápidamente a los Actions que hagan uso de esa imagen. Recuerda que un Dockerfile es una especie de receta para "cocinar" una imagen, por lo que los comandos que se incluyan en él solo se ejecutarán cuando se construya la imagen (cuando se "cocine"). Una vez construida la imagen, el único comando que se ejecutará es el que fijemos en la etiqueta "entrypoint" al llamar al contenedor (y pasarle argumentos) con el comando "docker run".

La etiqueta "args:" define los parámetros que se pasarán al script de nuestro contenedor. Probablemente uses alguno de los input de tu Action para pasárselo como parámetro al script del contenedor. Ten en cuenta que, como pasaba con las Composite Actions, los ficheros del repositorio del usuario que llama al Action también son visibles para el contenedor de dicho Action.

Al tener que definir un Dockerfile, las Docker Actions pueden parecer más complejas que las Composite Actions. Sin embargo, el fichero Dockerfile de markdown2man es bastante sencillo. Como nuestro script markdown2man está escrito en python, hice que la imagen se basase en la imagen de docker oficial para python 3.8:



Después, fijé los metadatos de la imagen:


Para configurar tu image, por ejemplo para instalar cosas, puedes usar los comandos RUN:


El comando ENV genera variables de entorno que pueden ser utilizados en otros comandos del Dockerfile:


Puedes usar el comando COPY para introducir ficheros de tu repositorio en la imagen. En este caso lo utilicé para copiar el fichero requirements.txt desde el repositorio a dentro de la imagen. Tus scripts se pueden incluir dentro de imagen, desde el repositorio del Action, de la misma manera:


Una vez que copié el script dentro de la imagen, lo hice ejecutable y lo enlacé desde la carpeta /usr/bin para incluirlo en el path del sistema:


Después de eso, se configura el script como el entrypoint de la imagen, de manera que se ejecute en cuanto se arranque la imagen con los argumentos mencionados en la etiqueta "args:" del fichero action.yml.



Puedes probar esa imagen en tu ordenador construyendo la imagen desde el Dockerfile y ejecutándola como un contenedor:

dante@Camelot:~/$ docker run -ti -v ~/your_project/:/work/ dantesignal31/markdown2man:latest /work/README.md mancifra


dante@Camelot:~$

Si el script del contenedor procesa ficheros del repositorio, nos vendrá bien probarlo localmente montando una carpeta de nuestro ordenador con el flag -v. Los dos últimos argumentos del ejemplo  (work/README.md y mancifra) son los argumentos que se le pasan al entrypoint.

Y eso es todo. Una vez que lo hayas probado todo ya puedes publicar tu Action y usarla en tus flujos:


Con una llamada como esa, se creará un fichero man llamado cifra.2.gz en una carpeta llamada man. Si la carpeta manpage_folder no existiese, entonces markdown2man la crearía para ti.

Tus Action son código de primera clase

Aunque tus Actions probablemente sean pequeñas, deberías tratarlas como lo harías con una app completa. Ten en cuenta que mucha gente encontrará tus Action a través del marketplace y los incluirá en sus flujos. Un error en tu Action puede romper los flujos de mucha gente, por eso hay que ser diligente y probar tu Action como harías con cualquier otra app.

Por eso, con mis Actions sigo la misma aproximación que en otros proyectos y configuro un flujo de GitHub para ejecutar tests contra cualquier push en la rama staging del repositorio de mi Action. Solo cuando los tests se ejecutan con éxito, se produce el merge automático de la rama staging con la principal y se genera una nueva release de la Action.

Vamos a usar el flujo de markdown2man como ejemplo. En él puedes ver que hay dos tipos de tests:

  • Tests unitarios: Comprueban el script de python en el que se basa markdown2man.

  • Tests de integración: Comprueban el comportamiento de markdown2man como Action. Aunque tu Action no se haya publicado aún, la puedes instalar desde un flujo del mismo repositorio (como puedes ver en las líneas 42 a 48). Así que lo que yo hago es llamar al Action precisamente desde la rama staging que estoy probando y uso ese Action con un fichero markdown que tengo preparado en la carpeta de test. si se genera un fichero manpage, entonces el test funcional tiene éxito (línea 53). Tener la oportunidad de probar tu Action contra tu propio repositorio está muy bien, ya que te permite comprobar cómo utilizaría la gente tu Action, sin necesidad de publicarlo.

Además de probarlo, deberías escribir un fichero README.md para tu Action, con el fin de explicar con detalle como usarlo. En ese documento deberían incluir al menos la siguiente información: 
  • Una descripción de los que hace el Action.
  • Los input y outputs obligatorios.
  • Los inputs y outputs opcionales.
  • Cualquier contraseña que el Action pudiera necesitar que se le facilite.
  • Que variables de entorno utiliza el Action.
  • Un ejemplo de cómo usar tu Action en un flujo.
Por último, yo añadiría también un fichero LICENSE, explicando los términos legales de uso de tu Action.

Conclusión

El punto fuerte de GitHub Actions es el alto grado de reusabilidad y compartición que promueve. Cada vez que te encuentres repitiendo el mismo conjunto de comandos, será el momento de hacer un Action con esos comandos y compartirlos a través del Marketplace. Si lo haces así obtendrás una pieza de funcionalidad que será mucho más fácil de usar en tus flujos que tirando de copia-pegas de montones de comandos y además estarás contribuyendo a mejorar el marketplace permitiendo que otros se puedan beneficiar del Action que hayas desarrollado.

Gracias a esa filosofía, el GitHub Marketplace ha crecido hasta hospedar una cantidad enorme de Actions, listos para ser usados y para salvarte de implementar una y otra vez la misma funcionalidad por ti mismo.

15 marzo 2023

Cómo usar GitHub Actions para integración y despliegue continuos


En un artículo anterior ya expliqué cómo usar Travis CI para aplicar integración continua en tus proyectos. El problema con Travis es que ha cambiado sus términos de uso y se ha vuelto bastante incómodo para aplicarlo en proyectos open source. Ellos siguen diciendo que son gratuitos para proyectos open source pero en realidad tienes que suplicar por los créditos gratuitos cada vez que se agotan y te hacen probar que sigues cumpliendo con lo que entienden por un proyecto open source (he leído casos de desarrolladores que fueron descartados por el mero hecho de tener activados los GitHub Sponsors).

Por eso, he acabado buscando alternativas para mis proyectos. Dado que ya uso GitHub para mis proyectos open source, es natural probar su sistema de integración y despliegue continuos: GitHub Actions.

Conceptualmente, GitHub Actions es similar a Travis CI, por eso no voy a repetir los conceptos que desarrollé en el artículo de Travis CI.  Si quieres repasar esos conceptos y las razones para aplicar integración y despliegue continuos (CI/CD) puedes leer el artículo que he enlazado al comienzo de este. Por eso, vamos a enfocarnos en cómo usar GitHub Actions.

Como pasaba en Travis CI, todo lo que haces en GitHub Actions gira en torno a los ficheros yaml que vas creando en la carpeta .github/workflows de tu repositorio. GitHub buscará en esa carpeta los flujos de CI/CD que tiene que ejecutar. Cada uno de esos ficheros define un flujo de trabajo (workflow). Puedes tener múltiples flujos de trabajo para ser ejecutados como respuesta a diferentes eventos de la plataforma de GitHub, como pushes sobre determinadas ramas, pull requests recibidos, altas de nuevas incidencias de usuario (issues) y un largo etcétera.

La diferencia es que encuentro GitHub Actions bastante más cómodo y potente que Travis CI. Lo que diferencia a GitHub Action es que facilita la reutilización de componentes de manera masiva. Cada paso se puede encapsular y compartir entre flujos diferentes de trabajo o incluso con otros desarrolladores en GitHub para que lo apliquen a sus respectivos flujos de trabajo. Con tanta gente desarrollando y compartiendo en GitHub lo normal usar las tareas de otros (los que se denomina Actions), incluso más que implementarlas por uno mismo. A no ser que estés automatizando algo realmente raro, lo normal es que alguien lo haya implementado ya y lo haya compartido. En este artículo usaremos las acciones de otros desarrolladores e implementaremos nuestros propios pasos. Además, otra ventaja de GitHub Actions es que puedes reutilizar tus propios flujos de trabajo, o compartirlos con otros, de manera que no tengas que implementarlos desde cero en otros flujos de trabajo.

En este artículo nos enfocaremos al típico flujo de "probar -> empaquetar -> distribuir" y lo llamaremos  test_and_deploy.yaml (puedes llamarlo como quieras si te sientes creativo, pero intenta ser expresivo). Tienes el código fuente empleado en este artículo en este commit de mi proyecto cifra-rust en GitHub.

Para crear ese fichero yaml tienes dos opciones: crearlo en tu IDE y hacerle push como a cualquier otro fichero o bien crearlo usando el editor web integrado con GitHub. Para tu primera vez mi consejo es que uses el editor web, ya que te guía mejor a la hora de hacer funcionar tu primer fichero yaml. Teniendo en cuenta lo anterior, ve a tu repositorio en GitHub y pincha sobre la pestaña de Actions:


Allí, se te ofrece crear tu primer flujo, cuando aceptas se te ofrece usar una plantilla predefinida como punto de partida (GitHub tiene muchísimas, para diferentes tareas y lenguajes) o bien crear un flujo desde cero. Por aquello de aprender, nosotros vamos a elegir la opción de partir desde cero ("set up a workflow yourself"). Será entonces cuando entraremos al editor web. El flujo estará relleno con algo muy básico.

Ahora vamos a analizar el fichero yaml de Cifra-rust para ver lo que podemos hacer con las GitHub Actions.


Cabecera

En las primeras líneas (de la 1 a la 12) puedes ver el nombre de este flujo (en la etiqueta "name"). Utiliza un nombre expresivo, que identifique de un vistazo lo que hace el flujo. Este nombre será útil también para reutilizar este flujo en otros repositorios.

La etiqueta "on" define qué eventos disparan este flujo. En mi caso el flujo se dispara con los pushes y pull requests sobre mi rama staging. Hay muchos más eventos que puedes usar.

La etiqueta "workflow_dispatch" te permite disparar manualmente un flujo desde el interfaz web de GitHub. Suelo meterlo en todos mis flujos, no hace daño contar con esa opción.



Trabajos (Jobs)

Lo siguiente es la etiqueta "jobs" (línea 15) que es justo donde comienza el meollo de los flujos. Un flujo está compuesto de jobs. Cada job se ejecuta en una máquina virtual diferente (el runner) de manera que sus respectivas dependencias quedan encapsuladas. Esa encapsulación es buena para evitar que las dependencias de un job estropeen las dependencias y el sistema de ficheros de los otros jobs. Mi consejo es que intentes que cada job se centre en una única tarea (sí, aquí también aplica el principio de maximizar la cohesión).

Por defecto los jobs se ejecutan en paralelo a menos que se fijen dependencias explícitas entre ellos. Si necesitas que un job B se ejecute después de que el job A se finalice correctamente lo que tienes que hacer es usar la etiqueta "needs" para fijar que B necesita que A se complete correctamente para comenzar. Por ejemplo, en Cifra-rust los trabajos "merge_staging_and_master", "deploy_crates_io" y "generate_deb_package" necesitan que el trabajo "tests" finalice correctamente para que ellos puedan comenzar.  Puedes ver un ejemplo del uso de la etiqueta "needs" en la línea 53:

Como "deploy_debian_package" necesita respectivamente que "generate_deb_package" finalice antes, al final acabas con un árbol de ejecución como el siguiente:


Actions

Cada job se compone de uno o más pasos (steps). Un step es una secuencia de comandos de shell. Estos pueden comandos pueden ser los nativos del sistema operativo sobre el que estés ejecutando el job o bien scripts que hayas incluido en tu repositorio. De la línea 112 a la 115 podemos ver uno de estos steps:

En el step anterior estamos llamando a un script de la carpeta ci_scripts del repositorio. Fíjate en el pipe ("|") junto a la etiqueta "run". Sin ese pipe sólo podrías incluir un comando junto a la etiqueta, pero gracias a él se pueden meter varios comandos separados en líneas independientes (como en los steps de las líneas 44 a 46).

Si te encuentras repitiendo los mismos comandos en flujos diferentes entonces deberías pensar en encapsular esos comandos en una action. Como te puedes imaginar. las Actions son la clave de las GitHub Actions. Una action es un conjunto de comandos empaquetados para ser compartidos y reutilizados en diferentes flujos de trabajo (tuyos o de otros usuarios). Una action tiene entradas (inputs) y salidas (outputs), pero lo que pasa dentro de ella no es tu problema siempre y cuando funcione como se le supone. El cómo desarrollar tus propias actions y compartirlas con otros merece su propio artículo. En el próximo artículo convertiré ese step para generar el manpage en una action que pueda ser reutilizada.

A la derecha, el editor web de GitHub Actions tiene un buscador para encontrar actions útiles para la tarea que queramos hacer. Supongamos que quieres instalar el toolchain de Rust, en ese caso podemos hacer esta búsqueda:

Aunque el editor web de GitHub es realmente completo y sirve para encontrar errores en los ficheros yaml, su buscador carece de una manera de filtrar y reordenar sus resultados. Aún así es tu mejor opción para encontrar actions de otros para tus flujos.

Una vez que haces click en cualquier resultado del buscador, se te muestra un resumen acerca de que texto incluir en tu fichero yaml para usar esa action. Puedes conseguir una información aún más detallada haciendo click en el enlace "View full Marketplace listing".

Como con los step, una action usa una etiqueta "name" para describir lo que se supone que va a hacer la tarea, así como una "id" si ese action debe ser referenciado desde otros puntos del flujo de trabajo (mira por ejemplo la línea 42). Lo que diferencia a un step de una action es que este último utiliza la etiqueta "uses". Esa etiqueta referencia la action que queremos usar. El texto a usar en esa etiqueta difiere para cada action, pero puedes averiguar qué escribir allí consultando las instrucciones que salen en los resultados de la búsqueda para esa action. En esas instrucciones se suele describir las entradas que acepta la action. Esas entradas se incluyen con la etiqueta "with". Por ejemplo, en las líneas 23 a 27 usé una action para instalar el framework de compilación de Rust: 

Como puedes ver, en la etiqueta "uses" puedes decir qué versión de la action usar. Hay wildcards para usar la última versión pero lo mejor es usar una versión específica para evitar que los flujos se rompan por actualizaciones de los actions.

Los jobs se componen de actions encadenados en forma de pasos. Los pasos de un job se ejecutan secuencialmente. Si cualquiera de ellos falla, el job completo fracasa.


Compartir datos entre pasos y flujos

Aunque los steps de un flujo se ejecutan en la misma máquina virtual no pueden compartir variables de entorno porque cada step lanza un proceso de bash diferente. Hay dos maneras de fijar una variable de entorno en un step que necesite ser usado en otro step del mismo job:

  • Rápido y sucio: Concatenar la variable de entorno a la variable $GITHUB_ENV de manera que esa variable pueda ser accedida después usando el contexto env. Por ejemplo, en la línea 141 creamos la variable de entorno:

Esa variable de entorno es accedida en la línea 146, en el step siguiente:


  • Fijar el output del step: El problema con el último método es que aunque te permite compartir datos entre steps no te permite hacerlo entre jobs diferentes. Fijar los outputs de un step es algo más lento pero deja a tu step preparado para compartir datos no sólo con otros steps del mismo job, sino con steps de cualquier workflow. Para configurar una variable de entorno como el output de un step hace falta pasar el valor de esa variable a ::set-output y darle nombre a esa variable, seguida de su valor tras una pareja de dos puntos ("::"). Tienes un ejemplo de cómo hacerlo en la línea 46:
 
Fíjate en que ese step debe ser identificado con una etiqueta "id" para poder recuperar luego la variable compartida. Como ese step se identifica como "version_tag", la variable creada como "package_tag" puede ser recuperada luego desde otro step del mismo job usando:

${{ steps.version_tag.outputs.package_tag }}

En realidad, ese método se usa en la línea 48 para preparar esa variable para ser recuperada desde otro job. Recuerda que hasta ahora hemos visto que ese método sirve para pasar datos entre los steps de un mismo job. Para exportar datos desde un job, para ser usados desde otro job, tienes que declararlo primero como un output del job (líneas 47-48):


Fíjate en que en la última captura de pantalla, el nivel de indentación debe estar al mismo nivel que la etiqueta "steps" para que se pueda configurar a package_tag como un output del job.

Para recuperar ese output desde otro job, el job de destino debe declarar al job de origen en su etiqueta "needs". Después de eso, el valor puede ser recuperado usando el siguiente formato:

${{ needs.<needed_job>.outputs.<output_varieble_name> }} 

En nuestro ejemplo, "deploy_debian_package" necesita el valor exportado en la línea 48, por eso declara su job (test) como una dependencia en la línea 131:

Después de eso, ya puede leer esa variable en la línea 157:



Pasando ficheros entre jobs

A veces, pasar una variable no es suficiente porque necesitas producir ficheros en un job para que luego sean consumidos en otro job.

Puedes compartir ficheros entre steps de un mismo job porque esos steps comparten la misma máuina virtual. Pero entre jobs necesitas trasferir ficheros desde una máquina virtual a otra.

Cuando generas un fichero (un artefacto) en un job, puedes subirlo a un almacenamiento temporal compartido, para permitir que otros jobs del mismo flujo tengan acceso a ese artefacto. Para subir y descargar un artefacto hacia/desde ese almacenamiento temporal tienes dos actions predefinidas: upload-artifact and download-artifact.

En nuestro ejemplo, el job "generate_deb_package" genera un paquete debian requerido por "deploy_debian_package". Por eso, en las líneas 122 a 126 "generated_deb_package" sube ese paquete:

En el otro lado "deploy_debian_package" descarga el artefacto salvado en las líneas 132 a 136:



Usando el repositorio de código fuente

Por defecto empiezas cada job con una máquina virtual limpia. Para hacer que tu código fuente se descargue en cada máquina virtual tienes que utilizar una action denominada checkout. En nuestro ejemplo se usa como el primer step del job "tests" (línea 21) para hacer que el código se compile y se pruebe: 

Puedes hacer que se descargue el código de cualquier rama, pero si no especificas ninguna en concreto se descargará el de la rama que haya disparado el evento para arrancar el job. En nuestro ejemplo, esa rama es la de staging.


Ejecutando nuestro flujo

Tienes dos opciones para probar un flujo: puedes provocar el evento configurado para disparar el flujo (en nuestro ejemplo hacer push a la rama de staging) o se puede lanzar el flujo manualmente desde el interfaz web de GitHub (suponiendo que hayas incluido la etiqueta "workflow_dispatch" en tu fichero yml, como te aconsejé).

Para lanzar el flujo manualmente, ve a la pestaña Actions del repositorio y selecciona el flujo que quieres lanzar. Entonces verás el botón "Run workflow" a mano derecha:


Una vez que ya has pulsado el botón, el flujo comenzará y la lista de la pestaña mostrará que el flujo está activo. Si seleccionamos ese flujo en la lista se mostrará un árbol de ejecución como el que mostré antes. Haciendo click en cualquiera de las cajas mostrará los logs generados en tiempo real para cada paso del job. Es extremadamente fácil navegar entre los diferentes logs generados.


Conclusión

La verdad es que he encontrado las GitHub Actions muy agradables de usar. Su enfoque a la reutilización y a compartir las actions hace realmente fácil crear flujos extremadamente complejos, dado que a menos que estés desarrollando flujos realmente raros, lo más probable será que la mayor parte (si no todos) de los componentes que vayas a necesitar ya hayan sido implementados y compartidos por otros, en forma de actions. De esta manera hacer flujos complejos se convierte en una tarea fácil de unir piezas ya disponibles.

La documentación de GitHub Actions es realmente buena y su popularidad facilita encontrar respuestas online para cualquier duda que pudiera surgirte.

Además, la estructura de los fichero yml me ha parecido coherente y lógicas, por lo que es fácil coger los conceptos y alcanzar un buen nivel realmente rápido.

Siendo gratuito e ilimitado para repositorios open source, sospecho que voy a migrar todos mis flujos de CI/CD desde Travis a GitHub Actions.