Skip to content

001 implementation examples

Moving to Minimal APIs reduces boilerplate, and using .NET Channels with a BackgroundService is the perfect pattern for long-running tasks. It ensures the HTTP request doesn't time out while extracting large files or waiting for IIS/Windows Services to restart. It immediately returns a 202 Accepted to the client.

Additionally, shifting the SQL Database orchestration to the Client gives your CI/CD pipeline (or orchestrator) full control over the deployment lifecycle. The client can verify the database state, trigger the local agent, wait for completion, and only then flip the database switch to make the new version "Active".

Here is the updated plan, diagram, and code.

1. Updated Architecture Diagram#

graph TD
    subgraph CICD[CI/CD Pipeline / Deployment Client]
        Client[Deployment Orchestrator Client]
    end

    subgraph TargetVM[Target VM e.g. vmbenefitsbmp1]
        subgraph ServerAPI [Minimal API Application]
            API[HTTP Endpoints]
            Chan[[.NET Channel]]
            BGS[Background Service]
        end
        FS[Local File System]
        IIS[IIS Web Server]
        WS[Windows Services]
    end

    subgraph Database[Database Server]
        SQL[(CommandCenter DB)]
    end

    %% Client Flow
    Client -->|1. SQL Pre-check| SQL
    Client -->|2. HTTP POST: Zip + JSON| API
    Client -->|4. Poll Status| API
    Client -->|5. SQL Update Active=1| SQL

    %% Server Internal Flow
    API -->|3a. Queue Task| Chan
    Chan -->|3b. Read Task| BGS

    %% Background Service Execution
    BGS -->|Extract| FS
    BGS -->|Configure| IIS
    BGS -->|Manage| WS
Hold "Alt" / "Option" to enable pan & zoom

2. Server API: Minimal API + Channels + BackgroundService#

This implementation uses .NET 8/9/10 features. The API accepts the file, saves it to a temporary location, queues the task in a Channel, and returns a tracking ID.

Program.cs

using System.Threading.Channels;
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

// 1. Register the Channel (Unbounded or Bounded depending on load)
builder.Services.AddSingleton(Channel.CreateUnbounded<DeploymentTask>());

// 2. Register the Background Service
builder.Services.AddHostedService<DeploymentBackgroundWorker>();

// 3. In-memory status dictionary (For production, use a lightweight DB or Distributed Cache)
builder.Services.AddSingleton<Dictionary<Guid, string>>();

var app = builder.Build();

// --- MINIMAL API ENDPOINTS ---

app.MapPost("/api/deploy", async (
    [FromForm] string configJson, 
    IFormFile package, 
    Channel<DeploymentTask> channel,
    Dictionary<Guid, string> statusStore) =>
{
    var config = System.Text.Json.JsonSerializer.Deserialize<DeploymentPayload>(configJson);
    var trackingId = Guid.NewGuid();

    // Save uploaded file to a temp path immediately so the HTTP request can complete
    string tempZipPath = Path.Combine(Path.GetTempPath(), $"{trackingId}.zip");
    using (var stream = new FileStream(tempZipPath, FileMode.Create))
    {
        await package.CopyToAsync(stream);
    }

    var task = new DeploymentTask(trackingId, config, tempZipPath);

    // Queue the task
    await channel.Writer.WriteAsync(task);
    statusStore[trackingId] = "Queued";

    // Return 202 Accepted with the tracking ID
    return Results.Accepted($"/api/deploy/status/{trackingId}", new { TrackingId = trackingId });
})
.DisableAntiforgery(); // Required for multipart/form-data in Minimal APIs without tokens

app.MapGet("/api/deploy/status/{id:guid}", (Guid id, Dictionary<Guid, string> statusStore) =>
{
    return statusStore.TryGetValue(id, out var status) 
        ? Results.Ok(new { Id = id, Status = status }) 
        : Results.NotFound();
});

app.Run();

// --- MODELS ---
public record DeploymentPayload(string Environment, string Version, string Package);
public record DeploymentTask(Guid TrackingId, DeploymentPayload Config, string TempZipPath);

DeploymentBackgroundWorker.cs

using System.IO.Compression;
using System.Threading.Channels;
using Microsoft.Web.Administration;

public class DeploymentBackgroundWorker : BackgroundService
{
    private readonly Channel<DeploymentTask> _channel;
    private readonly Dictionary<Guid, string> _statusStore;
    private readonly ILogger<DeploymentBackgroundWorker> _logger;

    public DeploymentBackgroundWorker(
        Channel<DeploymentTask> channel, 
        Dictionary<Guid, string> statusStore,
        ILogger<DeploymentBackgroundWorker> logger)
    {
        _channel = channel;
        _statusStore = statusStore;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // Process tasks sequentially as they arrive in the channel
        await foreach (var task in _channel.Reader.ReadAllAsync(stoppingToken))
        {
            try
            {
                _statusStore[task.TrackingId] = "Processing";
                _logger.LogInformation("Starting deployment {Id} for version {Version}", task.TrackingId, task.Config.Version);

                string targetPath = Path.Combine("C:\\Deployments", task.Config.Environment, task.Config.Version);

                // 1. Extract Files
                _statusStore[task.TrackingId] = "Extracting Files";
                Directory.CreateDirectory(targetPath);
                ZipFile.ExtractToDirectory(task.TempZipPath, targetPath, overwriteFiles: true);
                File.Delete(task.TempZipPath); // Cleanup temp file

                // 2. Configure IIS
                _statusStore[task.TrackingId] = "Configuring IIS";
                ConfigureIIS(task.Config, targetPath);

                // 3. Configure Windows Services
                _statusStore[task.TrackingId] = "Configuring Services";
                // InstallLocalService(task.Config, targetPath); // Implementation omitted for brevity

                _statusStore[task.TrackingId] = "Completed";
                _logger.LogInformation("Deployment {Id} completed successfully.", task.TrackingId);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Deployment {Id} failed.", task.TrackingId);
                _statusStore[task.TrackingId] = $"Failed: {ex.Message}";
            }
        }
    }

