使用GitHub Actions和Python构建Home CI / CD

一个晚上,当我下班回家时,我决定做一些功课。 我进行了几次编辑,并立即想尝试一下。 但是在实验之前,我必须进入VPS,推送更改,重建容器并运行它。 然后我决定是时候应对持续交付了。


最初,我在Circle CI,Travis或Jenkins之间进行选择。 由于几乎不需要如此强大的工具,因此我几乎立即排除了詹金斯。 快速了解Travis之后,我得出的结论是,在其中组装和测试很方便,但是您无法想象它会带来任何交付。 我从youtube上的过度侵入式广告中了解了Circle CI。 我开始尝试使用示例,但是在某个时候我被弄错了,并且经过了永恒的测试,这花了我很多宝贵的时间(总的来说,不必担心,但有足够的限制,但这打击了我)。 再次进行搜索时,我偶然发现了Github Actions。 在阅读了“入门”示例之后,我给人留下了很好的印象,并且快速浏览了文档之后,得出的结论是,我可以秘密地进行组装,收集和实际部署项目在一个地方,这是非常酷的。 他用发光的眼睛迅速画出所需的方案,然后齿轮开始旋转。


计划

首先,我们将尝试进行测试。 作为实验,我在Flask上编写了一个简单的Web服务器,其中包含2个端点:


列出一个简单的Web应用程序
from 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() 

和一些测试:


应用测试清单
 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() 

覆盖范围输出:


 coverage report Name Stmts Miss Cover ------------------------------------------------------------------ src/app.py 28 2 93% src/tests.py 37 0 100% ------------------------------------------------------------------ TOTAL 65 2 96% 

现在创建我们的第一个动作,它将运行测试。 根据文档,所有操作应存储在一个特殊目录中:


 $ mkdir -p .github/workflows $ touch .github/workflows/test_on_push.yaml 

我希望此操作可以在任何分支中的任何push事件上运行,但发行版除外(标记,因为会有单独的测试):


 on: push: tags: - '!refs/tags/*' branches: - '*' 

然后,我们创建一个任务,该任务将在最新可用的Ubuntu版本中运行:


 jobs: run_tests: runs-on: [ubuntu-latest] 

在步骤中,我们将检查代码,安装python,安装依赖项,运行测试并显示覆盖范围:


 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 

一起
 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 

让我们尝试创建一个提交,看看我们的动作如何工作。

通过动作界面中的测试


嗨,我们设法创建了第一个动作并运行它! 让我们尝试破坏某种测试并查看输出:

测试在动作界面中崩溃


测试失败。 指示灯变成红色,甚至在邮件中收到通知。 你需要什么! 目标方案的8分中有3分可以认为已实现。 现在,让我们尝试处理docker映像的组装,存储。


注意! 接下来,我们需要一个docker帐户


首先,我们将编写一个简单的Dockerfile,在其中执行我们的应用程序。


Docker文件
 #     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 

要将容器发送到集线器,必须登录到docker,但是由于我不希望全世界学习该帐户的密码,因此我将使用GitHub中内置的秘密。 通常,只能将密码设置为秘密,其余密码将以* .yaml进行硬编码,并且可以使用。 但是我想复制并粘贴我的操作而不进行任何更改,并从秘密中提取所有特定信息。



Github的秘密


DOCKER_LOGIN-登录hub.docker.com
DOCKER_PWD-密码
DOCKER_NAME-此项目的docker仓库名称(必须提前创建)


好的,准备工作已经完成,现在让我们创建第二个动作:


 $ touch .github/workflows/pub_on_release.yaml 

我们复制前一个动作的测试,但启动触发器除外(我没有找到如何导入动作)。 我们将其替换为“发布时启动”:


 on: release: types: [published] 

重要! 在事件上做出正确的条件非常重要。
例如,如果on.release没有指定类型,则此事件将触发至少两个事件:已发布和已创建。 即,将立即启动2个组装过程。


现在,在同一个文件中,我们将根据第一个任务执行另一个任务:


 build_and_pub: needs: [run_tests] 

需要-表示该任务直到run_tests结束才开始


