Análisis de un intérprete DSL de Jenkins Pipeline en Kotlin

Implementación de DSL de Jenkins Pipeline en Kotlin

Posted by RubentxuDev on Monday, October 23, 2023

Análisis de un intérprete de Jenkins Pipeline DSL en Kotlin

En este artículo, analizaremos un código escrito en Kotlin que implementa un intérprete DSL (Domain Specific Language) para los Pipelines de Jenkins. Este código es una alternativa a la implementación estándar de Groovy DSL en Jenkins y puede ejecutarse sin servidor en cualquier agente directamente o como parte de una aplicación CLI.

Aclaración: DSL en Kotlin vs DSL de Jenkins

Es importante aclarar que el código presentado en este artículo es una demostración de cómo se puede escribir un DSL (Domain Specific Language) en Kotlin para la creación y gestión de pipelines de CI/CD. No es una implementación del DSL de la pipeline declarativa de Jenkins.

El DSL de Jenkins, que está escrito en Groovy, es una herramienta poderosa y flexible que permite definir pipelines de CI/CD complejos con una sintaxis declarativa. Sin embargo, requiere la instalación y mantenimiento de un servidor Jenkins, y la sintaxis y las características del DSL pueden ser difíciles de aprender y utilizar para aquellos que no están familiarizados con Groovy.

Por otro lado, el DSL presentado en este artículo está escrito en Kotlin, un lenguaje de programación moderno y fácil de aprender que es compatible con la JVM. Este DSL permite definir pipelines de CI/CD con una sintaxis que es familiar para los desarrolladores de Kotlin, y puede ser ejecutado en cualquier entorno que soporte la JVM, sin necesidad de un servidor Jenkins.

Sin embargo, este DSL de Kotlin no es una implementación completa del DSL de Jenkins. No soporta todas las características y funcionalidades del DSL de Jenkins, y la sintaxis y la semántica de los dos DSL son diferentes. Por lo tanto, no se puede utilizar como un reemplazo directo del DSL de Jenkins.

En resumen, el DSL de Kotlin presentado en este artículo es una herramienta útil para la creación y gestión de pipelines de CI/CD, especialmente para aquellos que ya están familiarizados con Kotlin. Sin embargo, no es una implementación del DSL de Jenkins y no debe ser visto como tal.

Clases y funcionalidades

Enum Status

Esta enumeración define los posibles estados de una etapa o de todo el pipeline. Los estados pueden ser Success, Failure, Unstable, Aborted o NotBuilt.

enum class Status {
    Success,
    Failure,
    Unstable,
    Aborted,
    NotBuilt
}

Clases StageResult y PipelineResult

StageResult es una clase de datos que almacena el nombre de una etapa y su estado. PipelineResult es similar, pero almacena el estado del pipeline completo, una lista de los resultados de las etapas, las variables de entorno y los registros de logs.

data class StageResult(
    val name: String,
    val status: Status

)

data class PipelineResult(
    val status: Status,
    val stageResults: List<StageResult>,
    val env: EnvVars,
    val logs: MutableList<String>
)

Función pipeline

Esta función suspendida es la función principal que ejecuta el pipeline. Recibe un bloque de código que se ejecutará dentro del contexto de un objeto PipelineDsl. Esta función maneja las excepciones y determina el estado final del pipeline.

suspend fun pipeline(block: suspend PipelineDsl.() -> Unit): PipelineResult {
    val pipeline = PipelineDsl()

    var status: Status

    try {
        pipeline.block()

        status = if (pipeline.stageResults.any { it.status == Status.Failure }) Status.Failure else Status.Success
    } catch (e: Exception) {
        status = Status.Failure
        pipeline.stageResults.addAll(listOf(StageResult("Unknown", status)))
        throw e
    }

    return PipelineResult(status, pipeline.stageResults, pipeline.env, pipeline.logger.logs)
}

Clase PipelineDsl

Esta es la clase principal que define el DSL del pipeline. Contiene una serie de funciones que permiten definir un agente, un entorno y una serie de etapas. Cada etapa puede tener un conjunto de pasos que se ejecutarán en secuencia o en paralelo.

class PipelineDsl: Configurable{

    internal val logger  = PipelineLogger("INFO")

    val any = Placeholder.ANY

    val env = EnvVars()

    internal val workingDir: Path = Path.of(System.getProperty("user.dir"))

    var stageResults = mutableListOf<StageResult>()

    fun getPipeline(): PipelineDsl {
        return this
    }

