image


В прошлом году на митапе "Dynamics 365 & Power Platform meetup Moscow — 25 февраля 2020" я рассказывал про то как мы выстроили пайплайн непрерывной поставки CI/CD на базе GitLab CI для Microsoft Dynamics CRM.


В этой статье я расскажу и покажу как построить CI-часть пайплайна непрерывной поставки расширения функциональности Microsoft Dynamics CRM на базе Azure DevOps.


Я давно уже не заглядывал в Azure и на новогодних праздниках наконец-то появилось время в нем "поковыряться". :-) Соответственно, ничего сложного в данной статье я описывать не буду и если у вас уже есть построенный пайплайн в Azure DevOps то вы, скорее всего, ничего нового здесь не найдете. Однако, если вы чувствуете потребность в автоматизации выноса но не знаете с чего начать то эта статья как раз для вас.


Я буду использовать облачный сервис Azure DevOps Service т.к. развернуть локально и настроить не успел но, локально должно работать точно также. Начинается все, естественно, с репозитория. Для Azure Devops Pipelines можно использовать разные репозитории, Azure Repos Git, Bitbucket Cloud, GitHub, просто Git и Subversion но я, для удобства, остановлюсь на Azure Repos Git чтобы все было в одном проекте который я оставлю в публичном доступе здесь https://dev.azure.com/ZhukoffPublic/CRMCICD/.


Microsoft Dynamics CRM это CRM система на базе технологий ASP.NET, которое теперь входит в семейство Dynamics 365, которую можно расширять путем написания плагинов на C# для бэковой части и JScript для фронтальной.

Для демонстрационных целей я создал 4 проекта (вы их можете клонировать из моего репозитория):


  • Plugins — плагины CRM. В нашем случае плагином который переопределяет формирование полного имени.
  • Plugins.Tests — юнит-тесты для плагинов.
  • Solution — кастомизации CRM. В нашем случае это сущность Контакт и основная форма для него.
  • WebResources — вебресурсы CRM. В нашем случае это скрипт на форме Контакта который выводит нотификацию если не заполнен телефон.

Таким образом мы покроем основные элементы расширения MS Dynamics CRM.


Для автоматизации я буду использовать SparkleXrm написанный небезызвестным Scott Durow. Описание возможностей можно почитать здесь Simple, No fuss, Dynamics 365 Deployment Task Runner как пользовать можно посмотреть на его канале в YouTube. Также у него есть видеокурс на PaktPub Designing and Building Custom Apps using Dynamics 365 где он подробнейшим образом рассказывает что и как использовать и даже описывает как создать пайплайн на тот момент это был еще VSTS. Я выбрал spkl потому что он уже написан и сам проект в открытом доступе на GitHub, что позволяет сделать доработки самому если что-то не устраивает. Это вариант, конечно, не без недостатков, о них я расскажу позже.


Будем считать, что проект у нас есть и что он, конечно, собирается и деплоится из командной строки с помощью spkl. У меня это виртуальная машина установленной MS CRM 365 (9.0), Visual Studio, SDK, XRMToolBox и пр., короче, все в одном.


Сам пайплайн делится на две основные части, CI непрерывная интеграция (Continuous Integration) т.е. когда код непрерывно интегрируется в ветке репозитория и CD которая может быть реализована как непрерывная поставка (Continuous Delivery) или непрерывное развертывание (Continuous Deployment).


Мы начнем, конечно, с CI и думать сейчас как делать CD вовсе необязательно.


CI/CD Pipeline


Подготовительные работы


Создаем проект CRMCICD в Azure DevOps.



Клонируем репозиторий и заливаем наш проект.



Теперь создадим сам пайплайн, для этого нам нужно перейти в секцию Pipelines, жмем Create Pipeline, выбираем репозиторий Azure Repos Git далее выбираем наш репозиторий CRMCICD далее выбираем Starter Pipeline и получаем следующую картину.



