Dockerfile Generator for .NET
This Dockerfile for .NET was first proposed in the article Rethinking our ASP.NET Docker CI. I realised there are some choices to be made, so I've created a small generator to make it easier:
function Model() { let self = this;
this.version = "v2.6.4";
this.serviceName = ko.observable("Ktt.Docker.Todo.Api");
this.port = ko.observable("5000");
this.aspNetVersion = ko.observable("10.0");
this.addIntegrationTesting = ko.observable(false);
this.addMultiProjectSupport = ko.observable(true);
this.otherProjects = ko.observable("");
this.projectsToPublish = ko.computed(function () {
let projects = (this.otherProjects() || "").split(" ");
return ["$MAIN_API_NAME"]
.concat(projects)
.filter(x => x)
.join(" ");
}, this);
this.folderStructureTypes = ["root", "src/test", "src/test/shared"];
this.folderStructure = ko.observable("src/test");
this.modes = ["restore+build+test+publish", "restore+publish"];
this.mode = ko.observable("restore+build+test+publish");
this.addTesting = ko.computed(function () {
return this.mode().includes("test");
}, this);
this.addBuilding = ko.computed(function () {
return this.mode().includes("build");
}, this);
this.timeZone = ko.observable("Europe/Amsterdam");
this.srcFolder = ko.computed(function () {
return self.folderStructure() == "root" ? "" : "src/";
});
this.echo = ko.observable(true);
this.code = ko.observable("");
this.__skip = ["code"];
this.applyHighlight = function (elements) {
elements
.filter(x => x.tagName === "PRE")
.forEach(x => {
if (window.hljs) window.hljs.highlightElement(x);
});
};
}
document.addEventListener("DOMContentLoaded", function (event) {
if (window.hljs) {
window.hljs.addPlugin(new CopyButtonPlugin());
}
const model = new Model();
ko.persistChanges(model, "", { storage: new QueryStringStorage() }, 0);
ko.applyBindings(model, document.getElementById("source"));
});
Service Name:
Other projects:
Folder structure:
ASP.NET Version:
Port:
Time zone:
Mode:
Integration testing:
[(for test containers)](https://keestalkstech.com/rethinking-our-asp-net-docker-ci/#step-3-integration-testing-stage)
Echo titles:
Generator
#################################
Configuration settings ()
#################################
ARG MAIN_API_NAME="" ARG \ MAIN_API_DLL="$MAIN_API_NAME.dll" \ PROJECTS_TO_PUBLISH="" \ PORT= \ ASPNET_VERSION="" \
options: q[uiet], m[inimal], n[ormal], d[etailed], and diag[nostic]
minimal keeps your pipeline readable while informing you what's going on
VERBOSITY="minimal" \ APP_DIR="/app" \ TAG="" \ EXECUTE_TESTS="true"
##########################################################################
Build image, uses VERBOSITY, EXECUTE_TESTS, PROJECTS_TO_PUBLISH, APP_DIR
##########################################################################
FROM mcr.microsoft.com/dotnet/sdk:$ASPNET_VERSION AS build
ARG VERBOSITY EXECUTE_TESTS PROJECTS_TO_PUBLISH APP_DIR
ENV \ TZ= \ DOTNET_CLI_TELEMETRY_OPTOUT=true \ DOTNET_NOLOGO=true
WORKDIR /build
Let's restore the solution, nuget and project files and do a restore
in cacheable layers. This will speed up the build process greatly.
copy global files to restore
COPY .sln .config ./
copy files to restore
COPY /.csproj ./ RUN for file in $(ls .csproj); do mkdir -p ${file%.}/ && mv $file ${file%.}/; done; \ echo "" \ && echo "---------" \ && echo "RESTORING" \ && echo "---------" \ && echo "" \ && for file in $(ls .csproj); do mkdir -p src/${file%.}/ && mv $file src/${file%.}/; done; \ for p in $(find . -name "*.csproj"); do dotnet restore "$p" || exit 1; done
COPY . .
copy src files to restore
COPY src//.csproj ./ RUN for file in $(ls .csproj); do mkdir -p src/${file%.}/ && mv $file src/${file%.*}/; done
copy shared files to shared
COPY shared//.csproj ./ RUN for file in $(ls .csproj); do mkdir -p shared/${file%.}/ && mv $file shared/${file%.*}/; done
copy test files to restore
COPY test//.csproj ./ RUN for file in $(ls .csproj); do mkdir -p test/${file%.}/ && mv $file test/${file%.}/; done; \ echo "" \ && echo "---------" \ && echo "RESTORING" \ && echo "---------" \ && echo "" \ && for p in $(find . -name ".csproj"); do dotnet restore "$p" || exit 1; done
COPY src ./src COPY shared ./shared COPY test ./test
Note on build: don't use --no-restore, sometimes certain packages cannot be
restored by the dotnet restore. The build will add them, as it has more context (!?)
example: Package System.Text.Json, version 6.0.0 was not found
RUN echo "" \ && echo "--------" \ && echo "BUILDING" \ && echo "--------" \ && echo "" \ && dotnet build --configuration Release --verbosity "$VERBOSITY" -nowarn:NETSDK1004 || exit 1
defining the argument here caches the previous layers when the value switches
ARG EXECUTE_TESTS RUN echo "" \ && echo "-------" \ && echo "TESTING" \ && echo "-------" \ && echo ""; \ if [ "$EXECUTE_TESTS" = "true" ]; then \ dotnet test --filter "Category!=Integration" --configuration Release --logger "console;verbosity=$VERBOSITY" --no-build || exit 1; \ else \ echo "Skipping unit tests"; \ fi;
publish project(s)
RUN echo "" \ && echo "----------" \ && echo "PUBLISHING" \ && echo "----------" \ && echo ""; \ for project in $PROJECTS_TO_PUBLISH; do \ echo "Publishing $project..."; \ dotnet publish "$project/$project.csproj" \ --configuration Release \ --output "$APP_DIR/$project" \ -nowarn:NETSDK1004 || exit 1; \ done
########################
Integration test image
########################
FROM mcr.microsoft.com/dotnet/sdk:$ASPNET_VERSION AS integration-test
ENV \ TZ=Europe/Amsterdam \ DOTNET_CLI_TELEMETRY_OPTOUT=true \ DOTNET_NOLOGO=true
WORKDIR /build
install Docker
RUN apt-get update && \ apt-get install -y --no-install-recommends docker.io procps && \ rm -rf /var/lib/apt/lists/*
copy entire build context
COPY --from=build /build /build
run Docker daemon and tests
CMD ["bash", "-euo", "pipefail", "-c", "\ trap 'echo Shutting down dockerd; pkill dockerd || true' EXIT; \ echo 'Starting Docker daemon...'; \ dockerd > /var/log/dockerd.log 2>&1 & \ for i in {1..30}; do \ docker info > /dev/null 2>&1 && break || echo \"Waiting for Docker... ($i/30)\"; sleep 1; \ done; \ if ! docker info > /dev/null 2>&1; then \ echo '❌Docker failed to start. Log output:'; \ cat /var/log/dockerd.log || echo '(No log found)'; \ exit 1; \ fi; \ echo '✅ Docker is ready. Running integration tests...'; \ dotnet test --filter 'Category=Integration' --nologo --logger \"console;verbosity=${VERBOSITY:-minimal}\" \ "]
#####################################################################
Runtime image, uses PORT, TAG, MAIN_API_DLL, MAIN_API_NAME, APP_DIR
#####################################################################
FROM mcr.microsoft.com/dotnet/aspnet:$ASPNET_VERSION-alpine AS runtime
RUN apk add --no-cache icu-libs krb5-libs libgcc libintl libssl3 libstdc++ zlib tzdata
ARG PORT MAIN_API_NAME APP_DIR MAIN_API_DLL TAG
WORKDIR $APP_DIR COPY --from=build $APP_DIR .
create a new user and change directory ownership
RUN adduser --disabled-password \ --home "$APP_DIR" \ --gecos '' dotnetuser && chown -R dotnetuser:dotnetuser "$APP_DIR"
impersonate into the new user
USER dotnetuser
ENV \ ASPNETCORE_URLS=http://*:$PORT \ ASPNETCORE_ENVIRONMENT=Production \ TZ= \ DOTNET_CLI_TELEMETRY_OPTOUT=true \ DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
EXPOSE $PORT
WORKDIR "$APP_DIR/$MAIN_API_NAME" ENV PROGRAM="$MAIN_API_DLL" ENTRYPOINT ["sh", "-c", "exec dotnet \"$PROGRAM\""]
ko.bindingHandlers.saveHtmlContent = { init: function (element, valueAccessor) { var htmlObservable = valueAccessor();
// Function to get the inner text only (ignores HTML and comments)
function extractInnerText(htmlElement) {
return htmlElement.textContent || htmlElement.innerText;
}
// Save the inner text content
htmlObservable(extractInnerText(element));
// Set up a MutationObserver to monitor changes in the element's content
var observer = new MutationObserver(function () {
htmlObservable(extractInnerText(element));
});
// Start observing changes in the child elements
observer.observe(element, {
childList: true,
subtree: true,
characterData: true
});
// Cleanup when the element is removed
ko.utils.domNodeDisposal.addDisposeCallback(element, function () {
observer.disconnect();
});
}
};
feature-image {
display: none;
}
.entry-content { width: 100% !important; }
.grayed-out { opacity: 0.5; pointer-events: none; }
source {
max-width: 100%;
.form {
margin-top: 2em;
margin-bottom: 1em;
font-family: arial;
font-size: 14px;
background-color: var(--light-background);
padding: 2em;
column-count: 2;
column-gap: 2em;
& > div + div {
margin-top: 0.5em;
}
input {
background-color: #fff;
color: #000;
padding: 2px 2px 2px 10px;
border-radius: 4px;
}
input[type="text"] {
width: calc(100% - 120px);
}
input[type="number"] {
width: 75px;
}
label {
width: 120px;
display: inline-block;
}
select {
background-color: white;
width: calc(100% - 120px);
height: 2em;
padding: 4px;
padding-left: 10px;
margin-left: -4px;
border-radius: 4px;
}
}
pre {
padding: 2em !important;
}
}
.entry-content > p { margin-left: 0; max-width: 100%; }
@media screen and (max-width: 700px) { .form { column-count: 1 !important; }
.form br {
display: none;
}
}
ko.trackChange = (store, observable, key, echo = null) => { //initialize from stored value, or if no value is stored yet, //use the current value
const value = store.get(key);
if (value !== null) {
if (echo) echo("Restoring value for", key, value);
//restore current value
observable(value);
}
//track the changes
observable.subscribe(newValue => {
if (echo) echo("Storing new value for", key, newValue);
store.set(key, newValue);
});
};
ko.isComputed = instance => { if (!instance || !instance.__ko_proto__) { return false; }
if (instance.__ko_proto__ === ko.dependentObservable) {
return true;
}
// Walk the prototype chain
return ko.isComputed(instance.__ko_proto__);
};
const defaultOptions = Object.freeze({ storage: localStorage, traverseNonObservableProperties: true, debug: false });
ko.persistChanges = (model, prefix = "model-", options = defaultOptions, deep = 0) => { options = Object.assign({}, defaultOptions, options); options.echo = function () { if (!options.debug) return;
if (deep > 0) {
return console.log("-".repeat(deep), ...arguments);
}
console.log(...arguments);
};
const storageWrapper = {
set: (key, value) => options.storage.setItem(key, JSON.stringify(value)),
get: key => JSON.parse(options.storage.getItem(key))
};
const skip = new Set(model.__skip || []);
skip.add("__skip");
for (let n in model) {
const observable = model[n];
const key = prefix + n;
if (skip.has(n)) {
options.echo("Skipping", n, "because it is on the __skip list.");
continue;
}
if (ko.isComputed(observable)) {
options.echo("Skipping", n, "because it is computed.");
continue;
}
if (typeof observable === "function") {
if (!ko.isObservable(observable)) {
options.echo("Skipping", n, "because it is a function.");
continue;
}
ko.trackChange(storageWrapper, observable, key, options.echo);
options.echo("Tracking change for", n, "in", key);
continue;
}
if (!options.traverseNonObservableProperties) {
options.echo("Skipping", n, "because options.traverseNonObservableProperties is false.");
continue;
}
if (typeof observable === "object" && observable !== null && !Array.isArray(observable)) {
options.echo("Tracking change for object", key);
ko.persistChanges(observable, key + "-", options, deep + 1);
continue;
}
options.echo("Skipping", n, observable);
}
};
class QueryStringStorage { getItem(key) { let value = new URLSearchParams(window.location.search).get(key);
if (!value) return value;
if (value != "false" && value != "true" && value.length > 0 && !["{", "[", '"'].includes(value[0])) {
return '"' + value + '"';
}
return value;
}
setItem(key, value) {
const params = new URLSearchParams(window.location.search);
if (value && value.length > 0) {
let f = value[0];
if (f == '"') {
//string
value = value.substring(1, value.length - 1);
}
}
params.set(key, value);
window.history.replaceState({}, "", "?" + params.toString());
}
}