    suspend fun agent(any: Placeholder) {
        logger.debug("Running pipeline using any available agent...")
    }

    suspend fun environment(block: EnvVars.() -> Unit) {
        env.apply(block)
    }

    suspend fun stages(block: StagesDsl.() -> Unit): List<StageResult> {
        val dsl = StagesDsl()
        dsl.block()

        return dsl.stages.map { stage ->
            var status = Status.Success
            try {
                stage.run(getPipeline())
            } catch (e: Exception) {
                status = Status.Failure
            }
            StageResult(stage.name, status).also { stageResults.add(it) }
        }
    }

    suspend fun stagesAsync(block: StagesDsl.() -> Unit): List<StageResult> {
        val dsl = StagesDsl()
        dsl.block()

        return coroutineScope {
            dsl.stages.map { stage ->
                async {
                    var status = Status.Success
                    try {
                        stage.run(getPipeline())
                    } catch (e: Exception) {
                        status = Status.Failure
                    }
                    StageResult(stage.name, status)
                }
            }.awaitAll().also { stageResults.addAll(it) }
        }
    }
  
    enum class Placeholder {
        ANY
    }

    override fun configure(configuration: Map<String, Any>) {
        TODO("Not yet implemented")
    }

    fun toFullPath(workingDir: Path): String {
        return if (workingDir.isAbsolute) {
            workingDir.toString()
        } else {
            "${this.workingDir}/${workingDir}"
        }
    }
}

Clase Stage

Esta clase representa una etapa en el pipeline. Cada etapa tiene un nombre y un bloque de código que se ejecutará cuando se ejecute la etapa.

class Stage(val name: String, val block: suspend StageDsl.() -> Any) {

    suspend fun run(pipeline: PipelineDsl) : Any {

        val dsl = StageDsl(pipeline)
        val result = dsl.block()

        return result
    }
}

Clases StageDsl, StagesDsl y StepBlock

Estas clases proporcionan el DSL para definir las etapas y los pasos dentro de las etapas. StageDsl proporciona el contexto para definir los pasos dentro de una etapa, StagesDsl proporciona el contexto para definir varias etapas y StepBlock proporciona el contexto para definir los pasos que se ejecutarán dentro de una etapa.

class StageDsl(val pipeline: PipelineDsl) {

    suspend fun steps(block: suspend StepBlock.() -> Any) {
        val steps = StepBlock(pipeline)
        steps.block()
    }
}

class StagesDsl {
    val stages = mutableListOf<Stage>()

    fun stage(name: String, block: suspend StageDsl.() -> Unit) {
        stages.add(Stage(name, block))
    }
}

open class StepBlock(val pipeline: PipelineDsl) :  CoroutineScope by CoroutineScope(Dispatchers.Default) {
    val logger: IPipelineLogger = pipeline.logger

    val steps = mutableListOf<Step>()

    fun step(block: suspend () -> Unit) {
        steps += Step(block)
    }

    fun parallel(vararg steps: Pair<String, Step>) = runBlocking {
        steps.map { (name, step) ->
            async {
                println("Starting $name")
                step.block()
                println("Finished $name")
            }
        }.awaitAll()
    }
}

Clase EnvVars

Esta clase proporciona una manera de definir y manipular variables de entorno. Las variables de entorno se almacenan en un ConcurrentHashMap y se pueden obtener o establecer utilizando operadores de Kotlin.

class EnvVars {

    private val variables = ConcurrentHashMap<String, String>()

    operator fun String.plusAssign(value: String) {
        variables[this] = value
    }

    fun getVariables(): Map<String, String> = variables

    operator fun get(key: String): String? {
        return variables[key]
    }

    fun setProperty(name: String, value: String) {
        variables[name] = value
    }

    fun expand(s: String): String {
        var result = s
        val regex = Regex("\\$\\{([^}]*)\\}")
        val matches = regex.findAll(s)

        matches.forEach { matchResult ->
            val variableName = matchResult.groups[1]?.value
            val variableValue = variables[variableName]
                ?: throw Exception("Not Found Environment Var $variableName")
            result = result.replace("\${$variableName}", variableValue)
        }

        return result
    }
}

Clase Shell

Esta clase proporciona una manera de ejecutar comandos de shell dentro de los pasos del pipeline. Los comandos se ejecutan en un nuevo proceso y se capturan la salida estándar y la salida de error.

class Shell(pipeline: PipelineDsl) : StepBlock(pipeline) {