Это и есть пайплайн, YAML код в файле azure-pipelines.yml который исполняется агентом в каком-то окружении, windows или linux например. Он должен состоять минимум из трех секций:


  • trigger: триггер для запуска пайплайна, в данном случае это -main, т.е. по коммиту в ветку main.
  • pool: окружение где будет выполняться пайплайн, в данном случае образ ubuntu в облаке Azure.
  • steps: сами действия которые должны быть выполнены.

Если сохранить пайплайн в таком виде то он будет запускаться на каждый коммит в ветку main. Поэтому ставим trigger: none и сохраняем так пока его не настроим и не отладим.


Наш CI будет состоять из 2 этапов:


  • Сборка проекта и решения
  • Развертывание на локальную среду

2-й шаг не обязателен но я его добавил чтобы был пример развертывания на локальную среду с помощью spkl, полезно сразу получить то, что только что залили в main ветку.


Сборка проекта и решения


Первым делом меняем агента на windows т.к. linux нас никак не устроит.
vmImage: 'windows-latest'


Добавляем переменные для удобства.


variables:
  solution: '**/*.sln'
  buildPlatform: 'Any CPU'
  buildConfiguration: 'Debug'

Добавляем следующие шаги:


steps:
# Установка NuGet
- task: NuGetToolInstaller@1
  displayName: 'Install NuGet tool'

# Восстановление пакетов NuGet для проекта
- task: NuGetCommand@2
  displayName: 'Restore NuGet packages'
  inputs:
    restoreSolution: '$(solution)'

# Сборка
- task: VSBuild@1
  displayName: Buld
  inputs:
    solution: '$(solution)'
    platform: '$(buildPlatform)'
    configuration: '$(buildConfiguration)'

# Прогон юнит-тестов
- task: VSTest@2
  displayName: 'Run Unit Tests'
  inputs:
    platform: '$(buildPlatform)'
    configuration: '$(buildConfiguration)'

# Публикация собранной dll-ки в хранилище артефактов
- task: CopyFiles@2
  displayName: 'Copy plugins dll'
  inputs:
    SourceFolder: '$(Build.SourcesDirectory)\CRMCICD\Plugins\bin\Debug\'
    Contents: 'CRMCI.*.dll'
    targetFolder: '$(Build.StagingDirectory)/plugins'
- publish: '$(Build.StagingDirectory)/plugins'
  displayName: 'Publish plugins dll as an artifact'
  artifact: plugins

Где $(xxx) это обращение к переменным которые я объявил сам или встроенным, подробнее смотреть тут Use predefined variables а описание что и в каких папках лежит можно посмотреть тут Understanding the directory structure created by Azure DevOps tasks.


И будьте внимательны, это YAML и один лишний или недостающий пробел может все поломать.

Сохраняем и запускаем пайп руками, кнопка Run pipeline справа вверху.



В следующем окне ничего не меняя просто жмем Run.



В результате чего попадаем на страницу конкретного экземпляра пайплайна который мы только что запустили и который стоит в очереди на исполнение где-то в недрах Azure DevOps. Он состоит из одной джобы (Job).



Если кликнуть на джобу то попадем на страницу с логами выполнения каждой задачи где увидим их статус и время выполнения.



Если у кого-то не получилось можете взять версию пайпа из репозитория, коммит eb2a1465


Первое, что мне не нравится это версия пайпа, #20210129.1. Давайте сделаем свою, я обычно использую 4 цифры, где первые две это версия CRM а вторые 2 версия моих доработок, первая цифра это номер релиза а вторая номер сборки, например, 9.0.1.0. Данный подход позволяет иметь уникальный номер даже если одно и тоже решение делается для разных версий CRM. Получилось почти семантическое версионирование (подробнее см. https://semver.org/lang/ru/).


Итак, чтобы получить такой номер версии добавляем в пайп в секцию с переменными еще 3.


crmmajor: 9
crmminor: 0
rel: 1