重要! 如果您有多个操作文件,并且其中有多个任务,那么它们都将在不同的环境中同时运行。 为每个任务创建一个独立的环境,与其他任务无关。 如果您没有指定需求,那么测试任务和程序集将同时并彼此独立地启动。


添加环境变量,其中我们的秘密将是:


 env: LOGIN: ${{ secrets.DOCKER_LOGIN }} NAME: ${{ secrets.DOCKER_NAME }} 

现在,我们的任务步骤必须在其中登录到docker,收集容器并将其发布在注册表中:


 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}是一个github变量,用于存储行,该行带有对触发器起作用的事件的引用(分支名称,标签等),如果我们有格式为“ v0.0.0”的标签,则需要修剪前11个字符,则保留“ 0.0.0”。


推送代码并创建一个新标签。 而且我们看到我们的容器已成功组装并发送到注册表,并且我们没有在任何地方打开密码。



在Actions界面中构建和运送容器


检查集线器:



容器存储在Docker Hub中


一切正常,但是最困难的任务仍然是-部署。 在这里,您将已经需要一个VPS和一个白色IP地址,我们将在其中部署容器并在其中发送挂接。 从理论上讲,您可以在VPS或家庭服务器的侧面运行脚本,如果出现新图像,则可以触发该脚本,或者以某种方式与电报机器人一起玩。 当然,有很多方法可以做到这一点。 但是我将使用外部IP。 为了不变得聪明,我用简单的API在Flask中编写了一个小型Web服务。
简而言之,有1个端点“ /”。
GET请求返回JSON以及主机上的所有活动容器。
POST-接收以下格式的数据:


 { "owner": "  ", "repository": "  ", "tag": "v0.0.1", #    "ports": {"8080": 8080, “443”: 443} #      } 

在主机上会发生什么:


  1. 从收到的json中收集新图像的名称
  2. 一个新的图像正在膨胀
  3. 如果图像已下载,则当前容器将停止并删除
  4. 启动一个新容器,并发布端口(-p标志)

使用docker-py库完成与docker的所有工作


在没有任何最小保护的情况下在Internet上发布这样的服务是非常错误的,我做了一个类似的API KEY,该服务从环境变量中读取令牌,然后将其与标头{Authorization:CI_TOKEN}进行比较


用于部署的Web服务器列表
 # coding=utf-8 import os import sys import logging import logging.config import logging.handlers from flask import Flask from flask import request, jsonify import docker log = logging.getLogger(__name__) app = Flask(__name__) docker_client = docker.from_env() MY_AUTH_TOKEN = os.getenv('CI_TOKEN', None) #       def init_logging(): """   :return: """ log_format = f"[%(asctime)s] [ CI/CD server ] [%(levelname)s]:%(name)s:%(message)s" formatters = {'basic': {'format': log_format}} handlers = {'stdout': {'class': 'logging.StreamHandler', 'formatter': 'basic'}} level = 'INFO' handlers_names = ['stdout'] loggers = { '': { 'level': level, 'propagate': False, 'handlers': handlers_names }, } logging.basicConfig(level='INFO', format=log_format) log_config = { 'version': 1, 'disable_existing_loggers': False, 'formatters': formatters, 'handlers': handlers, 'loggers': loggers } logging.config.dictConfig(log_config) def get_active_containers(): """     :return: """ containers = docker_client.containers.list() result = [] for container in containers: result.append({ 'short_id': container.short_id, 'container_name': container.name, 'image_name': container.image.tags, 'created': container.attrs['Created'], 'status': container.status, 'ports': container.ports, }) return result def get_container_name(item: dict) -> [str, str]: """   image  POST  :param item: :return: """ if not isinstance(item, dict): return '' owner = item.get('owner') repository = item.get('repository') tag = item.get('tag', 'latest').replace('v', '') if owner and repository and tag: return f'{owner}/{repository}:{tag}', repository if repository and tag: return f'{repository}:{tag}', repository return '', '' def kill_old_container(container_name: str) -> bool: """    ,   :param container_name: :return: """ try: #    container = docker_client.containers.get(container_name) #  container.kill() except Exception as e: #       log.warning(f'Error while delete container {container_name}, {e}') return False finally: #   ,     log.debug(docker_client.containers.prune()) log.info(f'Container deleted. container_name = {container_name}') return True def deploy_new_container(image_name: str, container_name: str, ports: dict = None): try: #   image  docker hub'a log.info(f'pull {image_name}, name={container_name}') docker_client.images.pull(image_name) log.debug('Success') kill_old_container(container_name) log.debug('Old killed') #    docker_client.containers.run(image=image_name, name=container_name, detach=True, ports=ports) except Exception as e: log.error(f'Error while deploy container {container_name}, \n{e}') return {'status': False, 'error': str(e)}, 400 log.info(f'Container deployed. container_name = {container_name}') return {'status': True}, 200 @app.route('/', methods=['GET', 'POST']) def MainHandler(): """ GET -      POST -      : { "owner": "gonfff", "repository": "ci_example", "tag": "v0.0.1", "ports": {"8080": 8080} } :return: """ if request.headers.get('Authorization') != MY_AUTH_TOKEN: return jsonify({'message': 'Bad token'}), 401 if request.method == 'GET': return jsonify(get_active_containers()) elif request.method == 'POST': log.debug(f'Recieved {request.data}') image_name, container_name = get_container_name(request.json) ports = request.json.get('ports') if request.json.get('ports') else None result, status = deploy_new_container(image_name, container_name, ports) return jsonify(result), status def main(): init_logging() if not MY_AUTH_TOKEN: log.error('There is no auth token in env') sys.exit(1) app.run(host='0.0.0.0', port=5000) if __name__ == '__main__': main() 

对于此应用程序,我还做了setup.py以便将其安装在系统上。 您可以使用以下方法安装它:


 $ python3 setup.sy install 

前提是您已下载文件并且位于此应用程序的目录中。
安装后,必须将应用程序作为服务启用,以便在服务器重新启动时启动该应用程序,为此,我们使用systemd
这是一个示例配置文件:


 [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 

只能运行它:


 $ sudo systemctl daemon-reload $ sudo systemctl enable ci_example.service $ sudo systemctl start ci_example.service 

您可以使用以下命令查看Web Delivery服务器的日志


 $ sudo systemctl status ci_example.service 

服务器部分已准备就绪,仅需为我们的操作添加一个钩子即可。 为此,请添加服务器IP地址的机密和安装应用程序时生成的CI_TOKEN。
起初,我想对github市场使用卷曲的现成动作,但是不幸的是,它从POST请求的主体中删除了引号,这使得无法解析json。 这显然不适合我,因此我决定在ubuntu中使用内置的curl(我在上面收集容器),这对性能产生积极影响,因为它不需要组装其他容器:


 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}}'" 