    suspend fun execute(command: String, directory: File): String {
        logger.info("Executing command: $command in directory: $directory")
        return withContext(Dispatchers.IO) {
            val process = ProcessBuilder("/bin/bash", "-c", command)
                .directory(directory)
                .start()

            val stdout: InputStream = process.inputStream
            val stderr: InputStream = process.errorStream

            // Read the output and error (if any)
            val output = stdout.bufferedReader().readText()
            val error = stderr.bufferedReader().readText()

            val exitCode = process.waitFor()

            if (exitCode != 0) {
                throw Exception("Error executing command. Exit code: $exitCode, Error: $error for command: $command")
            }
            output
        }
    }
}

Clase PipelineLogger

Esta clase proporciona una manera de registrar mensajes en diferentes niveles de log. Los mensajes se almacenan en una lista y también se imprimen en la consola con colores y estilos diferentes dependiendo del nivel de log.

class PipelineLogger(private var logLevel: String) : IPipelineLogger {
    
    val logs = mutableListOf<String>()

    private val RESET = "\u001B[0m"
    private val RED = "\u001B[31m"
    private val YELLOW = "\u001B[33m"
    private val GREEN = "\u001B[32m"
    private val MAGENTA = "\u001B[35m"
    private val CYAN = "\u001B[36m"
    private val BOLD = "\u001B[1m"
    private val ITALIC = "\u001B[3m"

    private val LEVEL_NUMBERS = mapOf(
        "FATAL" to 100,
        "ERROR" to 200,
        "WARN" to 300,
        "INFO" to 400,
        "DEBUG" to 500,
        "SYSTEM" to 600
    )

    init {
        setLogLevel(logLevel)
    }

    private fun setLogLevel(level: String) {
        logLevel = level
    }


    private fun log(level: String, message: String) {
        val formatOpts = mutableMapOf(
            "color" to "",
            "level" to level,
            "text" to message,
            "style" to "",
            "reset" to RESET
        )

        if (!LEVEL_NUMBERS.containsKey(level)) return
        if (LEVEL_NUMBERS[level]!! <= LEVEL_NUMBERS[logLevel]!!) {
            when (level) {
                "FATAL", "ERROR" -> {
                    formatOpts["color"] = RED
                    formatOpts["style"] = BOLD
                }
                "WARN" -> {
                    formatOpts["color"] = YELLOW
                    formatOpts["style"] = BOLD
                }
                "INFO" -> {
                    formatOpts["color"] = GREEN
                }
                "DEBUG" -> {
                    formatOpts["color"] = MAGENTA
                    formatOpts["style"] = ITALIC
                }
                "SYSTEM" -> {
                    formatOpts["color"] = CYAN
                    formatOpts["style"] = ITALIC
                }
            }
            write(formatOpts)
        }
    }

    private fun write(formatOpts: Map<String, String>) {
        val msg = formatMessage(formatOpts)
        logs.add(msg)
        println(msg)
    }

    private fun formatMessage(options: Map<String, String>): String {
        return "${options["color"]}${options["style"]}[${options["level"]}] ${options["text"]}${options["reset"]}"
    }

    override fun info(message: String) {
        log("INFO", message)
    }

    override fun warn(message: String) {
        log("WARN", message)
    }

    override fun debug(message: String) {
        log("DEBUG", message)
    }

    override fun error(message: String) {
        log("ERROR", message)
    }

    override fun fatal(message: String) {
        log("FATAL", message)
    }

    override fun system(message: String) {
        log("SYSTEM", message)
    }

    fun whenDebug(body: () -> Unit) {
        if (logLevel == "DEBUG") {
            body()
        }
    }

    fun <T> prettyPrint(levelLog: String, obj: T) {
        log(levelLog, prettyPrintExtend(obj))
    }

    private fun <T> prettyPrintExtend(obj: T, level: Int = 0, sb: StringBuilder = StringBuilder()): String {
        fun indent(lev: Int) = sb.append("  ".repeat(lev))

        when (obj) {
            is Map<*, *> -> {
                sb.append("{\n")
                obj.forEach { (name, value) ->
                    indent(level + 1).append(name)
                    if (value is Map<*, *>) {
                        sb.append(" ")
                    } else {
                        sb.append(" = ")
                    }
                    sb.append(prettyPrintExtend(value, level + 1)).append("\n")
                }
                indent(level).append("}")
            }
            is List<*> -> {
                sb.append("[\n")
                obj.forEachIndexed { index, value ->
                    indent(level + 1)
                    sb.append(prettyPrintExtend(value, level + 1))
                    if (index != obj.size - 1) {
                        sb.append(",")
                    }
                    sb.append("\n")
                }
                indent(level).append("]")
            }
            is String -> sb.append('"').append(obj).append('"')
            else -> sb.append(obj)
        }
        return sb.toString()
    }

