بناء CI الرئيسية / CD مع GitHub Actions و Python

في إحدى الليالي ، عندما عدت إلى المنزل من العمل ، قررت أداء واجبات منزلية صغيرة. لقد أجريت عدة تعديلات وأردت على الفور تجربتها. ولكن قبل التجارب ، كان علي أن أذهب إلى VPS ، وادفع التغييرات ، وأعد بناء الحاوية وتشغيلها. ثم قررت أن الوقت قد حان للتعامل مع التسليم المستمر.


في البداية ، كان لدي خيار بين Circle CI أو Travis أو Jenkins. لقد استبعدت جنكينز على الفور تقريبًا بسبب عدم الحاجة إلى مثل هذه الأداة القوية. عندما قرأت بسرعة عن ترافيس ، توصلت إلى استنتاج مفاده أنه من المناسب تجميعها واختبارها ، لكن لا يمكنك أن تتخيل أي تسليم بها. لقد علمت عن Circle CI من الإعلانات المتطفلة بشكل مفرط على موقع youtube. بدأت بتجربة الأمثلة ، لكن في مرحلة ما كنت مخطئًا وأجري اختبارًا أبدية ، استغرق الأمر كثيرًا من الدقائق الثمينة للتجميع (بشكل عام ، هناك حد كافي لا يدعو للقلق ، ولكنه أصابني). أثناء البحث مرة أخرى ، عثرت على Github Actions. بعد اللعب بأمثلة Get Started ، كان لدي انطباع إيجابي ، وبعد إلقاء نظرة سريعة على الوثائق ، توصلت إلى استنتاج مفاده أنه من الرائع للغاية أن أتمكن من الاحتفاظ بأسرار التجميع وجمع المشاريع ونشرها عمليًا في مكان واحد. مع عيون متوهجة ، سرعان ما رسم المخطط المطلوب ، وتدور التروس.


خطة

أولاً ، سنحاول إجراء الاختبار. كتجربة ، كتبت خادم ويب بسيط على Flask مع نقطتي النهاية:


سرد تطبيق ويب بسيط
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 

أريد تشغيل هذا الإجراء على أي حدث دفع في أي فرع ، باستثناء الإصدارات (العلامات ، لأنه سيكون هناك اختبار منفصل):


 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 

دعونا نحاول إنشاء التزام ونرى كيف يعمل عملنا.

اجتياز الاختبارات في واجهة الإجراءات


هوراي ، تمكنا من إنشاء الإجراء الأول وتشغيله! دعونا نحاول كسر نوع من الاختبار وإلقاء نظرة على الإخراج:

اختبارات تعطل في واجهة الإجراءات


فشلت الاختبارات. تحول المؤشر إلى اللون الأحمر وتلقى إشعارًا في البريد. ما تحتاجه! 3 من 8 نقاط من المخطط المستهدف يمكن اعتبارها مستوفاة. الآن دعونا نحاول التعامل مع التجميع وتخزين صور عامل الميناء.


ملاحظة! بعد ذلك نحتاج إلى حساب عامل ميناء


أولاً ، سنكتب Dockerfile بسيط سيتم فيه تنفيذ طلبنا.


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 

لإرسال حاوية إلى المحور ، سيكون من الضروري تسجيل الدخول إلى عامل الإرساء ، لكن بما أنني لا أريد للعالم كله معرفة كلمة المرور الخاصة بالحساب ، فسأستخدم الأسرار المضمّنة في GitHub. بشكل عام ، يمكن وضع كلمات المرور فقط في الأسرار ، وسيتم ترميز الباقي في * .yaml ، وسيعمل هذا. لكنني أرغب في نسخ الإجراءات الخاصة بي دون لصقها ، وسحب جميع المعلومات المحددة من الأسرار.



أسرار جيثب


DOCKER_LOGIN - تسجيل الدخول في hub.docker.com
DOCKER_PWD - كلمة المرور
DOCKER_NAME - اسم مستودع المستودع لهذا المشروع (يجب إنشاؤه مسبقًا)


حسنًا ، لقد تم الإعداد ، فلنقم الآن بإنشاء عملنا الثاني:


 $ touch .github/workflows/pub_on_release.yaml 

نقوم بنسخ الاختبار من الإجراء السابق ، باستثناء مشغل الإطلاق (لم أجد كيفية استيراد الإجراءات). نستبدلها بـ "Launch on release":


 on: release: types: [published] 

! هام من المهم جدا لجعل الشرط الصحيح on.event
على سبيل المثال ، إذا لم يحدد on.release أنواعًا ، فسيشغل هذا الحدث حدثين على الأقل: تم نشره وإنشائه. وهذا هو ، سيتم إطلاق عمليات التجميع 2 على الفور.


الآن في نفس الملف سنفعل مهمة أخرى حسب الأول:


 build_and_pub: needs: [run_tests] 

يحتاج - يقول أن هذه المهمة لن تبدأ حتى تنتهي run_tests


! هام إذا كان لديك العديد من ملفات الإجراءات ، وداخلها العديد من المهام ، فستعمل جميعها في وقت واحد في بيئات مختلفة. يتم إنشاء بيئة منفصلة لكل مهمة ، بغض النظر عن المهام الأخرى. إذا لم تحدد الاحتياجات ، فسيتم تشغيل مهمة الاختبار والتجمعات في وقت واحد وبشكل مستقل عن بعضها البعض.


أضف متغيرات البيئة التي ستكون فيها أسرارنا:


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

الآن خطوات مهمتنا ، حيث يجب علينا تسجيل الدخول إلى عامل الميناء ، وجمع الحاوية ونشرها في التسجيل:


 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" لا يزال قائما.


ادفع الكود وقم بإنشاء علامة جديدة. ونرى أن الحاوية الخاصة بنا قد تم تجميعها وإرسالها بنجاح إلى السجل ، ولم نضيء كلمة المرور الخاصة بنا في أي مكان.



بناء وشحن الحاويات في واجهة الإجراءات


تحقق من المحور:



الحاوية المخزنة في عامل ميناء


كل شيء يعمل ، ولكن المهمة الأصعب تبقى - النشر. هنا ستحتاج بالفعل إلى VPS وعنوان IP أبيض حيث سنقوم بنشر الحاوية حيث يمكنك إرسال الخطاف. من الناحية النظرية ، على جانب VPS أو الخادم الرئيسي ، يمكنك تشغيل برنامج نصي على التاج ، والذي في حالة وجود صورة جديدة ستطلقه ، أو تلعب بطريقة ما مع روبوت التلغراف. بالتأكيد هناك الكثير من الطرق للقيام بذلك. ولكن سوف أعمل مع الملكية الفكرية الخارجية. لكي لا أكون ذكيًا ، كتبت خدمة ويب صغيرة في Flask مع واجهة برمجة تطبيقات بسيطة.
باختصار ، هناك نقطة نهاية واحدة / /.
إرجاع طلب GET json مع كافة الحاويات النشطة على المضيف.
POST - يستقبل البيانات بالتنسيق:


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

ما يحدث على المضيف:


  1. من json المستلمة يتم جمع اسم الصورة الجديدة
  2. صورة جديدة منتفخة
  3. إذا تم تنزيل الصورة ، فسيتم إيقاف الحاوية الحالية وحذفها
  4. إطلاق حاوية جديدة ، مع نشر المنافذ (علامة العلم)

يتم كل العمل مع عامل ميناء باستخدام مكتبة قفص الاتهام


سيكون من الخطأ جدًا نشر مثل هذه الخدمة على الإنترنت دون أدنى حماية ، وقد قمت بعمل تشابه لـ API KEY ، حيث تقرأ الخدمة الرمز المميز من متغيرات البيئة ، ثم تقارنها بالرأس {Authorization: CI_TOKEN}


قائمة خادم الويب للنشر
 # 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 

يمكنك عرض سجل خادم تسليم الويب باستخدام الأمر


 $ sudo systemctl status ci_example.service 

جزء الخادم جاهز ، ويظل فقط لإضافة ربط لعملنا. للقيام بذلك ، أضف أسرار عنوان IP الخاص بخادمنا و CI_TOKEN التي أنشأناها عند تثبيت التطبيق.
في البداية كنت أرغب في استخدام إجراء جاهز للتجعيد من سوق جيثب ، لكن لسوء الحظ ، فإنه يزيل علامات الاقتباس من نص طلب POST ، مما يجعل من المستحيل تحليل json. من الواضح أن هذا لم يناسبني ، وقررت استخدام الضفيرة المدمجة في أوبونتو (التي أجمع عليها حاويات) ، والتي كان لها بالمناسبة تأثير إيجابي على الأداء ، لأنها لا تتطلب تجميع حاوية إضافية:


 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


الآن سوف نرسل الطلبات إلى التطبيق المنشور:

الحصول على طلب للحاوية المنشورة


طلب آخر للحاوية المنشورة

طلب آخر للحاوية المنشورة


النتائج
GitHub Actions هي أداة مريحة للغاية ومرنة يمكنك من خلالها القيام بالعديد من الأشياء التي يمكن أن تبسط حياتك إلى حد كبير. كل هذا يتوقف على الخيال.
أنها تدعم اختبار التكامل مع الخدمات.
وكتواصل منطقي لهذا المشروع ، يمكنك إضافة القدرة على تمرير المعلمات المخصصة لتشغيل الحاوية إلى webhook.
في المستقبل ، سأحاول أن أضع هذا المشروع كأساس لنشر مخططات الدفة عندما أدرس وأجري تجربة مع K8s


إذا كان لديك نوع من المشاريع المنزلية ، فيمكن لـ GitHub Actions تبسيط العمل إلى حد كبير مع المستودع.


تفاصيل بناء الجملة يمكن العثور عليها هنا.
جميع مصادر المشروع

Source: https://habr.com/ru/post/ar476368/


All Articles