注意:指定--fail开关非常重要,否则,即使收到响应错误,任何请求都将成功。
还值得注意的是,请求中使用的变量不是真正的变量,而是GITHUB_REF例外调用的特殊函数,这就是为什么我很长一段时间无法理解为什么请求无法正常工作的原因。 但是,通过发挥作用,一切都完成了。


行动建立并部署
 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}}'" 

好的,我们将所有内容放在一起,现在我们将创建一个新版本并查看操作。

Webhook部署应用程序操作界面


事实证明,我们向部署服务发出GET请求(显示主机上的所有活动容器):

部署在VPS容器上


现在,我们将请求发送到已部署的应用程序:

GET请求到已部署的容器


对已部署容器的POST请求

对已部署容器的POST请求


结论
GitHub Actions是一个非常方便且灵活的工具,您可以使用它来做很多事情,从而大大简化您的生活。 这一切都取决于想象力。
他们支持与服务的集成测试。
作为该项目的逻辑继续,您可以向webhook添加传递自定义参数以启动容器的功能。
将来,当我研究和试验k8s时,我将尝试以该项目为基础来部署头盔图


如果您有某种家庭项目,那么GitHub Actions可以大大简化使用存储库的过程。


语法详细信息可以在这里找到
所有项目来源

Source: https://habr.com/ru/post/zh-CN476368/


All Articles