    fun echoBanner(level: String, messages: List<String>) {
        log(level, createBanner(messages))
    }

    fun errorBanner(msgs: List<String>) {
        error(createBanner(msgs))
    }

    private fun createBanner(msgs: List<String>): String {
        return """
        |===========================================
        |${msgFlatten(mutableListOf(), msgs.filter { it.isNotEmpty() }).joinToString("\n")}
        |===========================================
        """.trimMargin()
    }

    private fun msgFlatten(list: MutableList<String>, msgs: Any): List<String> {
        when (msgs) {
            is String -> list.add(msgs)
            is List<*> -> msgs.forEach { msg -> msgFlatten(list, msg ?: "") }
        }
        return list
    }
}

Uso óptimo de las corrutinas en Kotlin

Las corrutinas son una de las características más poderosas de Kotlin. Permiten escribir código asíncrono de una manera que se parece mucho al código síncrono normal, lo que hace que el código sea más fácil de leer y de entender. En nuestro código de Pipeline DSL, las corrutinas se utilizan en varios lugares para permitir la ejecución asíncrona de los pasos del pipeline.

¿Por qué son útiles las corrutinas?

Las corrutinas son útiles porque permiten ejecutar tareas de larga duración, como las operaciones de E/S o las llamadas a la red, sin bloquear el hilo principal de la aplicación. Esto es especialmente útil en las aplicaciones de servidor o en las interfaces de usuario, donde bloquear el hilo principal puede llevar a una mala experiencia del usuario o a una baja eficiencia del servidor.

Uso de corrutinas en el código

En nuestro código, las corrutinas se utilizan principalmente en la clase StepBlock. Cada paso en un bloque de pasos se define como una corrutina, lo que significa que puede ejecutarse de manera asíncrona. Esto es especialmente útil cuando tenemos pasos que se pueden ejecutar en paralelo, como se muestra en la función parallel.

La función parallel toma un número variable de pares de nombres de pasos y pasos, y ejecuta todos los pasos en paralelo utilizando la función async de las corrutinas. Esto significa que todos los pasos se inician al mismo tiempo y la función parallel espera hasta que todos los pasos hayan terminado.

Además, la función pipeline es una función suspendida, lo que significa que puede llamar a otras funciones suspendidas y puede ser llamada desde otras funciones suspendidas o desde un bloque de código de corrutinas. Esto permite que el pipeline se ejecute de manera asíncrona.

Beneficios de las corrutinas

El uso de corrutinas en este código tiene varios beneficios. En primer lugar, permite una ejecución más eficiente del pipeline, ya que los pasos que se pueden ejecutar en paralelo se ejecutan al mismo tiempo. En segundo lugar, hace que el código sea más fácil de leer y de entender, ya que el código asíncrono se parece mucho al código síncrono normal. Por último, las corrutinas son una característica integrada en Kotlin, por lo que no se necesita ninguna biblioteca adicional para utilizarlas.


Delegación de cierre en Kotlin

La delegación de cierre es un patrón de diseño comúnmente utilizado en los lenguajes de programación que soportan funciones de primera clase y cierres, como Kotlin. Este patrón permite a un objeto delegar una operación a una función o a un cierre, en lugar de implementar la operación por sí mismo.

En Kotlin, la delegación de cierre se utiliza a menudo para implementar DSLs (Domain Specific Languages). En un DSL, a menudo queremos que un bloque de código se ejecute en el contexto de un objeto específico. Esto se puede lograr utilizando la función de extensión invoke en una función lambda, lo que nos permite cambiar el receptor de la función lambda al objeto deseado.

En nuestro código de Pipeline DSL, la delegación de cierre se utiliza en varias funciones, como pipeline, agent, environment, stages, stage y steps. Todas estas funciones toman un bloque de código como argumento y utilizan la función invoke para ejecutar el bloque de código en el contexto de un objeto específico.

Por ejemplo, la función pipeline toma un bloque de código y lo ejecuta en el contexto de un objeto PipelineDsl. Esto permite que el bloque de código acceda directamente a las propiedades y funciones del objeto PipelineDsl, como si fuera el objeto this dentro del bloque de código.

