diff --git a/sdk/confidentialledger/Azure.Security.ConfidentialLedger/CHANGELOG.md b/sdk/confidentialledger/Azure.Security.ConfidentialLedger/CHANGELOG.md index c1b61f43125d..7e81615b1281 100644 --- a/sdk/confidentialledger/Azure.Security.ConfidentialLedger/CHANGELOG.md +++ b/sdk/confidentialledger/Azure.Security.ConfidentialLedger/CHANGELOG.md @@ -1,5 +1,10 @@ # Release History +## 1.5.0-beta.1 (Unreleased) + +### Features Added +- Added support to route to failover ledgers for the `GetLedgerEntry`, `GetLedgerEntryAsync`, `GetCurrentLedgerEntry`, and `GetCurrentLedgerEntryAsync` methods. + ## 1.4.1-beta.3 (Unreleased) ### Features Added diff --git a/sdk/confidentialledger/Azure.Security.ConfidentialLedger/src/Azure.Security.ConfidentialLedger.csproj b/sdk/confidentialledger/Azure.Security.ConfidentialLedger/src/Azure.Security.ConfidentialLedger.csproj index 6c9e88527448..54ba2535b2d3 100644 --- a/sdk/confidentialledger/Azure.Security.ConfidentialLedger/src/Azure.Security.ConfidentialLedger.csproj +++ b/sdk/confidentialledger/Azure.Security.ConfidentialLedger/src/Azure.Security.ConfidentialLedger.csproj @@ -2,7 +2,7 @@ Client SDK for the Azure Confidential Ledger service Azure Confidential Ledger - 1.4.1-beta.3 + 1.5.0-beta.1 1.3.0 Azure ConfidentialLedger diff --git a/sdk/confidentialledger/Azure.Security.ConfidentialLedger/src/ConfidentialLedgerClient.cs b/sdk/confidentialledger/Azure.Security.ConfidentialLedger/src/ConfidentialLedgerClient.cs index 3971677f1d25..a70f941c1dee 100644 --- a/sdk/confidentialledger/Azure.Security.ConfidentialLedger/src/ConfidentialLedgerClient.cs +++ b/sdk/confidentialledger/Azure.Security.ConfidentialLedger/src/ConfidentialLedgerClient.cs @@ -81,6 +81,7 @@ internal ConfidentialLedgerClient(Uri ledgerEndpoint, TokenCredential credential new ConfidentialLedgerResponseClassifier()); _ledgerEndpoint = ledgerEndpoint; _apiVersion = actualOptions.Version; + _failoverService = new ConfidentialLedgerFailoverService(_pipeline, ClientDiagnostics); } internal class ConfidentialLedgerResponseClassifier : ResponseClassifier diff --git a/sdk/confidentialledger/Azure.Security.ConfidentialLedger/src/ConfidentialLedgerFailoverService.cs b/sdk/confidentialledger/Azure.Security.ConfidentialLedger/src/ConfidentialLedgerFailoverService.cs new file mode 100644 index 000000000000..314434874221 --- /dev/null +++ b/sdk/confidentialledger/Azure.Security.ConfidentialLedger/src/ConfidentialLedgerFailoverService.cs @@ -0,0 +1,262 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Azure.Core.Pipeline; + +namespace Azure.Security.ConfidentialLedger +{ + internal class ConfidentialLedgerFailoverService + { + private readonly HttpPipeline _pipeline; + private readonly ClientDiagnostics _clientDiagnostics; + + internal const string IdentityServiceBaseUrl = "https://identity.confidential-ledger.core.azure.com"; + internal const string LedgerDomainSuffix = "confidential-ledger.azure.com"; + + private static ResponseClassifier _responseClassifier200; + private static ResponseClassifier ResponseClassifier200 => _responseClassifier200 ??= new StatusCodeClassifier(stackalloc ushort[] { 200 }); + + public ConfidentialLedgerFailoverService(HttpPipeline pipeline, ClientDiagnostics clientDiagnostics) + { + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + _clientDiagnostics = clientDiagnostics ?? throw new ArgumentNullException(nameof(clientDiagnostics)); + } + // Overloads for failover-only execution with collectionId gating. + public Task ExecuteOnFailoversAsync( + Uri primaryEndpoint, + Func> operationAsync, + string operationName, + string collectionIdGate, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(collectionIdGate)) + { + return operationAsync(primaryEndpoint); // collection gating: no failover + } + return ExecuteOnFailoversAsync(primaryEndpoint, operationAsync, operationName, cancellationToken); + } + + public T ExecuteOnFailovers( + Uri primaryEndpoint, + Func operationSync, + string operationName, + string collectionIdGate, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(collectionIdGate)) + { + return operationSync(primaryEndpoint); + } + return ExecuteOnFailovers(primaryEndpoint, operationSync, operationName, cancellationToken); + } + + private async Task> GetFailoverEndpointsAsync( + Uri primaryEndpoint, + CancellationToken cancellationToken = default) + { + try + { + string ledgerId = primaryEndpoint.Host.Substring(0, primaryEndpoint.Host.IndexOf('.')); + + Uri failoverUrl = new Uri($"{IdentityServiceBaseUrl}/failover/{ledgerId}"); + + using HttpMessage message = CreateFailoverRequest(failoverUrl); + Response response = await _pipeline.ProcessMessageAsync(message, new RequestContext()).ConfigureAwait(false); + return ParseFailoverEndpoints(primaryEndpoint, response); + } + catch (Exception) + { + // suppress metadata retrieval exception + } + return new List(); + } + + private List GetFailoverEndpoints( + Uri primaryEndpoint, + CancellationToken cancellationToken = default) + { + try + { + // retrieving sync metadata + string ledgerId = primaryEndpoint.Host.Substring(0, primaryEndpoint.Host.IndexOf('.')); + + Uri failoverUrl = new Uri($"{IdentityServiceBaseUrl}/failover/{ledgerId}"); + + using HttpMessage message = CreateFailoverRequest(failoverUrl); + Response response = _pipeline.ProcessMessage(message, new RequestContext()); + return ParseFailoverEndpoints(primaryEndpoint, response); + } + catch (Exception) + { + // suppress metadata retrieval exception + } + return new List(); + } + + private static List ParseFailoverEndpoints(Uri primaryEndpoint, Response response) + { + var endpoints = new List(); + if (response?.Status != 200) + { + return endpoints; + } + try + { + using JsonDocument jsonDoc = JsonDocument.Parse(response.Content); + jsonDoc.RootElement.TryGetProperty("ledgerId", out _); // optional + if (jsonDoc.RootElement.TryGetProperty("failoverLedgers", out JsonElement failoverArray)) + { + foreach (JsonElement failoverLedger in failoverArray.EnumerateArray()) + { + string failoverLedgerId = null; + try + { + switch (failoverLedger.ValueKind) + { + case JsonValueKind.String: + failoverLedgerId = failoverLedger.GetString(); + break; + case JsonValueKind.Object: + if (failoverLedger.TryGetProperty("name", out JsonElement nameProp) && nameProp.ValueKind == JsonValueKind.String) + { + failoverLedgerId = nameProp.GetString(); + } + else + { + foreach (JsonProperty prop in failoverLedger.EnumerateObject()) + { + if (prop.Value.ValueKind == JsonValueKind.String && string.Equals(prop.Name, "id", StringComparison.OrdinalIgnoreCase)) + { + failoverLedgerId = prop.Value.GetString(); + break; + } + } + } + break; + } + } + catch (JsonException jex) + { +#if DEBUG + Debug.WriteLine($"[ConfidentialLedgerFailoverService] JSON parse issue for failoverLedger element: {jex.Message}"); +#endif + _ = jex; // suppress unused warning in non-DEBUG builds + } + catch (InvalidOperationException ioex) + { +#if DEBUG + Debug.WriteLine($"[ConfidentialLedgerFailoverService] Invalid operation while parsing failoverLedger element: {ioex.Message}"); +#endif + _ = ioex; // suppress unused warning in non-DEBUG builds + } + + if (!string.IsNullOrEmpty(failoverLedgerId)) + { + Uri endpoint = new UriBuilder(primaryEndpoint) { Host = $"{failoverLedgerId}.{LedgerDomainSuffix}" }.Uri; + endpoints.Add(endpoint); + } + } + } + } + catch (Exception) + { + // ignore entire parse failure + } + return endpoints; + } + + private HttpMessage CreateFailoverRequest(Uri failoverUrl) + { + HttpMessage message = _pipeline.CreateMessage(new RequestContext(), ResponseClassifier200); + Request request = message.Request; + + request.Method = RequestMethod.Get; + + var uri = new RawRequestUriBuilder(); + uri.Reset(failoverUrl); + request.Uri = uri; + + request.Headers.Add("Accept", "application/json"); + + return message; + } + + private static bool IsRetriableFailure(RequestFailedException ex) + { + // Include 404 and specific UnknownLedgerEntry error code. + return ex.Status == 404 || + string.Equals(ex.ErrorCode, "UnknownLedgerEntry", StringComparison.OrdinalIgnoreCase) || + ex.Status >= 500 || + ex.Status == 408 || + ex.Status == 429 || + ex.Status == 503 || + ex.Status == 504; + } + + // Execute an operation only against discovered failover endpoints (skips primary). Used for specialized fallback flows. + public async Task ExecuteOnFailoversAsync( + Uri primaryEndpoint, + Func> operationAsync, + string operationName, + CancellationToken cancellationToken = default) + { + List endpoints = await GetFailoverEndpointsAsync(primaryEndpoint, cancellationToken).ConfigureAwait(false); + Exception last = null; + foreach (var ep in endpoints) + { + // attempt endpoint + try + { + cancellationToken.ThrowIfCancellationRequested(); + return await operationAsync(ep).ConfigureAwait(false); + } + catch (RequestFailedException ex) when (IsRetriableFailure(ex)) + { + // endpoint failed, continue + last = ex; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + } + throw last ?? new RequestFailedException("All failover endpoints failed in failovers mode"); + } + + public T ExecuteOnFailovers( + Uri primaryEndpoint, + Func operationSync, + string operationName, + CancellationToken cancellationToken = default) + { + List endpoints = GetFailoverEndpoints(primaryEndpoint, cancellationToken); + Exception last = null; + foreach (var ep in endpoints) + { + // attempt endpoint + try + { + cancellationToken.ThrowIfCancellationRequested(); + return operationSync(ep); + } + catch (RequestFailedException ex) when (IsRetriableFailure(ex)) + { + // endpoint failed + last = ex; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + } + throw last ?? new RequestFailedException("All failover endpoints failed in failovers mode"); + } + } +} diff --git a/sdk/confidentialledger/Azure.Security.ConfidentialLedger/src/Generated/ConfidentialLedgerClient.cs b/sdk/confidentialledger/Azure.Security.ConfidentialLedger/src/Generated/ConfidentialLedgerClient.cs index 1cc7f7def116..5878cfd30be3 100644 --- a/sdk/confidentialledger/Azure.Security.ConfidentialLedger/src/Generated/ConfidentialLedgerClient.cs +++ b/sdk/confidentialledger/Azure.Security.ConfidentialLedger/src/Generated/ConfidentialLedgerClient.cs @@ -4,8 +4,11 @@ // #nullable disable +using System.IO; using System; +using System.Collections.Generic; +using System.Text.Json; using System.Threading.Tasks; using Autorest.CSharp.Core; using Azure.Core; @@ -22,6 +25,7 @@ public partial class ConfidentialLedgerClient private readonly HttpPipeline _pipeline; private readonly Uri _ledgerEndpoint; private readonly string _apiVersion; + private readonly ConfidentialLedgerFailoverService _failoverService; /// The ClientDiagnostics is used to provide tracing support for the client library. internal ClientDiagnostics ClientDiagnostics { get; } @@ -252,8 +256,25 @@ public virtual async Task GetLedgerEntryAsync(string transactionId, st scope.Start(); try { - using HttpMessage message = CreateGetLedgerEntryRequest(transactionId, collectionId, context); - return await _pipeline.ProcessMessageAsync(message, context).ConfigureAwait(false); + try + { + using HttpMessage primaryMessage = CreateGetLedgerEntryRequest(_ledgerEndpoint, transactionId, collectionId, context); + return await _pipeline.ProcessMessageAsync(primaryMessage, context).ConfigureAwait(false); + } + catch (Exception) + { + Response failoverCurrent = await _failoverService.ExecuteOnFailoversAsync( + _ledgerEndpoint, + async (endpoint) => + { + using HttpMessage message = CreateGetCurrentLedgerEntryRequest(endpoint, collectionId, context); + return await _pipeline.ProcessMessageAsync(message, context).ConfigureAwait(false); + }, + nameof(GetCurrentLedgerEntryAsync), + collectionId, + context?.CancellationToken ?? default).ConfigureAwait(false); + return FormatLedgerEntry(failoverCurrent); + } } catch (Exception e) { @@ -288,8 +309,25 @@ public virtual Response GetLedgerEntry(string transactionId, string collectionId scope.Start(); try { - using HttpMessage message = CreateGetLedgerEntryRequest(transactionId, collectionId, context); - return _pipeline.ProcessMessage(message, context); + try + { + using HttpMessage primaryMessage = CreateGetLedgerEntryRequest(_ledgerEndpoint, transactionId, collectionId, context); + return _pipeline.ProcessMessage(primaryMessage, context); + } + catch (Exception) + { + Response failoverCurrent = _failoverService.ExecuteOnFailovers( + _ledgerEndpoint, + (endpoint) => + { + using HttpMessage message = CreateGetCurrentLedgerEntryRequest(endpoint, collectionId, context); + return _pipeline.ProcessMessage(message, context); + }, + nameof(GetCurrentLedgerEntry), + collectionId, + context?.CancellationToken ?? default); + return FormatLedgerEntry(failoverCurrent); + } } catch (Exception e) { @@ -459,8 +497,24 @@ public virtual async Task GetCurrentLedgerEntryAsync(string collection scope.Start(); try { - using HttpMessage message = CreateGetCurrentLedgerEntryRequest(collectionId, context); - return await _pipeline.ProcessMessageAsync(message, context).ConfigureAwait(false); + try + { + using HttpMessage primaryMessage = CreateGetCurrentLedgerEntryRequest(_ledgerEndpoint, collectionId, context); + return await _pipeline.ProcessMessageAsync(primaryMessage, context).ConfigureAwait(false); + } + catch (Exception) + { + return await _failoverService.ExecuteOnFailoversAsync( + _ledgerEndpoint, + async (endpoint) => + { + using HttpMessage message = CreateGetCurrentLedgerEntryRequest(endpoint, collectionId, context); + return await _pipeline.ProcessMessageAsync(message, context).ConfigureAwait(false); + }, + nameof(GetCurrentLedgerEntryAsync), + collectionId, + context?.CancellationToken ?? default).ConfigureAwait(false); + } } catch (Exception e) { @@ -490,8 +544,24 @@ public virtual Response GetCurrentLedgerEntry(string collectionId = null, Reques scope.Start(); try { - using HttpMessage message = CreateGetCurrentLedgerEntryRequest(collectionId, context); - return _pipeline.ProcessMessage(message, context); + try + { + using HttpMessage primaryMessage = CreateGetCurrentLedgerEntryRequest(_ledgerEndpoint, collectionId, context); + return _pipeline.ProcessMessage(primaryMessage, context); + } + catch (Exception) + { + return _failoverService.ExecuteOnFailovers( + _ledgerEndpoint, + (endpoint) => + { + using HttpMessage message = CreateGetCurrentLedgerEntryRequest(endpoint, collectionId, context); + return _pipeline.ProcessMessage(message, context); + }, + nameof(GetCurrentLedgerEntry), + collectionId, + context?.CancellationToken ?? default); + } } catch (Exception e) { @@ -2187,6 +2257,26 @@ internal HttpMessage CreateGetLedgerEntryRequest(string transactionId, string co return message; } + // Overload used for failover calls against alternate ledger endpoints. + internal HttpMessage CreateGetLedgerEntryRequest(Uri endpoint, string transactionId, string collectionId, RequestContext context) + { + var message = _pipeline.CreateMessage(context, ResponseClassifier200); + var request = message.Request; + request.Method = RequestMethod.Get; + var uri = new RawRequestUriBuilder(); + uri.Reset(endpoint); + uri.AppendPath("/app/transactions/", false); + uri.AppendPath(transactionId, true); + uri.AppendQuery("api-version", _apiVersion, true); + if (collectionId != null) + { + uri.AppendQuery("collectionId", collectionId, true); + } + request.Uri = uri; + request.Headers.Add("Accept", "application/json"); + return message; + } + internal HttpMessage CreateGetReceiptRequest(string transactionId, RequestContext context) { var message = _pipeline.CreateMessage(context, ResponseClassifier200); @@ -2237,6 +2327,25 @@ internal HttpMessage CreateGetCurrentLedgerEntryRequest(string collectionId, Req return message; } + // Overload used for failover calls against alternate ledger endpoints. + internal HttpMessage CreateGetCurrentLedgerEntryRequest(Uri endpoint, string collectionId, RequestContext context) + { + var message = _pipeline.CreateMessage(context, ResponseClassifier200); + var request = message.Request; + request.Method = RequestMethod.Get; + var uri = new RawRequestUriBuilder(); + uri.Reset(endpoint); + uri.AppendPath("/app/transactions/current", false); + uri.AppendQuery("api-version", _apiVersion, true); + if (collectionId != null) + { + uri.AppendQuery("collectionId", collectionId, true); + } + request.Uri = uri; + request.Headers.Add("Accept", "application/json"); + return message; + } + internal HttpMessage CreateGetUsersRequest(RequestContext context) { var message = _pipeline.CreateMessage(context, ResponseClassifier200); @@ -2661,5 +2770,85 @@ internal HttpMessage CreateGetUserDefinedFunctionsNextPageRequest(string nextLin private static ResponseClassifier ResponseClassifier201 => _responseClassifier201 ??= new StatusCodeClassifier(stackalloc ushort[] { 201 }); private static ResponseClassifier _responseClassifier200201; private static ResponseClassifier ResponseClassifier200201 => _responseClassifier200201 ??= new StatusCodeClassifier(stackalloc ushort[] { 200, 201 }); + + // Format a GetLedgerEntry-shaped response from a GetCurrentLedgerEntry response body. + // Expected current entry body: { "collectionId":"...", "contents":"...", "transactionId":"..." } + // Desired ledger entry body: { "entry": { same fields }, "state": "Ready" } + private Response FormatLedgerEntry(Response currentResponse) + { + try + { + if (currentResponse?.ContentStream == null) + { + return currentResponse; // nothing to do + } + currentResponse.ContentStream.Position = 0; + using (var doc = System.Text.Json.JsonDocument.Parse(currentResponse.ContentStream)) + { + var root = doc.RootElement; + using var ms = new System.IO.MemoryStream(); + var jsonWriterOptions = new System.Text.Json.JsonWriterOptions + { + Indented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + using (var writer = new System.Text.Json.Utf8JsonWriter(ms, jsonWriterOptions)) + { + writer.WriteStartObject(); + writer.WritePropertyName("entry"); + writer.WriteStartObject(); + if (root.TryGetProperty("collectionId", out var col)) writer.WriteString("collectionId", col.GetString()); + if (root.TryGetProperty("contents", out var contents)) writer.WriteString("contents", contents.GetString()); + if (root.TryGetProperty("transactionId", out var tx)) writer.WriteString("transactionId", tx.GetString()); + writer.WriteEndObject(); + writer.WriteString("state", "Ready"); + writer.WriteEndObject(); + } + ms.Position = 0; + // Wrap in a synthetic Response that mimics the original status/headers but with new content. + return new SyntheticResponse(currentResponse, ms.ToArray()); + } + } + catch (Exception) + { + return currentResponse; // fall back to original + } + } + + private sealed class SyntheticResponse : Response + { + private readonly Response _inner; + private readonly byte[] _content; + private System.IO.MemoryStream _stream; + + public SyntheticResponse(Response inner, byte[] content) + { + _inner = inner; + _content = content ?? Array.Empty(); + _stream = new System.IO.MemoryStream(_content, writable: false); + } + + public override int Status => _inner.Status; + public override string ReasonPhrase => _inner.ReasonPhrase; + public override Stream ContentStream + { + get => _stream; + set => _stream = value as System.IO.MemoryStream ?? new System.IO.MemoryStream(); + } + public override string ClientRequestId + { + get => _inner.ClientRequestId; + set => _inner.ClientRequestId = value; + } + public override void Dispose() + { + _stream?.Dispose(); + _inner?.Dispose(); + } + protected override bool ContainsHeader(string name) => _inner.Headers.Contains(name); + protected override IEnumerable EnumerateHeaders() => _inner.Headers; + protected override bool TryGetHeader(string name, out string value) => _inner.Headers.TryGetValue(name, out value); + protected override bool TryGetHeaderValues(string name, out IEnumerable values) => _inner.Headers.TryGetValues(name, out values); + } } }