Cake Frosting: More Maintainable C# DevOps
This week Cake V1.0 was released and with it: an upgrade to Frosting, a more robust technique for writing DevOps in a language the whole team can understand. The timing is fantastic, because I recently posted The One Thing I Wish I'd Known Before Using Cake, in which I warned of uncontrolled growth of the more traditional build.cake file. The solution I offered there still stands, however Cake Frosting offers an even better solution for modularity, encapsulation and maintainability. And the tooling is better too, if you're ready to accept some tradeoffs.
What Is Frosting?
Cake Frosting is one of four Cake "Runners", which are ways of running Cake and gaining access to its vast plugin ecosystem and its "Dependency Management" pattern whereby you define tasks and their dependencies rather than running C# code linearly. The four options are:
- .NET Tool
- .NET Framework
- .NET Core, and
- Frosting
The first three runners require building in a DSL, a domain specific language, created by the Cake team to remove unnecessary C# chrome and make your C# DevOps code concise and pretty.
That last runner allows you to abandon the traditional Cake DSL and instead write DevOps inside a .NET Console App with the addition of a Cake context and some custom attributes.
Essentially, instead of writing a DSL like this:
projDir = Directory("./src");
Task("Build")
.IsDependentOn("Clean")
.Does(() =>
{
DotNetCoreBuild(projDir)
});
With Frosting you write it like this:
// Cake DSL aliases like DotNetCoreBuild are extension methods off ICakeContext
using Cake.Common.Tools.DotNetCore;
[TaskName("Build")]
[IsDependentOn(typeof(CleanTask))]
public sealed class BuildTask : AsyncFrostingTask<BuildContext> {
public override Task RunAsync(BuildContext context) {
var projDir = context.Directory("./src");
context.DotNetCoreBuild(projDir);
return Task.FromResult(true);
}
}
Granted I threw in some unnecessary asynchronicity to show its support, but that still looks far more verbose by comparison. That's the big downside.
However, in exchange for that verbosity you get C# classes with all of the modularity, encapsulation, and maintainability that infers. Furthermore, it's strongly typed, so unlike with the DSL if you mistype a variable, method, or alias, the tooling reliably tells you before you compile, let alone before you run. And Intellisense works reliably. Even better, you get refactorings and static analysis and maybe ReSharper if you're into that kind of thing.
Lastly: check out line 5 where it's got strongly typed dependency management. So not only will the Cake runner ensure when you run BuildTask, that CleanTask and any of its dependencies are run (and only run once), but it will give a compiler error if CleanTask is renamed.
Getting Started
The Frosting setup documentation is fairly straight forward, but here's a quick recap:
Prerequisites:
- .Net Core 3.1.301 or newer
- Mac, Windows, or Linux
- Install Cake.Frosting.Template, which is a .NET Tool template pack that allows you to quickly create new Frosting projects. This is global, you only need to do it once.
dotnet new --install Cake.Frosting.Template
2. Go to an empty folder and create a project based on the Frosting template from above.
dotnet new cakefrosting
3. Run it with either the PowerShell or Bash bootstrapper.
.\build.ps1 --target=Default
./build.sh --target=Default
That's it, Cake just executed a "Default" task, it's dependent "World" task and it's dependent "Hello" task.
If you investigate the generated files you'll see the two bootstrappers, and a /build folder with a .csproj and a Program.cs file.
The Program.cs is the equivalent of Cake's traditional build.cake file, except with extra code for setting up a BuildContext object.
Adjusting To Frosting
If you're coming from the Cake DSL world then you'll need to be prepared to make some adjustments in how you code.
Aliases
All aliases in the DSL world are actually extension methods off of the context object. Thus in Frosting you'll need to know the namespace and have access to a context object.
For instance, if you're trying to access the CleanDirectories command like CleanDirectories("./**/bin")
to remove everything in all bin directories recursively, then you'll instead need to:
- Import the Cake.Common.IO namespace, as found on the documentation page
- Invoke the command off of the ICakeContext object (e.g.
context.CleanDirectories("./**/bin");
)
Directories / Files
If you're used to the convenience of Cake's ConvertableDirectories and ConvertableFiles where you can just concatenate Files and Directories together like:
var projectDir = Directory("./project/");
var subProjectDir = projectDir + Directory("subproject");
var subProjectCsProj = subProjectDir + File("subproj.csproj")
Then sadly you'll finding Frosting much more verbose. It requires saving those objects in the constructor of the Context object.
public class BuildContext : FrostingContext
{
public ConvertableDirectoryPath ProjDir { get; set; }
public ConvertableDirectoryPath SubProjDir { get; set; }
public ConvertableFilePath SubProjCsProj { get; set; }
public BuildContext(ICakeContext context)
: base(context)
{
ProjDir = context.Directory("./project");
SubProjDir = ProjDir + context.Directory("subproj");
SubProjCsProj = SubProjDir + context.File("subproject.csproj");
}
}
The convenience may or may not be worth the inconvenience.
Plugins and Tools
Fortunately some things are nearly as simple. With the DSL accessing plugins was a directive at the top of the file:
#addin "nuget:?package=Cake.AzureCli&version=1.2.0"
(shh, that's my open source AzureCli Cake plugin I'm quietly plugging)
The Frosting alternative is to just reference the project via NuGet.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.1</TargetFramework>
<RunWorkingDirectory>$(MSBuildProjectDirectory)</RunWorkingDirectory>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Cake.AzureCli" Version="1.2.0" />
<PackageReference Include="Cake.Frosting" Version="1.0.0" />
</ItemGroup>
</Project>
And tools are only slightly more complicated, requiring a call to InstallTool in Main():
public static class Program
{
public static int Main(string[] args)
{
return new CakeHost()
.UseContext<BuildContext>()
.InstallTool(new Uri("nuget:?package=ReportGenerator&version=4.8.1"))
.Run(args);
}
}
Summary
I'm a big fan of Frosting, however the tradeoff should be clear by now. Code is more verbose and many conveniences like aliases and directories are less convenient. But the strongly typed, maintainable code you'll get back is probably worth it, especially on larger projects. And more importantly two years into your project you definitely won't accidentally end up with a 2000 line build.cake file.
And that, my friends, is golden.