suspend fun pipeline(block: suspend PipelineDsl.() -> Unit): PipelineResult {
    val pipeline = PipelineDsl()
    pipeline.block()
    // ...
}

De manera similar, la función stages toma un bloque de código y lo ejecuta en el contexto de un objeto StagesDsl. Esto permite que el bloque de código defina varias etapas utilizando la función stage del objeto StagesDsl.

suspend fun stages(block: StagesDsl.() -> Unit): List<StageResult>{
    val dsl = StagesDsl()
    dsl.block()
    // ...
}

En resumen, la delegación de cierre es un patrón de diseño poderoso que permite a un objeto delegar una operación a un bloque de código. En Kotlin, este patrón se utiliza a menudo para implementar DSLs, permitiendo que un bloque de código se ejecute en el contexto de un objeto específico.


Extensiones de funciones en Kotlin

Las extensiones de funciones son una característica de Kotlin que permite “extender” una clase con nuevas funcionalidades sin tener que heredar de la clase. Esto se hace definiendo una nueva función que se “adjunta” a la clase existente.

En nuestro código de Pipeline DSL, las extensiones de funciones se utilizan en varios lugares para añadir nuevas funcionalidades a las clases existentes.

Por ejemplo, la función sh es una extensión de la clase PipelineDsl. Esta función permite ejecutar un comando de shell dentro del pipeline. La función sh utiliza la clase Shell para ejecutar el comando y capturar la salida.

suspend fun PipelineDsl.sh(script: String, returnStdout: Boolean = false): String {
    val shell = Shell(this)
    val output = shell.execute(script, this.workingDir.toFile())
    logger.info("Shell script executed successfully: $script")
    if (returnStdout) {
        return output
    }
    logger.info(output)
    return ""
}

Otro ejemplo es la función echo, que es una extensión de la clase StepBlock. Esta función permite registrar un mensaje en el logger del pipeline.

suspend fun StepBlock.echo(message: String) {
    logger.info(message)
    return Unit
}

Las extensiones de funciones son una característica muy útil de Kotlin que permite añadir nuevas funcionalidades a las clases existentes de una manera limpia y elegante. En nuestro código de Pipeline DSL, las extensiones de funciones se utilizan para añadir funcionalidades como la ejecución de comandos de shell y el registro de mensajes, que son esenciales para la ejecución del pipeline.


Alternativas a Jenkins Server y Groovy DSL

El código presentado en este artículo ofrece una alternativa viable a la utilización del servidor Jenkins y su DSL Groovy para la creación y gestión de pipelines de CI/CD. Al estar escrito en Kotlin, este DSL puede integrarse fácilmente en cualquier proyecto que utilice este lenguaje, y puede ejecutarse en cualquier entorno que soporte la JVM, sin necesidad de instalar y mantener un servidor Jenkins.

Aquí hay algunas posibles soluciones que se pueden implementar con este código:

1. Aplicaciones de línea de comandos (CLI)

Este DSL puede utilizarse para crear aplicaciones de línea de comandos que ejecuten pipelines de CI/CD. Estas aplicaciones pueden ser ejecutadas en cualquier agente o servidor, y pueden ser fácilmente integradas en scripts de shell o en otras herramientas de automatización.

2. Servicios web

El DSL puede ser utilizado para crear servicios web que acepten definiciones de pipeline en formato JSON o XML, las conviertan en pipelines de Kotlin y las ejecuten. Esto permitiría a los usuarios definir y ejecutar pipelines de CI/CD a través de una API REST, sin necesidad de instalar ningún software adicional.

3. Integración con otras herramientas de CI/CD

Este DSL podría ser integrado con otras herramientas de CI/CD que soporten la ejecución de código Kotlin. Por ejemplo, podría ser utilizado para definir pipelines en TeamCity, Bamboo o cualquier otra herramienta que soporte Kotlin.

4. Pruebas de integración y despliegue continuo

El DSL podría ser utilizado para escribir pruebas de integración que verifiquen el correcto funcionamiento de un pipeline de CI/CD. También podría ser utilizado para implementar pipelines de despliegue continuo que construyan, prueben y desplieguen aplicaciones en varios entornos.

En resumen, este DSL de Kotlin para pipelines de CI/CD ofrece una alternativa flexible y potente al servidor Jenkins y su DSL Groovy. Al estar basado en Kotlin, puede ser utilizado en una amplia gama de contextos y puede ser fácilmente integrado con otras herramientas y tecnologías.

