Create your own custom extension for Azure Bicep

A long-lived dream of many Azure Bicep users has been the ability to deploy outside the Azure environment, similar to what you can do with Terraform. With the experimental Bicep local-deploy feature that dream is becoming a reality! A tool that allows you to connect Azure Bicep to any system you want.

In this blog, you will learn about what Bicep local-deploy is and how you can create your own extension for Azure Bicep.

Note! This is currently an experimental feature. Expect breaking changes!

Bicep local-deploy is a feature that allows you to deploy a Bicep template locally instead of through the Azure Resource Manager. It uses the familiar Bicep authoring experience, but rather than deploying Azure resources, it deploys extensions created for Bicep that can run locally. As the name local-deploy suggests, the template is executed on your local machine and does not require a connection to Azure.

High-level overview of Bicep template orchestration

Local-deploy allows users to extend Bicep beyond Azure by connecting it to services such as Azure DevOps, GitHub, or any other service that supports a REST API. An extension can also be a small program, for example a username and password generator. There are no limitations with local-deploy. Extensions can be created by the community or by yourself.

Deployment works a bit differently than you might be used to. There’s a new targetScope set to local instead of resourceGroup or subscription. In addition, a new command has been introduced: bicep local-deploy <path to your bicepparam file>. This command initiates the orchestration of your Bicep template containing the extensions. It’s part of the bicep command set, so it’s also available for build agents.

One limitation is that you can’t combine targetScope values, meaning you can’t reference an extension in a file that has targetScope = 'resourceGroup'. This would be an awesome feature to have in the future, but for now, if you want to combine these scopes, the best approach is to use Azure Pipeline (or GitHub Action) outputs and use these outputs in another Bicep deployment. I have written a blog post explaining how to do this: Chaining Bicep Deployments using Outputs and Stage Dependencies in Azure Pipelines

Once you understand the structure of the project, it’s really easy to create your own extension. In this blog, we will be using .NET and C# as the programming language. In this section, we will create a SayHello Bicep extension that outputs Hello, <name>. This example will show you how each component in the framework works together.

Below, you will learn about the following components:

  • Model
  • Resource handler
  • Handler registration
  • Project information

A model represents a Bicep resource type. In the framework, a model is a class that defines the properties which can be set in the Bicep template. It’s important to decorate the class with the [ResourceType(name here)] attribute. The name specified in the attribute will be used in the Bicep template as the resource provider.

For better project organisation, I would recommend creating a folder called src/Models and save the SayHello.cs file here.

For the SayHello extension, we have created the following class in the file SayHello.cs:

using Azure.Bicep.Types.Concrete;
using Bicep.Local.Extension.Types.Attributes;
namespace SayHelloExtension.Models;
public class SayHelloToIdentifiers
{
[TypeProperty(
description: "Name of the person to say hello to.",
flags: ObjectTypePropertyFlags.Identifier | ObjectTypePropertyFlags.Required)]
public required string Name { get; set; }
}
[ResourceType("SayHelloTo")]
public class SayHelloTo : SayHelloToIdentifiers
{
[TypeProperty("The text output 'Say Hello' message.", ObjectTypePropertyFlags.ReadOnly)]
public string? OutputMessage { get; set; }
}
view raw SayHello.cs hosted with ❤ by GitHub

There are two classes created:

  • SayHelloToIdentifiers: this class is used to uniquely identify a resource instance, which will be referenced in the resource handler.
  • SayHelloTo: this class defines the resource properties. It inherits from the identifiers class and includes the OutputMessage property, which is decorated with the ObjectTypePropertyFlags.ReadOnly flag to mark it as read-only. In addition, the class includes the ResourceType attribute with the name, which marks it to be used under this name in the Bicep template.

The TypeProperty(<text>) attribute adds a description to the hover-over pop-up in the Bicep template, providing context about the property.

Here is a sneak peek of how the resource definition and output looks in Bicep:

resource greeting 'SayHelloTo' = {
name: 'John'
}
output message string = greeting.outputMessage // -> "Hello, John"
view raw sneakpeek.bicep hosted with ❤ by GitHub

The resource handler manages the execution logic of the custom Bicep resource type. When Bicep evaluates the resource, the handler reads the input identifier. In the case of the SayHello extension, this property is Name and it computes the outputMessage as Hello, {Name}.

Essentially, the handler is the “brain” that turns the resource input into an action (REST calls, etc.) and sets the outputs, while the model defines the structure Bicep works with.

For better project organisation, I would recommend creating a folder called src/Handler and save the SayHelloHandler.cs file here.

The handler inherits several methods:

  • CreateOrUpdate(): this method performs the actual operation and sets the OutputMessage.
  • Preview(): this method simulates the result without performing the actual operation (dry run/what-if). It should compute and return what CreateOrUpdate would output, but without making any changes or causing side effects. (This is not supported yet.)
  • Get(): this method retrieves the current state of an object (for example, an existing Azure DevOps project). This method is not implemented in the SayHello extension example below.
  • Delete(): this method deletes a resource. This method is also not implemented in the SayHello extension example below.

    If you connect the resource handler to an external service (for example, Azure DevOps), the handler is responsible for making the REST API calls to that service.

The SayHelloHandler is implemented as follows:

using Bicep.Local.Extension.Host.Handlers;
using SayHelloExtension.Models;
namespace SayHelloExtension.Handlers;
public class SayHelloHandler : TypedResourceHandler<SayHelloTo, SayHelloToIdentifiers>
{
protected override async Task<ResourceResponse> Preview(ResourceRequest request, CancellationToken cancellationToken)
{
await Task.CompletedTask;
// For preview, compute what the output would be without side effects.
request.Properties.OutputMessage = $"Hello, {request.Properties.Name}";
return GetResponse(request);
}
protected override async Task<ResourceResponse> CreateOrUpdate(ResourceRequest request, CancellationToken cancellationToken)
{
await Task.CompletedTask;
request.Properties.OutputMessage = $"Hello, {request.Properties.Name}";
return GetResponse(request);
}
protected override SayHelloToIdentifiers GetIdentifiers(SayHelloTo properties)
=> new()
{
Name = properties.Name,
};
}

Before the handler can be used, it must be registered as a service in the Program.cs file. Use the setup shown below. If you want to add more handlers to the service, you can do so by chaining additional calls: .WithResourceHandler<HandlerName>().

using Microsoft.AspNetCore.Builder;
using Bicep.Local.Extension.Host.Extensions;
using Microsoft.Extensions.DependencyInjection;
var builder = WebApplication.CreateBuilder();
builder.AddBicepExtensionHost(args);
builder.Services
.AddBicepExtension(
name: "SayHelloExtension",
version: "0.0.1",
isSingleton: true,
typeAssembly: typeof(Program).Assembly)
.WithResourceHandler<SayHelloHandler>();
var app = builder.Build();
app.MapBicepExtension();
await app.RunAsync();
view raw Program.cs hosted with ❤ by GitHub

Add a project file named src/SayHelloExtension.csproj. This file contains information about the packages and how the project will be built. The RootNamespace, AssemblyName, and the version of the Azure.Bicep.Local.Extension package can be adjusted to fit your needs. The AssemblyName will be used when building the project in the next step of this blog.

The contents of the csproj for the SayHello extension:

<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>SayHelloExtension</RootNamespace>
<AssemblyName>bicep-ext-say-hello</AssemblyName>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<InvariantGlobalization>true</InvariantGlobalization>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Bicep.Local.Extension" Version="0.37.4" />
</ItemGroup>
</Project>

You now have an extension based on source code, but before you can use it, it needs to be compiled into a binary so it can be distributed and used by Bicep.

Below is a PowerShell script that handles the compilation and publishing for you. First, the source code is compiled into binaries targeting different runtimes for each platform. Finally, the command bicep publish-extension is executed to prepare the binary for local-deploy and publish it to an Azure Container Registry. If you don’t want to publish to a container registry, you can set the target to a local folder instead. Place this script in the root of the project.