И, между variables и steps добавляем следующую строчку


name: $(crmmajor).$(crmminor).$(rel).$(BuildID) 

Таким образом мы переопределили номер версии пайпа и, соответственно, сборки. Переменная BuildID уникальна в рамках пайплайна и инкрементируется каждый раз когда запускается пайп.


Сохраняем и запускаем пайп. Теперь видим нашу версию в заголовке — Jobs in run #9.0.1.7. Однако, наша новая версия не сохранилась ни в dll-ке с плагинами ни в решении.


Чтобы это исправить необходим PowerShell скрипт который лежит в папке CRMCI/Solution/BuildScripts/update-build-versions.ps1 это доработанный скрипт с сайта документации Microsoft по Azure DevOps Example: Version your assemblies, который меняет версию на текущую версию пайпа во всех dll и XML-файле решения CRM в проекте Solution.


Добавим его запуск перед сборкой проекта.


- task: PowerShell@2
  displayName: 'Update version'
  inputs:
    filePath: '$(Build.SourcesDirectory)\CRMCI\Solution\BuildScripts\update-build-versions.ps1'
    arguments: 'BUILD_BUILDNUMBER $(Build.BuildNumber) BUILD_SOURCESDIRECTORY $(Build.SourcesDirectory)'

Сохраняем и запускаем пайп. Проверяем, что в dll с плагинами, которая сохранилась в артефакты стоит правильная версия.
Теперь добавим сборку солюшена CRM, которая будет состоять из 2 задач, сама сборка и публикация солюшена в хранилище артефактов.


# Сборка решения CRM
- task: CmdLine@2
  displayName: 'Pack CRM Solution'
  inputs:
      script: |
        dir
        @echo off
        set package_root=..\..        REM Find the spkl in the package folder (irrespective of version)
        For /R %package_root% %%G IN (spkl.exe) do (
            IF EXIST "%%G" (set spkl_path=%%G
            goto :continue)
            )
        :continue
        REM spkl instrument [path] [connection-string] [/p:release]
        "%spkl_path%" pack "$(Build.SourcesDirectory)\CRMCI\Solution\spkl.json" ""
        exit /b %ERRORLEVEL%

# Публикация решения CRM
- task: CopyFiles@2
  displayName: 'Copy CRM Solution'
  inputs:
      SourceFolder: '$(Build.SourcesDirectory)\CRMCI\Solution\'
      Contents: 'CICDDemo_*.zip'
      TargetFolder: '$(Build.StagingDirectory)/solutions'
- publish: '$(Build.StagingDirectory)/solutions'
  displayName: 'Publish solution as an artifact'
  artifact: solutions

Сохраняем и запускаем пайп, проверяем, что помимо dll в артефактах еще появилось решение. Версия пайпа на текущий момент в коммите 647dae37.


В моем решении только кастомизации. Если захотите добавить плагины то придется добавить еще одну задачу на копирование dll в папку решения т.к. чтобы плагины добавились в решение необходимо чтобы они были в определенном месте и сам файл был без точек в имени, поэтому пришлось использовать PShell а не CopyFiles, пример ниже.


- task: PowerShell@2
  displayName: 'Copy CRMCI.Plugins.dll в папку решения'
  inputs:
    targetType: 'inline'
    script: 'Copy-Item  -Path $(System.ArtifactsDirectory)\plugins\CRMCI.Plugins.dll  -Destination $(Build.SourcesDirectory)\CRMCI\Solution\package\PluginAssemblies\CRMCIPlugins-766F0C7B-4B44-EB11-A812-0022489AC434\CRMCIPlugins.dll -verbose'
    errorActionPreference: 'silentlyContinue'

Последний параметр errorActionPreference говорит о том, что при ошибке тихо продолжать работу и не останавливать пайп.


