Criando um pacote deb com jenkins e Cmake

Por Jean Hertel, 09/08/2018

, cmake , cpp , jenkins

Há alguns dias eu decidi adicionar a criação de pacotes debian automaticamente a cada commit através do Jenkins. Minha primeira reação foi ler a documentação oficial do Debian em como criar um pacote. Após varias horas frustrantes lendo a documentação, comecei a tentar gerar um script que fizesse tudo que era necessário. Como todo bom programador eu procurei por ajuda na internet, para descobrir que havia pouca ou nenhuma documentação em como fazer um pacote debian sem estar em uma distribuição debian. Por este motivo decidi escrever este artigo, na esperança que outras pessoas consigam gerar os pacotes mais fácilmente.

Antes de mais nada, precisamos entender o ambiente que temos. Estou realizando este tutorial para o adriconf, meu software de configuração de GPUs para Linux. Este software é escrito em C++ e utiliza CMake. Para as builds eu tenho um servidor rodando Jenkins e usando pipeline com arquivo Jenkinsfile.

O primeiro passo é criarmos o nosso Jenkinsfile, instruindo o mesmo a construir nosso projeto:

pipeline {
    agent any
    stages {
        stage('Build') {
            steps {
                echo 'Running build stage'
                sh 'mkdir build-dir'
                dir('build-dir'){
                    sh 'cmake ..'
                    sh 'cmake --build . --target adriconf'
                    sh 'make translations'
                }
            }
        }
    }
}

Com este primeiro passo podemos construir o nosso pacote, bem como gerar os arquivos de tradução.

Agora precisamos entender a estrutura de um pacote debian binário. Em geral eles tem a seguinte estrutura:

adriconf
├── DEBIAN
│   └── control
└── usr
    ├── bin
    │   └── adriconf
    └── share
        ├── doc
        │   └── adriconf
        │       ├── changelog.gz
        │       └── copyright
        └── locale
            ├── en
            │   └── LC_MESSAGES
            │       └── adriconf.mo
            ├── hr
            │   └── LC_MESSAGES
            │       └── adriconf.mo
            └── pt_BR
                └── LC_MESSAGES
                    └── adriconf.mo

A pasta DEBIAN/ possui arquivos de controle. É nela que iremos colocar os scripts de pré e pós instalação, bem como o arquivo control. No arquivo control é que definimos os meta-dados do pacote, como versão, autor, dependencias, etc.

A pasta usr/ irá conter a estrutura do pacote instalado no sistema do usuário e portanto deve ter todas as pastas e subpastas. No nosso caso, iremos instalar o executável adriconf na pasta /usr/bin/, as traduções na pasta /usr/share/locale/ e a documentação da aplicação na pasta /usr/share/doc/adriconf/.

Vamos agora olhar como construir cada parte desta árvore.

Gerando o binário

Esta deve ser a parte mais fácil. Após compilar o nosso programa com o cmake nós só precisamos criar um diretório e colar o executável nele. Para isso vamos adicionar mais um estágio ao nosso build:

stage('DebPackage') {
    steps {
        sh 'mkdir -p usr/bin/'
        sh 'cp build-dir/adriconf usr/bin/adriconf'
    }
}

Nota: o diretório usr/bin/ é criado dentro do nosso workspace.

Gerando as traduções

Como não teremos controle sobre quantas traduções existem, teremos que calcular isso no momento do build, e gerar os diretórios respectivos. A maneira que encontrei para fazer isso foi gerar as traduções, listar elas e por fim criar os diretórios para cada uma. Esta sequencia de comandos dá conta do trabalho:

cd build-dir
for fn in *.gmo; do
TRLANG=`echo "$fn" | cut -d"." -f1`;
mkdir -p ../usr/share/locale/$TRLANG/LC_MESSAGES/;
cp $fn ../usr/share/locale/$TRLANG/LC_MESSAGES/adriconf.mo;
done

Eu acabei esbarrando em um problema neste momento: como guardar variaveis de ambiente no jenkins? Não achei uma resposta fácil, então optei por tornar todo este treço uma unica chamada ao shell, adicionado ao nosso build o seguinte:

dir('build-dir') {
    sh 'for fn in *.gmo; do TRLANG=`echo "$fn" | cut -d"." -f1`; mkdir -p ../usr/share/locale/$TRLANG/LC_MESSAGES/; cp $fn ../usr/share/locale/$TRLANG/LC_MESSAGES/adriconf.mo; done'
}

Estes dois arquivos são obrigatórios e tem suas próprias sintaxes. Não vou detalhar exatamente como escrever um, pois as regras que tem na documentação oficial estão bem claras. O que vale falar aqui é o jeito que adicionamos isto na build. Eu já tinha ambos os arquivos gerados dentro do diretório DEBIAN, então precisei apenas copiar e zipar eles para as pastas devidas:

sh 'mkdir -p usr/share/doc/adriconf/'
sh 'cp DEBIAN/copyright usr/share/doc/adriconf/'
sh 'gzip -n9 DEBIAN/changelog'
sh 'cp DEBIAN/changelog.gz usr/share/doc/adriconf/'

Nota: O changelog tem que estar em formato zip e tem que possuir a opção -n, caso contrário o Lintian irá reclamar sobre timestamps incorretos.

O arquivo control

Este arquivo tem sua própria sintaxe e também está documentado no site oficial do Debian. O interessante aqui é o tamanho do pacote e a versão, que devemos gerar dinamicamente.

A sintaxe do meu arquivo control ficou desta forma:

Package: adriconf
Version: __VERSION__
Section: utils
Priority: optional
Architecture: amd64
Installed-Size: __BINARY_SIZE__
Maintainer: Jean Hertel <jean.hertel@hotmail.com>
Homepage: https://github.com/jlHertel/adriconf
Depends: libgl1-mesa-glx, libgtkmm-3.0, libboost-locale, libxml++2.6, libx11, libdrm2, libpciaccess0, intltool, libc6
Description: GUI Tool used to configure OpenGL drivers
    You can use it to optimize game settings or even workaround issues with them

Para o tamanho do pacote podemos calcular utilizando o comando du -s usr. Então adicionamos ao nosso build:

sh 'binarySize=$(du -s usr/ | cut -f1); replaceString="s/__BINARY_SIZE__/"$binarySize"/"; sed -i $replaceString DEBIAN/control'

Por fim para a versão do pacote eu optei por criar um arquivo chamado VERSION na raiz do projeto, de modo que eu só precise alterá-lo. Seu conteúdo é apenas o número da versão. Para utilizá-lo inclua no build:

sh 'versionStr=$(cat VERSION); sed -i "s/__VERSION__/"${versionStr}"/" DEBIAN/control'

Gerando o pacote

Para gerar o pacote precisamos apenas da ferramenta ar e tar. Além dos arquivos já mencionados, teremos um arquivo chamado debian-binary, que deverá possuir a string 2.0. Além deste arquivo obrigatório, eu gostaria de poder gerar a versão do pacote dinamicamente. Por este motivo, vamos utilizar novamente o arquivo VERSION, para sempre termos uma versão correta. Para gerar o pacote adicione o seguinte ao nosso build:

sh 'tar czf data.tar.gz usr/'
dir('DEBIAN') {
    sh 'tar czf ../control.tar.gz control'
}
sh 'echo 2.0 > debian-binary'
sh 'versionStr=$(cat VERSION);ar r adriconf-$versionStr.deb debian-binary control.tar.gz data.tar.gz'

Corrigindo problemas do Lintian

Finalmente temos um pacote pronto, mas se utilizarmos o lintian vamos perceber uma série de erros no mesmo. Para resolver eles, vamos adicionar mais algumas coisas ao nosso build:

dir('build-dir') {
    sh 'strip --strip-unneeded -o adriconf-stripped adriconf'
}

Com isto vamos remover todos os simbolos de debug do binário, ao mesmo tempo em que mantemos uma cópia original do mesmo, que pode ser útil para depuração.

O lintian também irá reclamar sobre permissões incorretas nos arquivos. Estas me pegou de surpresa, pois o executável terá que ter como dono o usuário root, mas o Jenkins não pode executar como root. Como fazemos então?

Para este caso o mais simples é primeiro corrigir a permissão com o chmod (que não foi necessário no meu caso). Para o usuário root, podemos usar um comando chamado fakeroot que irá “fingir” que somos root, de modo que os comandos ar e tar armazenem o nome de usuário do root. Altere a criação dos pacotes para o seguinte:

sh 'fakeroot tar czf data.tar.gz usr/'
dir('DEBIAN') {
    sh 'fakeroot tar czf ../control.tar.gz control'
}
sh 'echo 2.0 > debian-binary'
sh 'versionStr=$(cat VERSION);fakeroot ar r adriconf-$versionStr.deb debian-binary control.tar.gz data.tar.gz'

O script final de build ficou assim:

pipeline {
    agent any
    stages {
        stage('Build') {
            steps {
                echo 'Running build stage'
                sh 'mkdir build-dir'
                dir('build-dir'){
                    sh 'cmake ..'
                    sh 'cmake --build . --target adriconf'
                    sh 'make translations'
                }
            }
        }
    }

    stage('DebPackage') {
        sh 'mkdir -p usr/bin/'
        dir('build-dir') {
            sh 'strip --strip-unneeded -o adriconf-stripped adriconf'
        }

        sh 'cp build-dir/adriconf-stripped usr/bin/adriconf'
        sh 'mkdir -p usr/share/locale/'

        dir('build-dir') {
            sh 'for fn in *.gmo; do TRLANG=`echo "$fn" | cut -d"." -f1`; mkdir -p ../usr/share/locale/$TRLANG/LC_MESSAGES/; cp $fn ../usr/share/locale/$TRLANG/LC_MESSAGES/adriconf.mo; done'
        }

        sh 'mkdir -p usr/share/doc/adriconf/'
        sh 'cp DEBIAN/copyright usr/share/doc/adriconf/'
        sh 'gzip -n9 DEBIAN/changelog'
        sh 'cp DEBIAN/changelog.gz usr/share/doc/adriconf/'

        sh 'binarySize=$(du -s usr/ | cut -f1); replaceString="s/__BINARY_SIZE__/"$binarySize"/"; sed -i $replaceString DEBIAN/control'
        sh 'versionStr=$(cat VERSION); sed -i "s/__VERSION__/"${versionStr}"/" DEBIAN/control'

        sh 'fakeroot tar czf data.tar.gz usr/'
        dir('DEBIAN') {
            sh 'fakeroot tar czf ../control.tar.gz control'
        }
        sh 'echo 2.0 > debian-binary'
        sh 'versionStr=$(cat VERSION);fakeroot ar r adriconf-$versionStr.deb debian-binary control.tar.gz data.tar.gz'
    }
}

Se você quiser ver a geração do pacote no adriconf, dê uma olhada no commit 150cfc644a9ac2f63ba9a042ef75a72731adfb44.