[cmdletbinding()]
param(
[Parameter(Mandatory=$true)][string]$Target
)
$ErrorActionPreference = "Stop"
function ExecSafe([scriptblock] $ScriptBlock) {
& $ScriptBlock
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
}
$root = "$PSScriptRoot/src/"
$extName = "bicep-ext-say-hello"
# build various flavors
ExecSafe { dotnet publish --configuration Release $root -r osx-arm64 }
ExecSafe { dotnet publish --configuration Release $root -r linux-x64 }
ExecSafe { dotnet publish --configuration Release $root -r win-x64 }
# publish to the registry
ExecSafe { ~/.azure/bin/bicep publish-extension `
--bin-osx-arm64 "$root/src/bin/Release/net9.0/osx-arm64/publish/$extName" `
--bin-linux-x64 "$root/src/bin/Release/net9.0/linux-x64/publish/$extName" `
--bin-win-x64 "$root/src/bin/Release/net9.0/win-x64/publish/$extName.exe" `
--target "$Target" `
--force }

Now that you have compiled your source code and have either published it locally or to an Azure Container Registry (ACR), the only remaining configuration is to reference the path to the binary (if not publishing to an ACR) or the ACR URL in the bicepconfig.json file:

{
"experimentalFeaturesEnabled": {
"localDeploy": true
},
"extensions": {
"sayhello": "br:<link to ACR>:<version>" // ACR
"sayhello": "<path to binary>" // Or local
}
}

The extension name sayhello will be used later in Bicep, so give it a meaningful name. Additionally, since local-deploy is an experimental feature, make sure to set localDeploy to true under experimentalFeaturesEnabled.

Now that the extension has been compiled into a binary and configured as an extension in the bicepconfig, you can use it in a Bicep template. Make sure you have the Azure Bicep Visual Studio Code extension installed to take advantage of auto-completion.

Below, you can see a Bicep template with the targetScope set to local. This defines the scope for local-deploy. The keyword extension is then used, and the value sayhello is the extension name as configured in the bicepconfig. Finally, you see the resource definition SayHelloTo. This is the resource type you created in the Model, and the logic behind it is defined in the Resource Handler. The property outputMessage also comes from the Model definition.

Bicep template file

targetScope = 'local'
extension sayhello
param name string
resource resHello 'SayHelloTo' = {
name: name
}
output outputHello string = resHello.outputMessage // Outputs 'Hello, {name}'
view raw main.bicep hosted with ❤ by GitHub

Bicepparam file

using 'sayhello.bicep'
param name = 'John'
view raw main.bicepparam hosted with ❤ by GitHub

Output

The deployment output Hello, John after running the Bicep template with extension SayHello

The deployment of templates with targetScope local does not differ a lot from a normal Azure deployment. You can not use the regular az deployment group ... commands to deploy local scopes, instead you need to make use of bicep local-deploy <path to Bicepparam file>. This initiates orchestration of the Bicep template with the Bicepparam values, without requiring a connection to Azure. If you need to connect to protected REST APIs, you will have to handle authentication yourself within the Resource Handlers.

You can also run the bicep local-deploy command on build agents, you don’t have to run it on your local computer. This opens up many automation possibilities as the extension ecosystem continues to evolve over time!

GitHub Copilot can be a great tool to help you create Azure Bicep extensions. Here are a few tips you can apply:

  • Get results within minutes by scaffolding the quickstart example or referring to this blog post. Use the #fetch tool to quickly scaffold an environment and jumpstart your own extension.
  • If you have existing scripts, you can use their content as input for creating the model and handler.
  • I wrote a custom chat mode to make GitHub Copilot behave as an Azure Bicep Custom Extension Expert. This chat mode follows C# code convention, automatically validates generated code and does a breakdown of the prompts into actionable todos. See the file in my repository here: Azure Bicep extension specialist chat mode
  • When using the custom chat mode, providing a clear prompt with enough context about your goal works very well. For example:
You are tasked to write a new handler and model for the <goal>.

The model <model name> must contains the following properties:
* <Property name> using type <data type> as input
* <Property name> using type <data type> as output (readonly)

The handler <handler name> should implement <action e.g. the REST API to process the creation or update of a JIRA ticket and should output the URL of the JIRA ticket>. 

Use #fetch to get the REST API information: <link to rest api>

This is how you can create your own extension for Azure Bicep. The blog explains what local-deploy in Azure Bicep is and how to create your own extension. Additionally, it covered the components within the local-deploy framework and the role each component plays.

As a recap, during a local-scope deployment the invocation of the handler works as follows:

  1. The user defines the custom resource provider.
  2. The user executes the command bicep local-deploy <path to Bicepparam file>
  3. The handler in the binary is invoked:
  • A what-if (not yet supported) deployment triggers the Preview(...) method, which computes the result and returns what would happen when the deployment completes.
  • A normal local scope deployment triggers the CreateOrUpdate(...) method, which executes the operation defined in the handler.
  1. The handler returns a response containing the output value: resHello.outputMessage // Outputs 'Hello, {name}’

With this information, you now understand how the framework works and can start building your own extensions for Azure Bicep.

Relevant links

Leave a comment