Simplificando suas pipelines Jenkins com Shared Libraries

Jun 26, 2020

Se você já está no mundo de TI a algum tempo, deve ter percebido que existe uma palavra que vem ganhando cada vez mais peso... Essa palavra é automação. Empresas buscam reduzir falhas, agilizar processos, aumentar entregas e qualidade, e a automação vem ajudando a atingir esses objetivos.

Bem, agora que você já sabe o segredo, é só executar, certo? Nem tanto. A automação ajuda sim e resolve muitos problemas, mas traz diversas complexidades a sua estrutura, como gerenciamento, capacitação dos funcionários em novas tecnologias, documentação e principalmente controle do que está sendo feito.

“Tá, mas o que isso tem a ver com o Jenkins?”

Se você já trabalhou com o Jenkins ou seus concorrentes, sabe que esta é uma ferramenta que te ajuda a criar pipelines automatizadas, ou seja, receitas para o build e deploy de todo tipo de código escrito.

É muito empolgante começar a usar esse tipo de ferramenta pela praticidade que ela entrega, por exemplo, podemos construir pipelines para efetuar o build e deploy de um código NodeJS em minutos. Mas e se você tiver 30 projetos ou até mais? Copiar e colar pipelines entre os repositórios acaba se tornando insustentável, manter esses códigos atualizados em todos eles então nem se fala. Por isso quero apresentar aqui, uma maneira que me ajudou a organizar esse emaranhado de códigos e dar autonomia para que os próprios desenvolvedores construíssem suas pipelines.

A ideia deste post não é ensinar você a instalar o Jenkins nem criar pipelines perfeitas, mas sim uma maneira de organizá-las, simplificando e documentando tudo para outras pessoas.

Mas chega de explicações e vamos ao que interessa...

O que são Jenkins Shared Libraries?

Shared Libraries são funções criadas que são carregadas no Jenkins de uma maneira que são compartilhadas entre todas as pipelines. Por exemplo, o Stage do Jenkinsfile abaixo instala as dependências e faz o build de uma aplicação em NodeJS:

stage('Npm Install And Build') {
    steps {
      sh 'npm install'
      sh 'npm run build'
    }
}

Utilizando uma biblioteca compartilhada, podemos mudar este trecho de código para:

javaScriptBuild()

Mas qual o ganho? A princípio esta alteração parece dar mais trabalho do que resultado, porém vamos analisar com calma:

  1. É reutilizável, se 30 projetos precisarem realizar esta operação, o código aplicado será o mesmo;
  2. É centralizado, se você decidir que o npm não é mais a melhor escolha e agora você vai usar yarn, basta alterar em 1 único local, e todas as suas pipelines passarão a usar o novo código.
  3. É simples, os próprios desenvolvedores podem controlar o Jenkinsfile, pois eles conseguem entender o que vai ser feito em cada passo.

Criando o repositório da Shared Library

Para criarmos nossa primeira biblioteca, devemos entender a estrutura de pastas utilizada:

src/<myOrg>: Nesta pasta podemos criar funções que são compartilhadas por todos os nossos scripts groovy.

vars/: Este é o diretório que armazena os scripts que são expostos para suas pipelines, ou seja, se existir um arquivo chamado javaScriptBuild.groovy nesta pasta, o mesmo pode ser usado em suas pipelines com o comando javaScriptBuild(). As documentações dos seus scripts também ficam aqui, basta criar um arquivo chamado  javaScriptBuild.txt.

Entendido como o repositório é organizado, vamos começar criando o arquivo vars/javaScriptBuild.groovy com  o conteúdo abaixo:

import myOrg.NpmTools
import myOrg.Utils

def call(body) {
    node('master') {
        def utils = new Utils(this)
        utils.checkoutSCM()
        
        def npmTools = new NpmTools(this)
        npmTools.install()
        npmTools.build()
    }
}

No arquivo acima, estamos importando duas classes, que serão criados na sequencia e criando uma função padrão chamada call(), o que estiver dentro desta função será executado quando a mesma for invocada no Jenkinsfile.

Definimos também o node onde o build vai ser executado e instanciamos as duas classes importadas, Utils() e  javaScriptBuild(), estas classes devem ser escritas dentro do diretório src que comentamos anteriormente. Segue abaixo o conteúdo do arquivo src/myOrg/Utils.groovy:

package myOrg;

class Utils {
    def steps
    Utils(steps) { this.steps = steps }

    def checkoutSCM() {
        steps.stage('Checkout SCM') {
            steps.deleteDir()
            steps.checkout steps.scm
        }
    }
}

Agora o arquivo src/myOrg/NpmTools.groovy:

package myOrg;

class NpmTools {
    def steps
    NpmTools(steps) { this.steps = steps }

    def install() {
        steps.stage('Get Dependencies') {
            steps.sh "npm install"
        }
    }

    def build() {
        steps.stage('NPM Build') {
            steps.sh "npm run build"
        }
    }
}

Nestes arquivos podemos criar várias funções que podem ser utilizadas pelos arquivos da pasta vars. O arquivo Utils.groovy é onde colocamos as funções genéricas, como o checkout do código. O arquivo NpmTools.groovy já possui um escopo mais definido, no caso a utilização do comando NPM.

Com os arquivos criados, efetue o push para um repositório GIT, neste tutorial utilizaremos o repositório https://github.com/Brun0rr/jenkins-build-tools.

Configurando o Jenkins e executando a pipeline

Com o repositório pronto, devemos configura-lo no Jenkins para que possamos utilizar estas funções em nossas pipelines. Neste tutorial estou utilizando o Jenkins 2.222.4. Para isso, acesso o Jenkins e navegue até Manage Jenkins > Configure System na sessão Global Pipeline Libraries adicione o repositório contendo os arquivos e clique em Salvar. Em um ambiente produtivo, utilize credenciais e um repositório privado.

Para fins de testes criei um repositório com código Angular baseado no repositório Angular Quickstart. O repositório criado é https://github.com/Brun0rr/angular-quickstart, sinta-se livre para criar o seu próprio repositório.

Junto ao código Angular, vamos criar um arquivo chamado Jenkinsfile com o código abaixo:

@Library('build-tools') _

javaScriptBuild()

Neste código estamos importando a nossa Shared Library criada anteriormente usando a annotation @Library, o nome entre aspas deve ser igual ao criado na interface do Jenkins. O underline ("_") ao final deve ser usado caso a linha seguinte nao seja um Import. Com isso teremos aceso para executar todos os códigos escritos em groovy dentro da pastas vars.

Tendo tudo que precisamos para executar nossa primeira pipeline, basta criar um novo projeto no seu Jenkins apontando para o repositório do Angular que criamos.

No log de execução podemos notar a presença da seguinte linha, informado que nossa Shared Library está sendo carregada:

Loading library build-tools@master

Se tudo correu bem, teremos nossa primeira Shared Library sendo executada com sucesso.

A partir deste momento, caso seja necessário mudar de NPM para YARN por exemplo, basta criar um novo arquivo em groovy com seus respectivos comandos. Por exemplo, crie o arquivo src/myOrg/YarnTools.groovy em nossa Shared Library:

package myOrg;

class YarnTools {
    def steps
    YarnTools(steps) { this.steps = steps }

    def install() {
        steps.stage('Get Dependencies') {
            steps.sh "yarn install"
        }
    }

    def build() {
        steps.stage('NPM Build') {
            steps.sh "yarn run build"
        }
    }
}

Altere o arquivo vars/javaScriptBuild.groovy com  o conteúdo abaixo:

import myOrg.YarnTools
import myOrg.Utils

def call(body) {
    node('master') {
        def utils = new Utils(this)
        utils.checkoutSCM()
        
        def yarnTools = new YarnTools(this)
        yarnTools.install()
        yarnTools.build()
    }
}

Agora basta executar sua pipeline novamente.

Criando funcões parametrizadas

Em diversos casos é necessário criar funções um pouco mais dinâmicas, para isso podemos usar parâmetros passados pelo nosso Jenkinsfile para nossas funções.

Imaginando um cenário onde você deseja dar autonomia para que os desenvolvedores decidam entre NPM e YARN, podemos seguir da seguinte maneira. Altere o arquivo vars/javaScriptBuild.groovy com  o conteúdo abaixo:

import myOrg.NpmTools
import myOrg.YarnTools
import myOrg.Utils

def call(body) {
    def params = [:]
    body.resolveStrategy = Closure.DELEGATE_FIRST
    body.delegate = params
    body()

    // Check required params
    def message = ''
    
    if (!params.package_manager) {
        currentBuild.result = 'FAILURE'
        message = 'javaScriptBuild => Missing required param: package_manager\n'
    }

    if (!params.package_manager.equals('yarn') && !params.package_manager.equals('npm')){
        currentBuild.result = 'FAILURE'
        message = 'javaScriptBuild => Invalid value for param: package_manager\nValue: ' + params.package_manager 
    }

    if (currentBuild.result.equals('FAILURE')) {
        error(message)
    }

    if (params.package_manager.equals('yarn')) {
        node('master') {
            def utils = new Utils(this)
            utils.checkoutSCM()
            
            def yarnTools = new YarnTools(this)
            yarnTools.install()
            yarnTools.build()
        }
    }
    if (params.package_manager.equals('npm')) { 
        node('master') {
            def utils = new Utils(this)
            utils.checkoutSCM()

            def npmTools = new NpmTools(this)
            npmTools.install()
            npmTools.build()
        }
    }
}

Calma, isso pode parecer complexo, mas vamos analisar parte a parte:

Logo no inicio da função call temos 4 linhas que são necessárias para que os parametros passados pelo Jenkinsfile sejam acessíveis pela função através da variável params (Ex.: params.package_manager). Na sequência temos duas condições para validar se o parametro package_manager foi informado e se o valor é npm ou yarn, caso alguma das condições falhe, é inserido uma mensagem de erro na variável message e informada na terceira condicão. Logo na sequência temos mais duas condições que decide qual classe devemos carregar, NpmTools ou YarnTools.

Para finalizar, basta alterar o Jenkinsfile passando o parâmetro de acordo com a necessidade:

@Library('build-tools') _

javaScriptBuild {
    package_manager = 'npm'
}

Vale lembrar que os parametros também podem ser passados pra suas funcões da pasta src. No exemplo abaixo tenho uma função chamada zipFolder na classe Utils.groovy recebendo um path por parâmentro:

...
def utils = new Utils(this)
utils.zipFolder(params.path)
...
...
def zipFolder(path) {
    // Code Here
}
...

Conclusão

As Shared Libraries são extremamente poderosas e permitem que você construa um modelo de pipeline exclusivamente seu. Os limites para o que se pode fazer é apenas a sua criatividade

Referências:

Extending with Shared Libraries
Jenkins – an open source automation server which enables developers around the world to reliably build, test, and deploy their software