How to build a .NET template and use it within Visual Studio. Part 2: Creating a template package
This is a 2 part-series.
- Part 1: Key concepts that you should know when creating a .NET template. If you want to read it, click here
- Part 2: How to convert a few .NET apps into .NET templates, package them together in a single NuGet pack and use them as templates within Visual Studio.
Just show me the code
As always if you don’t care about the post I have upload the source code on my Github.
Also I have upload the NuGet package to nuget.org.
Creating the MyTechRamblings.Templates package
In the following sections I will be converting 3 apps into .NET templates, package them in a NuGet pack named MyTechRamblings.Templates
and showing you how to use them within Visual Studio.
The MyTechRamblings.Templates
package will contain 2 solution templates and 1 project template.
Before start coding remember what I said in part 1:
- Using custom .NET templates within Visual Studio is only available in Visual Studio version 16.8 or higher.
- Also if you’re creating or using a solution template you need at least Visual Studio version 16.10 or higher.
1. Prerequisites:
I have develop 3 apps beforehand, that’s because in this post I will focus on the process of converting these 3 apps in templates.
The 3 apps I have built are the following ones:
-
NET 5 Web Api
- It is an entire solution application.
- It uses a N-layer architecture with 3 layers:
WebApi
layer.Library
layer.Repository
layer.
- It uses the
Microsoft.Build.CentralPackageVersions
MSBuild SDK. This SDK allows us to manage all the NuGet package versions in a single file. The file can be found in the/build
folder. - The api has the following features already built-in:
HealthChecks
Swagger
Serilog
AutoMapper
Microsoft.Identity.Web
Dockerfile
- It includes an
Azure Pipelines
YAML file and aGitHub Action
YAML file that deploys the api into an Azure App Service.
If you want to take a look at the api source code, click HERE
-
NET 5 Worker Service that consumes RabbitMq messages
- It is an entire solution application.
- The application is a
BackgroundService
that consumes messages from a RabbitMq server. It uses a N-layer architecture with 3 layers:Worker
layer.Library
layer.Repository
layer.
- It uses the
Microsoft.Build.CentralPackageVersions
MSBuild SDK. This SDK allows us to manage all the NuGet package versions in a single file. The file can be found in the/build
folder. - The service has the following features already built-in:
Serilog
AutoMapper
Microsoft.Extensions.Hosting.Systemd
Microsoft.Extensions.Hosting.WindowsServices
Dockerfile
- It includes an
Azure Pipelines
YAML file and aGitHub Action
YAML file that deploys the service into an Azure App Service.
If you want to take a look at the worker source code, click HERE
-
NET Core 3.1 Azure Function that gets triggered by a timer
- This one is not an entire solution, instead it is just a single project.
- It is a NET Core 3.1 Azure Function that is triggered by a timer.
- The function has the following features already built-in:
Dependency Injection
Logging
- It includes an
Azure Pipelines
YAML file and aGitHub Action
YAML file that deploys the function into Azure Functions.
If you want to take a look at the function source code, click HERE
2. Convert the NET 5 Web Api into a template
2.1. Create the template.json file
The first step is to create the template.json
file, but before start building it you need to know what you want to parameterize in the template.
After taking a look at the different features I have built on the api, I have come with a list of features that I want to parameterize.
Parameter Name | Description | Default value |
---|---|---|
Docker | Adds or removes a Dockerfile file. | true |
ReadMe | Adds or removes a README markdown file describing the project. | true |
Tests | Adds or removes the tests projects from the solution. Removes an Integration Test project and a Unit Test project. | true |
GitHub | Adds or removes a GitHub Action file from the solution. This GitHub Action is used to deploy the api into an Azure Web App. | false |
AzurePipelines | Adds or removes an Azure pipeline YAML file from the solution. The pipeline is used to deploy the api into an Azure Web App. | true |
DeploymentType | Specifies how you want to deploy the api. The possible values are DeployAsZip or DeployAsContainer . Depending of the value you choose the content of the deployment pipeline will vary. If you choose to not create neither a GitHub Action nor an Azure Pipeline this parameter is useless. |
DeployAsZip |
AcrName | An Azure ACR registry name. Only used if you are going to be deploying using a container. | acrcponsndev |
AzureSubscriptionName | An Azure DevOps Service Endpoint Name. Only used if deploying with Azure Pipelines. | cponsn-dev-subscription |
AppServiceName | The name of the Azure App Service where the app will be deployed. | app-svc-demo-dev |
Authorization | Enables or disables the use of authorization using Microsoft.Identity.Web |
true |
AzureAdTenantId | Azure Active Directory Tenant Id. Only necessary if Authorization is enabled. |
8a0671e2-3a30-4d30-9cb9-ad709b9c744a |
AzureAdDomain | Azure Active Directory Domain Name. Only necessary if Authorization is enabled. |
cpnoutlook.onmicrosoft.com |
AzureAdClientId | Azure Active Directory App Client Id. Only necessary if Authorization is enabled. |
fdada45d-8827-466f-82a5-179724a3c268 |
AzureAdSecret | Azure Active Directory App Secret Value. Only necessary if Authorization is enabled. |
1234 |
HealthCheck | Enables or disables the use of healthchecks. | true |
HealthCheckPath | HealthCheck api path. Only necessary if HealthCheck is enabled. |
/health |
Swagger | Enables or disables the use of Swagger. | true |
SwaggerPath | Swagger api path. Only necessary if Swagger is enabled. |
api-docs |
Contact | The contact details to use if someone wants to contact you. Only necessary if Swagger is enabled. |
user@example.com |
CompanyName | The name of the company. Only necessary if Swagger is enabled. |
mytechramblings |
CompanyWebsite | The website of the comany. Only necessary if Swagger is enabled. |
www.mytechramblings.com |
ApiDescription | The description of the api. Only necessary if Swagger is enabled. |
Put your api info here. |
- If you are working with Docker just set the
Docker
parameter totrue
and a Dockerfile will be placed alongside your api. - If you want to add some tests in your solution set the
Tests
parameter totrue
. A/test
folder containing a unit test project and a integration test project will be added inside your solution. - If the api is going to be deployed with Azure Devops set the
AzurePipelines
parameter to true. A/pipelines
folder containing a YAML pipeline will be added inside your solution. - If the api is going to be deployed with GitHub set the
GitHub
parameter to true. A/.github
folder containing a GitHub Action will be added inside your solution. - Depending of how the api is going to be deployed set the
DeploymentType
parameter accordingly.- If you are going to deploy using containers set the value to
DeployAsContainer
and the deployment pipeline will be updated into a container deployment pipeline. - If you are going to deploy using a .zip file set the value to
DeployAsZip
, and the deployment pipeline will be updated into an artifact deployment pipeline.
- If you are going to deploy using containers set the value to
- You can enable or disable features like
HealthChecks
orSwagger
. - If you are using Authorization with Azure Active Directory, set the
Authorization
parameter to true and set theAzureAdTenantId
,AzureAdDomain
,AzureAdClientId
,AzureAdSecret
parameters accordingly.
As can be seen from the table below there is also a default value for each parameter. A good tip when building a template with a lot of parameters is to set the default values to your most used scenario, so you won’t need to set them every time you want to scaffold a new app.
After listing which features I wanted to parameterize, here’s the template.json
file:
{
"$schema": "http://json.schemastore.org/template",
"author": "mytechramblings.com",
"classifications": [
"Cloud",
"Web",
"WebAPI"
],
"name": "NET 5 WebApi",
"description": "A WebApi solution.",
"groupIdentity": "Dotnet.Custom.WebApi",
"identity": "Dotnet.Custom.WebApi.CSharp",
"shortName": "mtr-api",
"defaultName": "WebApi1",
"tags": {
"language": "C#",
"type": "solution"
},
"sourceName": "ApplicationName",
"preferNameDirectory": true,
"primaryOutputs": [
{ "path": "ApplicationName.sln" }
],
"sources": [
{
"modifiers": [
{
"condition": "(!Docker)",
"exclude":
[
"Dockerfile",
".dockerignore"
]
},
{
"condition": "(!ReadMe)",
"exclude":
[
"README.md"
]
},
{
"condition": "(!Tests)",
"exclude":
[
"test/ApplicationName.Library.Impl.UnitTest/**/*",
"test/ApplicationName.WebApi.IntegrationTest/**/*"
]
},
{
"condition": "(!GitHub)",
"exclude":
[
".github/**/*"
]
},
{
"condition": "(!AzurePipelines)",
"exclude":
[
"pipelines/**/*"
]
},
{
"condition": "(!Swagger)",
"exclude":
[
"src/ApplicationName.WebApi/Extensions/ServiceCollectionExtensions/ServiceCollectionSwaggerExtension.cs"
]
},
{
"condition": "(!HealthCheck)",
"exclude":
[
"src/ApplicationName.WebApi/Extensions/ServiceCollectionExtensions/ServiceCollectionHealthChecksExtension.cs",
"src/ApplicationName.WebApi/Extensions/ApplicationBuilderExtensions/ApplicationBuilderWriteResponseExtension.cs"
]
}
]
}
],
"symbols": {
"Docker": {
"type": "parameter",
"datatype": "bool",
"description": "Adds an optimised Dockerfile to add the ability to build a Docker image.",
"defaultValue": "true"
},
"ReadMe": {
"type": "parameter",
"datatype": "bool",
"defaultValue": "true",
"description": "Add a README.md markdown file describing the project."
},
"Tests": {
"type": "parameter",
"datatype": "bool",
"defaultValue": "true",
"description": "Adds an integration project and unit test projects."
},
"GitHub": {
"type": "parameter",
"datatype": "bool",
"description": "Adds a GitHub action continuous integration pipeline.",
"defaultValue": "false"
},
"AzurePipelines": {
"type": "parameter",
"datatype": "bool",
"description": "Adds an Azure Pipelines YAML.",
"defaultValue": "true"
},
"DeploymentType": {
"type": "parameter",
"datatype": "choice",
"choices": [
{
"choice": "DeployAsContainer",
"description": "The app will be deployed as a container."
},
{
"choice": "DeployAsZip",
"description": "The app will be deployed as a zip file."
}
],
"defaultValue": "DeployAsZip",
"description": "Select how you want to deploy the application."
},
"DeployContainer": {
"type": "computed",
"value": "(DeploymentType == \"DeployAsContainer\")"
},
"DeployZip": {
"type": "computed",
"value": "(DeploymentType == \"DeployAsZip\")"
},
"AcrName": {
"type": "parameter",
"datatype": "string",
"defaultValue": "acrcponsndev",
"replaces": "ACR-REGISTRY-NAME",
"description": "An Azure ACR registry name. Only used if deploying with containers."
},
"AzureSubscriptionName": {
"type": "parameter",
"datatype": "string",
"defaultValue": "cponsn-dev-subscription",
"replaces": "AZURE-SUBSCRIPTION-ENDPOINT-NAME",
"description": "An Azure Subscription Name. Only used if you are going to be deploying with Azure Pipelines."
},
"AppServiceName": {
"type": "parameter",
"datatype": "string",
"defaultValue": "app-svc-demo-dev",
"replaces": "APP-SERVICE-NAME",
"description": "The name of Azure App Service."
},
"Authorization": {
"type": "parameter",
"datatype": "bool",
"defaultValue": "true",
"description": "Enables the use of authorization with Microsoft.Identity.Web."
},
"AzureAdTenantId":{
"type": "parameter",
"datatype": "string",
"defaultValue": "8a0671e2-3a30-4d30-9cb9-ad709b9c744a",
"replaces": "AAD-TENANT-ID",
"description": "Azure Active Directory Tenant Id. Only necessary if Authorization is enabled."
},
"AzureAdDomain":{
"type": "parameter",
"datatype": "string",
"defaultValue": "cpnoutlook.onmicrosoft.com",
"replaces": "AAD-DOMAIN",
"description": "Azure Active Directory Domain Name. Only necessary if Authorization is enabled."
},
"AzureAdClientId":{
"type": "parameter",
"datatype": "string",
"defaultValue": "fdada45d-8827-466f-82a5-179724a3c268",
"replaces": "AAD-CLIENT-ID",
"description": "Azure Active Directory App Client Id. Only necessary if Authorization is enabled."
},
"AzureAdSecret":{
"type": "parameter",
"datatype": "string",
"defaultValue": "1234",
"replaces": "AAD-SECRET-VALUE",
"description": "Azure Active Directory App Secret Value. Only necessary if Authorization is enabled."
},
"HealthCheck": {
"type": "parameter",
"datatype": "bool",
"defaultValue": "true",
"description": "Enables the use of healthchecks."
},
"HealthCheckPath": {
"type": "parameter",
"datatype": "string",
"defaultValue": "/health",
"replaces": "HEALTHCHECK-PATH",
"description": "HealthCheck path. Only necessary if HealthCheck is enabled."
},
"Swagger": {
"type": "parameter",
"datatype": "bool",
"defaultValue": "true",
"description": "Enable the use of Swagger."
},
"SwaggerPath": {
"type": "parameter",
"datatype": "string",
"defaultValue": "api-docs",
"replaces": "SWAGGER-PATH",
"description": "Swagger UI Path. Do not add a backslash. Only necessary if Swagger is enabled."
},
"Contact": {
"type": "parameter",
"datatype": "string",
"defaultValue": "user@example.com",
"replaces": "API-CONTACT",
"description": "The contact details to use if someone wants to contact you. Only necessary if Swagger is enabled."
},
"CompanyName": {
"type": "parameter",
"datatype": "string",
"defaultValue": "mytechramblings",
"replaces": "COMPANY-NAME",
"description": "The name of the company. Only necessary if Swagger is enabled."
},
"CompanyWebsite": {
"type": "parameter",
"datatype": "string",
"defaultValue": "https://www.mytechramblings.com",
"replaces": "COMPANY-WEBSITE",
"description": "The website of the company. Needs to be a valid Uri. Only necessary if Swagger is enabled."
},
"ApiDescription": {
"type": "parameter",
"datatype": "string",
"defaultValue": "Put your api info here",
"replaces": "API-DESCRIPTION",
"description": "The description of the WebAPI. Only necessary if Swagger is enabled."
}
},
"SpecialCustomOperations": {
"**/*.yml": {
"operations": [
{
"type": "conditional",
"configuration": {
"if": [ "#if" ],
"else": [ "#else" ],
"elseif": [ "#elseif" ],
"endif": [ "#endif" ],
"actionableIf": [ "##if" ],
"actionableElse": [ "##else" ],
"actionableElseif": [ "##elseif" ],
"actions": [ "uncomment", "reduceComment" ],
"trim": "true",
"wholeLine": "true",
"evaluator": "C++"
}
},
{
"type": "replacement",
"configuration": {
"original": "#",
"replacement": "",
"id": "uncomment"
}
},
{
"type": "replacement",
"configuration": {
"original": "##",
"replacement": "#",
"id": "reduceComment"
}
}
]
}
}
}
Let me make an in-depth rundown of the meaning of every value:
-
author
: The author of the template. -
classifications
: A set of characteristics of the template that a user might search for it.- The classification items will appear as tags in the .NET CLI. You can search templates by classification using the
dotnet new --tag <CLASSIFICATION_ITEM>
command. - You can search templates by classification within Visual Studio using the
Project Type
dropdown.
- The classification items will appear as tags in the .NET CLI. You can search templates by classification using the
-
name
: The name of the template. That’s the name that will appear in the .NET CLI and in VS. -
description
: The description of the template. The description will appear in the .NET CLI when you ran thedotnet new <TEMPLATE_NAME> -h
command. In VS it will appear in theCreate new project dialog
. -
groupIdentity
: The ID of the group this template belongs to. -
identity
: A unique ID for this template. -
shortName
: The short name is used for selecting the template in the .NET CLI. TheshortName
appears when you rundotnet new -l
and it is probably the one that you will use the most when you create a new solution using the template. For example you could ceate a new solution using this api template running thedotnet new mtr-api
command.
Right now theshortName
has no use in Visual Studio. -
defaultName
: The name that will be used when you create a new solution using the template if no name has been specified. -
tags
: You can add multiple tags but at least you have to specify thelanguage
tag and thetype
tag.- The language tag specifies the programming language.
- The type tag specifies the type of the template project. The possible values are:
project
,solution
,item
.
-
sourceName
: The template engine will look for any occurrence of thesourceName
and replace it. It will rename files if there is an occurrence. It will also replace file content if there is an occurrence.
I’m usingApplicationName
as thesourceName
and naming every.csproj
with theApplicationName
prefix:ApplicationName.sln
ApplicationName.WebApi.csproj
ApplicationName.Library.Contracts.csproj
ApplicationName.Library.Impl.csproj
ApplicationName.Repository.Contracts.csproj
ApplicationName.Repository.Impl.csproj
When you create a new solution using this template every
.csproj
file and the.sln
file will be renamed by the template engine fromApplicationName
to the name chosen by the user.
Also the namespace prefix inside all the.csharp
files will be renamed fromApplicationName
to the name chosen by the user. -
preferNameDirectory
: Indicates whether to create a directory for the template. If you set the value tofalse
the solution will be placed in your current directory. -
specialCustomOperations
: The templating engine supports conditional operators, but it only supports them in a certain file types. If you want to use conditionals operators in another file types you need to add them here.
In my template I want to add conditional operators on the YML files, that’s why I’m adding a custom operation that applies to all the yml files (**/*.yml
) -
sources.modifiers
: The sources.modifiers allows us to include or exclude files from the solution based on a condition. -
symbols
: The symbols section is where you specify the inputs you want to parameterize and also define the behaviour of those inputs.
The symbols
section and the sources.modifiers
section is the meat of the template.json
file.
I’m not going to try to explain the meaning of every symbol that I have placed on the symbols
section, mainly because it will be quite repetitive because most of the symbols are using exactly the same strategy.
Instead of that, I’m going to explain the strategies I have used, so in the end every symbol from the symbols
section will drop down in one of the strategies described in the next section.
Symbol replacement
- Replaces a fixed string with the value of the symbol.
Example:
Here I have the symbol HealthCheckPath
of type parameter
. It has a default value of /health
and a replace value of HEALTHCHECK-VALUE
.
"HealthCheckPath": {
"type": "parameter",
"datatype": "string",
"defaultValue": "/health",
"replaces": "HEALTHCHECK-PATH",
"description": "HealthCheck path. Only necessary if HealthCheck is enabled."
}
When you try to create a new solution using this template:
- The template engine will try to find the
HEALTHCHECK-PATH
string anywhere in the template and if it finds it, it will be replaced either by the user input or the default value (/health
).
If you take a look at theStartup.cs
, you’ll see thereplaces
value is hard-coded in the template.
endpoints.MapHealthChecks("HEALTHCHECK-PATH", new HealthCheckOptions
{
ResponseWriter = ApplicationBuilderWriteResponseExtension.WriteResponse
});
When the user creates a new solution, the template engine will find the magic string HEALTHCHECK-PATH
and replaced it by the user input or the defaultValue
.
If you take a look at the symbols section from the template.json
file, you will find a lot of symbols using this very same strategy but with a different replaces
value, this is because that’s the easiest way to update certain parts of the source code with user inputs.
Use conditional operators based on the symbol value
- It allows us to skip entire chunks of code based on a symbol value.
Example:
Here I have the symbol Authorization
of type bool
with the default value of true
.
"Authorization": {
"type": "parameter",
"datatype": "bool",
"defaultValue": "true",
"description": "Enables the use of authorization with Microsoft.Identity.Web."
},
- If the user sets the value to
false
, the template engine needs to remove anyMicrosoft.Web.Identity
reference from the solution. - If the user sets this symbol to
true
, the template engine needs to keep the references to theMicrosoft.Web.Identity
library.
If you take a look at the Startup.cs
, you’ll see that all the Microsoft.Web.Identity
references are being wrapped in a conditional operator.
#if Authorization
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Identity.Web;
#endif
...
#if Authorization
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(Configuration, "AzureAd");
#endif
#if Authorization
app.UseAuthentication();
app.UseAuthorization();
#endif
The Microsoft.Web.Identity
references are only being kept if the symbol Authorization
is set to true
.
But that’s not enough, we need to remove the Microsoft.Web.Identity
references from the appsettings.json
file too.
{
//#if (Authorization)
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"TenantId": "AAD-TENANT-ID",
"Domain": "AAD-DOMAIN",
"ClientId": "AAD-CLIENT-ID",
"ClientSecret": "AAD-SECRET-VALUE",
"TokenValidationParameters": {
"ValidateIssuer": true,
"ValidIssuer": "https://login.microsoftonline.com/AAD-TENANT-ID/v2.0",
"ValidateAudience": true,
"ValidAudiences": [ "AAD-CLIENT-ID" ]
}
}
//#endif
}
And also from the api controller:
#if Authorization
[Authorize]
#endif
[ApiVersion("1.0")]
[ApiController]
[ProducesResponseType(typeof(ErrorResult), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ErrorResult), StatusCodes.Status500InternalServerError)]
[Route("v{version:apiVersion}/[controller]")]
[Produces("application/json")]
public class MyServiceController : ControllerBase
{
...
}
Use a symbol value to compute another one
- You can set the value of a symbol using the value of aonther one.
Example:
The DeploymentType
symbol is of type choice
and with its value you can set the value of the DeployContainer
symbol and the DeployZip
symbol.
"DeploymentType": {
"type": "parameter",
"datatype": "choice",
"choices": [
{
"choice": "DeployAsContainer",
"description": "The app will be deployed as a container."
},
{
"choice": "DeployAsZip",
"description": "The app will be deployed as a zip file."
}
],
"defaultValue": "DeployAsZip",
"description": "Select how you want to deploy the application."
},
"DeployContainer": {
"type": "computed",
"value": "(DeploymentType == \"DeployAsContainer\")"
},
"DeployZip": {
"type": "computed",
"value": "(DeploymentType == \"DeployAsZip\")"
},
The DeployContainer
symbol and the DeployZip
symbol are used to tailor the deployment pipeline.
- If the user sets the
DeploymentType
symbol toDeployAsContainer
, then theDeployContainer
symbol is set totrue
.
TheDeployContainer
symbol value is used to create a deployment pipeline that will build and deploy a docker image. - If the user sets the
DeploymentType
symbol toDeployAsZip
, then theDeployZip
symbol is set totrue
.
TheDeployZip
symbol value is used to create a deployment pipeline that will build and deploy a zipped artifact.
Take a look at how the Azure Pipelines YAML file uses the DeployAsZip
and DeployAsContainer
symbol value to add a specific pipeline type.
trigger:
- master
pool:
vmImage: 'ubuntu-latest'
variables:
- name: buildConfiguration
value: 'Release'
- name: azureSubscription
value: 'AZURE-SUBSCRIPTION-ENDPOINT-NAME'
- name: appServiceName
value: 'APP-SERVICE-NAME'
#if (DeployContainer)
- name: registryName
value: 'ACR-REGISTRY-NAME'
#endif
#if (DeployContainer)
steps:
- task: AzureCLI@2
displayName: AZ ACR Login
inputs:
azureSubscription: $(azureSubscription)
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: 'az acr login --name $(registryName)'
- task: AzureCLI@2
displayName: AZ ACR Build
inputs:
azureSubscription: $(azureSubscription)
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: 'az acr build -t ApplicationName:$(Build.BuildId) -t ApplicationName:latest -r $(registryName) -f Dockerfile .'
useGlobalConfig: true
workingDirectory: '$(Build.SourcesDirectory)'
- task: AzureWebAppContainer@1
displayName: Deploy to App Service
inputs:
azureSubscription: '$(azureSubscription)'
appName: '$(appServiceName)'
containers: '$(registryName).azurecr.io/ApplicationName:latest'
#endif
#if (DeployZip)
steps:
- task: DotNetCoreCLI@2
displayName: Restore
inputs:
command: 'restore'
projects: '**/*.csproj'
- task: DotNetCoreCLI@2
displayName: Build
inputs:
command: 'build'
projects: '**/*.csproj'
arguments: '--configuration $(buildConfiguration) --no-restore'
- task: DotNetCoreCLI@2
displayName: Test
inputs:
command: 'test'
projects: '**/*UnitTest.csproj'
arguments: '--configuration $(buildConfiguration) --no-restore'
- task: DotNetCoreCLI@2
displayName: Publish
inputs:
command: publish
publishWebProjects: false
projects: '**/ApplicationName.WebApi.csproj'
arguments: '--configuration $(BuildConfiguration) --output $(Build.ArtifactStagingDirectory)'
zipAfterPublish: True
- task: AzureWebApp@1
displayName: Deploy Azure Web App
inputs:
azureSubscription: '$(azureSubscription)'
appName: '$(appServiceName)'
appType: 'webApp'
package: $(Build.ArtifactStagingDirectory)/**/*.zip
#endif
Take a look at how the GitHub Action YAML file uses the DeployAsZip
and DeployAsContainer
symbol value to add a specific pipeline type.
name: .NET api deploy to Azure App Service
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
env:
AZURE_WEBAPP_NAME: APP-SERVICE-NAME
#if (DeployContainer)
CONTAINER_REGISTRY: ACR-REGISTRY-NAME.azurecr.io
#endif
#if (DeployContainer)
jobs:
build-and-deploy-to-dev:
runs-on: ubuntu-latest
environment: dev
steps:
# Checkout the repo
- uses: actions/checkout@master
# Authenticate to Azure
- name: 'Azure authentication'
uses: azure/login@v1
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
# Authenticate to ACR
- name: 'ACR authentication'
uses: azure/docker-login@v1
with:
login-server: ${{ env.CONTAINER_REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
# Build and push the Docker image
- name: 'Docker Build & Push to ACR'
run: |
docker build . -t ${{ env.CONTAINER_REGISTRY }}/ApplicationName:${{ github.sha }}
docker push ${{ env.CONTAINER_REGISTRY }}/ApplicationName:${{ github.sha }}
# Deploy to Azure
- name: 'Deploy to Azure Web App for Container'
uses: azure/webapps-deploy@v2
with:
app-name: ${{ env.AZURE_WEBAPP_NAME }}
images: ${{ env.CONTAINER_REGISTRY }}/ApplicationName:${{ github.sha }}
#endif
#if (DeployZip)
jobs:
build-and-deploy:
runs-on: ubuntu-latest
environment: dev
steps:
# Checkout the repo
- uses: actions/checkout@master
# Setup .NET Core SDK
- name: 'Setup .NET Core'
uses: actions/setup-dotnet@v1
with:
dotnet-version: '5.0.x'
# Run dotnet build and publish
- name: 'dotnet build and publish'
run: |
dotnet restore
dotnet build --configuration Release
dotnet publish -c Release -o ${{ github.workspace }}/.output
# Deploy to Azure Web apps
- name: 'Run Azure webapp deploy action using publish profile credentials'
uses: azure/webapps-deploy@v2
with:
app-name: ${{ env.AZURE_WEBAPP_NAME }} # Replace with your app name
publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }} # Define secret variable in repository settings as per action documentation
package: ${{ github.workspace }}/.output
#endif
Excluding files based on a symbol value
- The
source.modifiers
section can be combined with thesymbols
section to include or exclude existing files based on a symbol value.
Example 1:
The Dockerfile
symbol is of type parameter
and the default value is true
.
"Docker": {
"type": "parameter",
"datatype": "bool",
"description": "Adds an optimised Dockerfile to add the ability to build a Docker image.",
"defaultValue": "true"
}
- If the symbol value is set to
false
, the template engine needs to remove theDockerfile
and the.dockerignore
files from the solution. - If the symbol value is set to
true
, the template engine needs to keep both files.
To achieve the desired behaviour, you can add the following object inside the source.modifiers
array.
The Docker
symbol value is used as the condition to exclude both files.
{
"condition": "(!Docker)",
"exclude":
[
"Dockerfile",
".dockerignore"
]
}
The template engine will remove the Dockerfile
and the .dockerignore
files, if the value of the Docker
symbol equals to false
.
Example 2:
This is a more interesting example. The Test
symbol is of type parameter
and the default value is true
"Tests": {
"type": "parameter",
"datatype": "bool",
"defaultValue": "true",
"description": "Adds an integration and unit test projects."
}
- If the symbol value is set to
false
, the template engine needs to:- Remove the folder that contains the unit test project from the solution.
- Remove the folder that contains the integration test project from the solution.
- Remove the test project references from the
.sln
file.
- If the symbol value is set to
true
, the template engines needs to keep the test projects.
To achieve the desired behaviour, you can add the following object in the source.modifiers
array.
{
"condition": "(!Tests)",
"exclude":
[
"test/ApplicationName.Library.Impl.UnitTest/**/*",
"test/ApplicationName.WebApi.IntegrationTest/**/*"
]
},
The Tests
symbol value is used as the condition to exclude both test folders.
Also the Tests
symbol is used to remove the test project references from the .sln
file via a conditional operator.
#if (Tests)
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Test", "Test", "{0323DDF8-0F80-4DF1-8F12-C37353534337}"
EndProject
#endif
...
#if (Tests)
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ApplicationName.WebApi.IntegrationTest", "test\ApplicationName.WebApi.IntegrationTest\ApplicationName.WebApi.IntegrationTest.csproj", "{205B0B75-C24F-40E4-BA69-46AE61CF9C20}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ApplicationName.Library.Impl.UnitTest", "test\ApplicationName.Library.Impl.UnitTest\ApplicationName.Library.Impl.UnitTest.csproj", "{C22AA50D-81CC-4A9B-9926-7AB8445246A9}"
EndProject
#endif
2.2. Create the dotnetcli.host.json file
If your template needs some command line parameters you can customize them by adding a dotnetcli.host.json
file inside the .template.config
folder.
In the dotnetcli.host.json
file you can specify the short and long names for each of the command line parameters.
This step is not mandatory and in my case I have quite a sizeable list of parameters to customize, but nonetheless I prefer to customize each parameter name as I see fit.
Here’s how the dotnetcli.host.json
looks:
{
"$schema": "http://json.schemastore.org/dotnetcli.host",
"symbolInfo":
{
"Docker": {
"longName": "dockerfile",
"shortName": "d"
},
"ReadMe": {
"longName": "readme",
"shortName": "r"
},
"Tests": {
"longName": "tests",
"shortName": "t"
},
"GitHub": {
"longName": "github",
"shortName": "ga"
},
"AzurePipelines": {
"longName": "azure-pipelines",
"shortName": "ap"
},
"DeploymentType": {
"longName": "deploy-type",
"shortName": "dt"
},
"AcrName": {
"longName": "acr-name",
"shortName": "acr"
},
"AzureSubscriptionName": {
"longName": "azure-subscription",
"shortName": "az"
},
"AppServiceName": {
"longName": "app-service-name",
"shortName": "asn"
},
"Authorization": {
"longName": "authorization",
"shortName": "auth"
},
"AzureAdTenantId":{
"longName": "aad-tenant-id",
"shortName": "at"
},
"AzureAdDomain":{
"longName": "aad-domain-name",
"shortName": "ad"
},
"AzureAdClientId":{
"longName": "aad-client-id",
"shortName": "ac"
},
"AzureAdSecret":{
"longName": "aad-secret-value",
"shortName": "as"
},
"HealthCheck": {
"longName": "healthcheck",
"shortName": "hck"
},
"HealthCheckPath": {
"longName": "healthcheck-path",
"shortName": "hp"
},
"Swagger": {
"longName": "swagger",
"shortName": "s"
},
"SwaggerPath": {
"longName": "swagger-path",
"shortName": "sp"
},
"Contact": {
"longName": "contact-mail",
"shortName": "cm"
},
"CompanyName": {
"longName": "company-name",
"shortName": "cn"
},
"CompanyWebsite": {
"longName": "company-website",
"shortName": "cw"
},
"ApiDescription": {
"longName": "api-description",
"shortName": "desc"
}
}
}
Remember that there are default values defined in the template.json
file for each parameter, so you don’t have to specify each and every one of the command line parameters if you don’t need to.
If you want to create a solution using the default values an override only a couple of parameters, you could totally do it.
For example, this command: dotnet new mtr-api --github true --azure-pipelines false
will create an api solution using the default values for every command line parameter except the github
and azure-pipelines
parameters.
2.3. Create the ide.host.json file
If your template needs some command line parameters and you want to use it within Visual Studio you need to add an ide.host.json
file inside the .template.config
folder.
This file will be used to show the command line parameters inside a project dialog when you try to create a new project.
Also you can customize your templates appearance in the Visual Studio template list with an icon. If it is not provided, a default icon will be associated with your project template.
Here’s how the ide.host.json
for the api looks like:
{
"$schema": "http://json.schemastore.org/vs-2017.3.host",
"order": 0,
"icon": "icon.png",
"symbolInfo": [
{
"id": "Docker",
"name":
{
"text": "Adds a Dockerfile."
},
"isVisible": true
},
{
"id": "ReadMe",
"name":
{
"text": "Adds a Readme."
},
"isVisible": true
},
{
"id": "Tests",
"name":
{
"text": "Adds a Unit Test project and a Integration Test project."
},
"isVisible": true
},
{
"id": "GitHub",
"name":
{
"text": "Adds a GitHub action to deploy the application."
},
"isVisible": true
},
{
"id": "AzurePipelines",
"name":
{
"text": "Adds an Azure Pipeline YAML to deploy the application."
},
"isVisible": true
},
{
"id": "DeploymentType",
"isVisible": true
},
{
"id": "AcrName",
"name":
{
"text": "Name of the ACR Registry, only used if deploying with container."
},
"isVisible": true
},
{
"id": "AzureSubscriptionName",
"name":
{
"text": "An Azure subscription service endpoint name, only used if deploying with Azure DevOps."
},
"isVisible": true
},
{
"id": "AppServiceName",
"name":
{
"text": "The name of Azure App Service."
},
"isVisible": true
},
{
"id": "Authorization",
"name":
{
"text": "Enable the use of authorization with Microsoft.Identity.Web."
},
"isVisible": true
},
{
"id": "AzureAdTenantId",
"name":
{
"text": "Azure Active Directory Tenant Id. Only necessary if Authorization is enabled."
},
"isVisible": true
},
{
"id": "AzureAdDomain",
"name":
{
"text": "Azure Active Directory Domain Name. Only necessary if Authorization is enabled."
},
"isVisible": true
},
{
"id": "AzureAdClientId",
"name":
{
"text": "Azure Active Directory App Client Id. Only necessary if Authorization is enabled."
},
"isVisible": true
},
{
"id": "AzureAdSecret",
"name":
{
"text": "Azure Active Directory App Secret Value. Only necessary if Authorization is enabled."
},
"isVisible": true
},
{
"id": "HealthCheck",
"name":
{
"text": "Enable the use of healthchecks."
},
"isVisible": true
},
{
"id": "HealthCheckPath",
"name":
{
"text": "HealthCheck path. Only necessary if HealthCheck is enabled."
},
"isVisible": true
},
{
"id": "Swagger",
"name":
{
"text": "Enable the use of Swagger."
},
"isVisible": true
},
{
"id": "SwaggerPath",
"name":
{
"text": "Swagger UI Path. Only necessary if Swagger is enabled."
},
"isVisible": true
},
{
"id": "Contact",
"name":
{
"text": "The contact details to use if someone wants to contact you. Only necessary if Swagger is enabled."
},
"isVisible": true
},
{
"id": "CompanyName",
"name":
{
"text": "The name of the company. Only necessary if Swagger is enabled."
},
"isVisible": true
},
{
"id": "CompanyWebsite",
"name":
{
"text": "The website of the company. Needs to be a valid Uri. Only necessary if Swagger is enabled."
},
"isVisible": true
},
{
"id": "ApiDescription",
"name":
{
"text": "The description of the WebAPI. Only necessary if Swagger is enabled."
},
"isVisible": true
}
]
}
3. Convert the remaining 2 apps into templates
- We still need to convert the
Worker Service
application and theAzure Function
into a .NET template.
The conversion process is exactly the same as the one described for the webapi, so I’m not going to bother writing about it or this post is going to drag on forever.
In both case the template.json
file is quite similar, if you’re interested in the end result:
- Here`s the link to the
template.json
file for theWorker Service
application. - Here’s the link to the
template.json
file for theAzure Function
.
4. Create the MyTechRamblings.Templates NuGet package
In Part 1 I told you that there are a couple of ways for creating a template pack:
- Using a
.csproj
file and thedotnet pack
command. - Using a
.nuspec
file and thenuget pack
command fromnuget.exe
.
I’m using a .nuspec
file. It looks like this:
<?xml version="1.0" encoding="utf-8"?>
<package>
<metadata>
<id>MyTechRamblings.Templates</id>
<version>0.5.0</version>
<description>This nuget is an example about how to pack multiple dotnet templates in a single NuGet package and use it within Visual Studio or the .NET CLI.</description>
<authors>Carlos Pons (www.mytechramblings.com)</authors>
<title>MyTechRamblings Dotnet Templates</title>
<copyright>Copyright 2021: www.mytechramblings.com. All right Reserved</copyright>
<requireLicenseAcceptance>false</requireLicenseAcceptance>
<license type="expression">MIT</license>
<tags>.NET WebApi Rabbit Function Templates</tags>
<projectUrl>https://github.com/karlospn/MyTechRamblings.Templates</projectUrl>
<repository type="git" url="https://github.com/karlospn/MyTechRamblings.Templates.git" branch="main" />
<language>en-US</language>
<packageTypes>
<packageType name="Template" />
</packageTypes>
</metadata>
<files>
<file src="**" exclude="**\bin\**\*;**\obj\**\*;**\*.user;**\*.lock.json;_rels\**\*;package\**\*" />
</files>
</package>
5. Publish the package to nuget.org
I don’t want to create and publish the package manually.
Instead of that, I have created a Powershell
script that fetches the nuget executable from the nuget.org
website, bundles everything together and creates the pack using the nuget pack
command.
Set-StrictMode -Version Latest
$templateName = "template"
$templatePath = "./$templateName/mtr"
$contentDirectory = "./$templateName/mtr/content"
$nugetPath = "./$templateName/nuget.exe"
$nugetOut = "./$templateName/nuget"
$nugetUrl = "https://dist.nuget.org/win-x86-commandline/v5.9.1/nuget.exe"
Write-Output "Copy WebApiNet5 template"
Copy-Item -Path "./src/WebApiNet5Template" -Recurse -Destination "$contentDirectory/WebApiNet5Template" -Container
Write-Output "Copy HostedServiceNet5RabbitConsumer template"
Copy-Item -Path "./src/HostedServiceNet5RabbitConsumerTemplate" -Recurse -Destination "$contentDirectory/HostedServiceNet5RabbitConsumerTemplate" -Container
Write-Output "Copy AzureFunctionTimerProjectTemplate template"
Copy-Item -Path "./src/AzureFunctionTimerProjectTemplate" -Recurse -Destination "$contentDirectory/AzureFunctionTimerProjectTemplate" -Container
Write-Output "Copy nuspec"
Copy-item -Force -Recurse "MyTechRamblings.Templates.nuspec" -Destination $templatePath
Write-Output "Download nuget.exe from $nugetUrl"
Invoke-WebRequest -Uri $nugetUrl -OutFile $nugetPath
Write-Output "Pack nuget"
$cmdArgList = @( "pack", "$templatePath\MyTechRamblings.Templates.nuspec",
"-OutputDirectory", "$nugetOut", "-NoDefaultExcludes")
& $nugetPath $cmdArgList
And also I have created a GitHub Action that executes the script and pushes the .nupkg
into the nuget.org
feed.
name: push the template package to nuget.org
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
workflow_dispatch:
jobs:
build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v2
- name: Setup .NET Core
uses: actions/setup-dotnet@v1
with:
dotnet-version: 5.0.x
- name: Create nupkg
run: .\create-nuget.ps1
shell: powershell
- name: Push to Nuget.org
run: dotnet nuget push "**/*.nupkg" -k ${{ secrets.NUGET_API_KEY }} -s https://api.nuget.org/v3/index.json --skip-duplicate
6. Install and use the MyTechRamblings.Templates with the .NET CLI
To Install the package you can run this command:
dotnet new -i MyTechRamblings.Templates::0.5.0
The command will try to fetch the NuGet from nuget.org
and install it.
Or if you have the .nupkg in your local filesystem, you can ran this command:
dotnet new -i .\MyTechRamblings.Templates.0.5.0.nupkg
After installing the templates pack you should run the dotnet new -l
command and verify that the 3 templates appear on the list (mtr-api
, mtr-rabbit-worker
, mtr-az-func-timer
).
Now you can create a new solution running any of these commands:
dotnet new mtr-api
dotnet new mtr-rabbit-worker
dotnet new mtr-az-func-timer
This commands will create a solution using the default values. If you want to set a specific parameter execute the dotnet new
command with the -h
flag to list every command line parameter available.
7. Use the MyTechRamblings.Templates within Visual Studio
Be sure to enable the following option in Visual Studio:
Tools > Options > Preview Features > Show all .NET Core templates in the New Project dialog
.
And remember:
- The MyTechRamblings.Templates package contains a couple of solution templates ( the api template and the worker template). If you want to use them you need at least Visual Studio version 16.10 or higher.
Try to create a new project withing Visual Studio and the new templates should appear in the Create a new project
dialog.
If the templates doesn’t show up, just search for them in the search box.
If you select any of the templates a dialog will show up and it will contain every parameter that you have specified in the ide.host.json
.
- WebAPI Visual Studio dialog:
- Worker Service Visual Studio dialog:
- Azure function Visual Studio dialog: