Introduction
There are many CI / CD systems currently in use. Everyone has certain advantages and disadvantages, and everyone chooses the most suitable for the project. The purpose of this article is to acquaint you with Nuke on the example of a web project using the retiring .NET Framework with a view to further updating to .NET 5. The project already uses the Fake collector, but there was a need to update and refine it, which ultimately led to the transition on Nuke.
Initial data
A web project written in C # based on the .NET Framework 4.8, Razor Pages + TypeScript frontend scripts compiled to JS files.
Build and publish your application using Fake 4 .
Hosting on AWS (Amazon Web Services)
Setting: Production, Staging, Demo
goal
It is necessary to update the build system, while providing extensibility and flexible customization. You also need to ensure that the configuration in the Web.config file is configured for the specified environment.
I considered different options for build systems and in the end the choice fell on Nuke , since it is quite simple and in fact is a console application extensible through packages. In addition, Nuke is quite dynamic and well documented . A plus is the presence of a plugin for the IDE (development environment - Rider). I refused to switch to Fake 5 because of the desire to ensure the linguistic consistency of the project and to lower the entry threshold for new developers. Also, scripts are harder to debug. Cake , Psake also dropped it due to its "scripting".
Preparation
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 .
The code can be improved by breaking some stages into separate Targets, reducing the length of the code in methods by adding the ability to disable individual stages. But the purpose of the article is to introduce the Nuke collector and show the usage with a real example.