diff --git a/.gitignore b/.gitignore index 0808c4a..6f69276 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,9 @@ x86/ [Ww][Ii][Nn]32/ [Aa][Rr][Mm]/ [Aa][Rr][Mm]64/ +# Restore source files in project folders named Arm — the rules above target build output only +!src/**/[Aa][Rr][Mm]/ +!src/**/[Aa][Rr][Mm]/** bld/ [Bb]in/ [Oo]bj/ diff --git a/src/AzLocal.Client/AzLocal.Client.csproj b/src/AzLocal.Client/AzLocal.Client.csproj index 1d69839..ea22fdb 100644 --- a/src/AzLocal.Client/AzLocal.Client.csproj +++ b/src/AzLocal.Client/AzLocal.Client.csproj @@ -5,7 +5,8 @@ - + + diff --git a/src/AzLocal.Client/AzlocalClientFactory.cs b/src/AzLocal.Client/AzlocalClientFactory.cs index f80463c..ccf15dd 100644 --- a/src/AzLocal.Client/AzlocalClientFactory.cs +++ b/src/AzLocal.Client/AzlocalClientFactory.cs @@ -1,8 +1,13 @@ +using Azure.Security.KeyVault.Secrets; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; + namespace AzLocal.Client; /// -/// Creates pre-configured instances and exposes the -/// for use with Azure SDK clients in local development. +/// One-stop factory for creating Azure SDK clients pre-configured to talk to the +/// local AzLocal emulator. Pass new AzlocalClientFactory() and call the +/// method for the service you need — no connection strings, no Azure tenant required. /// public sealed class AzlocalClientFactory { @@ -17,9 +22,103 @@ public AzlocalClientFactory(string baseUrl = DefaultBaseUrl) _baseUrl = baseUrl.TrimEnd('/'); } + // ------------------------------------------------------------------------- + // Blob Storage + // ------------------------------------------------------------------------- + + /// + /// Returns a pointed at the emulator for the given + /// storage account. Use this to manage containers and list blobs. + /// + /// var blobs = factory.CreateBlobServiceClient("myaccount"); + /// await blobs.CreateBlobContainerAsync("uploads"); + /// + /// + public BlobServiceClient CreateBlobServiceClient(string account) + { + var options = BuildBlobOptions(); + return new BlobServiceClient(GetBlobEndpoint(account), Credential, options); + } + + /// + /// Returns a pointed at a specific container. + /// + /// var container = factory.CreateBlobContainerClient("myaccount", "uploads"); + /// await container.UploadBlobAsync("file.txt", stream); + /// + /// + public BlobContainerClient CreateBlobContainerClient(string account, string container) + { + var uri = new Uri($"{_baseUrl}/azu/{account}/{container}"); + return new BlobContainerClient(uri, Credential, BuildBlobOptions()); + } + + /// + /// Returns a for a specific blob. + /// + /// var blob = factory.CreateBlobClient("myaccount", "uploads", "folder/file.txt"); + /// await blob.UploadAsync(stream, overwrite: true); + /// + /// + public BlobClient CreateBlobClient(string account, string container, string blobName) + { + var uri = new Uri($"{_baseUrl}/azu/{account}/{container}/{blobName}"); + return new BlobClient(uri, Credential, BuildBlobOptions()); + } + + // ------------------------------------------------------------------------- + // Key Vault + // ------------------------------------------------------------------------- + + /// + /// Returns a pointed at the emulator for the given vault. + /// + /// var secrets = factory.CreateSecretClient("myvault"); + /// await secrets.SetSecretAsync("my-api-key", "super-secret"); + /// var value = await secrets.GetSecretAsync("my-api-key"); + /// + /// + public SecretClient CreateSecretClient(string vault) + { + var uri = new Uri($"{_baseUrl}/kv/{vault}"); + var options = new SecretClientOptions + { + Retry = { MaxRetries = 0 } + }; + return new SecretClient(uri, Credential, options); + } + + // ------------------------------------------------------------------------- + // Service Bus + // ------------------------------------------------------------------------- + + /// + /// Returns a pre-configured for the Service Bus emulator. + /// The Azure Service Bus SDK uses AMQP (not HTTP), so direct SDK integration is not + /// supported. Use this client to call the emulator's HTTP queue endpoints directly. + /// + /// var sb = factory.CreateServiceBusHttpClient("mynamespace"); + /// await sb.PostAsync("myqueue/messages", new StringContent("hello")); + /// + /// + public HttpClient CreateServiceBusHttpClient(string @namespace) + { + var client = new HttpClient + { + BaseAddress = new Uri($"{_baseUrl}/sb/{@namespace}/") + }; + client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "fake-azlocal-token"); + return client; + } + + // ------------------------------------------------------------------------- + // Raw HTTP (ARM or any other emulated service) + // ------------------------------------------------------------------------- + /// - /// Creates an with its base address set to the emulator URL, - /// and a pre-set Authorization header carrying the fake bearer token. + /// Returns a general-purpose with the emulator base address + /// and auth header set. Use this for ARM calls or any service not covered above. /// public HttpClient CreateHttpClient() { @@ -29,6 +128,16 @@ public HttpClient CreateHttpClient() return client; } - /// Returns the blob storage base URL for the given account. + /// Returns the blob storage base URI for the given account. public Uri GetBlobEndpoint(string account) => new($"{_baseUrl}/azu/{account}"); + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private static BlobClientOptions BuildBlobOptions() => new() + { + // Disable retries so tests fail fast instead of hanging on connection errors. + Retry = { MaxRetries = 0 } + }; } diff --git a/src/AzLocal.Host/HostBuilder.cs b/src/AzLocal.Host/HostBuilder.cs index fb900a1..fe5d50e 100644 --- a/src/AzLocal.Host/HostBuilder.cs +++ b/src/AzLocal.Host/HostBuilder.cs @@ -1,9 +1,60 @@ -namespace AzLocal.Host; +using AzLocal.Core.Interfaces; +using AzLocal.Middleware; +using AzLocal.Services.Arm; +using AzLocal.Services.BlobStorage; +using AzLocal.Services.KeyVault; +using AzLocal.Services.ServiceBus; +using AzLocal.State; -public static class HostBuilder +namespace AzLocal.Host; + +/// +/// Configures all DI registrations for the AzLocal emulator. +/// Call once in Program.cs before building the app. +/// +public static class EmulatorBuilder { + /// + /// Registers all emulator services into the DI container: + /// + /// State store — selected by AzLocal:StateMode (InMemory / JsonSnapshot / Sqlite) + /// Blob file store + /// All implementations (Blob, KeyVault, ServiceBus, ARM) + /// + /// public static WebApplicationBuilder Configure(WebApplicationBuilder builder) { + RegisterStateStore(builder); + + builder.Services.AddSingleton(); + + // Each IServiceHandler registers its own routes in MapRoutes(). + // Program.cs iterates all of them so adding a new service here is the only change needed. + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + builder.Services.AddAuthorization(); + return builder; } + + private static void RegisterStateStore(WebApplicationBuilder builder) + { + var mode = builder.Configuration["AzLocal:StateMode"] ?? "InMemory"; + switch (mode) + { + case "Sqlite": + builder.Services.AddSingleton(); + break; + case "JsonSnapshot": + builder.Services.AddSingleton(); + break; + default: + builder.Services.AddSingleton(); + break; + } + } } diff --git a/src/AzLocal.Host/Program.cs b/src/AzLocal.Host/Program.cs index 1760df1..812dda6 100644 --- a/src/AzLocal.Host/Program.cs +++ b/src/AzLocal.Host/Program.cs @@ -1,6 +1,32 @@ +using AzLocal.Core.Interfaces; +using AzLocal.Host; +using AzLocal.Middleware; + var builder = WebApplication.CreateBuilder(args); +EmulatorBuilder.Configure(builder); + var app = builder.Build(); -app.MapGet("/", () => "Hello World!"); +// Middleware pipeline — order matters. +app.UseMiddleware(); // 1. log every request +app.UseMiddleware(); // 2. intercept IMDS token requests +app.UseMiddleware(); // 3. stamp all requests as authenticated +app.UseAuthorization(); // 4. enforce [Authorize] using the stub identity +app.UseMiddleware(); // 5. parse and store Azure request context + +// Auto-discover and register every IServiceHandler's routes. +// Adding a new service only requires registering it in HostBuilder — nothing changes here. +foreach (var handler in app.Services.GetServices()) +{ + handler.MapRoutes(app); + app.Logger.LogInformation("Registered service: {ServiceName}", handler.ServiceName); +} + +app.MapGet("/", () => new +{ + status = "AzLocal emulator running", + version = "1.0", + docs = "http://localhost:4566/" +}); app.Run(); diff --git a/src/AzLocal.Host/appsettings.json b/src/AzLocal.Host/appsettings.json index 10f68b8..161c961 100644 --- a/src/AzLocal.Host/appsettings.json +++ b/src/AzLocal.Host/appsettings.json @@ -2,8 +2,19 @@ "Logging": { "LogLevel": { "Default": "Information", + "AzLocal": "Debug", "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "AzLocal": { + "StateMode": "InMemory", + "BaseUrl": "http://localhost:4566", + "BlobStorePath": "", + "SnapshotPath": "", + "SqlitePath": "", + "SubscriptionId": "00000000-0000-0000-0000-000000000001", + "TenantId": "00000000-0000-0000-0000-000000000002", + "DefaultLocation": "eastus" + } } diff --git a/src/AzLocal.Services/Arm/ArmRoutes.cs b/src/AzLocal.Services/Arm/ArmRoutes.cs new file mode 100644 index 0000000..716fad4 --- /dev/null +++ b/src/AzLocal.Services/Arm/ArmRoutes.cs @@ -0,0 +1,13 @@ +namespace AzLocal.Services.Arm; + +/// +/// Route constants for the ARM (Azure Resource Manager) emulator endpoints. +/// Mirrors the real ARM REST API path structure under the /arm prefix. +/// +public static class ArmRoutes +{ + public const string Subscriptions = "/arm/subscriptions"; + public const string Subscription = "/arm/subscriptions/{subscriptionId}"; + public const string ResourceGroups = "/arm/subscriptions/{subscriptionId}/resourcegroups"; + public const string ResourceGroup = "/arm/subscriptions/{subscriptionId}/resourcegroups/{resourceGroupName}"; +} diff --git a/src/AzLocal.Services/Arm/ResourceGroupHandler.cs b/src/AzLocal.Services/Arm/ResourceGroupHandler.cs index e49e421..73254ad 100644 --- a/src/AzLocal.Services/Arm/ResourceGroupHandler.cs +++ b/src/AzLocal.Services/Arm/ResourceGroupHandler.cs @@ -1,5 +1,122 @@ -namespace AzLocal.Services.Arm; +using AzLocal.Core.Interfaces; +using AzLocal.Core.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using System.Text.Json; -public class ResourceGroupHandler +namespace AzLocal.Services.Arm; + +/// +/// Emulates the ARM Resource Groups API. Supports create, get, list, and delete +/// so the Azure SDK can manage resource group lifecycle against the local emulator. +/// +public class ResourceGroupHandler : IServiceHandler { + private readonly IStateStore _state; + private readonly ILogger _logger; + + public string ServiceName => "ARM/ResourceGroups"; + + public ResourceGroupHandler(IStateStore state, ILogger logger) + { + _state = state; + _logger = logger; + } + + public void MapRoutes(WebApplication app) + { + app.MapGet(ArmRoutes.ResourceGroups, ListResourceGroupsAsync); + app.MapGet(ArmRoutes.ResourceGroup, GetResourceGroupAsync); + app.MapPut(ArmRoutes.ResourceGroup, CreateOrUpdateResourceGroupAsync); + app.MapDelete(ArmRoutes.ResourceGroup, DeleteResourceGroupAsync); + } + + /// Returns all resource groups for the given subscription as an ARM paged list. + private async Task ListResourceGroupsAsync(string subscriptionId, HttpContext ctx) + { + var groups = await _state.ListAsync($"arm/resourcegroups/{subscriptionId}/"); + _logger.LogDebug("ListResourceGroups subscriptionId={Sub} count={Count}", subscriptionId, groups.Count); + + SetRequestId(ctx); + return Results.Ok(new { value = groups.Select(g => ResourceGroupResponse(subscriptionId, g)) }); + } + + /// Returns a single resource group by name, or 404 if it does not exist. + private async Task GetResourceGroupAsync(string subscriptionId, string resourceGroupName, HttpContext ctx) + { + var rg = await _state.GetAsync(RgKey(subscriptionId, resourceGroupName)); + if (rg is null) + { + _logger.LogWarning("GetResourceGroup not found sub={Sub} rg={RG}", subscriptionId, resourceGroupName); + return Results.NotFound(); + } + + SetRequestId(ctx); + return Results.Ok(ResourceGroupResponse(subscriptionId, rg)); + } + + /// Creates or updates a resource group. Reads location from the request body. + private async Task CreateOrUpdateResourceGroupAsync( + string subscriptionId, string resourceGroupName, HttpContext ctx) + { + string location = "local"; + try + { + using var doc = await JsonDocument.ParseAsync(ctx.Request.Body); + if (doc.RootElement.TryGetProperty("location", out var loc)) + location = loc.GetString() ?? "local"; + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "CreateResourceGroup could not parse body — using default location"); + } + + var existing = await _state.GetAsync(RgKey(subscriptionId, resourceGroupName)); + var isNew = existing is null; + + var rg = new ResourceGroup + { + Name = resourceGroupName, + Location = location, + SubscriptionId = subscriptionId, + ProvisioningState = "Succeeded" + }; + + await _state.SetAsync(RgKey(subscriptionId, resourceGroupName), rg); + _logger.LogInformation("ResourceGroup {Action} sub={Sub} rg={RG} location={Location}", + isNew ? "created" : "updated", subscriptionId, resourceGroupName, location); + + SetRequestId(ctx); + return isNew ? Results.Created() : Results.Ok(ResourceGroupResponse(subscriptionId, rg)); + } + + /// Deletes a resource group. No-ops if it does not exist. + private async Task DeleteResourceGroupAsync(string subscriptionId, string resourceGroupName, HttpContext ctx) + { + await _state.DeleteAsync(RgKey(subscriptionId, resourceGroupName)); + _logger.LogInformation("ResourceGroup deleted sub={Sub} rg={RG}", subscriptionId, resourceGroupName); + + SetRequestId(ctx); + return Results.Accepted(); + } + + #region Private helpers + + private static string RgKey(string subscriptionId, string name) => + $"arm/resourcegroups/{subscriptionId}/{name}".ToLowerInvariant(); + + private static void SetRequestId(HttpContext ctx) => + ctx.Response.Headers["x-ms-request-id"] = Guid.NewGuid().ToString(); + + private static object ResourceGroupResponse(string subscriptionId, ResourceGroup rg) => new + { + id = $"/subscriptions/{subscriptionId}/resourceGroups/{rg.Name}", + name = rg.Name, + location = rg.Location, + tags = rg.Tags, + properties = new { provisioningState = rg.ProvisioningState } + }; + + #endregion } diff --git a/src/AzLocal.Services/Arm/SubscriptionHandler.cs b/src/AzLocal.Services/Arm/SubscriptionHandler.cs index 7227674..46bd900 100644 --- a/src/AzLocal.Services/Arm/SubscriptionHandler.cs +++ b/src/AzLocal.Services/Arm/SubscriptionHandler.cs @@ -1,5 +1,70 @@ -namespace AzLocal.Services.Arm; +using AzLocal.Core.Interfaces; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; -public class SubscriptionHandler +namespace AzLocal.Services.Arm; + +/// +/// Emulates the ARM Subscriptions API. Returns a single fake subscription sourced from +/// AzLocal:SubscriptionId and AzLocal:TenantId in configuration so the +/// Azure SDK can resolve subscription context without a real Azure tenant. +/// +public class SubscriptionHandler : IServiceHandler { + private readonly ILogger _logger; + private readonly object _fakeSubscription; + private readonly string _subscriptionId; + + public string ServiceName => "ARM/Subscriptions"; + + public SubscriptionHandler(IConfiguration config, ILogger logger) + { + _logger = logger; + _subscriptionId = config["AzLocal:SubscriptionId"] ?? "00000000-0000-0000-0000-000000000001"; + var tenantId = config["AzLocal:TenantId"] ?? "00000000-0000-0000-0000-000000000002"; + + _fakeSubscription = new + { + subscriptionId = _subscriptionId, + displayName = "AzLocal Dev Subscription", + state = "Enabled", + tenantId + }; + } + + public void MapRoutes(WebApplication app) + { + // Cast to Delegate so the framework treats this as a route handler and writes the + // IResult to the response — without the cast, the HttpContext-only signature matches + // RequestDelegate and the return value is silently discarded. + app.MapGet(ArmRoutes.Subscriptions, (Delegate)ListSubscriptionsAsync); + app.MapGet(ArmRoutes.Subscription, GetSubscriptionAsync); + } + + /// Returns the configured fake subscription as a paged ARM list response. + private Task ListSubscriptionsAsync(HttpContext ctx) + { + _logger.LogDebug("ListSubscriptions"); + SetRequestId(ctx); + return Task.FromResult(Results.Ok(new { value = new[] { _fakeSubscription } })); + } + + /// Returns the fake subscription if the requested ID matches, otherwise 404. + private Task GetSubscriptionAsync(string subscriptionId, HttpContext ctx) + { + SetRequestId(ctx); + if (!subscriptionId.Equals(_subscriptionId, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning("GetSubscription not found subscriptionId={SubscriptionId}", subscriptionId); + return Task.FromResult(Results.NotFound()); + } + + _logger.LogDebug("GetSubscription subscriptionId={SubscriptionId}", subscriptionId); + return Task.FromResult(Results.Ok(_fakeSubscription)); + } + + private static void SetRequestId(HttpContext ctx) => + ctx.Response.Headers["x-ms-request-id"] = Guid.NewGuid().ToString(); } diff --git a/src/AzLocal.Services/BlobStorage/BlobRoutes.cs b/src/AzLocal.Services/BlobStorage/BlobRoutes.cs index 09626e3..1a239d2 100644 --- a/src/AzLocal.Services/BlobStorage/BlobRoutes.cs +++ b/src/AzLocal.Services/BlobStorage/BlobRoutes.cs @@ -1,5 +1,10 @@ namespace AzLocal.Services.BlobStorage; +/// +/// Route constants for the Blob Storage emulator endpoints. +/// {*blobName} is a catch-all segment so blob names containing forward slashes +/// (e.g. "folder/subfolder/file.txt") are captured as a single parameter. +/// public static class BlobRoutes { public const string ListContainers = "/azu/{account}"; diff --git a/src/AzLocal.Services/BlobStorage/BlobServiceHandler.cs b/src/AzLocal.Services/BlobStorage/BlobServiceHandler.cs index dd8550f..bd7ae71 100644 --- a/src/AzLocal.Services/BlobStorage/BlobServiceHandler.cs +++ b/src/AzLocal.Services/BlobStorage/BlobServiceHandler.cs @@ -2,6 +2,7 @@ using AzLocal.Core.Models; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using System.Text; using System.Xml; @@ -13,14 +14,16 @@ public class BlobServiceHandler : IServiceHandler private readonly IStateStore _state; private readonly IBlobFileStore _blobs; private readonly ILogger _logger; + private readonly string _baseUrl; public string ServiceName => "BlobStorage"; - public BlobServiceHandler(IStateStore state, IBlobFileStore blobs, ILogger logger) + public BlobServiceHandler(IStateStore state, IBlobFileStore blobs, ILogger logger, IConfiguration config) { _state = state; _blobs = blobs; _logger = logger; + _baseUrl = (config["AzLocal:BaseUrl"] ?? "http://localhost").TrimEnd('/'); } public void MapRoutes(WebApplication app) @@ -45,7 +48,7 @@ private async Task ListContainersAsync(string account, HttpContext ctx) var xml = BuildXml(writer => { writer.WriteStartElement("EnumerationResults"); - writer.WriteAttributeString("ServiceEndpoint", $"http://localhost/azu/{account}"); + writer.WriteAttributeString("ServiceEndpoint", $"{_baseUrl}/azu/{account}"); writer.WriteStartElement("Containers"); foreach (var c in containers) { @@ -73,7 +76,7 @@ private async Task ListBlobsAsync(string account, string container, Htt var xml = BuildXml(writer => { writer.WriteStartElement("EnumerationResults"); - writer.WriteAttributeString("ServiceEndpoint", $"http://localhost/azu/{account}"); + writer.WriteAttributeString("ServiceEndpoint", $"{_baseUrl}/azu/{account}"); writer.WriteAttributeString("ContainerName", container); writer.WriteStartElement("Blobs"); foreach (var b in blobs) diff --git a/src/AzLocal.Services/KeyVault/KeyVaultRoutes.cs b/src/AzLocal.Services/KeyVault/KeyVaultRoutes.cs index 9f57b22..1443ec5 100644 --- a/src/AzLocal.Services/KeyVault/KeyVaultRoutes.cs +++ b/src/AzLocal.Services/KeyVault/KeyVaultRoutes.cs @@ -1,5 +1,12 @@ -namespace AzLocal.Services.KeyVault; +namespace AzLocal.Services.KeyVault; +/// +/// Route constants for the Key Vault Secrets emulator endpoints. +/// {vault} is the vault name, matching how the real Key Vault API scopes secrets per vault. +/// public static class KeyVaultRoutes { + public const string Secrets = "/kv/{vault}/secrets"; + public const string SecretByName = "/kv/{vault}/secrets/{secretName}"; + public const string SecretVersion = "/kv/{vault}/secrets/{secretName}/{version}"; } diff --git a/src/AzLocal.Services/KeyVault/KeyVaultSecretHandler.cs b/src/AzLocal.Services/KeyVault/KeyVaultSecretHandler.cs index 98dddac..340307b 100644 --- a/src/AzLocal.Services/KeyVault/KeyVaultSecretHandler.cs +++ b/src/AzLocal.Services/KeyVault/KeyVaultSecretHandler.cs @@ -1,6 +1,154 @@ -namespace AzLocal.Services.KeyVault; +using AzLocal.Core.Interfaces; +using AzLocal.Core.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using System.Text.Json; -public class KeyVaultSecretHandler +namespace AzLocal.Services.KeyVault; + +public class KeyVaultSecretHandler : IServiceHandler { + private readonly IStateStore _state; + private readonly ILogger _logger; + private readonly string _baseUrl; + + public string ServiceName => "KeyVault"; + + public KeyVaultSecretHandler(IStateStore state, ILogger logger, IConfiguration config) + { + _state = state; + _logger = logger; + _baseUrl = (config["AzLocal:BaseUrl"] ?? "http://localhost").TrimEnd('/'); + } + + public void MapRoutes(WebApplication app) + { + app.MapGet(KeyVaultRoutes.Secrets, ListSecretsAsync); + app.MapPut(KeyVaultRoutes.SecretByName, SetSecretAsync); + app.MapGet(KeyVaultRoutes.SecretByName, GetSecretAsync); + app.MapGet(KeyVaultRoutes.SecretVersion, GetSecretVersionAsync); + app.MapDelete(KeyVaultRoutes.SecretByName, DeleteSecretAsync); + } + + #region Route handlers + + private async Task ListSecretsAsync(string vault, HttpContext ctx) + { + var secrets = await _state.ListAsync($"keyvault/secrets/{vault}/"); + _logger.LogDebug("ListSecrets vault={Vault} count={Count}", vault, secrets.Count); + + // Return only the latest enabled version of each secret — values are never included in list responses. + var latest = secrets + .Where(s => s.Enabled) + .GroupBy(s => s.Name) + .Select(g => g.OrderByDescending(s => s.UpdatedOn).First()) + .Select(s => new { id = SecretUrl(vault, s.Name, s.Version), attributes = Attributes(s) }); + + SetRequestId(ctx); + return Results.Ok(new { value = latest }); + } + + private async Task SetSecretAsync(string vault, string secretName, HttpContext ctx) + { + string value; + try + { + using var doc = await JsonDocument.ParseAsync(ctx.Request.Body); + value = doc.RootElement.GetProperty("value").GetString() ?? string.Empty; + } + catch (Exception ex) when (ex is JsonException or KeyNotFoundException) + { + _logger.LogWarning(ex, "SetSecret bad request vault={Vault} secret={Secret}", vault, secretName); + return Results.BadRequest("Request body must be JSON with a 'value' property."); + } + + var secret = new KeyVaultSecret + { + Name = secretName, + Value = value, + Version = Guid.NewGuid().ToString("N"), + CreatedOn = DateTimeOffset.UtcNow, + UpdatedOn = DateTimeOffset.UtcNow + }; + secret.Id = SecretUrl(vault, secretName, secret.Version); + + await _state.SetAsync(SecretKey(vault, secretName, secret.Version), secret); + _logger.LogInformation("Secret set vault={Vault} secret={Secret} version={Version}", vault, secretName, secret.Version); + + SetRequestId(ctx); + return Results.Ok(SecretResponse(vault, secret)); + } + + private async Task GetSecretAsync(string vault, string secretName, HttpContext ctx) + { + var all = await _state.ListAsync($"keyvault/secrets/{vault}/{secretName}/"); + var secret = all.Where(s => s.Enabled).OrderByDescending(s => s.UpdatedOn).FirstOrDefault(); + if (secret is null) + { + _logger.LogWarning("Secret not found vault={Vault} secret={Secret}", vault, secretName); + return Results.NotFound(); + } + + SetRequestId(ctx); + return Results.Ok(SecretResponse(vault, secret)); + } + + private async Task GetSecretVersionAsync(string vault, string secretName, string version, HttpContext ctx) + { + var secret = await _state.GetAsync(SecretKey(vault, secretName, version)); + if (secret is null) + { + _logger.LogWarning("Secret version not found vault={Vault} secret={Secret} version={Version}", vault, secretName, version); + return Results.NotFound(); + } + + SetRequestId(ctx); + return Results.Ok(SecretResponse(vault, secret)); + } + + private async Task DeleteSecretAsync(string vault, string secretName, HttpContext ctx) + { + var all = await _state.ListAsync($"keyvault/secrets/{vault}/{secretName}/"); + foreach (var s in all) + { + s.Enabled = false; + await _state.SetAsync(SecretKey(vault, secretName, s.Version), s); + } + + _logger.LogInformation("Secret deleted (soft) vault={Vault} secret={Secret} versions={Count}", vault, secretName, all.Count); + SetRequestId(ctx); + return Results.Ok(); + } + + #endregion + + #region Private helpers + + private static string SecretKey(string vault, string name, string version) => + $"keyvault/secrets/{vault}/{name}/{version}".ToLowerInvariant(); + + private string SecretUrl(string vault, string name, string version) => + $"{_baseUrl}/kv/{vault}/secrets/{name}/{version}"; + + private static void SetRequestId(HttpContext ctx) => + ctx.Response.Headers["x-ms-request-id"] = Guid.NewGuid().ToString(); + + private static object Attributes(KeyVaultSecret s) => new + { + enabled = s.Enabled, + created = s.CreatedOn.ToUnixTimeSeconds(), + updated = s.UpdatedOn.ToUnixTimeSeconds(), + expires = s.ExpiresOn?.ToUnixTimeSeconds() + }; + + private object SecretResponse(string vault, KeyVaultSecret secret) => new + { + value = secret.Value, + id = SecretUrl(vault, secret.Name, secret.Version), + attributes = Attributes(secret) + }; + #endregion } diff --git a/src/AzLocal.Services/ServiceBus/ServiceBusQueueHandler.cs b/src/AzLocal.Services/ServiceBus/ServiceBusQueueHandler.cs index d9d65c5..5e9381d 100644 --- a/src/AzLocal.Services/ServiceBus/ServiceBusQueueHandler.cs +++ b/src/AzLocal.Services/ServiceBus/ServiceBusQueueHandler.cs @@ -1,5 +1,204 @@ -namespace AzLocal.Services.ServiceBus; +using AzLocal.Core.Interfaces; +using AzLocal.Core.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using System.Text.Json; -public class ServiceBusQueueHandler +namespace AzLocal.Services.ServiceBus; + +public class ServiceBusQueueHandler : IServiceHandler { + private readonly IStateStore _state; + private readonly ILogger _logger; + + public string ServiceName => "ServiceBus"; + + public ServiceBusQueueHandler(IStateStore state, ILogger logger) + { + _state = state; + _logger = logger; + } + + public void MapRoutes(WebApplication app) + { + app.MapPost(ServiceBusRoutes.Messages, SendMessageAsync); + app.MapGet(ServiceBusRoutes.Messages, PeekMessagesAsync); + app.MapPost(ServiceBusRoutes.MessageHead, ReceiveMessageAsync); // peek-lock + app.MapDelete(ServiceBusRoutes.MessageHead, ReceiveAndDeleteAsync); // destructive receive + app.MapDelete(ServiceBusRoutes.MessageLock, CompleteMessageAsync); // complete after peek-lock + app.MapPost(ServiceBusRoutes.DeadLetter, DeadLetterMessageAsync); + } + + #region Route handlers + + /// Enqueues a new message. Body is the raw message payload. + private async Task SendMessageAsync(string @namespace, string queue, HttpContext ctx) + { + using var reader = new StreamReader(ctx.Request.Body); + var body = await reader.ReadToEndAsync(); + + var message = new ServiceBusMessage + { + MessageId = Guid.NewGuid().ToString(), + Body = body, + EnqueuedAt = DateTimeOffset.UtcNow + }; + + // Carry any x-ms-prop-* headers as application properties. + foreach (var header in ctx.Request.Headers.Where(h => h.Key.StartsWith("x-ms-prop-", StringComparison.OrdinalIgnoreCase))) + message.ApplicationProperties[header.Key[10..]] = header.Value.ToString(); + + await _state.SetAsync(ActiveKey(@namespace, queue, message.MessageId), message); + _logger.LogInformation("Message sent ns={Namespace} queue={Queue} messageId={MessageId}", @namespace, queue, message.MessageId); + + ctx.Response.Headers["x-ms-request-id"] = Guid.NewGuid().ToString(); + ctx.Response.Headers["Content-Location"] = $"/sb/{@namespace}/{queue}/messages/{message.MessageId}"; + return Results.Created(); + } + + /// Returns up to 32 messages without removing them from the queue. + private async Task PeekMessagesAsync(string @namespace, string queue, HttpContext ctx) + { + var messages = await _state.ListAsync(ActivePrefix(@namespace, queue)); + var page = messages.OrderBy(m => m.EnqueuedAt).Take(32).ToList(); + _logger.LogDebug("PeekMessages ns={Namespace} queue={Queue} count={Count}", @namespace, queue, page.Count); + + SetRequestId(ctx); + return Results.Ok(page); + } + + /// + /// Receives the oldest message under a peek-lock. The caller must complete or dead-letter + /// the message using the returned LockToken before the lock expires. + /// Returns 204 when the queue is empty. + /// + private async Task ReceiveMessageAsync(string @namespace, string queue, HttpContext ctx) + { + var messages = await _state.ListAsync(ActivePrefix(@namespace, queue)); + var message = messages.OrderBy(m => m.EnqueuedAt).FirstOrDefault(); + if (message is null) + { + _logger.LogDebug("ReceiveMessage ns={Namespace} queue={Queue} — queue empty", @namespace, queue); + return Results.NoContent(); + } + + message.DeliveryCount++; + await _state.SetAsync(ActiveKey(@namespace, queue, message.MessageId), message); + + var lockToken = Guid.NewGuid().ToString(); + await _state.SetAsync(LockKey(@namespace, queue, lockToken), message.MessageId); + + _logger.LogInformation("Message received (peek-lock) ns={Namespace} queue={Queue} messageId={MessageId} lock={Lock}", + @namespace, queue, message.MessageId, lockToken); + + SetRequestId(ctx); + ctx.Response.Headers["BrokerProperties"] = JsonSerializer.Serialize(new + { + MessageId = message.MessageId, + LockToken = lockToken, + DeliveryCount = message.DeliveryCount, + EnqueuedTimeUtc = message.EnqueuedAt + }); + return Results.Ok(message.Body); + } + + /// + /// Receives and immediately deletes the oldest message (no lock required to complete). + /// Returns 204 when the queue is empty. + /// + private async Task ReceiveAndDeleteAsync(string @namespace, string queue, HttpContext ctx) + { + var messages = await _state.ListAsync(ActivePrefix(@namespace, queue)); + var message = messages.OrderBy(m => m.EnqueuedAt).FirstOrDefault(); + if (message is null) return Results.NoContent(); + + await _state.DeleteAsync(ActiveKey(@namespace, queue, message.MessageId)); + _logger.LogInformation("Message receive-deleted ns={Namespace} queue={Queue} messageId={MessageId}", @namespace, queue, message.MessageId); + + SetRequestId(ctx); + ctx.Response.Headers["BrokerProperties"] = JsonSerializer.Serialize(new + { + MessageId = message.MessageId, + DeliveryCount = message.DeliveryCount + 1, + EnqueuedTimeUtc = message.EnqueuedAt + }); + return Results.Ok(message.Body); + } + + /// Completes a previously peek-locked message, removing it from the queue. + private async Task CompleteMessageAsync(string @namespace, string queue, string lockToken, HttpContext ctx) + { + var messageId = await _state.GetAsync(LockKey(@namespace, queue, lockToken)); + if (messageId is null) + { + _logger.LogWarning("CompleteMessage lock not found ns={Namespace} queue={Queue} lock={Lock}", @namespace, queue, lockToken); + return Results.NotFound(); + } + + await _state.DeleteAsync(ActiveKey(@namespace, queue, messageId)); + await _state.DeleteAsync(LockKey(@namespace, queue, lockToken)); + _logger.LogInformation("Message completed ns={Namespace} queue={Queue} messageId={MessageId}", @namespace, queue, messageId); + + SetRequestId(ctx); + return Results.Ok(); + } + + /// Moves a peek-locked message to the dead-letter queue. + private async Task DeadLetterMessageAsync(string @namespace, string queue, string lockToken, HttpContext ctx) + { + var messageId = await _state.GetAsync(LockKey(@namespace, queue, lockToken)); + if (messageId is null) + { + _logger.LogWarning("DeadLetter lock not found ns={Namespace} queue={Queue} lock={Lock}", @namespace, queue, lockToken); + return Results.NotFound(); + } + + var message = await _state.GetAsync(ActiveKey(@namespace, queue, messageId)); + if (message is not null) + { + string? reason = null; + try + { + using var doc = await JsonDocument.ParseAsync(ctx.Request.Body); + doc.RootElement.TryGetProperty("deadLetterReason", out var prop); + reason = prop.GetString(); + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "DeadLetter could not parse reason body — continuing without reason"); + } + + message.DeadLetterReason = reason; + await _state.SetAsync(DeadLetterKey(@namespace, queue, messageId), message); + await _state.DeleteAsync(ActiveKey(@namespace, queue, messageId)); + } + + await _state.DeleteAsync(LockKey(@namespace, queue, lockToken)); + _logger.LogInformation("Message dead-lettered ns={Namespace} queue={Queue} messageId={MessageId}", @namespace, queue, messageId); + + SetRequestId(ctx); + return Results.Ok(); + } + + #endregion + + #region Private helpers + + private static string ActivePrefix(string ns, string queue) => + $"servicebus/{ns}/{queue}/active/".ToLowerInvariant(); + + private static string ActiveKey(string ns, string queue, string messageId) => + $"servicebus/{ns}/{queue}/active/{messageId}".ToLowerInvariant(); + + private static string LockKey(string ns, string queue, string lockToken) => + $"servicebus/{ns}/{queue}/locks/{lockToken}".ToLowerInvariant(); + + private static string DeadLetterKey(string ns, string queue, string messageId) => + $"servicebus/{ns}/{queue}/deadletter/{messageId}".ToLowerInvariant(); + + private static void SetRequestId(HttpContext ctx) => + ctx.Response.Headers["x-ms-request-id"] = Guid.NewGuid().ToString(); + + #endregion } diff --git a/src/AzLocal.Services/ServiceBus/ServiceBusRoutes.cs b/src/AzLocal.Services/ServiceBus/ServiceBusRoutes.cs index ce1b32b..125d3f0 100644 --- a/src/AzLocal.Services/ServiceBus/ServiceBusRoutes.cs +++ b/src/AzLocal.Services/ServiceBus/ServiceBusRoutes.cs @@ -1,6 +1,14 @@ -namespace AzLocal.Services.ServiceBus; +namespace AzLocal.Services.ServiceBus; +/// +/// Route constants for the Service Bus queue emulator endpoints. +/// Peek-lock flow: POST head → receive with lock token → DELETE lock to complete, or POST deadletterqueue to dead-letter. +/// Destructive flow: DELETE head → receive and immediately remove in one step. +/// public static class ServiceBusRoutes { - + public const string Messages = "/sb/{namespace}/{queue}/messages"; + public const string MessageHead = "/sb/{namespace}/{queue}/messages/head"; + public const string MessageLock = "/sb/{namespace}/{queue}/messages/{lockToken}"; + public const string DeadLetter = "/sb/{namespace}/{queue}/messages/{lockToken}/deadletterqueue"; }