Если вы используете TypeScript или webpack или что-то подобное то необходимо добавить еще и сборку скриптов. В случае со spkl их тоже придется подкладывать в рабочую папку. :-(

В итоге мы получили пайп результат работы которого лежит в артефактах в виде dll с плагинами и солюшена CRM которые можно будет использовать для развертывания на среды в рамках CD для чего в AzureDevOps сделаны отдельные пайплайны Release pipelines. О них я расскажу в следующий раз.


Развертывание на локальную среду


Т.к. развертывание на локальную среду подразумевает доступ к локальной CRM то облачный агент нам не подойдет. Нужно настраивать свой. Как это сделать можно почитать тут Self-hosted Windows agents и тут How to create and configure Azure DevOps Pipelines Agent. Перед тем как устанавливать сам агент установите необходимое ПО, минимально это следующее:



В итоге, после установки и настройки агента, мы должны увидеть в настройках что он виден и запущен. У меня он в пуле Default.



Т.к. это отдельный этап и он должен выполняться на другом агенте то разобьем наш пайп на этапы, все что мы уже сделали это будет первый этап а деплой это второй. Для этого мы разобьем наш пайп на этапы (stages), которые в свою очередь состоят из джобов (jobs) которые в свою очередь уже состоят из уже знакомых нам тасков (tasks).


Здесь надо отметить, что для каждой джобы указывается агент (pool), что позволяет выполнять разные джобы на разных агентах специально заточенных под конкретную работу и экономить ресурсы дорогих агентов.

Если в этапе есть несколько джобов то они запустятся одновременно, это где-то полезно а где-то нет. Если нужно последовательное выполнение джобов то либо разнесите их по разным этапам либо проставьте условие dependsOn, подробнее тут Specify conditions.

Также, в пайпе можно перезапускать "упавшие" джобы а не конкретные такси. Учитывайте это при построении пайпа.

Структура следующая:


stages:
- stage: Build
  displayName: 'Build solution'
  jobs:
    - job: Build
      displayName: 'Build job'
      pool: 'Custom'
      steps:
      - task: NuGetToolInstaller@1
        displayName: 'Install NuGet tool'

- stage: Deploy
  displayName: 'Deploy local'
  jobs:
    - job: Deploy
      displayName: 'Deploy job'
      pool: 'Custom'
      steps:
      - task: NuGetToolInstaller@1
        displayName: 'Install NuGet tool'

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


Наш файл будет выглядеть так, коммит 97f91fec


trigger: none

pool:
  vmImage: 'windows-latest'

variables:
  solution: '**/*.sln'
  buildPlatform: 'Any CPU'
  buildConfiguration: 'Debug'
  crmmajor: 9
  crmminor: 0
  rel: 1

name: $(crmmajor).$(crmminor).$(rel).$(BuildID)

stages:
- stage: Build
  displayName: 'Build solution'
  jobs:
    - job: Build
      displayName: 'Build job'
      pool: 'Default'
      steps:
      # Установка NuGet
      - task: NuGetToolInstaller@1
        displayName: 'Install NuGet tool'
      # Восстановление пакетов NuGet для проекта
      - task: NuGetCommand@2
        displayName: 'Restore NuGet packages'
        inputs:
          restoreSolution: '$(solution)'
      # Обновление версии
      - task: PowerShell@2
        displayName: 'Update version'
        inputs:
          filePath: '$(Build.SourcesDirectory)\CRMCI\Solution\BuildScripts\update-build-versions.ps1'
          arguments: 'BUILD_BUILDNUMBER $(Build.BuildNumber) BUILD_SOURCESDIRECTORY $(Build.SourcesDirectory)'
      # Сборка
      - task: VSBuild@1
        displayName: Buld
        inputs:
          solution: '$(solution)'
          platform: '$(buildPlatform)'
          configuration: '$(buildConfiguration)'
      # Прогон юнит-тестов
      - task: VSTest@2
        displayName: 'Run Unit Tests'
        inputs:
          platform: '$(buildPlatform)'
          configuration: '$(buildConfiguration)'
      # Публикация собранной dll-ки в хранилище артефактов
      - task: CopyFiles@2
        displayName: 'Publish plugins dll to artifacts'
        inputs:
          SourceFolder: '$(Build.SourcesDirectory)\CRMCI\Plugins\bin\Debug\'
          Contents: 'CRMCI*.dll'
          targetFolder: '$(Build.StagingDirectory)/plugins'
      - publish: '$(Build.StagingDirectory)/plugins'
        displayName: 'Publish plugins dll as an artifact'
        artifact: plugins
      # Сборка решения CRM
      - task: CmdLine@2
        displayName: 'Pack CRM Solution'
        inputs:
            script: |
              dir
              @echo off
              set package_root=..\..              REM Find the spkl in the package folder (irrespective of version)
              For /R %package_root% %%G IN (spkl.exe) do (
                  IF EXIST "%%G" (set spkl_path=%%G
                  goto :continue)
                  )
              :continue
              REM spkl instrument [path] [connection-string] [/p:release]
              "%spkl_path%" pack "$(Build.SourcesDirectory)\CRMCI\Solution\spkl.json" ""
              exit /b %ERRORLEVEL%
      # Публикация решения CRM
      - task: CopyFiles@2
        displayName: 'Copy CRM Solution'
        inputs:
            SourceFolder: '$(Build.SourcesDirectory)\CRMCI\Solution\'
            Contents: 'CICDDemo_*.zip'
            TargetFolder: '$(Build.StagingDirectory)/solutions'
      - publish: '$(Build.StagingDirectory)/solutions'
        displayName: 'Publish solution as an artifact'
        artifact: solutions

- stage: Deploy
  displayName: 'Deploy local'
  jobs:
    - job: Deploy
      displayName: 'Deploy job'
      pool: 'Default'
      steps:
      - task: NuGetToolInstaller@1
        displayName: 'Install NuGet tool'

На странице пайплайна мы теперь видим два этапа.



Далее разберем таски второго этапа.
Первые две это восстановление NuGet пакетов чтобы у нас был доступен spkl.


Здесь, конечно, хорошо бы оптимизировать и закачивать только spkl т.к. остальное нам не нужно.

  # Установка NuGet
  - task: NuGetToolInstaller@1
    displayName: 'Install NuGet tool'
  # Восстановление пакетов NuGet для проекта
  - task: NuGetCommand@2
    displayName: 'Restore NuGet packages'
    inputs:
      restoreSolution: '$(solution)'

Дальше начинаются костыли. :-) Т.к. я не нашел в spkl возможность импорта решения отдельно, там есть только совмещенный шаг сборки решения и его импорта import, который и придется использовать. Поэтому придется еще раз сделать обновление версии.


  # Обновление версии
  - task: PowerShell@2
    displayName: 'Update version'
    inputs:
      filePath: '$(Build.SourcesDirectory)\CRMCI\Solution\BuildScripts\update-build-versions.ps1'
      arguments: 'BUILD_BUILDNUMBER $(Build.BuildNumber) BUILD_SOURCESDIRECTORY $(Build.SourcesDirectory)'

