Привет, Хабр! Меня зовут Дмитрий Селютин, я ведущий разработчик команды R&D в Cloud.ru.

Ситуации, когда при решении совершенно конкретной задачи упираешься в сложности откуда-то сбоку, возникают в разработке с завидной регулярностью. В задачах, зависящих от автоматизации, очень часто случается, что слабым местом оказываются непосредственно инструменты для этой автоматизации, если они вообще есть. Такие инструменты могут рождаться и умирать, но порой они могут возрождаться заново. Сегодня поделюсь рассказом о том, как в ходе исследований производительности нашего облака Cloud.ru Evolution мы внезапно сделали SDK и CLI посредством генерации кода и интроспекции. Статья будет полезной всем, перед кем стоит задача быстро обернуть сгенерированный API на Python в нечто более симпатичное и поможет из этого автоматически сделать CLI. Ну а для тех, кто не связан с темой, это будет поучительная история из разряда «если у вас завалялся кусочек кода, не спешите его выбрасывать».

Что такое Cloud.ru Evolution и почему мы его тестировали

Как любезно сообщает нам главная страница на сайте, Cloud.ru Evolution — это публичное облако, построенное на собственных разработках и свободно распространяемых компонентах. На момент, когда состоялось знакомство Cloud.ru Evolution и команды R&D, облако было публичным исключительно с технологической точки зрения, но вовсе не с бытовой: продуктовые команды еще дорабатывали сервисы и подготавливали их к выходу в свет. Разумеется, перед столь важным событием нельзя было обойти вопрос количественных и качественных характеристик. Пропускная способность, сетевые задержки, производительность блочных устройств — все это нужно было проверить. Команда Cloud.ru Evolution сосредоточилась в первую очередь на публичном запуске продукта, и были рады помощи; так на сцене появились представители команды R&D.

Мы, команда R&D (Research and Development), призваны решать самые разные задачи, но разнообразные исследования — это чуть ли не половина нашей деятельности, что самым незамысловатым образом и отражено в нашем названии. Поэтому, когда на горизонте возникла задача по исследованию производительности Cloud.ru Evolution, мы засучили рукава и нырнули в совершенно новый и неизвестный ранее нам продукт. Вводная была очень простой: есть облако, в нем можно создавать виртуальные машины, хочется проверить, насколько хорошо работают сетевые интерфейсы и блочные устройства под нагрузкой. Вспомнив о той самой заветной букве R в названии отдела, мы отправились знакомиться. Тогда мы еще не догадывались, насколько резво раскроется еще и вторая буква, D.

Первые робкие шаги

На разработку отправили двух отважных инженеров, меня и Володю Митрофанова (Володя, если ты это читаешь, — привет!). Знакомство с Cloud.ru Evolution не слишком отличалось от знакомства с человеком: как и положено подобным платформам, она встретила нас «лицом», в данном случае — веб-интерфейсом. На первом этапе нам надо было создать пару виртуальных машин, обеспечить между ними сетевую связность и попробовать измерить пропускную способность сети и блочных устройств, как порознь (только сетевые интерфейсы или только блочные устройства), так и вместе. Традиционно на ум нам пришли старые добрые iperf и fio  (возможно, не очень добрые, но точно старые); прогонять эту мысль мы не стали и, создав пару ВМ, первейшим же делом туда установили соответствующие пакеты.

Поскольку весь сценарий у нас состоял из шагов в духе «здесь запусти iperf в режиме сервера, а здесь – клиента», «тут запусти fio», «покрути указанное пользователем при запуске время», было вполне логично эти шаги описать в каком-то относительно автоматизированном виде. Несмотря на мою пылкую любовь к make, было понятно, что здесь нужно нечто более гибкое и подходящее, и наш взор упал на Ansible. Мы сделали довольно бесхитростные скрипты на грозной связке из Ansible и shell; существенная часть времени ушла на постижение Ansible, с которым мы ранее не были знакомы. Довольно быстро мы поняли, что конфигурацию Ansible нам было бы удобнее генерировать; так родился простенький скрипт на Python, который мы ласково назвали Геной (gena.py – от GENerate Ansible). Гена запускался на отдельной ВМ, которая исполняла роль дирижера, и генерировал конфигурацию, получая на вход некоторую базовую информацию об этих двух ВМ. Несколько раз нам приходилось по разным причинам пересоздавать ВМ, но делать это в Cloud.ru Evolution было легко и приятно. А вот заново раскатывать на них конфигурации было делом долгим и неблагодарным, так что помощь Гены пришлась весьма кстати.

Как известно, текстовые логи читать любят не все, поэтому собранные iperf и fio данные мы решили превращать в нечто достойное просмотра. Так родился еще один скрипт, который, в полном соответствии с духом предшественника, был назван Лопа (lopa.py, LOg PArser). Этот скрипт также был не слишком мудреным. От него требовалось принять на вход директорию с собранными логами, разобрать их и выдать некую простую выжимку. Внимательный читатель скажет, что это все похоже на какой-то зоопарк. В общем, так и есть, мы решали вполне конкретную задачу, бесхитростно и прямолинейно. Наконец, доведя конфигурацию и скрипты до работоспособности, мы запустили-таки полноценный прогон, собрали данные, превратили их в таблицы, проанализировали, а затем устроили маленький внутренний премьерный показ.

Пытаемся перейти на строевой шаг

Результаты оказались интересными и даже позволили сразу сделать несколько важных выводов и наметить дальнейшие шаги по улучшению Cloud.ru Evolution перед выходом в свет. Но, разумеется, платформа поддерживает больше двух ВМ, поэтому мы решили, что было бы недурно расширить наши эксперименты. В частности, попробовать запускать такую же конфигурацию на множестве пар аналогичных ВМ одновременно, в том числе с некоторым шагом, скажем, две, четыре, восемь пар и так далее, по нарастающей.

Уже знакомые читателю Гена и Лопа были доработаны, получив невероятные навыки генерировать конфигурации и анализировать логи для ВМ числом больше двух. Дополнительно мы подшаманили более статичные вещи, включая запускаемый нами Ansible playbook. Все это было делом несложным, поскольку вся машинерия изначально писалась в расчете на то, что часть конфигурации поступает от пользователя напрямую при запуске скриптов. В общем, что и как улучшать и дорабатывать в этой части, мы понимали. А вот чего мы понять не могли — как же нам удачно создать много однотипных ВМ? А время, меж тем, неумолимо шло, и решать вопрос надо было как можно скорее.

Не вдаваясь в технические детали реализации, отмечу: мы входили в команду R&D, доступа к внутренностям Cloud.ru Evolution у нас не было и не могло быть. Все, что у нас было – это веб-интерфейс (который тогда выглядел сильно иначе, чем сейчас). Но что раньше, что теперь создавать в нем большое количество ВМ было можно, но вот так, чтобы еще и собрать о них нужные скриптам данные — проблематично. Помня о предыдущем опыте, мы понимали, что отладка и наши умелые руки наверняка потребуют еще не раз создавать эти ВМ заново. Таким образом, встал вопрос: как же нам создавать ВМ не руками, а посредством каких-нибудь скриптов?

Hacking requests или «костыль по запросу»

Осознав открывающиеся перед нами очаровательные перспективы по ежедневному кликанью мышкой в веб-интерфейсе, мы поняли, что надо искать решение, как простым смертным, не входящим в продуктовую команду, создавать ВМ. Одним из наших «связных» был архитектор Cloud.ru Evolution Петр Предтеченский. Ранее он проводил для нас очень бодрый и увлекательный экскурс по некоторым аспектам платформы, отвечал на самые нелепые наши вопросы и вообще всячески помогал нам. Пользуясь случаем, хотелось бы его поблагодарить еще и здесь. Петр, если ты читаешь эти строки, огромное тебе спасибо! Итак, мы опять пришли к Петру. Разумеется, мы предполагали, что мы с такими «хотелками» не одни, и наверняка уже есть какой-то способ ловко создавать хоть миллионы ВМ по щелчку пальцев. Ведь есть же, да?

Петр ответил: «способ создать ВМ по щелчку пальцев — есть!». С той, правда, оговоркой, что сначала нужно изобрести пальцы, а потом научиться ими щелкать. Как и многие уважающие себя веб-сервисы, наш подопытный предоставляет REST API, которым можно воспользоваться для своих нужд. И даже есть доступная публично схема, с документацией! Петр любезно предоставил образец скрипта, где при помощи модуля requests ловко отправлялся REST-запрос на создание подсетей. И это было прекрасное начало!

Навыки наши в мире REST, признаться, были не просто скупыми, а околонулевыми. Оценив характер и размер схемы, я несколько приуныл: она была большой и явно генерировалась автоматически, равно как и сопутствующая документация. Но отступать было некуда: право слово, не создавать же каждый раз ВМ руками? Времени, как обычно, было катастрофически мало. Вооружившись документацией, браузером и примером, предоставленным коллегой, мы состряпали наш основной скрипт — который успешно создавал ВМ, и еще парочку вспомогательных.

Приведу некоторую адаптированную выдержку из того, как выглядела функция создания ВМ в первоначальной версии скриптов.

def create_vm (endpoint, access_token, project_id, availability_zone_id, vm_ip, vm_name="bench"): 
    hdr = {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ' + access_token
    }   
    bdy = json.dumps([{
        "project_id": project_id,
        "availability_zone_id": availability_zone_id,
        "name": vm_name,
        "description": vm_name,
        "flavor_id": "ffffffff-ffff-ffff-ffff-ffffffffffff",
        "image_id": "ffffffff-ffff-ffff-ffff-ffffffffffff",
        "subnets": [
          {
            "subnet_id": "ffffffff-ffff-ffff-ffff-ffffffffffff",
            "security_groups": [
              "ffffffff-ffff-ffff-ffff-ffffffffffff"
            ],
            "new_floating_ip": False,
            "ip_address": vm_ip
          }
        ],
        "disks": [
          {
            "disk_type_id": "ffffffff-ffff-ffff-ffff-ffffffffffff",
            "name": vm_name+"-Load",
            "size": 16
          },
          {
            "disk_type_id": "ffffffff-ffff-ffff-ffff-ffffffffffff",
            "name": vm_name+"-Remote",
            "size": 100
          }
        ],
        "metadata_fields": [
            {
                "image_metadata_id": "ffffffff-ffff-ffff-ffff-ffffffffffff",
                "value": vm_name
            },
            {
                "image_metadata_id": "ffffffff-ffff-ffff-ffff-ffffffffffff",
                "value": "cloud"
            },
            {
                "image_metadata_id": "ffffffff-ffff-ffff-ffff-ffffffffffff",
                "value": "blah",
            },
            {
                "image_metadata_id": "ffffffff-ffff-ffff-ffff-ffffffffffff",
                "value": "blah"
            },
            {
                "image_metadata_id": "ffffffff-ffff-ffff-ffff-ffffffffffff",
                "value": "cloud"
            }
        ],
    }])
    # Print Ready requset for debug
    #print(json.dumps(json.loads(bdy), indent=4))

    resp = requests.post(f"{endpoint}/svc/v1/vms", headers=hdr, data=bdy)
    #assert (resp.status_code == 201), "ERROR: Failed to create VM!"
    
    print("Respose code: {}".format(resp.status_code))
    print(json.dumps(resp.json(),indent=4))
    print("headers: {}".format(resp.headers))

