- Creating a Containerized .NET App
- The Build & Publish Pipeline
- Running Tests as part of the Pipeline
First we need to containerize our application to make sure we have the same reproducible building steps. This prevents the classic "It builds and works on my machine". This way it doesn't matter which machine is building the image. It's always build the same way.
- First open Visual Studio or Rider.
- Choose the template
- Give it a name.
3.1. (Rider | Optional): Enable versioning by SelectingCreate Git repository
.
3.2. (Rider): Choose the typeWeb API
- Enable
Docker Support
- Choose Linux if using Linux containers i.e. WSL or Hyper-V (Recommended)
- Choose Windows if using Windows containers i.e. Hyper-V
- You should now have something matching a picture below:
- Press
Create
.
Create the solution folder:
mkdir ci-cd-lecture
Change directory to the solution folder:
cd ci-cd-lecture
Create the project inside the solution folder:
dotnet new webapi -o MyWebApi
Create a Dockerfile
in the project directory with the contents from [[#The Docker file]] section.
You should now have the following Dockerfile in your project directory:
FROM mcr.microsoft.com/dotnet/aspnet:5.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build
WORKDIR /src
COPY ["MyApi/MyApi.csproj", "MyApi/"]
RUN dotnet restore "MyApi/MyApi.csproj"
COPY . .
WORKDIR "/src/MyApi"
RUN dotnet build "MyApi.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "MyApi.csproj" -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MyApi.dll"]
FROM mcr.microsoft.com/dotnet/aspnet:5.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
...
This is the base of which our image is build upon.
...
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build
WORKDIR /src
COPY ["MyApi/MyApi.csproj", "MyApi/"]
RUN dotnet restore "MyApi/MyApi.csproj"
COPY . .
WORKDIR "/src/MyApi"
RUN dotnet build "MyApi.csproj" -c Release -o /app/build
...
Here we first copy the MyWebApi.csproj
project file and then restore our NuGet packages.
We then copy the entire solution to our image and then builds the Release version of our application.
...
FROM build AS publish
RUN dotnet publish "MyApi.csproj" -c Release -o /app/publish
...
Here we make dotnet publish our application which builds and optimizes our code and artifacts ready to release.
...
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MyApi.dll"]
Finally we copy the app from where we published our application in the publish
step.
This is crucial for having a well optimized and small image to deploy later on. Because we avoid copying all the other cached files or source code that would otherwise just bloat our image for no reason.
From the solution folder, build the image:
docker build -t myapi -f MyAPI/Dockerfile .
Run the container:
docker run --rm -it -e ASPNETCORE_ENVIRONMENT=Development -p 80:80 myapi
Go to the swagger UI to test it out:
http://127.0.0.1/swagger/index.html
Press Ctrl+C
in the terminal to stop the container.
We are going to use GitHub Actions in this example for simplicity and easy access for everyone, but the general concepts apply to all CI/CD pipeline tools.
name: Image Build Pipeline
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Build the Docker image
run: docker build . --file MyApi/Dockerfile --tag myapi:$(date +%s)
Add a new commit and push it to the main branch and see if it executes.
If you go to the Actions
tab in the GitHub repository, you should see something like the following:
name: Image Build Pipeline
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
env:
REGISTRY: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name}}
IMAGE_NAME: myapi
jobs:
build-publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Docker Setup Buildx
uses: docker/setup-buildx-action@v1.6.0
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@v1.10.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Image
run: |
docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest -f MyApi/Dockerfile .
- name: Push Image
if: github.event_name != 'pull_request'
run: |
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
All this is cool, but we need to make sure our tests pass, before we publish anything.
Create a new test project in your solution and reference your API project.
In your WeatherForecastController
add the following method:
...
public bool ReturnTrue()
{
return true;
}
...
Add the NuGet package: Moq
.
Then add the following unit test:
using Microsoft.Extensions.Logging;
using Moq;
using MyApi.Controllers;
using Xunit;
namespace MyApiTest
{
public class WeatherForecastControllerTest
{
[Fact]
public void ShouldBe_ReturnTrue()
{
var logger = new Mock<ILogger<WeatherForecastController>>();
var _sut = new WeatherForecastController(logger.Object);
Assert.True(_sut.ReturnTrue());
}
}
}
Add the test project to the build step in the Dockerfile:
RUN dotnet restore "MyApiTest/MyApiTest.csproj"
So it should look like:
...
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build
COPY ["MyApi/MyApi.csproj", "MyApi/"]
COPY ["MyApiTest/MyApiTest.csproj", "MyApiTest/"]
RUN dotnet restore "MyApi/MyApi.csproj"
RUN dotnet restore "MyApiTest/MyApiTest.csproj"
COPY . .
RUN dotnet build "MyApi/MyApi.csproj" -c Release -o /app/build
...
Add the following new step to the Dockerfile build:
...
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS test
COPY --from=build . .
RUN dotnet test "MyApiTest/MyApiTest.csproj"
...
Try making a commit and push it to the main branch.
You should see a successful action execution like earlier.
Try to change the newly added controller method to return false instead of true:
...
public bool ReturnTrue()
{
return false;
}
...
Make a new commit and push it to the main branch.
You should now see it fail and if we look in the log you should see something familiar to the following image: