Uma noite, quando cheguei em casa do trabalho, decidi fazer um pouco de lição de casa. Fiz várias edições e imediatamente quis experimentar com elas. Porém, antes dos experimentos, eu tinha que entrar no VPS, fazer alterações, reconstruir o contêiner e executá-lo. Então decidi que era hora de lidar com a entrega contínua.

Inicialmente, eu tive uma escolha entre Circle CI, Travis ou Jenkins. Eu descartei Jenkins quase imediatamente devido à falta de necessidade de uma ferramenta tão poderosa. Após uma rápida leitura sobre Travis, cheguei à conclusão de que é conveniente montar e testar nele, mas você não pode imaginar nenhuma entrega com ele. Eu aprendi sobre o Circle CI com anúncios excessivamente intrusivos no youtube. Comecei a experimentar exemplos, mas em algum momento eu estava enganado e fiz testes eternos, o que levou muitos preciosos minutos de montagem (em geral, há um limite suficiente para não me preocupar, mas isso me atingiu). Ao retomar a pesquisa, me deparei com o Github Actions. Depois de brincar com os exemplos de introdução, tive uma impressão positiva e, depois de uma rápida olhada na documentação, cheguei à conclusão de que é muito legal manter segredos para montar, coletar e praticamente implantar projetos em um só lugar. Com olhos brilhantes, ele rapidamente desenhou o esquema desejado e as engrenagens giraram.

Primeiro, tentaremos fazer o teste. Como experimental, escrevi um servidor Web simples no Flask com 2 pontos de extremidade:
Listando um aplicativo Web simplesfrom flask import Flask from flask import request, jsonify app = Flask(__name__) def validate_post_data(data: dict) -> bool: if not isinstance(data, dict): return False if not data.get('name') or not isinstance(data['name'], str): return False if data.get('age') and not isinstance(data['age'], int): return False return True @app.route('/', methods=['GET']) def hello(): return 'Hello World!' @app.route('/api', methods=['GET', 'POST']) def api(): """ /api entpoint GET - returns json= {'status': 'test'} POST - { name - str not null age - int optional } :return: """ if request.method == 'GET': return jsonify({'status': 'test'}) elif request.method == 'POST': if validate_post_data(request.json): return jsonify({'status': 'OK'}) else: return jsonify({'status': 'bad input'}), 400 def main(): app.run(host='0.0.0.0', port=8080) if __name__ == '__main__': main()
E alguns testes:
Listagem de teste de aplicativos import unittest import app as tested_app import json class FlaskAppTests(unittest.TestCase): def setUp(self): tested_app.app.config['TESTING'] = True self.app = tested_app.app.test_client() def test_get_hello_endpoint(self): r = self.app.get('/') self.assertEqual(r.data, b'Hello World!') def test_post_hello_endpoint(self): r = self.app.post('/') self.assertEqual(r.status_code, 405) def test_get_api_endpoint(self): r = self.app.get('/api') self.assertEqual(r.json, {'status': 'test'}) def test_correct_post_api_endpoint(self): r = self.app.post('/api', content_type='application/json', data=json.dumps({'name': 'Den', 'age': 100})) self.assertEqual(r.json, {'status': 'OK'}) self.assertEqual(r.status_code, 200) r = self.app.post('/api', content_type='application/json', data=json.dumps({'name': 'Den'})) self.assertEqual(r.json, {'status': 'OK'}) self.assertEqual(r.status_code, 200) def test_not_dict_post_api_endpoint(self): r = self.app.post('/api', content_type='application/json', data=json.dumps([{'name': 'Den'}])) self.assertEqual(r.json, {'status': 'bad input'}) self.assertEqual(r.status_code, 400) def test_no_name_post_api_endpoint(self): r = self.app.post('/api', content_type='application/json', data=json.dumps({'age': 100})) self.assertEqual(r.json, {'status': 'bad input'}) self.assertEqual(r.status_code, 400) def test_bad_age_post_api_endpoint(self): r = self.app.post('/api', content_type='application/json', data=json.dumps({'name': 'Den', 'age': '100'})) self.assertEqual(r.json, {'status': 'bad input'}) self.assertEqual(r.status_code, 400) if __name__ == '__main__': unittest.main()
Saída de cobertura:
coverage report Name Stmts Miss Cover ------------------------------------------------------------------ src/app.py 28 2 93% src/tests.py 37 0 100% ------------------------------------------------------------------ TOTAL 65 2 96%
Agora crie nossa primeira ação, que executará os testes. De acordo com a documentação, todas as ações devem ser armazenadas em um diretório especial:
$ mkdir -p .github/workflows $ touch .github/workflows/test_on_push.yaml
Desejo que esta ação seja executada em qualquer evento push em qualquer ramificação, exceto nas liberações (tags, porque haverá testes separados):
on: push: tags: - '!refs/tags/*' branches: - '*'
Em seguida, criamos uma tarefa que será executada na versão mais recente disponível do Ubuntu:
jobs: run_tests: runs-on: [ubuntu-latest]
Em etapas, iremos verificar o código, instalar o python, instalar dependências, executar testes e exibir a cobertura:
steps: # - uses: actions/checkout@master # python - uses: actions/setup-python@v1 with: python-version: '3.8' architecture: 'x64' - name: Install requirements # run: pip install -r requirements.txt - name: Run tests run: coverage run src/tests.py - name: Tests report run: coverage report
Todos juntos name: Run tests on any Push event # push , . # on: push: tags: - '!refs/tags/*' branches: - '*' jobs: run_tests: runs-on: [ubuntu-latest] steps: # - uses: actions/checkout@master # python - uses: actions/setup-python@v1 with: python-version: '3.8' architecture: 'x64' - name: Install requirements # run: pip install -r requirements.txt - name: Run tests run: coverage run src/tests.py - name: Tests report run: coverage report
Vamos tentar criar um commit e ver como nossa ação funciona.

Passando testes na interface Actions
Hooray, conseguimos criar a primeira ação e executá-la! Vamos tentar quebrar algum tipo de teste e ver a saída:

Testes falham na interface Actions
Os testes falharam. O indicador ficou vermelho e até recebeu uma notificação pelo correio. O que você precisa! 3 de 8 pontos do esquema de destino podem ser considerados cumpridos. Agora vamos tentar lidar com a montagem, o armazenamento de nossas imagens do docker.
Nota! Em seguida, precisamos de uma conta docker
Primeiro, escreveremos um Dockerfile simples no qual nosso aplicativo será executado.
Dockerfile # FROM python:3.8-alpine # /app COPY ./ /app # RUN apk update && pip install -r /app/requirements.txt --no-cache-dir # ( Distutils) RUN pip install -e /app # EXPOSE 8080 # CMD web_server # distutils #CMD python /app/src/app.py
Para enviar um contêiner para o hub, será necessário fazer login na janela de encaixe, mas como não quero que o mundo inteiro saiba a senha da conta, usarei os segredos incorporados ao GitHub. Em geral, somente as senhas podem ser colocadas em segredo, e o restante será codificado em * .yaml, e isso funcionará. Mas eu gostaria de copiar e colar minhas ações sem alterações e obter todas as informações específicas dos segredos.

Segredos do Github
DOCKER_LOGIN - faça o login em hub.docker.com
DOCKER_PWD - senha
DOCKER_NAME - nome do repositório do docker para este projeto (deve ser criado com antecedência)
Ok, a preparação está concluída, agora vamos criar nossa segunda ação:
$ touch .github/workflows/pub_on_release.yaml
Copiamos o teste da ação anterior, com exceção do gatilho de inicialização (não encontrei como importar as ações). Nós o substituímos por "Launch on release":
on: release: types: [published]
IMPORTANTE! É muito importante garantir a condição correta.
Por exemplo, se on.release não especificar tipos, esse evento disparará pelo menos 2 eventos: publicados e criados. Ou seja, 2 processos de montagem serão iniciados imediatamente.
Agora, no mesmo arquivo, faremos outra tarefa, dependendo da primeira:
build_and_pub: needs: [run_tests]
needs - diz que esta tarefa não será iniciada até que run_tests termine
IMPORTANTE! Se você possui vários arquivos de ação e, dentro deles, várias tarefas, todos eles são executados simultaneamente em diferentes ambientes. Um ambiente separado é criado para cada tarefa, independente de outras tarefas. se você não especificar necessidades, a tarefa de teste e os conjuntos serão iniciados simultaneamente e independentemente um do outro.
Adicione variáveis de ambiente nas quais nossos segredos serão:
env: LOGIN: ${{ secrets.DOCKER_LOGIN }} NAME: ${{ secrets.DOCKER_NAME }}
Agora, as etapas de nossa tarefa, nelas, devemos efetuar login na janela de encaixe, coletar o contêiner e publicá-lo no registro:
steps: - name: Login to docker.io run: echo ${{ secrets.DOCKER_PWD }} | docker login -u ${{ secrets.DOCKER_LOGIN }} --password-stdin - uses: actions/checkout@master - name: Build image run: docker build -t $LOGIN/$NAME:${GITHUB_REF:11} -f Dockerfile . - name: Push image to docker.io run: docker push $LOGIN/$NAME:${GITHUB_REF:11}
$ {GITHUB_REF: 11} é uma variável do github que armazena uma linha com uma referência ao evento para o qual o gatilho funcionou (nome do ramo, tag etc.). Se tivermos tags do formato "v0.0.0", será necessário aparar os 11 primeiros. caracteres, então "0.0.0" permanece.
Empurre o código e crie uma nova tag. E vemos que nosso contêiner foi montado e enviado com sucesso para o registro e não iluminamos nossa senha em nenhum lugar.

