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
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 });
}
}