Отмечу, что Cloud.ru Evolution оперирует над сущностями посредством уникальных идентификаторов. Для упрощения в данном скрипте все идентификаторы сущностей заменены на "ffffffff-ffff-ffff-ffff-ffffffffffff", что, конечно же, не соответствует реальности. На практике отлов нужных именно для наших сценариев сущностей занял как раз больше всего времени; часть из них были вшиты в скрипт, часть — задавались параметрами на вызовах функций, но тоже забирались из заранее известных. На тот момент мы даже не вполне понимали, что есть отдельные запросы, которые позволяют эти идентификаторы получить, и двигались сугубо по наитию. В роли наития выступал браузер с запросами и ответами при нажимании разных кнопок. Вместе с тем, если отбросить технические красоты, на уровне обывателя все свелось к тому, что нам просто нужно отправлять ловко составленные словари и посредством модуля requests направлять прямиком в Cloud.ru Evolution.

Были ли мы этим удовлетворены? С практической точки зрения, да: описанного выше вполне хватило для того, чтобы отчитаться о проделанной задаче на данном конкретном этапе. Основным двигателем прогресса была неизменная нехватка времени, поэтому мы делали все незамысловато и прямолинейно. Вместе с тем, не покидало тревожное ощущение, что это еще не конец. Кроме того, посетила мысль, что едва ли мы окажемся единственными жаждущими автоматизации. Так родилась идея сделать SDK и CLI на Python.

Мое странное хобби

Мысль подкупала простотой и очевидностью: раз уже все равно была необходимость дергать облако разными скриптами, а скрипты запускаются из командной строки, быть может, стоит как-то красиво обернуть REST API и поверх этого сделать нехитрый CLI? Чтобы вместо пачки разнообразных скриптов, которые делают каждый какую-то задачу под нужды тестирования, взять и написать универсальную прослойку, которая принимает от нас нужные параметры и преобразует их в команды, уже знакомые нашей платформе? Недолго поразмыслив, я пришел к выводу, что нет нужды совершать неожиданные ходы, поэтому вопрос о нужности этого функционала адресовал все тому же человеку — архитектору Cloud.ru Evolution Петру Предтеченскому. Оказалось, такие идеи появляются не только у разработчиков R&D; Петр тут же нашел соответствующую задачу в бэклоге, согласился, что затея неплоха, а я пошел убеждать руководство, что игра стоит свеч.

Добиться вышеуказанной цели мне удалось частично: все-таки для нас эта активность, как для R&D, была явным «боковиком». Поэтому решили, что, вероятнее всего, мы состряпаем какой-нибудь прототип, а доводить его до ума будет продуктовая команда. Либо же, как вариант, мы делаем все, если задача попадет в наши руки, но все равно отдадим на поддержку продуктовой команде. Приоритет у этих задач был низким, как ввиду наличия других важных задач, так и ввиду того, что не было гигантской очереди из нуждающихся в SDK и CLI. В любом случае итогом обсуждения стало заведение двух задач на SDK и CLI соответственно, и я в промежутках между основными задачами стал все это пробовать писать.

Времени выдавалось не так и много: в команде R&D нет недостатка в задачах. Тем не менее, время от времени я стремительно набрасывался на эти скрипты, а потом — столь же стремительно — убегал обратно. Моей главной навязчивой идеей стало то, что хорошо бы покрыть весь имеющийся REST API. «Кому захочется это писать вручную? В таком ручном коде мы не нуждаемся, мы и без него видим жизнь живописной. У нас и JSON-схема есть!», — решил я и отправился изучать, чем эту схему можно разжевать, чтобы на выходе получить качественный и бодрый код.

Генерируем код и пользуемся им (нет)

Из всего, что я рассматривал, больше всего меня зацепили два кандидата: swagger-codegen‑cli и openapi‑generator‑cli; уже потом я узнал: это – практически одно и то же. Попробовав разные варианты, я решил остановиться на openapi-generator-cli: при генерации он очень активно использовал аннотацию типов, ловко выковыривая нужные типы из схемы, пытался проводить валидацию запросов и ответов. Он быстро сумел поддержать наши способы аутентификации и вообще производил довольно приятное впечатление. Забегая вперед, скажу, что по-настоящему важным оказался лишь первый пункт; все остальное можно списать на вкусовщину и персональные навыки. Постепенно использование openapi-generator-cli превратилось в следующий Makefile (конечно, неортодоксально для скриптов на Python, но вы ведь помните, что я люблю make?):

Makefile 
.PHONY: era-openapi
era-openapi: evolution.json
        docker run -v $(shell pwd):/local \
                openapitools/openapi-generator-cli generate \
                        --package-name era_openapi -i /local/$< -g python -o /local/$@
        cd $@ && python3 setup.py install --user

evolution.json:
        curl -s $(EVOLUTION_OPENAPI_JSON) > $@

К сожалению, сгенерированный в нашем случае код выглядел жутковато, и едва ли подходил на роль прекрасного API будущего, который я себе представлял. Так, например, сгенерированный вызов для создания ВМ выглядит примерно так:

class PublicVmCreateRequest(BaseModel):
    """
    PublicVmCreateRequest
    """ # noqa: E501
    project_id: StrictStr
    availability_zone_id: Optional[StrictStr] = None
    availability_zone_name: Optional[Annotated[str, Field(min_length=1, strict=True, max_length=64)]] = None
    name: Annotated[str, Field(min_length=1, strict=True, max_length=64)]
    description: Optional[Annotated[str, Field(min_length=0, strict=True, max_length=255)]] = ''
    flavor_id: Optional[StrictStr] = None
    flavor_name: Optional[Annotated[str, Field(min_length=1, strict=True, max_length=64)]] = None
    image_id: Optional[StrictStr] = None
    image_name: Optional[Annotated[str, Field(min_length=1, strict=True, max_length=64)]] = None
    placement_group_id: Optional[StrictStr] = None
    placement_group_name: Optional[Annotated[str, Field(min_length=1, strict=True, max_length=64)]] = None
    subnets: Optional[Annotated[List[VmCreateSubnetSection], Field(min_length=1, max_length=8)]] = None
    disks: Annotated[List[Any], Field(min_length=1)]
    metadata_fields: Optional[List[VmCreateMetadataByIdSection]] = None
    image_metadata: Optional[Dict[str, StrictStr]] = None
    tag_ids: Optional[List[StrictStr]] = None
    tag_names: Optional[List[Annotated[str, Field(min_length=1, strict=True, max_length=64)]]] = None
    __properties: ClassVar[List[str]] = ["project_id", "availability_zone_id", "availability_zone_name", "name", "description", "flavor_id", "flavor_name", "image_id", "image_name", "placement_group_id", "placement_group_name", "subnets", "disks", "metadata_fields", "image_metadata", "tag_ids", "tag_names"]


class VMsApi:
    @validate_call
    def create_vm_svc_v1_vms_post(
        self,
        public_vm_create_request: List[PublicVmCreateRequest],
        _request_timeout: Union[
            None,
            Annotated[StrictFloat, Field(gt=0)],
            Tuple[
                Annotated[StrictFloat, Field(gt=0)],
                Annotated[StrictFloat, Field(gt=0)]
            ]
        ] = None,
        _request_auth: Optional[Dict[StrictStr, Any]] = None,
        _content_type: Optional[StrictStr] = None,
        _headers: Optional[Dict[StrictStr, Any]] = None,
        _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
    ) -> List[PublicVmResponse]:
        pass

В сравнении с вышеупомянутым рукописным кодом, бросается в глаза, что в сгенерированном интерфейсе некоторые аргументы еще и дополнительно заворачиваются во всякие вложенные словари или списки. Вины генератора здесь нет: так действительно написано в схеме. Однако далеко не всегда в API хочется видеть абсолютно то же самое, что и в схеме; зачастую хочется видеть что-то более простое и прямолинейное. Способ использования сгенерированных классов мне тоже не очень понравился: из каждого клиента на каждый API приходилось вручную создавать объект, а всем этим объектам — прокидывать разные аргументы на инициализации. Вот, к примеру, как выглядит получение списка ВМ, с точки зрения генератора openapi:

# Defining the host is optional and defaults to http://localhost
# See configuration.py for a list of all supported configuration parameters.
configuration = era_openapi.Configuration(
    host = "http://localhost"
)

# The client must configure the authentication and authorization parameters
# in accordance with the API server security policy.
# Examples for each auth method are provided below, use the example that
# satisfies your auth use case.

# Configure Bearer authorization: token
configuration = era_openapi.Configuration(
    access_token = os.environ["BEARER_TOKEN"]
)

# Enter a context with an instance of the API client
with era_openapi.ApiClient(configuration) as api_client:
    # Create an instance of the API class
    api_instance = era_openapi.VMsApi(api_client)
    project_id = 'project_id_example' # str | 
    tag_ids = ['tag_ids_example'] # List[str] |  (optional)
    availability_zone_ids = ['availability_zone_ids_example'] # List[str] |  (optional)
    image_ids = ['image_ids_example'] # List[str] |  (optional)
    flavor_ids = ['flavor_ids_example'] # List[str] |  (optional)
    placement_group_ids = ['placement_group_ids_example'] # List[str] |  (optional)
    statuses = [era_openapi.VmState()] # List[VmState] |  (optional)
    name = 'name_example' # str |  (optional)
    empty_fip = True # bool |  (optional)
    offset = 0 # int |  (optional) (default to 0)
    limit = 50 # int |  (optional) (default to 50)
    order_by = 'created_time' # str |  (optional) (default to 'created_time')
    order_desc = True # bool |  (optional) (default to True)

    try:
        # Get Vms
        api_response = api_instance.get_vms_svc_v1_vms_get(project_id, tag_ids=tag_ids, availability_zone_ids=availability_zone_ids, image_ids=image_ids, flavor_ids=flavor_ids, placement_group_ids=placement_group_ids, statuses=statuses, name=name, empty_fip=empty_fip, offset=offset, limit=limit, order_by=order_by, order_desc=order_desc)
        print("The response of VMsApi->get_vms_svc_v1_vms_get:\n")
        pprint(api_response)
    except Exception as e:
        print("Exception when calling VMsApi->get_vms_svc_v1_vms_get: %s\n" % e)

Все это выглядело довольно громоздко; кроме того, учитывая, что часть опций для будущего CLI не хотелось постоянно задавать руками, появилось непреодолимое желание задавать их в виде конфигурации. Как известно, можно решить любую проблему, добавив дополнительный уровень абстракции. Попробуем обернуть сгенерированный API во что-то более красивое и пользоваться этими обертками, а конфигурационный файл прилепим сверху.

Творчески переосмысливаем деятельность генератора

Так родились облик API и файл конфигурации, который позволял задать часть параметров:

~/era.yaml

iam_endpoint: https://auth.iam.sbercloud.ru
endpoint: https://console.cloud.ru/api/svp
project_id: ffffffff-ffff-ffff-ffff-ffffffffffff
client_id: service-account-id
client_secret: service-account-secret

example.py

import pathlib

from era import Config
from era import Client

config = pathlib.Path("~/.era.yaml").expanduser()
with open(config, "r", encoding="UTF-8") as stream:
    config = Config.load(stream=stream)

with Client(config=config) as client:
    for az in client.vm.list():
        print(az)

Итак, рабочая идея выглядела просто: берем сгенерированный код, каждую функцию любовно оборачиваем в свою собственную обертку, сверху на argparse пишем еще какую-нибудь обертку, которая все это прокидывает в CLI, и мы победители. Выглядело все это примерно так, как описано ниже (большая часть логики оставлена за кадром):

api.py

class AvailabilityZone(_era_openapi.AvailabilityZonesApi):
    pass


class VM(_era_openapi.VMsApi):
    def create_vm_svc_v1_vms_post(self,
            name: str, project_id: str, availability_zone_id: str, flavor_id: str, image_id: str,
            disk: list, subnet: list, pubkeys: list, count: _typing.Optional[int] = None, **arguments):
        request = a_really_long_dance_with_arguments_conversion()
        return super().create_vm_svc_v1_vms_post(public_vm_create_request=request)


class APIMeta:
    pass # boring metaclass twists


class API(metaclass=APIMeta):
    pass # boring twists


OPENAPI = {
    "az": (AvailabilityZone, {
        "list": AvailabilityZone.get_availability_zone_list_svc_v1_availability_zones_get,
    }),
    "vm": (VM, {
        "create": VM.create_vm_svc_v1_vms_post,
        "list": VM.get_vms_svc_v1_vms_get,
    }),
}


class ClientMeta(type):
    def __new__(metacls, clsname, bases, ns):
        for (name, (apicls, calls)) in OPENAPI.items():
            apicls = type(f"API[{name}]", (API,), calls, apicls=apicls)
            ns.setdefault(name, metacls.API(apicls))

        return super().__new__(metacls, clsname, bases, ns)

class Client(metaclass=ClientMeta):
    pass

cli.py

class CommandMeta(type):
    def __new__(metacls, name, bases, ns, /, identifier=None):
        if identifier is None:
            identifier = name
        ns.setdefault("identifier", identifier)

        return super().__new__(metacls, name, bases, ns)


class Command(metaclass=CommandMeta):
    def __init__(self, path: tuple, parser: _argparse.ArgumentParser):
        def predicate(member: _typing.Any):
            return (isinstance(member, type) and issubclass(member, Command))

        subcommands = tuple(_inspect.getmembers(self.__class__, predicate))
        if subcommands:
            subparsers = parser.add_subparsers(dest="command", required=True)
            for (_, subcommand) in subcommands:
                subpath = subcommand.identifier
                subparser = subparsers.add_parser(subpath, description=subcommand.__doc__)
                subcommand = subcommand(path=(path + (subpath,)), parser=subparser)
                subparser.set_defaults(path=(path + (subpath,)), command=subcommand)

        self.__parser = parser

        return super().__init__()


class APICommand(Command):
    def __init__(self, path: tuple, parser: _argparse.ArgumentParser):
        parser.add_argument("-c", "--config",
            help="configuration path",
            type=_pathlib.Path,
            nargs="?",
            default=_pathlib.Path("~/.era.yaml").expanduser())

        return super().__init__(path=path, parser=parser)

    def __call__(self, path: tuple, json: True, config: _pathlib.Path, **arguments):
        # some manipulations with arguments
        with _api.Client(config=config) as client:
            result = client(path=path, **arguments)
            _yaml.dump(result, _sys.stdout, allow_unicode=True, indent=4)


class APICommand(Command):
    def __init__(self, path: tuple, parser: _argparse.ArgumentParser):
        # snip
        return super().__init__(path=path, parser=parser)


class CLI(Command):
    class AZCommand(Command, identifier="az"):
        class ListCommand(APICommand, identifier="list"):
            pass

Основная задумка проста: мы наследуем свои классы от сгенерированных автоматически. Там, где нужно, переопределяем методы, и все это приклеиваем к классу Client через метакласс ClientMeta. В дальнейшем все это определяется на уровне CLI. Свое детище я торжественно нарек ERA (Evolution REST API), и решил, что буду спокойно, раз уж оно не так и горит, разрабатывать его в неспешном ритме. Все, что нужно — с любовью и заботой дописывать по мере появления свободного времени новые классы, переопределять нужные методы и писать сверху такой же ручной CLI. План надежен, как швейцарские часы, ведь задача — вялотекущая. Сиди да пиши себе спокойно: если никто не придет с тем, что эта задача нужна, и смысла активизировать работы особенно нет. Так ведь?

Безумие, вышедшее из моря

Планы сидеть и спокойно писать SDK и CLI до глубокой старости в одночасье рухнули, когда задачи по тестированию возобновились, но уже в новом окружении. Володю Митрофанова заменила Оля Артемьева (Оля, привет!). Естественно, низкоприоритетная задача, которая не горит, да и вообще не очень нужна, вдруг резко стала актуальной, и о скриптах вспомнили все, в том числе — я. Каких-то чудес от скриптов не ждали, но ничего лучшего по-прежнему не было, поэтому проект воспрянул, аки феникс. 

Встречаем пробудившегося Ктулху