    private void ConfigureIIS(DeploymentPayload config, string targetPath)
    {
        using var serverManager = new ServerManager(); 
        string appPoolName = $"BenefitManager_{config.Environment}";
        string vDirWebPath = "/" + config.Version;

        if (serverManager.ApplicationPools[appPoolName] == null)
        {
            serverManager.ApplicationPools.Add(appPoolName).StartMode = StartMode.AlwaysRunning;
        }

        var site = serverManager.Sites[config.Environment];
        if (site != null && site.Applications[vDirWebPath] == null)
        {
            var app = site.Applications.Add(vDirWebPath, Path.Combine(targetPath, "Tasper.BenefitManager"));
            app.ApplicationPoolName = appPoolName;
        }
        serverManager.CommitChanges();
    }
}


3. The Client: Pre-checks, Polling, and SQL Finalization#

The client now acts as a true orchestrator. It checks the DB, sends the file, waits for the API to finish, and finalizes the DB state.

ClientOrchestrator.cs

using System.Net.Http.Headers;
using System.Text.Json;
using Dapper;
using Microsoft.Data.SqlClient;

public class ClientOrchestrator
{
    private readonly string _dbConnectionString = "Data Source=vmbenefitsdbp1;Initial Catalog=CommandCenter;Integrated Security=True;TrustServerCertificate=True";
    private readonly HttpClient _httpClient = new HttpClient();

    public async Task RunDeploymentAsync(string apiUrl, string zipFilePath, DeploymentPayload config)
    {
        Console.WriteLine("1. Performing SQL Pre-checks...");
        bool versionExists = await CheckIfVersionExistsAsync(config.Version);
        if (versionExists)
        {
            Console.WriteLine($"Warning: Version {config.Version} already exists in the database. Proceeding as an update.");
        }

        Console.WriteLine("2. Uploading package to Server API...");
        Guid trackingId = await UploadPackageAsync(apiUrl, zipFilePath, config);

        Console.WriteLine($"3. Waiting for Server API to complete (Tracking ID: {trackingId})...");
        bool isSuccess = await PollForCompletionAsync(apiUrl, trackingId);

        if (isSuccess)
        {
            Console.WriteLine("4. Server API finished. Finalizing SQL Database (Setting Active = 1)...");
            await FinalizeDatabaseAsync(config.Version);
            Console.WriteLine("Deployment completely successfully!");
        }
        else
        {
            Console.WriteLine("Deployment failed at the Server API level. Database will not be updated.");
        }
    }

    private async Task<bool> CheckIfVersionExistsAsync(string version)
    {
        using var conn = new SqlConnection(_dbConnectionString);
        var count = await conn.ExecuteScalarAsync<int>(
            "SELECT COUNT(1) FROM [dbo].[TblApplicationVersion] WHERE [Identifier] = @Version", 
            new { Version = version });
        return count > 0;
    }

    private async Task<Guid> UploadPackageAsync(string apiUrl, string zipFilePath, DeploymentPayload config)
    {
        using var content = new MultipartFormDataContent();
        content.Add(new StringContent(JsonSerializer.Serialize(config)), "configJson");

        using var fileStream = new FileStream(zipFilePath, FileMode.Open, FileAccess.Read);
        using var streamContent = new StreamContent(fileStream);
        streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/zip");
        content.Add(streamContent, "package", Path.GetFileName(zipFilePath));

        var response = await _httpClient.PostAsync($"{apiUrl}/api/deploy", content);
        response.EnsureSuccessStatusCode(); // Throws if not 2xx

        var responseString = await response.Content.ReadAsStringAsync();
        var result = JsonSerializer.Deserialize<JsonElement>(responseString);
        return result.GetProperty("trackingId").GetGuid();
    }

    private async Task<bool> PollForCompletionAsync(string apiUrl, Guid trackingId)
    {
        while (true)
        {
            var response = await _httpClient.GetAsync($"{apiUrl}/api/deploy/status/{trackingId}");
            if (!response.IsSuccessStatusCode) return false;

            var responseString = await response.Content.ReadAsStringAsync();
            var result = JsonSerializer.Deserialize<JsonElement>(responseString);
            string status = result.GetProperty("status").GetString();

            Console.WriteLine($"   Status: {status}");

            if (status == "Completed") return true;
            if (status.StartsWith("Failed")) return false;

            await Task.Delay(3000); // Wait 3 seconds before polling again
        }
    }

    private async Task FinalizeDatabaseAsync(string version)
    {
        using var conn = new SqlConnection(_dbConnectionString);

        // This executes the MERGE statements from your original PowerShell script
        string sql = @"
            DECLARE @CurrentDateTime DATETIME = CURRENT_TIMESTAMP;

            MERGE INTO [dbo].[TblApplicationVersion] AS Target
            USING (SELECT @Version AS Identifier) AS Source
            ON Target.[Identifier] = Source.Identifier
            WHEN MATCHED THEN UPDATE SET Target.[Online] = 1, Target.[ChangeDate] = @CurrentDateTime
            WHEN NOT MATCHED BY TARGET THEN 
                INSERT ([Identifier], [Online], [ChangeDate],[CreateDate])
                VALUES (Source.Identifier, 1, @CurrentDateTime, @CurrentDateTime);

            -- (Include the rest of the TblServerVersion MERGE logic here)
        ";

        await conn.ExecuteAsync(sql, new { Version = version });
    }
}