Теперь собираем и импортируем решение. Для этого нам понадобится строка подключения к CRM, которую мы сохраним в переменные пайпа под именем connstr-local. Кнопку добавления переменных можно найти на странице редактирования пайпа.



  # Импорт решения CRM
  - task: CmdLine@2
    displayName: 'Deploy solution'
    inputs:
      script: |
        @echo off
        set package_root=..\..        For /R %package_root% %%G IN (spkl.exe) do (
          IF EXIST "%%G" (set spkl_path=%%G
          goto :continue)
          )

        :continue
        @echo Using '%spkl_path%' 
        "%spkl_path%" import "$(Build.SourcesDirectory)\CRMCI\Solution\spkl.json" "$(connstr-local)"

        if errorlevel 1 (
        echo Error Code=%errorlevel%
        exit /b %errorlevel%
        )

По хорошему, надо скачивать решение собранное на предыдущем этапе и импортировать его, это можно сделать с помощью PowerShell или написать консольку.

Дальше мы скачиваем собранную dll-ку с плагинами и подкладываем туда где она должна оказаться если бы мы собирали решение т.к. я не нашел способа явно параметром указать откуда брать dll при импорте плагинов. Опять костыль. :-)


  # Скачивание плагинов
  - task: DownloadPipelineArtifact@2
    displayName: 'Download plugins artifacts'
    inputs:
      buildType: 'specific'
      project: 'b44552ad-1d85-4c3b-834b-5109b4c9c2b4'
      definition: '1'
      buildVersionToDownload: 'latest'
      artifactName: 'plugins'
      targetPath: '$(System.ArtifactsDirectory)/plugins'
  # Копируем dll
  - task: CopyFiles@2
    displayName: 'Copy plugins dll'
    inputs:
      SourceFolder: '$(System.ArtifactsDirectory)\plugins'
      Contents: '*.dll'
      TargetFolder: '$(Build.SourcesDirectory)\CRMCI\Plugins\bin\Debug\'
      OverWrite: true

