Наверняка все знают, что helm chart можно упаковывать в helm пакет и этот самый пакет загружать в registy. Нужно это для того, чтобы потом в конечном сервисе использовать только файлы Chart.yaml и values.yaml. Вещь удобная, в целом можно обойтись и bash скриптами, но сегодня мы данный функционал попробуем реализовать гораздо более интересно с помощью python.
Вначале немного обрисуем весьма условное ТЗ.
- Утилита консольная, должна поставлять с «бинарем» в комплекте и иметь CLI.
- Нужен функционал линтинга чартов, будем использовать обычный helm lint.
- Функционал упаковки пакета, все тот же helm, а именно helm package.
- Загрузка пакета пока только в gitlab registry в два канала, develop — для тестовых релизом и stable — для стабильных. Более подробно в официальной документации.
- Нужен способ и желательно отдельный для проверки локальной версии, заданной в Chart.yaml и версией в registry. Дело в том, что в gitlab registy можно загрузить два helm пакета с одинаковой версий и при использовании в конечном проекте будет взята та, что была загружена последней. Это нам не подходит ибо добавляет «человеческий фактор» и снижает контроль того, что реально на данный момент используется. Подобная проверка должна быть отдельным аргументом для возможности ее использования в CI и в качестве защиты при попытке загрузить одну и туже версию в stable канал. На develop пофиг.
С наброском кажется определились. Приступим к коду.
Модуль CLI:
"""Unit for CLI."""
from dataclasses import dataclass
from importlib.metadata import version
import argparse
import os
@dataclass
class BasicArgs:
"""Basis class.
This class has all CLI arguments, except HelmLintArgs.
"""
token: str
ssl_path: str
registry_url: str
project_id: str
chart_name: str
chart_path: str
tool_config_path: str
@dataclass
class HelmLintArgs:
"""Class for helm linting cmd args."""
command: str
helm_args: str
debug: str
chart_path: str
@dataclass
class HelmReleaseArgs(BasicArgs):
"""Class for helm release cmd args."""
command: str
helm_args: str
@dataclass
class HelmReleaseStageArgs(BasicArgs):
"""Class for helm release_stage cmd args."""
command: str
helm_args: str
branch: str
@dataclass
class HelmCheckVersionArgs(BasicArgs):
"""Class for helm check cmd args."""
command: str
helm_args: str
def create_common_arguments_group(parser):
"""Create common CLI arguments."""
common_args = parser.add_argument_group('Common arguments')
common_args.add_argument(
"-t", "--token",
required=False,
default=os.environ.get("CHART_RELEASE_TOKEN", ""),
help="Registry upload token",
dest="token"
)
common_args.add_argument(
"--ssl",
required=False,
default=os.environ.get("SSL_PATH", "/usr/local/share/ca-certificates/CA.crt"),
help="Path to SSL certificate",
dest="ssl_path"
)
common_args.add_argument(
"-u", "--registry-url",
dest="registry_url",
required=False,
default=os.environ.get("REGISTRY_URL", "gitlab.com"),
help="Registry URL"
)
common_args.add_argument(
"-p", "--project-id",
required=False,
default=os.environ.get("RELEASE_PROJECT_ID", ""),
dest="project_id",
help="Actual if usage gitlab registry"
)
common_args.add_argument(
"-n", "--chart_name",
required=True,
dest="chart_name",
help="Chart name."
)
common_args.add_argument(
"-path", "--path",
required=True,
dest="chart_path",
help="Chart path"
)
common_args.add_argument(
"-c", "--config",
required=False,
default=os.environ.get("TOOL_CONFIG_PATH", "hc-releaser.config"),
dest="tool_config_path",
help="Path to tool config file."
)
def create_switch_args_fabric(class_type, args):
"""Arguments fabric.
Returns the passed class object.
"""
if class_type == HelmLintArgs:
return class_type(
command=args.command,
helm_args=args.helm_args,
debug=args.debug,
chart_path=args.chart_path
)
else:
return class_type(
command=args.command,
helm_args=args.helm_args,
token=args.token,
ssl_path=args.ssl_path,
registry_url=args.registry_url,
project_id=args.project_id,
chart_path=args.chart_path,
chart_name=args.chart_name,
tool_config_path=args.tool_config_path,
**({'branch': args.branch} if class_type is HelmReleaseStageArgs else {})
)
def parse_args():
"""Create CLI."""
parser = argparse.ArgumentParser(description="Helm chart releaser.")
parser.add_argument(
"-v", "--version",
action="version",
version=f"%(prog)s {version('chart_releaser')}",
help="Show package version"
)
subparsers = parser.add_subparsers(
title="subcommands",
description='Valid subcommands',
dest="command"
)
## Add helm parser
helm_parser = subparsers.add_parser("helm", help="Usage 'linting|release|release_stage|check'")
helm_subparsers = helm_parser.add_subparsers(dest='helm_args')
## Linting parser
linting_parser = helm_subparsers.add_parser('linting', help='helm lint cmd')
linting_parser.add_argument(
"-d", "--debug",
required=False,
action='store_true',
help="Helm lint with debug",
dest="debug"
)
linting_parser.add_argument(
"-p", "--path",
required=True,
help="Helm chart path",
dest="chart_path"
)
## Helm release parser
helm_release_parser = helm_subparsers.add_parser(
"release",
help="Helm release cmd"
)
## Helm release stage parser
helm_release_stage_parser = helm_subparsers.add_parser(
"release_stage",
help="Helm release stage cmd"
)
helm_release_stage_parser.add_argument(
"-b", "--branch",
required=True,
help="Git branch",
dest="branch"
)
## Helm check version parser
helm_check_version_parser = helm_subparsers.add_parser(
"check",
help="Helm check version cmd"
)
create_common_arguments_group(helm_release_parser)
create_common_arguments_group(helm_release_stage_parser)
create_common_arguments_group(helm_check_version_parser)
args = parser.parse_args()
if args.command == "helm" and args.helm_args is not None:
switch_classes = {
"linting": HelmLintArgs,
"release": HelmReleaseArgs,
"release_stage": HelmReleaseStageArgs,
"check": HelmCheckVersionArgs
}
return create_switch_args_fabric(switch_classes.get(args.helm_args), args)
else:
parser.print_help()
Давайте разбираться, что тут происходит.
Сначала мы описываем классы для наших аргументов CLI. Структура CLI будет модульная и расширяемая, сейчас она выглядит так: <binary_name> helm <choise> <args>.
Чтобы ее реализовать мы создаем базовый класс, от которого будет наследоваться почти все классы наших аргументов.
Метод модуля CLI create_common_arguments_group() как раз создает эти самые общие аргументы. Этот метод мы будем вызывать ниже, для конкретных парсеров аргументов. Они создаются в методе parse_args.
Используя такой подход в создании CLI мы должны возвращать объект конкретного класса, передавая в него нужные аргументы, их мы описывали в качестве полей класса. Поэтому дабы избежать дублирования кода, мы используем подход с созданием так называемой «фабрики аргументов», то есть передаем в метод create_switch_args_fabric() класс и возвращаем объект класса.
Модуль логирования:
"""Unit logger."""
import logging
import json
class JSONFormatter(logging.Formatter):
"""Custom json formatter."""
def format(self, record):
"""Make logs format."""
log_record = {
'asctime': self.formatTime(record, self.datefmt),
'levelname': record.levelname,
'message': record.getMessage(),
}
return json.dumps(log_record, ensure_ascii=False)
class Logging():
"""Loggin class."""
def __init__(self, logger_name):
"""Class desinger."""
self.logger_name = logger_name
self.logger = logging.getLogger(self.logger_name)
self.logger.setLevel(logging.INFO)
formatter = JSONFormatter()
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(formatter)
self.logger.addHandler(console_handler)
def log(self, level, message, *args):
"""Log method."""
self.logger.log(level, message, *args)
def info(self, message, *args):
"""INFO logging level."""
self.log(logging.INFO, message, *args)
def debug(self, message, *args):
"""DEBUG logging level."""
self.log(logging.DEBUG, message, *args)
def warning(self, message, *args):
"""WARNING logging level."""
self.log(logging.WARNING, message, *args)
def error(self, message, *args):
"""ERROR logging level."""
self.log(logging.ERROR, message, *args)
def critical(self, message, *args):
"""CRITICAL logging level."""
self.log(logging.CRITICAL, message, *args)
Наверное тут я ничего нового добавить не смогу, данный код я использую почти во всех своих мини проектах, в том числе и описанных на этом сайте. Просто реализуем свой модуль логирования со своим форматированием и методами на основе библиотеки logging. Лог пишем в json формате в консоль.
Модуль для работы с gitlab API:
"""Unit for gitlab API."""
from dataclasses import dataclass
from chart_releaser.cli import BasicArgs
from chart_releaser.logger import Logging
import sys
import requests
@dataclass
class GitlabApi:
"""Class for work with gitlab API."""
cmd_args: BasicArgs
logger: Logging
def send_request(self, request_method, chart_version, token_type, is_stage=False):
"""Send request to gitlab API."""
packages_list = []
if request_method == "POST":
chart_file = {
'chart': open(f"{self.cmd_args.chart_name}-{chart_version}.tgz", 'rb')
}
if is_stage is False:
requests.post(
f"https://{self.cmd_args.registry_url}/api/v4/projects/{self.cmd_args.project_id}/packages/helm/api/stable/charts",
files=chart_file, auth=('JOB-TOKEN', self.cmd_args.token),
verify=self.cmd_args.ssl_path, timeout=120
)
else:
requests.post(
f"https://{self.cmd_args.registry_url}/api/v4/projects/{self.cmd_args.project_id}/packages/helm/api/develop/charts",
files=chart_file, auth=('JOB-TOKEN', self.cmd_args.token),
verify=self.cmd_args.ssl_path, timeout=120
)
elif request_method == "GET":
headers = {
token_type : self.cmd_args.token
}
data = requests.get(
f"https://{self.cmd_args.registry_url}/api/v4/projects/{self.cmd_args.project_id}/packages?package_type=helm&package_name={self.cmd_args.chart_name}",
headers=headers,
verify=self.cmd_args.ssl_path, timeout=120
)
if data.status_code == 401:
self.logger.error("Authorization falied.")
sys.exit(1)
elif data.status_code == 404:
self.logger.error(
"Project %s not found or you do not have permissions for project.",
self.cmd_args.project_id
)
sys.exit(1)
elif data.status_code == 200:
total_page_number = int(data.headers["X-Total-Pages"])
current_page_number = int(data.headers["X-Page"])
while current_page_number <= total_page_number:
response = requests.get(
f"https://{self.cmd_args.registry_url}/api/v4/projects/"
f"{self.cmd_args.project_id}/packages?package_type=helm&package_name={self.cmd_args.chart_name}",
params={'page': current_page_number}, headers=headers,
verify=self.cmd_args.ssl_path, timeout=120
).json()
for _, item in enumerate(response):
packages_mapping = {
"name": item["name"],
"version": item["version"]
}
packages_list.append(packages_mapping)
current_page_number += 1
return packages_list
else:
self.logger.error("Unexpected status code %s", data.status_code)
sys.exit(1)
return None
Тут уже становится более понятно, для чего еще нам был нужен базовый класс для аргументов, передав только его, мы получаем в другом модуле все нужные нам переменные для работы с gitlab API.
Метод класса send_request() здесь единственный и разделен логикой по типу запроса, либо GET, либо POST.
При выполнении POST запроса мы загружаем наш пакет (это tgz архив) в нужный канал, о которых писал выше. В какой канал загрузить мы определяем ориентируясь на значение переменной is_stage.
GET — тут мы пробегаемся по списку всех пакетов в registry, естественно с реализацией пагинации, ибо gitlab не отдает сразу все и формируем словарь с именем пакета и его версией. Данный словарь добавляем в массив для дальнейшего использования в других модулях.
Модуль обработки аргументов:
"""Handlers unit."""
from chart_releaser.cli import (
BasicArgs, HelmLintArgs,
HelmReleaseStageArgs, HelmReleaseArgs,
HelmCheckVersionArgs
)
from chart_releaser.logger import Logging
from chart_releaser.gitlab_api import GitlabApi
from dataclasses import dataclass
from pathlib import Path
import subprocess
import os
import sys
import yaml
import requests
from subprocess import run
@dataclass
class Helper:
"""Helper class."""
cmd_args: BasicArgs
logger: Logging
token_type: str
def get_chart_version(self, is_stage=True):
"""Get chart version.
Return chart version any way except release args.
If this is release return non-zero exit code.
"""
self.logger.info("Get %s chart version...", self.cmd_args.chart_name)
with open(f"{self.cmd_args.chart_path}/Chart.yaml", 'r', encoding="utf8") as chart_file:
chart_version = yaml.safe_load(chart_file)
chart_version = chart_version["version"]
self.logger.info("Chart %s current version: %s", self.cmd_args.chart_name, chart_version)
packages_data = GitlabApi.send_request(self, "GET", chart_version, self.token_type)
try:
previous_chart_version = packages_data[-1]["version"]
if previous_chart_version == chart_version and is_stage is False:
self.logger.error(
"Local chart version: %s equal repository chart version: %s. "
"This is not allowed for stable channel.",
chart_version, previous_chart_version
)
sys.exit(1)
self.logger.info(
"Chart %s repository version: %s",
self.cmd_args.chart_name,
previous_chart_version
)
except (IndexError, KeyError):
self.logger.warning("Packages in package registry is empty...")
return chart_version
@dataclass
class HelmLintHandler:
"""Helm lint handler class."""
cmd_args: HelmLintArgs
logger: Logging
def handler(self):
"""Run handler linting args."""
charts_dir = []
for file_path in Path(self.cmd_args.chart_path).rglob("Chart.yaml"):
chart_dir = str(file_path).replace("Chart.yaml", "")
if os.path.isdir(chart_dir):
charts_dir.append(chart_dir)
for chart_dir in charts_dir:
try:
self.logger.info("Run linting for chart %s...", chart_dir)
if self.cmd_args.debug:
run( [ "helm", "lint", chart_dir, "--debug" ], check=True)
else:
run( [ "helm", "lint", chart_dir ], check=True)
except subprocess.CalledProcessError as err:
self.logger.error("Helm lint failed with error: %s", err)
sys.exit(1)
class HelmReleaseStageHandler(Helper):
"""Helm release_stage handler."""
cmd_args: HelmReleaseStageArgs
logger: Logging
def handler(self):
"""Run handler helm release_stage args."""
try:
self.logger.info("Create helm package...")
chart_version = self.get_chart_version(self.token_type)
run (
[
"helm", "package",
"--version", f"{chart_version}-{self.cmd_args.branch}",
self.cmd_args.chart_path
],
check=True
)
GitlabApi.send_request(
self, "POST",
f"{chart_version}-{self.cmd_args.branch}",
self.token_type,
is_stage=True
)
self.logger.info(
"Upload %s-%s.tgz to https://%s.",
self.cmd_args.chart_name,
f"{chart_version}-{self.cmd_args.branch}",
self.cmd_args.registry_url
)
except (subprocess.CalledProcessError, requests.exceptions.RequestException) as err:
self.logger.error("Make helm release stage failed with error: %s", err)
sys.exit(1)
class HelmReleaseHandler(Helper):
"""Helm release handler class."""
cmd_args: HelmReleaseArgs
logger: Logging
def handler(self):
"""Helm release handler."""
try:
self.logger.info("Create helm package...")
run ( ["helm", "package", self.cmd_args.chart_path ], check=True)
chart_version = self.get_chart_version(is_stage=False)
GitlabApi.send_request(self, "POST", chart_version, self.token_type)
self.logger.info(
"Upload %s-%s.tgz to https://%s.",
self.cmd_args.chart_name,
chart_version,
self.cmd_args.registry_url
)
except (subprocess.CalledProcessError, requests.exceptions.RequestException) as err:
self.logger.error("Make helm release stage failed with error: %s", err)
sys.exit(1)
class HelmCheckVersionHandler(Helper):
"""Helm check version handler class."""
cmd_args: HelmCheckVersionArgs
logger: Logging
def handler(self):
"""Helm check args handler."""
chart_version = self.get_chart_version()
packages_data = GitlabApi.send_request(self, "GET", chart_version, self.token_type)
for _, package in enumerate(packages_data):
if package["name"] == self.cmd_args.chart_name and chart_version == package["version"]:
self.logger.error("You must increase Chart version.")
sys.exit(1)
self.logger.info("Check was success.")
@dataclass
class HandlerFactory:
"""Class make handler fabric."""
@staticmethod
def create_handler(cmd_args, logger, token_type):
"""Create an object of the class depending on the class of arguments."""
if isinstance(cmd_args, HelmLintArgs):
return HelmLintHandler(cmd_args, logger)
elif isinstance(cmd_args, HelmReleaseStageArgs):
return HelmReleaseStageHandler(cmd_args, logger, token_type)
elif isinstance(cmd_args, HelmReleaseArgs):
return HelmReleaseHandler(cmd_args, logger, token_type)
elif isinstance(cmd_args, HelmCheckVersionArgs):
return HelmCheckVersionHandler(cmd_args, logger, token_type)
Наверное самое интересное здесь. В данном модуле мы реализуем полиморфизм, в основном модуле main.py это будет видно более наглядно.
Класс Helper имеет всего один метод и является базовым для других классов в этом модуле. Метод этого класса получает текущую версию чарта из файла Chart.yaml, получает последнею версию чарта из registry, сравнивает их, если мы пытаемся вызвать данный метод в релизе для stable канала и выводит ошибку, если версии равны. Если же мы вызываем для релиза в develop канал, нам просто возвращается версия чарта из Chart.yaml.
Далее идут классы, которые являются обработчиками переданных нами аргументов CLI, в каждом классе реализован всего один метод handler , это одно из условий создания полиморфизма, то есть способность метода обрабатывать данные разных типов.
Класс HandlerFactory это «фабрика обработчиков», аналогична фабрике аргументов из модуля cli.py.
Сами helm команды вызываются через методы библиотеки subprocess.
Основной модуль:
"""Main unit."""
from chart_releaser.cli import parse_args
from chart_releaser.logger import Logging
from chart_releaser.handlers import HandlerFactory
import configparser
cmd_args = parse_args()
logger = Logging("chart-releaser")
handlers = HandlerFactory()
config_parser = configparser.ConfigParser()
try:
config_parser.read(cmd_args.tool_config_path)
token_type = config_parser.get("main", "TOKEN_TYPE")
except (configparser.NoSectionError, AttributeError):
token_type = "JOB-TOKEN"
def main():
"""Entry point."""
if cmd_args is not None:
handler = handlers.create_handler(cmd_args, logger, token_type)
handler.handler()
if __name__ == "__main__":
main()
Здесь мы вводим использование объекта config_parser, он нужен, чтобы считывать конфиг вида:
[main]
TOKEN_TYPE: PRIVATE-TOKEN
Дело в том, что когда мы отправляем GET запрос в нашем модуле gitlab_api.py, для его успешного выполнения мы должны в headers запроса передать тип используемого токена и его значение. Для интеграции утилиты с gitlab-ci, лучше использовать CI_JOB_TOKEN переменную, это временная переменная в gitlab-ci, которая существует только на момент создания job и такой токен обладает правами пользователя, который этот job запустил. Следовательно, чтобы его использовать, в header должно быть передано JOB-TOKEN. Но при локальной разработке и тестировании мы им воспользоваться не можем, нужно использовать свой персональный токен и для него header будет PRIVATE-TOKEN. По умолчанию утилита всегда передает JOB-TOKEN в хедер, но если создать файл конфигурации, как описано выше, то мы можем пользоваться утилитой локально, без необходимости менять данный тип в коде.
В функции main выполняется код, только если аргументы не пустые, если они пустые возвращается метод print_help() библиотеки argparser из нашего модуля cli.py.
Далее мы создаем объект handler и вызываем единственный метод handler(), вот так просто за счет полиморфизма мы получаем разное поведение утилиты в зависимости от переданных аргументов.
Тестирование:
Начнем с линтинга:
hc-releaser helm linting -p helm-charts
Тут мы передали директорию с чартами и утилита рекурсивно выполнила helm lint внутри каждой под директории.
Также мы можем передать путь до корневой директории чарта, чтобы выполнить lint только в нем.
Загрузка чарта в develop канал:
Для проверки этой задачи нам понадобится gitlab-ci. Воспользуемся вот таким docker-compose файлом.
version: '3.7'
services:
web:
image: 'gitlab/gitlab-ce:latest'
restart: always
hostname: 'localhost'
container_name: gitlab-ce
environment:
GITLAB_OMNIBUS_CONFIG: |
external_url 'http://localhost'
GITLAB_HOME: "/gitlab"
ports:
- '8080:80'
- '8443:443'
- '2222:22'
volumes:
- '$GITLAB_HOME/config:/etc/gitlab'
- '$GITLAB_HOME/logs:/var/log/gitlab'
- '$GITLAB_HOME/data:/var/opt/gitlab'
networks:
- gitlab
networks:
gitlab:
name: gitlab-network
Запускаем, создаем тестовый проект в gitlab UI и проверяем:
hc-releaser helm release_stage -t <personal token> -u localhost:8080 -n gerrit -p 1 -path helm-charts/gerrit/helm-charts -b test
Не переживайте за токен на скрине, он от локального гитлаба, поэтому опустим рассуждения о «секурности».
Внутри директории запуска скрипта также можно обнаружить файл gerrit-0.1.0-<branch>.tgz. Я добавил возможность при загрузке в develop канал, проставлять ветку. Это нужно чтобы во-первых не путаться в UI, во-вторых для правильной работы аргумента «check», так как он будет искать пакет с чартом по его имени, а не каналу.
Давайте проверим его наличие в package registry.
Проверим, что это действительно то, что мы загрузили.
Для теста я просто поместил SUPERTEST: «test» в values.yaml чарта gerrit.
Чтобы его проверить, мы должны создать Chart.yaml следующего содержания:
apiVersion: v1
description: test
name: test
version: 0.1.0
appVersion: 1.0.0
dependencies:
- name: gerrit
version: "0.1.2-test"
repository: "http://localhost:8080/api/v4/projects/1/packages/helm/develop"
Далее в UI gitlab внутри нашего проекта создать ACCESS_TOKEN, он нужен для авторизации в registry.
Добавляем себе данный репозиторий:
helm repo add develop http://localhost:8080/api/v4/projects/1/packages/helm/develop --username <access token name> --password <access token password>
Скачиваем чарт:
helm dependency update .
Далее идем в созданную хельмом директорию charts, распаковываем архив и смотрим наш values.yaml.
Именно то, что и ожидалось. Проверим дополнительно, изменив переменную на TEST: «test».
Проделываем процедуру, описанную выше.
А вот так, это выглядит в registry:
Видно, что мы имеем наличие нескольких копий одной и той же версии. Забегая немного вперед скажу, что в UI каналы не отображаются, все будет в одной куче, что конечно не удобно.
Загрузка чарта в stable канал:
hc-releaser helm release -t <personnal token> -u localhost:8080 -n gerrit -p 1 -path helm-charts/gerrit/helm-charts
Поменяем values на STABLE: «test».
Также меняем канал в Chart.yaml:
apiVersion: v1
description: test
name: test
version: 0.1.0
appVersion: 1.0.0
dependencies:
- name: gerrit
version: "^0.1.0"
repository: "http://localhost:8080/api/v4/projects/1/packages/helm/stable"
Добавляем:
helm repo add stable http://localhost:8080/api/v4/projects/1/packages/helm/stable --username <access token name> --password <access token password>
Все работает, попробуем попытаться загрузить пакет в канал еще раз и увидим ошибку.
Если версию меняем, то можем загрузить в stable.
Также мы можем сравнить локальную версию с той, что в registry с помощью отельной команды.
hc-releaser helm check -t <personnal token> -u localhost:8080 -n gerrit -p 1 -path helm-charts/gerrit/helm-charts
В случае если версии равны, мы увидим следующее:
Также вернется не нулевой код выхода, что приведет к падению job в gitlab-ci. Данный функционал именно для использования в CI, ибо защита от заливки той же версии в stable канал, конечно, есть, но в таком случае мы бы узнали об этом, только после мерджа в целевую ветку. Вынесение подобной проверки отдельно, позволяет выявить данную проблему еще на этапе просто комита в свою ветку.
Если же локальная версия выше:
Полный проект можно найти на github.
И на pypi.