Construir e enviar contêiner na interface Actions
Verifique o hub:

Contêiner armazenado no hub docker
Tudo funciona, mas a tarefa mais difícil permanece - a implantação. Aqui você já precisará de um VPS e um endereço IP branco onde implantaremos o contêiner e onde poderá enviar o gancho. Em teoria, no lado do VPS ou do servidor doméstico, você pode executar um script na coroa, que no caso de uma nova imagem a dispararia ou, de alguma forma, brincar com o bot de telegrama. Certamente, existem várias maneiras de fazer isso. Mas vou trabalhar com ip externo. Para não ser inteligente, escrevi um pequeno serviço da Web no Flask com uma API simples.
Em resumo, há 1 ponto final "/".
Uma solicitação GET retorna json com todos os contêineres ativos no host.
POST - recebe dados no formato:
{ "owner": " ", "repository": " ", "tag": "v0.0.1", # "ports": {"8080": 8080, “443”: 443} # }
O que acontece no host:
- do json recebido o nome de uma nova imagem
- uma nova imagem está aumentando
- se a imagem foi baixada, o contêiner atual é parado e excluído
- um novo contêiner é lançado, com a publicação de portas (sinalizador -p)
Todo o trabalho com o docker é feito usando a biblioteca docker-py
Seria muito errado publicar um serviço desse tipo na Internet sem nenhuma proteção mínima, e eu criei uma aparência de API KEY, o serviço lê o token das variáveis de ambiente e o compara com o cabeçalho {Authorization: CI_TOKEN}
Listagem de servidores da Web para implantação Para este aplicativo, eu também fiz o setup.py para instalá-lo no sistema. Você pode instalá-lo usando:
$ python3 setup.sy install
desde que você tenha baixado os arquivos e esteja no diretório deste aplicativo.
Após a instalação, você deve ativar o aplicativo como um serviço para que ele se inicie no caso de uma reinicialização do servidor, para isso usamos systemd
Aqui está um arquivo de configuração de exemplo:
[Unit] Description=Deployment web server After=network-online.target [Service] Type=simple RestartSec=3 ExecStart=/usr/local/bin/ci_example Environment=CI_TOKEN=#<I generate it with $(openssl rand -hex 20)> [Install] WantedBy=multi-user.target
Resta apenas executá-lo:
$ sudo systemctl daemon-reload $ sudo systemctl enable ci_example.service $ sudo systemctl start ci_example.service
Você pode visualizar o log do servidor de entrega na web usando o comando
$ sudo systemctl status ci_example.service
A parte do servidor está pronta, resta apenas adicionar um gancho à nossa ação. Para fazer isso, adicione os segredos do endereço IP do nosso servidor e o CI_TOKEN que geramos quando instalamos o aplicativo.
No começo, eu queria usar uma ação pronta para curl do mercado github, mas, infelizmente, remove as aspas do corpo da solicitação POST, o que impossibilitou a análise de json. Obviamente, isso não me agradou e decidi usar o curl embutido no ubuntu (no qual coleciono contêineres), que aliás tinha um efeito positivo no desempenho, porque não requer a montagem de um contêiner adicional:
deploy: needs: [build_and_pub] runs-on: [ubuntu-latest] steps: - name: Set tag to env run: echo ::set-env name=TAG::$(echo ${GITHUB_REF:11}) - name: Send webhook for deploy run: "curl --silent --show-error --fail -X POST ${{ secrets.DEPLOYMENT_SERVER }} -H 'Authorization: ${{ secrets.DEPLOYMENT_TOKEN }}' -H 'Content-Type: application/json' -d '{\"owner\": \"${{ secrets.DOCKER_LOGIN }}\", \"repository\": \"${{ secrets.DOCKER_NAME }}\", \"tag\": \"${{ env.TAG }}\", \"ports\": {\"8080\": 8080}}'"
Nota: é muito importante especificar a opção --fail, caso contrário, qualquer solicitação será bem-sucedida, mesmo que um erro tenha sido recebido em resposta.
Também é importante notar que as variáveis usadas na solicitação não são realmente variáveis, mas funções especiais chamadas com exceção de GITHUB_REF, e é por isso que por um longo tempo não entendi por que a solicitação não estava funcionando corretamente. Mas, tendo feito disso uma função, tudo deu certo.
Ação cria e implanta name: Publish on Docker Hub and Deploy on: release: types: [published] # jobs: run_tests: # runs-on: [ubuntu-latest] steps: # - uses: actions/checkout@master # python - uses: actions/setup-python@v1 with: python-version: '3.8' architecture: 'x64' - name: Install requirements # run: pip install -r requirements.txt - name: Run tests # run: coverage run src/tests.py - name: Tests report run: coverage report build_and_pub: # needs: [run_tests] runs-on: [ubuntu-latest] env: LOGIN: ${{ secrets.DOCKER_LOGIN }} NAME: ${{ secrets.DOCKER_NAME }} steps: - name: Login to docker.io # docker.io run: echo ${{ secrets.DOCKER_PWD }} | docker login -u ${{ secrets.DOCKER_LOGIN }} --password-stdin # - uses: actions/checkout@master - name: Build image # image hub.docker .. login/repository:version run: docker build -t $LOGIN/$NAME:${GITHUB_REF:11} -f Dockerfile . - name: Push image to docker.io # registry run: docker push $LOGIN/$NAME:${GITHUB_REF:11} deploy: # registry, # curl needs: [build_and_pub] runs-on: [ubuntu-latest] steps: - name: Set tag to env run: echo ::set-env name=RELEASE_VERSION::$(echo ${GITHUB_REF:11}) - name: Send webhook for deploy run: "curl --silent --show-error --fail -X POST ${{ secrets.DEPLOYMENT_SERVER }} -H 'Authorization: ${{ secrets.DEPLOYMENT_TOKEN }}' -H 'Content-Type: application/json' -d '{\"owner\": \"${{ secrets.DOCKER_LOGIN }}\", \"repository\": \"${{ secrets.DOCKER_NAME }}\", \"tag\": \"${{ env.RELEASE_VERSION }}\", \"ports\": {\"8080\": 8080}}'"
Ok, reunimos tudo, agora vamos criar uma nova versão e analisar as ações.

Webhook para interface de ações do aplicativo de implantação
Tudo acabou, fazemos uma solicitação GET ao serviço de implantação (exibe todos os contêineres ativos no host):

Implantado no contêiner VPS
Agora, enviaremos solicitações para nosso aplicativo implantado:

Solicitação GET para o contêiner implantado

Solicitação POST para contêiner implantado
Conclusões
O GitHub Actions é uma ferramenta muito conveniente e flexível com a qual você pode fazer muitas coisas que podem simplificar bastante sua vida. Tudo depende da imaginação.
Eles suportam testes de integração com serviços.
Como uma continuação lógica deste projeto, você pode adicionar ao webhook a capacidade de passar parâmetros personalizados para iniciar o contêiner.
No futuro, tentarei tomar como base este projeto para a implantação de gráficos de leme quando estudar e experimentar os k8s
Se você tem algum tipo de projeto inicial, as Ações do GitHub podem simplificar bastante o trabalho com o repositório.
Detalhes da sintaxe podem ser encontrados aqui.
Todas as fontes do projeto