Возрождение феникса, впрочем, проходило туго. Первейшая же попытка запустить все на новом окружении не смогла выполнить вообще ничего, поскольку вся система авторизации там отличалась, начиная с получения bearer-токена. Это место пришлось править, и нам удалось пройти дальше... чтобы увидеть, что перед нами — целая гора нереализованного функционала, который раньше никогда не был нужен. Отчет Оли за то время выглядел так:

Всего четыре скупых строки, а сколько всего за ними стояло! Казалось, перед нами разверзся портал в ад. Дело в том, что подсети — это отдельный пласт Evolution API, равно как и группы безопасности. Каждую из этих сущностей нужно уметь создавать, обновлять, получать список таких объектов... как же все это обернуть и в SDK, и в CLI, грамотно прокинуть в сгенерированный код и не сойти с ума? Абсолютно каждый, даже самый простой кусочек REST API, приходилось описывать в виде класса, упоминать в списке известных API, затем делать все то же самое в CLI. При таком подходе постоянно на каждом из уровней что-то терялось. Кроме того, это делалось долго; а временные ограничения от нас не только никуда не делись, но и вовсе стали более жесткими по множеству причин, начиная от получения доступа до проблем с окружением.

Помимо этого всплывали другие разнообразные проблемы. Схема периодически менялась. Более того, схема, которую мы изначально использовали, отличалась от реальной схемы в новом окружении. Какие-то вещи приходилось менять и править прямо на уровне схемы, чтобы генератор openapi вообще мог ее переварить. Так, уже упомянутый ранее Makefile стал на полученную при помощи curl схему натравливать отдельный скрипт с костылями и разнообразными правками:

evolution.json:
        curl -s $(EVOLUTION_OPENAPI_JSON) | python3 scripts/fixup.py > $@

Спустя несколько итераций правок схемы, ручных дополнений в духе уже описанного выше API, внесения разнообразных хаков и хитростей, я понял, что долго в таком ритме не выдержу. Планы разрабатывать SDK и CLI до глубокой старости претерпели серьезное изменение: старость могла наступить слишком быстро, ведь ее приход активно подстегивал сам процесс разработки. В то же самое время страдала и Оля, находясь под гнетом тотальной неработоспособности разработанных мной инструментов, а также постоянного тестирования разных костылей, которые я изобретал. Нам по-прежнему были нужны в первую очередь всякие радости в CLI; кое-где мы выкручивались использованием SDK напрямую, кое-где создавали костыли поверх сгенерированного API. Но было очевидно, что так продолжаться бесконечно не могло; очевидно, требовалось какое-то иное решение. А время продолжало ускользать.

Тогда я понял, что, по-видимому, надо попытаться взглянуть на задачу не с точки зрения «давай попытаемся слепить это так же быстро, как в прошлый раз», а под углом «как бы я это делал, если бы это был домашний проект, с нуля, с учетом пережитого?». Само собой, раз делаем вид, что это — «домашний проект», может, попробуем взглянуть свежим взглядом на выходных, отрешившись от мыслей, что мы работаем?

Налаживаем общение

Возможно, на выходных думается легче? Или помогает переосмыслить проект под другим углом? Ответа я и сам не знаю, но идея, которая меня посетила, была настолько убийственно проста, насколько же невероятно грустна в осознании. Как она не пришла в голову раньше?

Мы не можем контролировать сгенерированный код, как ввиду отсутствия времени на правку и поддержку генератора, так и потому, что не можем повлиять на изменения в схемах. Но мы можем контролировать обертки, а вот CLI как раз генерировать из них. Конечно, нам хочется иметь хоть какую-то валидацию параметров на всех уровнях, от CLI до сгенерированного кода; а раз так, нам хорошо бы иметь какую-нибудь типизацию. Путем долгих проб и ошибок я остановился на таком варианте описания:

api.py

class APIMeta(type):
    def __new__(metacls, clsname, bases, ns, *, openapi: type, identifier: str):
        # long and boring iteration over ns, class memoization, let's cut it to line below...
        return super().__new__(metacls, clsname, bases, ns)

class AvailabilityZone(API, openapi=_era_openapi.AvailabilityZonesApi, identifier="az"):
    @Method.wrap(_era_openapi.AvailabilityZonesApi.get_availability_zone_list_svc_v1_availability_zones_get)
    def list(self, *,
            project_id: _core.ProjectId):
        return self.openapi(self.list, **{
            "project_id": project_id,
        })

class SecurityGroup(API, openapi=_era_openapi.SecurityGroupsApi, identifier="security_group"):
    @Method.wrap(_era_openapi.SecurityGroupsApi.get_security_groups_by_project_id_svc_v1_security_groups_get, paginated=True)
    def list(self, *,
            project_id: _core.ProjectId,
            availability_zone_id: _core.AvailabilityZoneId | None = None,
            tag_ids: _typing.Annotated[
                    tuple[_core.TagId, ...] | None,
                    {
                        "option_strings": ("--tag-id",),
                        "metavar": "TAG_ID",
                    },
                ] = None,
            name: str | None = None):
        return self.openapi(self.list, **{
            "project_id": project_id,
            "availability_zone_id": availability_zone_id,
            "tag_ids": tag_ids,
            "name": name,
        })

class VM(API, openapi=_era_openapi.VMsApi, identifier="vm"):
    @Method.wrap(_era_openapi.VMsApi.set_vm_power_svc_v1_vms_vm_id_set_power_post)
    def power(self, *,
            vm_id: _core.VMId,
            state: str):
        return self.openapi(self.power, **{
            "vm_id": vm_id,
            "vm_set_power_request": {
                "state": state,
            },
        })

    @Method.pseudo
    def power_on(self, *,
            vm_id: _core.VMId):
        return self.power(vm_id=vm_id, state="power_on")

