From 9361cd44953c8ddeddbbef83bc82372631a39931 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Sat, 25 Apr 2026 09:50:52 -0400 Subject: [PATCH] Add production Jenkins pipeline for Azure App Service deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fully manual pipeline (no triggers): build/test → publish → generate idempotent EF migration SQL (archived as artifact) → apply to Azure SQL via sqlcmd → ZIP deploy to App Service → smoke test. Includes jenkins/Dockerfile (adds .NET 8 SDK, Azure CLI, mssql-tools18, dotnet-ef 8.0.11 to jenkins/jenkins:lts) and .config/dotnet-tools.json tool manifest. Co-Authored-By: Claude Sonnet 4.6 --- .config/dotnet-tools.json | 12 +++ Jenkinsfile | 168 ++++++++++++++++++++++++++++++++++++++ jenkins/Dockerfile | 50 ++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 .config/dotnet-tools.json create mode 100644 Jenkinsfile create mode 100644 jenkins/Dockerfile diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..02afa3f --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "8.0.11", + "commands": [ + "dotnet-ef" + ] + } + } +} diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..b7ff8c5 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,168 @@ +pipeline { + agent any + + // No triggers — start this pipeline manually from the Jenkins UI only. + + environment { + DOTNET_CLI_HOME = '/tmp/dotnet_cli_home' + WEB_PROJECT = 'src/PowderCoating.Web/PowderCoating.Web.csproj' + INFRA_PROJECT = 'src/PowderCoating.Infrastructure/PowderCoating.Infrastructure.csproj' + PUBLISH_DIR = "${WORKSPACE}/publish" + DEPLOY_ZIP = "${WORKSPACE}/deploy_${BUILD_NUMBER}.zip" + MIGRATION_SQL = "${WORKSPACE}/migration_${BUILD_NUMBER}.sql" + } + + stages { + + stage('Checkout') { + steps { + checkout([ + $class: 'GitSCM', + branches: [[name: 'refs/heads/master']], + userRemoteConfigs: scm.userRemoteConfigs + ]) + echo "Building commit: ${GIT_COMMIT}" + } + } + + stage('Build & Test') { + steps { + sh 'dotnet restore' + sh 'dotnet build --no-restore -c Release' + sh ''' + dotnet test --no-build -c Release \ + --logger "trx;LogFileName=results.trx" \ + --results-directory TestResults + ''' + } + post { + always { + junit testResults: 'TestResults/*.trx', allowEmptyResults: true + } + } + } + + stage('Publish') { + steps { + sh """ + dotnet publish '${WEB_PROJECT}' \ + -c Release --no-build \ + -o '${PUBLISH_DIR}' + """ + } + } + + // Generates an idempotent SQL migration script (no live DB connection required). + // The script checks which migrations have already been applied before running each one. + stage('Generate Migration Script') { + steps { + sh """ + dotnet ef migrations script \ + --idempotent \ + --output '${MIGRATION_SQL}' \ + --project '${INFRA_PROJECT}' \ + --startup-project '${WEB_PROJECT}' \ + --context ApplicationDbContext \ + --no-build + """ + archiveArtifacts artifacts: "migration_${BUILD_NUMBER}.sql", fingerprint: true + echo "Migration script archived — review it in the Jenkins build artifacts before this pipeline runs next time." + } + } + + stage('Apply Migration to Azure SQL') { + steps { + withCredentials([ + string(credentialsId: 'PCL_SQL_SERVER', variable: 'SQL_SERVER'), + string(credentialsId: 'PCL_SQL_DATABASE', variable: 'SQL_DATABASE'), + string(credentialsId: 'PCL_SQL_USER', variable: 'SQL_USER'), + string(credentialsId: 'PCL_SQL_PASSWORD', variable: 'SQL_PASSWORD') + ]) { + sh ''' + echo "Applying migration to ${SQL_SERVER}/${SQL_DATABASE} ..." + /opt/mssql-tools18/bin/sqlcmd \ + -S "${SQL_SERVER}" \ + -d "${SQL_DATABASE}" \ + -U "${SQL_USER}" \ + -P "${SQL_PASSWORD}" \ + -C \ + -b \ + -i "${MIGRATION_SQL}" + echo "Migration applied successfully." + ''' + } + } + } + + stage('Deploy to Azure App Service') { + steps { + withCredentials([ + string(credentialsId: 'PCL_AZURE_CLIENT_ID', variable: 'AZ_CLIENT_ID'), + string(credentialsId: 'PCL_AZURE_CLIENT_SECRET', variable: 'AZ_CLIENT_SECRET'), + string(credentialsId: 'PCL_AZURE_TENANT_ID', variable: 'AZ_TENANT_ID'), + string(credentialsId: 'PCL_AZURE_SUBSCRIPTION_ID', variable: 'AZ_SUBSCRIPTION_ID'), + string(credentialsId: 'PCL_AZURE_RESOURCE_GROUP', variable: 'AZ_RG'), + string(credentialsId: 'PCL_AZURE_APP_NAME', variable: 'AZ_APP') + ]) { + sh ''' + az login --service-principal \ + --username "$AZ_CLIENT_ID" \ + --password "$AZ_CLIENT_SECRET" \ + --tenant "$AZ_TENANT_ID" \ + --output none + + az account set --subscription "$AZ_SUBSCRIPTION_ID" + + echo "Packaging deployment artifact ..." + cd "$PUBLISH_DIR" + zip -r "$DEPLOY_ZIP" . + + echo "Pushing ZIP to ${AZ_APP} ..." + az webapp deployment source config-zip \ + --resource-group "$AZ_RG" \ + --name "$AZ_APP" \ + --src "$DEPLOY_ZIP" + + az logout + echo "Deploy complete." + ''' + } + } + } + + stage('Smoke Test') { + steps { + withCredentials([ + string(credentialsId: 'PCL_AZURE_APP_NAME', variable: 'AZ_APP') + ]) { + sh ''' + APP_URL="https://${AZ_APP}.azurewebsites.net" + echo "Smoke-testing ${APP_URL} ..." + HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + --max-time 45 --retry 3 --retry-delay 10 \ + "${APP_URL}") + echo "HTTP status: ${HTTP_STATUS}" + # 200 = OK, 302 = redirect to login (both are healthy) + if [ "$HTTP_STATUS" != "200" ] && [ "$HTTP_STATUS" != "302" ]; then + echo "SMOKE TEST FAILED — got HTTP ${HTTP_STATUS}" + exit 1 + fi + echo "Smoke test passed." + ''' + } + } + } + } + + post { + success { + echo "Production deployment #${BUILD_NUMBER} (${GIT_COMMIT}) completed successfully." + } + failure { + echo "Pipeline #${BUILD_NUMBER} FAILED — review the stage logs above." + } + always { + cleanWs() + } + } +} diff --git a/jenkins/Dockerfile b/jenkins/Dockerfile new file mode 100644 index 0000000..1d4b4a9 --- /dev/null +++ b/jenkins/Dockerfile @@ -0,0 +1,50 @@ +# Custom Jenkins image for Powder Coating Logix production deployments. +# Adds: .NET 8 SDK, Azure CLI, sqlcmd (mssql-tools18), dotnet-ef global tool. +# +# Build: docker build -t pcl-jenkins ./jenkins +# Run: docker run -d -p 8080:8080 -p 50000:50000 \ +# -v jenkins_home:/var/jenkins_home \ +# --name pcl-jenkins pcl-jenkins + +FROM jenkins/jenkins:lts + +USER root + +# ── Base utilities ──────────────────────────────────────────────────────────── +RUN apt-get update && apt-get install -y --no-install-recommends \ + wget curl gnupg2 apt-transport-https lsb-release zip unzip ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# ── .NET 8 SDK ──────────────────────────────────────────────────────────────── +RUN wget -q https://packages.microsoft.com/config/debian/12/packages-microsoft-prod.deb \ + -O /tmp/ms-prod.deb \ + && dpkg -i /tmp/ms-prod.deb \ + && rm /tmp/ms-prod.deb \ + && apt-get update \ + && apt-get install -y --no-install-recommends dotnet-sdk-8.0 \ + && rm -rf /var/lib/apt/lists/* + +# ── Azure CLI ───────────────────────────────────────────────────────────────── +RUN curl -sL https://aka.ms/InstallAzureCLIDeb | bash \ + && rm -rf /var/lib/apt/lists/* + +# ── mssql-tools18 (sqlcmd) ─────────────────────────────────────────────────── +RUN curl -fsSL https://packages.microsoft.com/keys/microsoft.asc \ + | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \ + && echo "deb [signed-by=/usr/share/keyrings/microsoft-prod.gpg] \ + https://packages.microsoft.com/debian/12/prod bookworm main" \ + > /etc/apt/sources.list.d/mssql-release.list \ + && apt-get update \ + && ACCEPT_EULA=Y apt-get install -y --no-install-recommends \ + mssql-tools18 unixodbc-dev \ + && rm -rf /var/lib/apt/lists/* + +ENV PATH="$PATH:/opt/mssql-tools18/bin" + +# ── dotnet-ef global tool ───────────────────────────────────────────────────── +# Installed into /root/.dotnet/tools (not JENKINS_HOME, which is a volume mount +# and would be wiped on first run). A symlink exposes it system-wide. +RUN DOTNET_CLI_HOME=/root dotnet tool install --global dotnet-ef --version 8.0.11 \ + && ln -s /root/.dotnet/tools/dotnet-ef /usr/local/bin/dotnet-ef + +USER jenkins