[Программирование, .NET, Amazon Web Services, C#, DevOps] Nuke: настраиваем сборку и публикацию .NET-проекта

Автор Сообщение
news_bot ®

Стаж: 6 лет 3 месяца
Сообщений: 27286

Создавать темы news_bot ® написал(а)
15-Янв-2021 14:32

ВведениеВ настоящее время существует множество систем CI/CD. У всех есть определенные достоинства и недостатки и каждый выбирает себе наиболее подходящую под проект. Цель данной статьи - познакомить с Nuke на примере web-проекта, использующего уходящий на покой .NET-Framework с прицелом дальнейшего обновления до .NET 5. В проекте уже используется сборщик Fake, но возникла необходимость его обновления и доработки, что в итоге привело переходу на Nuke.Исходные данные
  • Web-проект, написанный на C#, в основе которого лежит .NET-Framework 4.8, Razor Pages + frontend скрипты на TypeScript, компилирующиеся в JS-файлы.
  • Сборка и публикация приложения с помощью Fake 4.
  • Хостинг на AWS (Amazon Web Services)
  • Окружения: Production, Staging, Demo
ЦельНеобходимо обновить систему сборки, обеспечивая при этом расширяемость и гибкую настройку. Также нужно обеспечить настройку конфигурации в файле Web.config под заданное окружение.
Я рассматривал разные варианты систем сборки и в итоге выбор пал на Nuke, так как он довольно простой и по сути представляет собой консольное приложение расширяемое за счёт пакетов. Кроме того, Nuke довольно динамично развивается и хорошо документирован. Плюсом идёт наличие плагина к IDE (среда разработки - Rider). Я отказался перехода на Fake 5 из-за стремления обеспечить языковое единство проекта и снизить порог входа, вновь пришедшим разработчикам. Кроме того, скрипты сложнее отлаживать. Cake, Psake также отбросил из-за "скриптовости".ПодготовкаNuke имеет dotnet tool, с помощью которого добавляется build-проект. Для начала установим его.
$ dotnet tool install Nuke.GlobalTool --global
Первоначальная настройка осуществляется командой nuke :setup, которая запускает текстовый wizard с вопросами названия проекта, расположения исходных файлов, каталога для артефактов и прочее.
В результате добавился проект _build
В каталоге boot лежат shell-скрипты для запуска сборщика.
Класс Build содержит основной код сборщика. Схема работы классическая - запускается цепочка взаимозависимых Target-ов. Вся информация выводится о процессе сборки выводится к консоль с помощью методов класса Logger. Например:
Logger.Info($"Starting build for {ApplicationForBuild} using {BuildEnvironment} environment");

Существует возможность передавать опции сборки через аргументы командной стройки. Для этого к полю класса Build применяется аттрибут [Parameter]. Ниже я приведу пример использования.Написание кода сборщикаВ моем случае сборка и публикация проекта состоит нескольких этапов
  • Восстановление Nuget-пакетов
  • Сборка проекта
  • Публикация приложения
Перед непосредственным запуском настраиваю конфигурацию в зависимости от окружения и других параметров сборки, которые передаются через аргументы командной строки
[Parameter("Configuration to build - Default is 'Release'")]
readonly Configuration Configuration = Configuration.Release;
[Parameter(Name="application")]
readonly string ApplicationForBuild;
[Parameter(Name="environment")]
public readonly string BuildEnvironment;
Конфигуратор создается перед запуском сборки. Для этого я переопределяю метод базового класса OnBuildInitialized, который вызывается после того как, приведённые выше, параметры проинициализированы. Существует ещё несколько виртуальных методов в классе NukeBuild с префиксом On, вызываемые после определенных событий (например, старт/окончание сборки).Код
protected override void OnBuildInitialized()
{
  ConfigurationProvider = new ConfigurationProvider(ApplicationForBuild, BuildEnvironment, RootDirectory);
  string configFilePath = $"./appsettings.json";
  if (!File.Exists(configFilePath))
  {
  throw new FileNotFoundException($"Configuration file {configFilePath} is not found");
  }
  string configFileContent = File.ReadAllText(configFilePath);
  if (string.IsNullOrEmpty(configFileContent))
  {
  throw new ArgumentNullException($"Config file {configFilePath} content is empty");
  }
  /* Настойка конфигурации typescript */
  ToolsConfiguration = JsonConvert.DeserializeObject<ToolsConfiguration>(configFileContent);
  if (ToolsConfiguration == null || string.IsNullOrEmpty(ToolsConfiguration.TypeScriptCompilerFolder))
  {
  throw new ArgumentNullException($"Typescript compiler path is not defined");
  }
  base.OnBuildInitialized();
}
Код конфигурации
public class ApplicationConfig
{
  public string ApplicationName { get; set; }
  public string DeploymentGroup { get; set; }
  /* Опции для замены в файле Web.config */
  public Dictionary<string, string> WebConfigReplacingParams { get; set; }
  public ApplicationPathsConfig Paths { get; set; }
}
Непосредственно конфигуратор
public class ConfigurationProvider
{
  readonly string Name;
  readonly string DeployEnvironment;
  readonly AbsolutePath RootDirectory;
  ApplicationConfig CurrentConfig;
  public ConfigurationProvider(string name,
                               string deployEnvironment,
                               AbsolutePath rootDirectory)
  {
    RootDirectory = rootDirectory;
    DeployEnvironment = deployEnvironment;
    Name = name;
  }
  public ApplicationConfig GetConfigForApplication()
  {
    if (CurrentConfig != null) return CurrentConfig;
    string configFilePath = $"./BuildConfigs/{Name}/{DeployEnvironment}.json";
    if (!File.Exists(configFilePath))
    {
    throw new FileNotFoundException($"Configuration file {configFilePath} is not found");
    }
    string configFileContent = File.ReadAllText(configFilePath);
    if (string.IsNullOrEmpty(configFileContent))
    {
    throw new ArgumentNullException($"Config file {configFilePath} content is empty");
    }
    CurrentConfig = JsonConvert.DeserializeObject<ApplicationConfig>(configFileContent);
    CurrentConfig.Paths = new ApplicationPathsConfig(RootDirectory, Name, CurrentConfig.ApplicationName);
    return CurrentConfig;
  }
}
Восстановление Nuget-пакетовЭтап очистки артефактов (Clean) я опускаю, так как он достаточно банален и сводится к удалению файлов. Процесс восстановления пакетов также прост: указываем пути к файлу конфигурации, к исполняемому файлу, рабочий каталог (RootDirectory) и папку для пакетов:Код
Target Restore => _ => _
      .DependsOn(Clean)
      .Executes(() =>
      {
        NuGetTasks.NuGetRestore(config =>
        {
          config = config
            .SetProcessToolPath(RootDirectory / ".nuget" / "NuGet.exe")
            .SetConfigFile(RootDirectory / ".nuget" / "NuGet.config")
            .SetProcessWorkingDirectory(RootDirectory)
            .SetOutputDirectory(RootDirectory / "packages");
          return config;
        });
      });
Сборка проектаКод собирается в два шага. Сначала компилируется .NET-проект, далее TypeScript-файлы компилируются в JavaScript-код.Код
Target Compile => _ => _
  .DependsOn(Restore)
  .Executes(() =>
  {
    AbsolutePath projectFile = ApplicationConfig.Paths.ProjectDirectory.GlobFiles("*.csproj").FirstOrDefault();
    if (projectFile == null)
    {
      throw new ArgumentNullException($"Cannot found any projects in {ApplicationConfig.Paths.ProjectDirectory}");
    }
    MSBuild(config =>
    {
      config = config
      .SetOutDir(ApplicationConfig.Paths.BinDirectory)
      .SetConfiguration(Configuration) //указываем режим сборки: Debug/Release
      .SetProperty("WebProjectOutputDir", ApplicationConfig.Paths.ApplicationOutputDirectory)
      .SetProjectFile(projectFile)
      .DisableRestore(); //так как мы восстановили пакеты на предыдущем этапе, то отключаем восстановление на этапе сборки
      return config;
    });
    /* Запускаем tsc как отдельный процесс. Копируем файлы в каталог для публикации */
    IProcess typeScriptProcess = ProcessTasks.StartProcess(@"node",$@"tsc -p {ApplicationConfig.Paths.ProjectDirectory}", ToolsConfiguration.TypeScriptCompilerFolder);
    if (!typeScriptProcess.WaitForExit())
    {
      Logger.Error("Typescript build is failed");
      throw new Exception("Typescript build is failed");
    }
    CopyDirectoryRecursively(ApplicationConfig.Paths.TypeScriptsSourceDirectory, ApplicationConfig.Paths.TypeScriptsOutDirectory, DirectoryExistsPolicy.Merge, FileExistsPolicy.Overwrite);
  });
Публикация приложенияПроводится также в несколько этапов: подготовка артефактов и собственно публикация. Сначала идёт трансформация конфигурации в файле Web.config под соответствующее окружение. Она заключается в замене значений определенных опций. Необходимые значения считываются из json-файла конфигурации окружения.Все файлы архивируются и отправляются через CodeDeploy на сервер. Для работы с AWS я подключил NuGet-пакеты AWSSDK: AWSSDK.Core, AWSSDK.S3, AWSSDK.CodeDeploy. Я написал обертки над вызовами AWS CodeDeploy. Они особого интереса не предоставляют и служат скорее цели сокращения объема кода в классе Build.Код
Target Publish => _ => _
    .DependsOn(Compile)
    .Executes(async () =>
        {
          PrepareApplicationForPublishing();
          await PublishApplicationToAws();
        });
void PrepareWebConfig(Dictionary<string, string> replaceParams)
{
  if (replaceParams?.Any() != true) return;
  Logger.Info($"Setup Web.config for environment {BuildEnvironment}");
  AbsolutePath webConfigPath = ApplicationConfig.Paths.ApplicationOutputDirectory / "Web.config";
  if (!FileExists(webConfigPath))
  {
    Logger.Error($"{webConfigPath} is not found");
    throw new FileNotFoundException($"{webConfigPath} is not found");
  }
  XmlDocument webConfig = new XmlDocument();
  webConfig.Load(webConfigPath);
  XmlNode settings = webConfig.SelectSingleNode("configuration/appSettings");
  if (settings == null)
  {
    Logger.Error("Node configuration/appSettings in the config is not found");
    throw new ArgumentNullException(nameof(settings),"Node configuration/appSettings in the config is not found");
  }
  foreach (var newParam in replaceParams)
  {
    XmlNode nodeForChange = settings.SelectSingleNode($"add[@key='{newParam.Key}']");
    ((XmlElement) nodeForChange)?.SetAttribute("value", newParam.Value);
  }
  webConfig.Save(webConfigPath);
}
void PrepareApplicationForPublishing()
{
  AbsolutePath specFilePath = ApplicationConfig.Paths.PublishDirectory / AppSpecFile;
  AbsolutePath specFileTemplate = ApplicationConfig.Paths.BuildToolsDirectory / AppSpecTemplateFile;
  PrepareWebConfig(ApplicationConfig.WebConfigReplacingParams);
  DeleteFile(ApplicationConfig.Paths.ApplicationOutputDirectory);
  CopyDirectoryRecursively(ApplicationConfig.Paths.ApplicationOutputDirectory, ApplicationConfig.Paths.PublishDirectory / DeployAppDirectory,
  DirectoryExistsPolicy.Merge, FileExistsPolicy.Overwrite);
  CopyDirectoryRecursively(ApplicationConfig.Paths.BuildToolsDirectory / DeployScriptsDirectory, ApplicationConfig.Paths.TypeScriptsOutDirectory,
  DirectoryExistsPolicy.Merge, FileExistsPolicy.Overwrite);
  CopyFile(ApplicationConfig.Paths.BuildToolsDirectory / AppSpecTemplateFile, ApplicationConfig.Paths.PublishDirectory / AppSpecFile, FileExistsPolicy.Overwrite);
  CopyDirectoryRecursively(ApplicationConfig.Paths.BuildToolsDirectory / DeployScriptsDirectory, ApplicationConfig.Paths.PublishDirectory / DeployScriptsDirectory,
  DirectoryExistsPolicy.Merge, FileExistsPolicy.Overwrite);
  Logger.Info($"Creating archive '{ApplicationConfig.Paths.ArchiveFilePath}'");
  CompressionTasks.CompressZip(ApplicationConfig.Paths.PublishDirectory, ApplicationConfig.Paths.ArchiveFilePath);
}
async Task PublishApplicationToAws()
{
  string s3bucketName = "";
  IAwsCredentialsProvider awsCredentialsProvider = new AwsCredentialsProvider(null, null, "");
  using S3FileManager fileManager = new S3FileManager(awsCredentialsProvider, RegionEndpoint.EUWest1);
  using CodeDeployManager codeDeployManager = new CodeDeployManager(awsCredentialsProvider, RegionEndpoint.EUWest1);
  Logger.Info($"AWS S3: upload artifacts to '{s3bucketName}'");
  FileMetadata metadata = await fileManager.UploadZipFileToBucket(ApplicationConfig.Paths.ArchiveFilePath, s3bucketName);
  Logger.Info(
  $"AWS CodeDeploy: create deploy for '{ApplicationConfig.ApplicationName}' in group '{ApplicationConfig.DeploymentGroup}' with config '{DeploymentConfig}'");
  CodeDeployResult deployResult =
  await codeDeployManager.CreateDeployForRevision(ApplicationConfig.ApplicationName, metadata, ApplicationConfig.DeploymentGroup, DeploymentConfig);
  StringBuilder resultBuilder = new StringBuilder(deployResult.Success ? "started successfully\n" : "not started\n");
  resultBuilder = ProcessDeloymentResult(deployResult, resultBuilder);
  Logger.Info($"AWS CodeDeploy: deployment has been {resultBuilder}");
  DeleteFile(ApplicationConfig.Paths.ArchiveFilePath);
  Directory.Delete(ApplicationConfig.Paths.ApplicationOutputDirectory, true);
  string deploymentId = deployResult.DeploymentId;
  DateTime startTime = DateTime.UtcNow;
  /* Ожидаем когда деплой завершится и выводим сообщение */
  do
  {
    if(DateTime.UtcNow - startTime > TimeSpan.FromMinutes(30)) break;
    Thread.Sleep(3000);
    deployResult = await codeDeployManager.GetDeploy(deploymentId);
    Logger.Info($"Deployment proceed: {deployResult.DeploymentInfo.Status}");
  }
  while (deployResult.DeploymentInfo.Status == DeploymentStatus.InProgress
        || deployResult?.DeploymentInfo.Status == DeploymentStatus.Created
        || deployResult?.DeploymentInfo.Status == DeploymentStatus.Queued);
  Logger.Info($"AWS CodeDeploy: deployment has been done");
}
ЗаключениеВ итоге получился сборщик, который можно расширять за счёт собственного кода или сторонних пакетов. Конфигурация окружений вынесена во внешние файлы, таким образом легко можно добавить новое. При этом аргументы командной строки позволяют легко перенацелить приложение на определенное окружение. При большом желании можно построить свой build сервер.
Код можно улучшить, разбив некоторые этапы на отдельные Target-ы, сократить длину кода в методах, добавив возможность отключения отдельных этапов. Но цель статьи - познакомить со сборщиком Nuke .и показать использование на реальном примере.
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_programmirovanie (Программирование), #_.net, #_amazon_web_services, #_c#, #_devops, #_.net, #_build, #_deploy, #_c#, #_aws, #_programmirovanie (
Программирование
)
, #_.net, #_amazon_web_services, #_c#, #_devops
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 20-Май 12:21
Часовой пояс: UTC + 5