Что происходит под капотом? Сразу видно, что тут, как и ранее, в ход пошли метаклассы. Каждый API начинается с описания, какой соответствующий класс openapi он оборачивает. При регистрации нового класса API, внутри запоминается соответствие между этим новым классом и его аналогом в openapi. Также мы обходим все методы свежесозданного класса, проверяя, является ли метод тоже оберткой; обертка метода — специальный объект, который возвращают декораторы @Method.wrap и @Method.pseudo. Для оберток типа @Method.wrap мы запоминаем аргументы и оборачиваемый вызов openapi; для оберток типа @Method.pseudo соответствующего метода openapi нет, однако мы все равно хотим иметь аналогичную команду на уровне CLI.

Мы можем позвать обернутые методы openapi изнутри через специальный метод openapi; на вход этот метод примет, для какой функции нужно позвать соответствующий сгенерированный код. Например, вызванный внутри класса AvailabilityZone метод self.openapi(self.list, ...) будет переадресован в вызов era_openapi.AvailabilityZonesApi.get_availability_zone_list_svc_v1_availability_zones_get.

Как это выглядит в CLI?

era -h 
$ era az list -h
usage: era az list [-h] [-f SPEC] [--project PROJECT_ID] [YAML ...]

Get Availability Zone List

positional arguments:
  YAML                  YAML configurations

options:
  -h, --help            show this help message and exit
  -f SPEC, --filter SPEC
                        filter results
  --project PROJECT_ID  project id (id=project_id, type=string, default=ffffffff-ffff-ffff-ffff-ffffffffffff) 

$ era vm power -h
usage: era vm power [-h] [-f SPEC] --vm VM_ID --state STATE [YAML ...]

Set Vm Power

positional arguments:
  YAML                  YAML configurations

options:
  -h, --help            show this help message and exit
  -f SPEC, --filter SPEC
                        filter results
  --vm VM_ID            virtual machine identifier (id=vm_id, type=string)
  --state STATE         state (id=state, type=string)
$ era vm power-on -h
usage: era vm power-on [-h] [-f SPEC] --vm VM_ID [YAML ...]

positional arguments:
  YAML                  YAML configurations

options:
  -h, --help            show this help message and exit
  -f SPEC, --filter SPEC
                        filter results
  --vm VM_ID            virtual machine identifier (id=vm_id, type=string)

$ era security-group list -h 
usage: era security-group list [-h] [-f SPEC] [--project PROJECT_ID] [--az AVAILABILITY_ZONE_ID] [--tag TAG_ID] [--name NAME] [YAML ...]

Get Security Groups By Project Id

positional arguments:
  YAML                  YAML configurations

options:
  -h, --help            show this help message and exit
  -f SPEC, --filter SPEC
                        filter results
  --project PROJECT_ID  project id (id=project_id, type=string,  default=ffffffff-ffff-ffff-ffff-ffffffffffff)
  --az AVAILABILITY_ZONE_ID, --availability-zone AVAILABILITY_ZONE_ID
                        availability zone id (id=availability_zone_id, type=string, default=nil)
  --tag TAG_ID          tag id (id=tag_ids, type=[]string, default=nil)
  --name NAME           name (id=name, type=string, default=nil)

Как это все генерируется? Да очень просто не слишком просто, но вполне ожидаемо: мы получаем сигнатуру функции через inspect.signature, а дальше обходим все объекты inspect.Parameter, пропуская параметрself.  Приведу для понимания сжатую реализацию Method.create, которая будет вызвана из декораторов Method.wrap и Method.pseudo.

@_dataclasses.dataclass(frozen=True, eq=True)
class Argument:
    parameter: _inspect.Parameter
    type: type
    metadata: dict[str, _typing.Any]

    @classmethod
    def create(cls, parameter: _inspect.Parameter):
        # Нудный и скучный анализ полученного inspect.Parameter, включая typing.Annotated, проверку базового типа tuple и т.д.
        # В итоге создаст экземпляр класса Argument, где, помимо parameter, будут также базовый тип и метаданные из typing.Annotated.
        return cls(parameter=parameter, type=typeid, metadata=metadata)


@_dataclasses.dataclass(frozen=True, eq=True)
class Method:
    call: _typing.Callable
    arguments: tuple[Argument, ...]
    _: _dataclasses.KW_ONLY
    openapi: _typing.Callable | None = None

    @classmethod
    def create(cls,
            method: _typing.Callable,
            openapi: _typing.Callable | None = None):
        mapping = {}
        defaults = {}
        signature = _inspect.signature(method, eval_str=True)
        parameters = iter(signature.parameters.values())
        instance = next(parameters)
        parameters = tuple(parameters)

        arguments = tuple(map(Argument.create, parameters))
        parameters = ((instance,) + tuple(map(_operator.attrgetter("parameter"), arguments)))

        if openapi is not None and not method.__doc__:
            method.__doc__ = openapi.__doc__.strip().splitlines()[0]
        method.__signature__ = signature.replace(parameters=parameters)

        return cls(call=method, arguments=arguments, openapi=openapi)

    def __iter__(self):
        yield from self.arguments

По сути, код сводится к тому, что мы превращаем каждый inspect.Parameter уже в наш класс Argument, запоминая сам параметр, а также расшифровывая фактический тип и дополнительные данные. Во что же превратится такая запись?

tag_ids: _typing.Annotated[
    tuple[_core.TagId, ...] | None,
    {
        "option_strings": ("--tag-id",),
        "metavar": "TAG_ID",
    },
] = None,