Análisis de las pruebas

Las pruebas son una parte esencial de cualquier código, ya que nos permiten verificar que nuestro código funciona como se espera. En nuestro código de Pipeline DSL, se incluyen varias pruebas que demuestran cómo se puede utilizar el DSL para definir y ejecutar pipelines.

Las pruebas están escritas utilizando el marco de pruebas de Kotlin llamado kotest. Este marco permite escribir pruebas en un estilo de especificación de comportamiento, lo que hace que las pruebas sean fáciles de leer y entender.

Prueba “Pipeline with stages and steps should run”

Esta prueba verifica que un pipeline con varias etapas y pasos se ejecute correctamente. En la prueba, se define un pipeline con dos etapas, “Build” y “Test”. Cada etapa tiene varios pasos, que incluyen la ejecución de comandos de shell y la impresión de mensajes.

La prueba verifica que el estado del pipeline sea Success, que el pipeline tenga dos resultados de etapas, y que el estado de cada etapa sea Success. También verifica que los mensajes impresos en los pasos estén presentes en los registros de logs y que las variables de entorno estén correctamente establecidas.

"Pipeline with stages and steps should run" {
    val result = pipeline {
        agent(any)
        environment {
            "DISABLE_AUTH" += "true"
            "DB_ENGINE"    += "sqlite"
        }
        stages {
            stage("Build") {
                steps {
                    parallel(
                        "a" to Step {
                            delay(1000)
                            echo("This is branch a")
                        },
                        "b" to Step {
                            delay(500)
                            echo("This is branch b")
                        }
                    )
                    sh("pwd", returnStdout=true)
                    echo("Variable de entorno para DB_ENGINE es ${env["DB_ENGINE"]}")
                }
            }
            stage("Test") {
                steps {
                    sh("ls -la", returnStdout=true)
                    echo("Tests complete")
                    sh("ls -la /home", returnStdout=true)
                }
            }
        }
    }

    result.status shouldBe Status.Success
    result.stageResults.size shouldBe 2
    // ...
}

Prueba “Pipeline should fail if a stage fails”

Esta prueba verifica que un pipeline falle si una de sus etapas falla. En la prueba, se define un pipeline con una etapa que falla al intentar ejecutar un comando de shell que no existe.

La prueba verifica que el estado del pipeline sea Failure y que el estado de la etapa fallida sea Failure.

"Pipeline should fail if a stage fails" {
    val result = pipeline {
        // ...
        stages {
            stage("Failing Stage") {
                steps {
                    sh("command-that-does-not-exist", returnStdout=true)
                }
            }
        }
    }

    result.status shouldBe Status.Failure
    // ...
}

Estas pruebas demuestran cómo se puede utilizar el DSL para definir y ejecutar pipelines, y cómo se pueden manejar los errores y las fallas en las etapas y los pasos del pipeline.

Conclusión

En este artículo, hemos explorado cómo se puede utilizar Kotlin para crear un DSL para pipelines de CI/CD, ofreciendo una alternativa a la implementación estándar de Groovy DSL en Jenkins. Hemos analizado las diferentes clases y funciones utilizadas en el código, y hemos discutido conceptos avanzados de Kotlin como las corrutinas, la delegación de cierre y las extensiones de funciones.

Este DSL de Kotlin para pipelines de CI/CD es solo un ejemplo de lo que se puede lograr con Kotlin y su soporte para DSLs. Kotlin ofrece una gran flexibilidad y potencia para la creación de DSLs, lo que permite crear lenguajes de dominio específico que son fáciles de leer y de escribir, y que se integran perfectamente con el resto del código de Kotlin.

Si estás interesado en aprender más sobre cómo crear DSLs en Kotlin, te recomiendo que leas los posts sobre kotlin DSL de Glenn Sandoval. Esta serie de posts ofrece una introducción detallada a los constructores de DSL en Kotlin, y cubre temas como las funciones de extensión, funciones de primera clase y de orden superior, clases anidadas etc.

En resumen, Kotlin ofrece una gran cantidad de características y funcionalidades que facilitan la creación de DSLs potentes y flexibles. Ya sea que estés creando un DSL para pipelines de CI/CD, como en nuestro ejemplo, o para cualquier otro dominio, Kotlin es sin duda una excelente opción.

PD. El codigo lo puedes encontrar en mi repositorio GitHub