Следующий шаг это импорт плагинов, в том числе и шагов.


  # Импорт плагинов
  - task: CmdLine@2
    displayName: 'Deploy plugins'
    inputs:
      script: |
        @echo off
        set package_root=..\..        For /R %package_root% %%G IN (spkl.exe) do (
          IF EXIST "%%G" (set spkl_path=%%G
          goto :continue)
          )

        :continue
        @echo Using '%spkl_path%' 
        "%spkl_path%" plugins "$(Build.SourcesDirectory)\CRMCI\Plugins\spkl.json" "$(connstr-local)"

        if errorlevel 1 (
        echo Error Code=%errorlevel%
        exit /b %errorlevel%
        )

Ну и последним шагом будет импорт веб-ресурсов. Тут spkl просто берет файлы из проекта и публикует их согласно маппингу в spkl.json.


  # Импорт веб-ресурсов
  - task: CmdLine@2
    displayName: 'Deploy web-resources'
    inputs:
      script: |
        @echo off
        set package_root=..\..        For /R %package_root% %%G IN (spkl.exe) do (
          IF EXIST "%%G" (set spkl_path=%%G
          goto :continue)
          )

        :continue
        @echo Using '%spkl_path%' 
        "%spkl_path%" webresources "$(Build.SourcesDirectory)\CRMCI\\WebResources\spkl.json" "$(connstr-local)"

        if errorlevel 1 (
        echo Error Code=%errorlevel%
        exit /b %errorlevel%
        )

Вот, собственно, и все. Теперь можно вернуть триггер на срабатывание по коммиту в ветке main.


trigger:
- main

Но это не совсем то, что нам нужно т.к. пайп будет вызываться на изменение любого файла, в том числе, самого файла azure-pipelines.yml что не всегда удобно. Поэтому, в секцию trigger можно добавить исключения по ветке, тегу или по маске. Я добавил следующее.


trigger:
  branches:
    include:
    - main
  paths:
    include:
    - '*'
    exclude:
    - 'azure-pipelines.yml'
    - '*.md'

Также если в комментарии к коммиту присутствует одно из следующих словосочетаний то он не запустится:


  • [skip ci] or [ci skip]
  • skip-checks: true or skip-checks:true
  • [skip azurepipelines] or [azurepipelines skip]
  • [skip azpipelines] or [azpipelines skip]
  • [skip azp] or [azp skip]

Подробнее смотрите здесь CI triggers


Первая часть закончена, CI построен и даже немного больше. Тут, есть конечно, куда развиваться, например, добавить статический анализ кода (кому интересно см. лабу Подключение SonarCloud к AzureDevops) или автотесты.


В следующей статье расскажу про релизные пайплайны и CD.

a2-ia.png)