Она превратится в Argument(parameter=tag_ids, type=tuple[_core.TagId], metadata={"option_strings": ("--tag-id",), "metavar": "TAG_ID"}). Содержимое метаданных может показаться вам подозрительно знакомым, и не без причины: они напрямую пробросятся в argparse.ArgumentParser.add_argument(). Собственно, ниже — упрощенная версия того, что теперь делает ранее упомянутый CLI:

api_commands = {key.replace("_", "-"):value for (key, value) in sorted(_api.Client)}

for (command, api) in api_commands.items():

    parser = main_subparsers.add_parser(command, description=api.__doc__)

    subparsers = parser.add_subparsers(dest="command",

        required=True, metavar=f"{{{','.join(subcommands)}}}")

    subcommands = {key.replace("_", "-"):value for (key, value) in sorted(api)}

    for (subcommand, method) in subcommands.items():

        subparser = subparsers.add_parser(subcommand, description=method.call.__doc__)

        for argument in method:

            (args, kwargs) = convert_argparse(argument)

            subparser.add_argument(*args, **kwargs)

Вся магия происходит внутри условной функции convert_argparse: она преобразует наш внутренний Argument в нечто подходящее для argparse.ArgumentParser.add_argument(). Таким образом, мы обходим все классы API внутри класса Client, для каждого класса API обходим все его «волшебные» методы, а для каждого метода обходим каждый аргумент, потенциально снабженный дополнительной аннотацией.

Такой подход, хотя и потребовал много времени на свою реализацию, существенно упростил дальнейшее тестирование: мы легко и непринужденно добавляли поддержку новых функциональных возможностей для SDK, автоматически также получая его реализацию в CLI. Попутно я добавил возможность переключаться между конфигурациями для разных окружений, а также возможность задавать ряд аргументов командной строки посредством подгрузки произвольного количества конфигураций YAML в CLI. Каждый аргумент, помимо своего описания в командной строке, также может быть задан в рамках отдельного файла. Но все это уже было украшательством, которое, хотя и стало возможным благодаря этим изменениям, все же существенно уже на ход нашего тестирования не повлияло.

Можно ли подружиться?

Возникает резонный вопрос: а можно ли сделать лучше? Безусловно! Отбросим на секунду тот факт, что сам код, вероятно, оставляет желать лучшего, и подумаем, а можно ли сделать еще один шаг в сторону именно логических улучшений?

Я думаю, хорошим ответом здесь было бы уменьшение количества кода, который пишется непосредственно внутри классов. Откровенно говоря, значительную часть этого можно так же генерировать из некоторых описаний, где для каждого аргумента задается как тип, так и то, в какой аргумент он транслируется на уровне низкоуровневого API. Это здорово уменьшило бы объем кода внутри, особенно для тривиального случая ниже:

class AvailabilityZone(API, openapi=_era_openapi.AvailabilityZonesApi, identifier="az")
    @Method.wrap(_era_openapi.AvailabilityZonesApi.get_availability_zone_list_svc_v1_availability_zones_get)
    def list(self, *,
            project_id: _core.ProjectId):
        return self.openapi(self.list, **{
            "project_id": project_id,
        })

Все это вполне можно расписать в виде некой конфигурации. Для каких-то более сложных случаев, где запрос оборачивается в какую-то внутреннюю модель openapi, пожалуй, тоже. Остается не вполне ясным, как обрабатывать вариант, когда требуется позвать произвольный код; но и этот вопрос относится к решаемым.

Чему мы научились, Палмер?

Чем же заканчивается эта история? Было ли это полезным? И да, и нет. Мы решили конкретные задачи для нужд тестирования, и так получилось, что в ходе решения этих задач изобрели некий инструмент. Полезен ли он за пределами этого тестирования? Трудно сказать. За то время, что проводилось тестирование, команда Cloud.ru Evolution успела начать писать обертки для Terraform. Учитывая, что последний все же является индустриальным стандартом, логичным кажется сконцентрироваться на его поддержке. Хотя, конечно, отмечу, что использование этих скриптов не требует погружения в специфику Terraform.

Мне кажется, существенную помощь нам оказал удачный первоначальный выбор инструмента: кто знает, пришла бы мне в голову идея об использовании аннотации типов для генерации CLI, если бы я не видел, что она сплошь и рядом используется в сгенерированном коде. И, конечно, эта эволюция пусть и, казалось бы, временной утилиты, в режиме цейтнота позволила нам в сжатые сроки протестировать платформу перед запуском. Но, учитывая историю даже описанной выше задачи, я в очередной раз убедился: никогда нельзя быть уверенным, потребуется ли какой-то код снова, а если потребуется, то когда и зачем?

Главные выводы, которые я сделал, могут показаться очевидными при спокойной разработке, но в режиме аврала порой ускользают:

  1. Не стоит наивно полагать, что код будет одноразовым. Тогда к моменту часа «Ч» уже будет хоть какой-то фундамент.

  2. Исходи из предпосылки, что этот код был нужен вчера. Необходимость в нем может возникнуть в любое время.

  3. Решая совершенно конкретную задачу, ты можешь внезапно столкнуться с тем, что сделаешь что-то совершенно иное за рамками задачи.

  4. Генерация кода может прийти на помощь, а если тебя не устраивает результат, ты всегда можешь как-то быстро это обернуть.

  5. Там, где тебе не хватает чужой генерации кода, ты всегда можешь сгенерировать что-то сам.

Пожалуй, для меня этих выводов уже достаточно, чтобы считать этот опыт полезным. Надеюсь, что-то полезное или хотя бы интересное из этого рассказа удалось почерпнуть и вам.

Интересное в блоге:

Комментарии (0)