Введение

В настоящее время существует множество систем 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]. Ниже я приведу пример использования.

Написание кода сборщика

В моем случае сборка и публикация проекта состоит нескольких этапов

  1. Восстановление Nuget-пакетов

  2. Сборка проекта

  3. Публикация приложения

Перед непосредственным запуском настраиваю конфигурацию в зависимости от окружения и других параметров сборки, которые передаются через аргументы командной строки

[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 .и показать использование на реальном примере.