From 11bd2995d70d3a13d6218eae759d43797905dccb Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 22 Oct 2026 12:57:19 +0300 Subject: [PATCH 01/16] feat: let the config engine sign in as a member and carry on past failures --- SW.Bitween.Configuration/BitweenApi.cs | 25 +++++++++++++++++- .../Configuration/Kinds.cs | 9 +++++++ .../Configuration/Planner.cs | 26 +++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/SW.Bitween.Configuration/BitweenApi.cs b/SW.Bitween.Configuration/BitweenApi.cs index 3cb6519f..5b55a8d2 100644 --- a/SW.Bitween.Configuration/BitweenApi.cs +++ b/SW.Bitween.Configuration/BitweenApi.cs @@ -17,7 +17,9 @@ public class BitweenApiException(string message, HttpStatusCode? status = null) /// Calls to one Bitween's API as the signed-in member: the access token goes with each call, and is /// renewed with the refresh token when it has expired, the way the browser renews it - the renewed /// tokens are saved back to the profile. Or, for a job with no profile, as a service account: its -/// token is traded for a session, and traded again whenever that runs out; nothing is saved. +/// token is traded for a session, and traded again whenever that runs out; nothing is saved. Or as a +/// member by email and password, for a Bitween too old to have service accounts: signed in again +/// whenever the session runs out, the password held only by this object. /// public class BitweenApi : IDisposable { @@ -25,6 +27,7 @@ public class BitweenApi : IDisposable readonly Profiles profiles; readonly string profileName; readonly string serviceToken; + readonly (string Email, string Password)? member; readonly SemaphoreSlim renewing = new(1, 1); Profile profile; @@ -48,6 +51,13 @@ public BitweenApi(string url, string serviceToken, bool insecure = false, HttpMe this.serviceToken = serviceToken; } + /// As a member, by email and password: for a Bitween with no service accounts. + public BitweenApi(string url, string email, string password, bool insecure = false, HttpMessageHandler handler = null) + : this(null, null, new Profile { Url = url.TrimEnd('/'), Insecure = insecure }, handler) + { + member = (email, password); + } + public string Url => profile.Url; public bool AsService => serviceToken != null; @@ -189,6 +199,7 @@ async Task RenewAsync(string stale) { if (profile.AccessToken != stale && !Expired(profile.AccessToken)) return; if (serviceToken != null) await TradeAsync(); + else if (member != null) await SignInAgainAsync(); else await RefreshAsync(); } finally @@ -206,6 +217,18 @@ async Task TradeAsync() profile.AccessToken = (string)JsonNode.Parse(body)?["jwt"]; } + async Task SignInAgainAsync() + { + var response = await http.PostAsJsonAsync("api/accounts/login", new { Username = member!.Value.Email, Password = member.Value.Password }); + var body = await response.Content.ReadAsStringAsync(); + if (!response.IsSuccessStatusCode) + throw new BitweenApiException($"{profile.Url} refused the sign-in: {Refusal(response.StatusCode, body)}", response.StatusCode); + var answer = JsonNode.Parse(body); + if ((bool?)answer?["mustChangePassword"] == true) + throw new BitweenApiException($"The account must change its password first: sign in to {profile.Url} in a browser and change it."); + profile.AccessToken = (string)answer?["jwt"]; + } + async Task RefreshAsync() { if (string.IsNullOrEmpty(profile.RefreshToken)) diff --git a/SW.Bitween.Configuration/Configuration/Kinds.cs b/SW.Bitween.Configuration/Configuration/Kinds.cs index d4817c8d..db607b02 100644 --- a/SW.Bitween.Configuration/Configuration/Kinds.cs +++ b/SW.Bitween.Configuration/Configuration/Kinds.cs @@ -77,6 +77,15 @@ public class InformationTypes : Kind protected override bool Include(string id, JsonObject api) => id != AggregationDocumentId; + public override List Differences(Context ctx, JsonObject want, Existing have) + { + var differ = base.Differences(ctx, want, have); + // Bitween keeps a message type name only while the type is on the bus, so one an older + // Bitween kept for a type off it would read as a change on every apply after. + if (!((bool?)want["busEnabled"] ?? (bool?)have.File["busEnabled"] ?? false)) differ.Remove("busMessageTypeName"); + return differ; + } + protected override JsonObject UpdateBody(Context ctx, Existing have, JsonObject want) { var body = base.UpdateBody(ctx, have, want); diff --git a/SW.Bitween.Configuration/Configuration/Planner.cs b/SW.Bitween.Configuration/Configuration/Planner.cs index 4b33e5c9..3f0fffb6 100644 --- a/SW.Bitween.Configuration/Configuration/Planner.cs +++ b/SW.Bitween.Configuration/Configuration/Planner.cs @@ -243,6 +243,32 @@ public async Task ApplyAsync(Plan plan, TextWriter output) } } + /// + /// Every change it can make, carrying on past one that fails, and why each that failed did: for a + /// copy of a whole Bitween, where one subscription waiting for its adapter shouldn't hold up the + /// rest. A change that names what failed before it fails too, and says so. + /// + public async Task> ApplyAllAsync(Plan plan) + { + var failed = new List<(Change, string)>(); + try + { + foreach (var change in plan.Changes) + { + try { await CarryOutAsync(change); } + catch (Exception ex) when (ex is BitweenApiException or ConfigException or HttpRequestException) + { + failed.Add((change, ex.Message)); + } + } + } + finally + { + await SaveManagedAsync(plan, TextWriter.Null); + } + return failed; + } + async Task CarryOutAsync(Change change) { switch (change.Op) From 76021df4fe8769a2cf1ff4dfca4ebb27fe3f2d6a Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 22 Oct 2026 12:57:39 +0300 Subject: [PATCH 02/16] feat: let a mirror's copy recreate prod's legacy response links --- .../Resources/Subscriptions/Create.cs | 5 +- .../ResponseRoutingValidation.cs | 9 +++- .../Resources/Subscriptions/Update.cs | 5 +- SW.Bitween.Api/Services/BitweenOptions.cs | 3 ++ .../Services/Mirror/MirrorCopyGate.cs | 43 +++++++++++++++ .../Services/Mirror/MirrorOptions.cs | 15 ++++++ .../Tests/ResponseSubscriptionTests.cs | 53 +++++++++++++++++-- SW.Bitween.Web/Startup.cs | 1 + 8 files changed, 125 insertions(+), 9 deletions(-) create mode 100644 SW.Bitween.Api/Services/Mirror/MirrorCopyGate.cs create mode 100644 SW.Bitween.Api/Services/Mirror/MirrorOptions.cs diff --git a/SW.Bitween.Api/Resources/Subscriptions/Create.cs b/SW.Bitween.Api/Resources/Subscriptions/Create.cs index 75065e1f..c26dde63 100644 --- a/SW.Bitween.Api/Resources/Subscriptions/Create.cs +++ b/SW.Bitween.Api/Resources/Subscriptions/Create.cs @@ -80,7 +80,7 @@ public async Task Handle(SubscriptionCreate model) private class Validate : AbstractValidator { - public Validate(BitweenDbContext dbContext, AdapterRequirements adapterRequirements) + public Validate(BitweenDbContext dbContext, AdapterRequirements adapterRequirements, Services.Mirror.MirrorCopyGate mirrorCopy) { // Same rule Documents enforces on BusMessageTypeName. Publishing to a name no // information type carries is legitimate - the consumer may be another product - @@ -93,7 +93,8 @@ public Validate(BitweenDbContext dbContext, AdapterRequirements adapterRequireme RuleFor(i => i.ResponseSubscriptionId).CustomAsync(async (responseSubId, context, _) => { - var failure = await ResponseRoutingValidation.CheckDestination(dbContext, responseSubId); + var failure = await ResponseRoutingValidation.CheckDestination(dbContext, responseSubId, + recreatingProd: mirrorCopy.RecreatingProd); if (failure != null) context.AddFailure(nameof(SubscriptionCreate.ResponseSubscriptionId), failure); }); diff --git a/SW.Bitween.Api/Resources/Subscriptions/ResponseRoutingValidation.cs b/SW.Bitween.Api/Resources/Subscriptions/ResponseRoutingValidation.cs index 7f31ea30..fd1ae9a6 100644 --- a/SW.Bitween.Api/Resources/Subscriptions/ResponseRoutingValidation.cs +++ b/SW.Bitween.Api/Resources/Subscriptions/ResponseRoutingValidation.cs @@ -31,8 +31,12 @@ internal static class ResponseRoutingValidation /// Returns the failure message, or null when the destination is allowed. /// The subscription being saved; null while it is being created. /// What it points at today; null while it is being created. + /// + /// A mirror's copy recreating what prod already has (see ): + /// an Internal or ApiCall target is prod's saved one, left alone there, so it is let through here too. + /// public static async Task CheckDestination(BitweenDbContext dbContext, int? responseSubscriptionId, - int? selfId = null, int? savedTarget = null) + int? selfId = null, int? savedTarget = null, bool recreatingProd = false) { if (responseSubscriptionId is null || responseSubscriptionId == savedTarget) return null; @@ -41,7 +45,8 @@ public static async Task CheckDestination(BitweenDbContext dbContext, in .Select(s => (SubscriptionType?)s.Type) .SingleOrDefaultAsync(); - if (type != SubscriptionType.Response) + var keptLegacy = recreatingProd && type is SubscriptionType.Internal or SubscriptionType.ApiCall; + if (type != SubscriptionType.Response && !keptLegacy) return "A response can only be handed to a response subscription. To reach anything else, " + "publish the response on the bus and let a gateway's routes pick it up."; diff --git a/SW.Bitween.Api/Resources/Subscriptions/Update.cs b/SW.Bitween.Api/Resources/Subscriptions/Update.cs index 4f5f7082..26c50029 100644 --- a/SW.Bitween.Api/Resources/Subscriptions/Update.cs +++ b/SW.Bitween.Api/Resources/Subscriptions/Update.cs @@ -104,7 +104,8 @@ private ValueTask GetSub(BitweenDbContext dbContext, IHttpContextA return dbContext.FindAsync(subId); } - public Validate(BitweenDbContext dbContext, IHttpContextAccessor httpContextAccessor, AdapterRequirements adapterRequirements) + public Validate(BitweenDbContext dbContext, IHttpContextAccessor httpContextAccessor, AdapterRequirements adapterRequirements, + Services.Mirror.MirrorCopyGate mirrorCopy) { RuleFor(i => i.Name).NotEmpty(); RuleFor(i => i.MatchExpression).Must(ValidateMatch); @@ -154,7 +155,7 @@ public Validate(BitweenDbContext dbContext, IHttpContextAccessor httpContextAcce { var subscription = await GetSub(dbContext, httpContextAccessor); var failure = await ResponseRoutingValidation.CheckDestination(dbContext, responseSubId, - subscription?.Id, subscription?.ResponseSubscriptionId); + subscription?.Id, subscription?.ResponseSubscriptionId, mirrorCopy.RecreatingProd); if (failure != null) context.AddFailure(nameof(SubscriptionUpdate.ResponseSubscriptionId), failure); }); diff --git a/SW.Bitween.Api/Services/BitweenOptions.cs b/SW.Bitween.Api/Services/BitweenOptions.cs index f842ee94..2a362512 100644 --- a/SW.Bitween.Api/Services/BitweenOptions.cs +++ b/SW.Bitween.Api/Services/BitweenOptions.cs @@ -174,6 +174,9 @@ public BitweenOptions() /// public SW.Bitween.NativeAdapters.ChannelMessages.MailServerOptions MailServer { get; } = new(); + /// The prod this staging copies its setup from, faked, to rehearse upgrades on. + public global::SW.Bitween.Services.Mirror.MirrorOptions Mirror { get; } = new(); + /// Days an exchange is kept before the retention job removes it. 0 keeps exchanges for ever. public int ExchangeRetentionDays { get; set; } diff --git a/SW.Bitween.Api/Services/Mirror/MirrorCopyGate.cs b/SW.Bitween.Api/Services/Mirror/MirrorCopyGate.cs new file mode 100644 index 00000000..20cb9676 --- /dev/null +++ b/SW.Bitween.Api/Services/Mirror/MirrorCopyGate.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Concurrent; +using System.Security.Cryptography; +using Microsoft.AspNetCore.Http; + +namespace SW.Bitween.Services.Mirror; + +/// +/// Tells a save that recreates prod's setup, during a mirror's copy, from one a person makes. +/// +/// Some rules leave alone what is already saved and refuse it only on a new save: a response handed +/// to an Internal or ApiCall subscription, from before Response subscriptions existed, is the one +/// today. A copy is a new save of something prod already has, so it is let through the way prod's own +/// is. The copy's calls to this Bitween carry a key it opens for the length of the copy; a save is +/// treated as recreating prod only when it carries an open key, on a Bitween marked as a mirror. +/// People editing on staging never carry one, so they meet the same rules as on prod. +/// +/// +public sealed class MirrorCopyGate(IHttpContextAccessor httpContextAccessor, BitweenOptions options) +{ + public const string Header = "X-Bitween-Mirror-Copy"; + + static readonly ConcurrentDictionary Open = new(StringComparer.Ordinal); + + /// A key for one copy's calls; close it when the copy ends. + public static string OpenKey() + { + var key = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); + Open[key] = 0; + return key; + } + + public static void CloseKey(string key) + { + if (key != null) Open.TryRemove(key, out _); + } + + /// Whether the request being handled is a mirror's copy recreating what prod already has. + public bool RecreatingProd => + options.Mirror.Target && + httpContextAccessor.HttpContext?.Request.Headers[Header].ToString() is { Length: > 0 } key && + Open.ContainsKey(key); +} diff --git a/SW.Bitween.Api/Services/Mirror/MirrorOptions.cs b/SW.Bitween.Api/Services/Mirror/MirrorOptions.cs new file mode 100644 index 00000000..c6d47439 --- /dev/null +++ b/SW.Bitween.Api/Services/Mirror/MirrorOptions.cs @@ -0,0 +1,15 @@ +namespace SW.Bitween.Services.Mirror; + +/// +/// Whether this Bitween is a staging that mirrors a prod's setup, with every value faked, to rehearse +/// an upgrade on. +/// +public class MirrorOptions +{ + /// + /// Set by the deployment, never from the UI: whether this Bitween may be overwritten by a copy of + /// another. A copy replaces the whole setup, so a prod - which would never set this - can't be + /// overwritten by a click, whatever its settings say. + /// + public bool Target { get; set; } +} diff --git a/SW.Bitween.IntegrationTests/Tests/ResponseSubscriptionTests.cs b/SW.Bitween.IntegrationTests/Tests/ResponseSubscriptionTests.cs index a6ddb11b..3b4e2226 100644 --- a/SW.Bitween.IntegrationTests/Tests/ResponseSubscriptionTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/ResponseSubscriptionTests.cs @@ -166,6 +166,52 @@ public async Task A_legacy_target_already_saved_is_kept_but_cannot_be_chosen_aga Assert.Contains(await UpdateFailures(sourceId, otherInternalId), f => f.PropertyName == "ResponseSubscriptionId"); } + /// + /// A mirror's copy recreates prod's setup as a new save, so a legacy target prod keeps would be + /// refused. Only the copy's own calls, while the copy is open and on a Bitween marked as a + /// mirror, keep it; anyone else on the same staging meets prod's rule. + /// + [Fact] + public async Task A_mirror_copy_recreates_a_legacy_target_and_nobody_else_can() + { + var (docId, internalId, _) = await Targets(); + var key = Services.Mirror.MirrorCopyGate.OpenKey(); + try + { + Assert.DoesNotContain(await CreateFailures(docId, internalId, Gate(mirror: true, key)), f => f.PropertyName == "ResponseSubscriptionId"); + Assert.Contains(await CreateFailures(docId, internalId, Gate(mirror: true, key: null)), f => f.PropertyName == "ResponseSubscriptionId"); + Assert.Contains(await CreateFailures(docId, internalId, Gate(mirror: false, key)), f => f.PropertyName == "ResponseSubscriptionId"); + Assert.Contains(await CreateFailures(docId, internalId, Gate(mirror: true, "not-a-key-the-copy-opened")), f => f.PropertyName == "ResponseSubscriptionId"); + } + finally + { + Services.Mirror.MirrorCopyGate.CloseKey(key); + } + // Once the copy is over, its key opens nothing. + Assert.Contains(await CreateFailures(docId, internalId, Gate(mirror: true, key)), f => f.PropertyName == "ResponseSubscriptionId"); + } + + private static Services.Mirror.MirrorCopyGate Gate(bool mirror, string key) + { + var request = new DefaultHttpContext(); + if (key != null) request.Request.Headers[Services.Mirror.MirrorCopyGate.Header] = key; + var options = new BitweenOptions(); + options.Mirror.Target = mirror; + return new Services.Mirror.MirrorCopyGate(new OwnRequest(request), options); + } + + /// + /// A request of its own. HttpContextAccessor keeps one for the whole async flow, so setting one + /// here would replace the request the update validator reads its subscription's id from. + /// + private sealed class OwnRequest(HttpContext context) : IHttpContextAccessor + { + public HttpContext HttpContext { get; set; } = context; + } + + /// A person's save: no copy's key, as every request on a staging carries. + private static Services.Mirror.MirrorCopyGate NoCopy() => Gate(mirror: true, key: null); + [Fact] public async Task Response_subscriptions_cannot_hand_on_to_each_other_in_a_loop() { @@ -604,13 +650,14 @@ private async Task Update(int subscriptionId, SubscriptionUpdate model) // The validators are private nested classes the request pipeline finds by scanning, so calling // a handler directly never runs them. Built by reflection, the way AccountCreateValidatorTests does. - private async Task> CreateFailures(int docId, int responseSubscriptionId) + private async Task> CreateFailures(int docId, int responseSubscriptionId, + Services.Mirror.MirrorCopyGate gate = null) { await using var scope = fixture.CreateScope(); var validator = (IValidator)Activator.CreateInstance( typeof(Resources.Subscriptions.Create).GetNestedType("Validate", BindingFlags.NonPublic)!, scope.ServiceProvider.GetRequiredService(), - scope.ServiceProvider.GetRequiredService())!; + scope.ServiceProvider.GetRequiredService(), gate ?? NoCopy())!; var result = await validator.ValidateAsync(new SubscriptionCreate { @@ -632,7 +679,7 @@ private async Task Update(int subscriptionId, SubscriptionUpdate model) var validator = (IValidator)Activator.CreateInstance( typeof(Resources.Subscriptions.Update).GetNestedType("Validate", BindingFlags.NonPublic)!, scope.ServiceProvider.GetRequiredService(), http, - scope.ServiceProvider.GetRequiredService())!; + scope.ServiceProvider.GetRequiredService(), NoCopy())!; var result = await validator.ValidateAsync(new SubscriptionUpdate { diff --git a/SW.Bitween.Web/Startup.cs b/SW.Bitween.Web/Startup.cs index f03fbee0..6db077e6 100644 --- a/SW.Bitween.Web/Startup.cs +++ b/SW.Bitween.Web/Startup.cs @@ -160,6 +160,7 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddSingleton(); services.AddSingleton(); services.AddScoped(); services.AddSingleton(); From e5f529617ac7b413fed9a59a8a543c52fb256323 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 22 Oct 2026 12:58:06 +0300 Subject: [PATCH 03/16] feat: prod mirror, a staging that copies prod's setup with every value faked --- README.md | 1 + SW.Bitween.Api/Resources/Mirror/Mirror.cs | 75 ++ SW.Bitween.Api/Services/Mirror/MirrorFaker.cs | 436 ++++++++++ .../Services/Mirror/MirrorOptions.cs | 22 +- .../Services/Mirror/MirrorService.cs | 722 ++++++++++++++++ SW.Bitween.Api/Services/Mirror/MirrorSetup.cs | 217 +++++ .../Services/Settings/SettingsCatalog.cs | 52 ++ SW.Bitween.Sdk/Model/Permissions.cs | 18 +- SW.Bitween.UnitTests/MirrorFakerTests.cs | 136 ++++ SW.Bitween.UnitTests/MirrorSetupTests.cs | 162 ++++ SW.Bitween.UnitTests/SettingsCatalogTests.cs | 1 + SW.Bitween.Web/ClientApp/src/api/client.ts | 8 + .../ClientApp/src/api/http/httpClient.ts | 2 + .../ClientApp/src/api/http/mirror.ts | 19 + SW.Bitween.Web/ClientApp/src/api/queryKeys.ts | 6 + SW.Bitween.Web/ClientApp/src/api/types.ts | 141 ++++ SW.Bitween.Web/ClientApp/src/pages.ts | 9 + .../ClientApp/src/pages/mirror/MirrorPage.tsx | 768 ++++++++++++++++++ .../mirror/__tests__/MirrorPage.test.tsx | 182 +++++ SW.Bitween.Web/ClientApp/src/routePages.tsx | 1 + SW.Bitween.Web/ClientApp/src/router.tsx | 2 + SW.Bitween.Web/Startup.cs | 1 + docs/admin-ui.md | 1 + docs/api-reference.md | 3 + docs/configuration.md | 4 + docs/prod-mirror.md | 62 ++ docs/security.md | 2 + 27 files changed, 3050 insertions(+), 3 deletions(-) create mode 100644 SW.Bitween.Api/Resources/Mirror/Mirror.cs create mode 100644 SW.Bitween.Api/Services/Mirror/MirrorFaker.cs create mode 100644 SW.Bitween.Api/Services/Mirror/MirrorService.cs create mode 100644 SW.Bitween.Api/Services/Mirror/MirrorSetup.cs create mode 100644 SW.Bitween.UnitTests/MirrorFakerTests.cs create mode 100644 SW.Bitween.UnitTests/MirrorSetupTests.cs create mode 100644 SW.Bitween.Web/ClientApp/src/api/http/mirror.ts create mode 100644 SW.Bitween.Web/ClientApp/src/pages/mirror/MirrorPage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/mirror/__tests__/MirrorPage.test.tsx create mode 100644 docs/prod-mirror.md diff --git a/README.md b/README.md index e54b0beb..753a9b9d 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ The database schema is created on first start. A new database refuses to start w | [Configuration](docs/configuration.md) | Every configuration key, runtime setting and Helm value | | [Deployment](docs/deployment.md) | Docker image, Helm chart, databases, storage and CI/CD | | [Operations](docs/operations.md) | Monitoring, troubleshooting, logs and data retention | +| [Prod mirror](docs/prod-mirror.md) | Copying a prod's setup onto a staging, every value faked, to try an upgrade there first | | [Admin UI](docs/admin-ui.md) | A tour of the web interface | | [API reference](docs/api-reference.md) | REST conventions and every endpoint | | [Development](docs/development.md) | Local setup, tests, migrations and extending Bitween | diff --git a/SW.Bitween.Api/Resources/Mirror/Mirror.cs b/SW.Bitween.Api/Resources/Mirror/Mirror.cs new file mode 100644 index 00000000..cb20fd72 --- /dev/null +++ b/SW.Bitween.Api/Resources/Mirror/Mirror.cs @@ -0,0 +1,75 @@ +using System.Threading.Tasks; +using SW.Bitween.Services.Mirror; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.Mirror; + +/// Whether this Bitween is a mirror and how it reaches prod, without calling prod: the page's first answer. +public class Get(BitweenDbContext dbContext, RequestContext requestContext, BitweenOptions options) : IQueryHandler +{ + public async Task Handle() + { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Mirror.View); + var m = options.Mirror; + var signIn = !string.IsNullOrWhiteSpace(m.SourceToken) ? "token" + : !string.IsNullOrWhiteSpace(m.SourceEmail) && !string.IsNullOrEmpty(m.SourcePassword) ? "member" + : null; + return new + { + m.Target, + m.SourceUrl, + SignIn = signIn, + Configured = !string.IsNullOrWhiteSpace(m.SourceUrl) && signIn != null, + }; + } +} + +/// +/// How close this staging is to prod, read from both now: per kind what is the same, differs, is +/// missing or is only here; adapters; the mock addresses that stand in for prod's; and what is left to +/// do. Prod is only read. A prod that can't be reached, or is too old, answers with why. +/// +[HandlerName("parity")] +public class Parity(BitweenDbContext dbContext, RequestContext requestContext, MirrorService mirror) : IQueryHandler +{ + public async Task Handle() + { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Mirror.View); + try + { + return await mirror.ParityAsync(); + } + catch (MirrorRefusal refusal) + { + return new { Error = refusal.Message }; + } + } +} + +public class MirrorCopyRequest +{ + /// Also remove what staging has and prod doesn't. Off unless asked, since it deletes. + public bool RemoveExtras { get; set; } +} + +/// +/// Copies prod's setup onto this staging, faked: creates what is missing, updates what differs, and +/// with removes what prod doesn't have. Carries on past an +/// item that fails, and says which and why. Only on a Bitween its deployment marks as a mirror. +/// +[HandlerName("copy")] +public class Copy(BitweenDbContext dbContext, RequestContext requestContext, MirrorService mirror) : ICommandHandler +{ + public async Task Handle(MirrorCopyRequest request) + { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Mirror.Operate); + try + { + return await mirror.CopyAsync(request?.RemoveExtras ?? false); + } + catch (MirrorRefusal refusal) + { + throw new SWValidationException("MIRROR", refusal.Message); + } + } +} diff --git a/SW.Bitween.Api/Services/Mirror/MirrorFaker.cs b/SW.Bitween.Api/Services/Mirror/MirrorFaker.cs new file mode 100644 index 00000000..0df5f91b --- /dev/null +++ b/SW.Bitween.Api/Services/Mirror/MirrorFaker.cs @@ -0,0 +1,436 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace SW.Bitween.Services.Mirror; + +/// +/// Turns prod's values into stand-ins of the same shape, so a staging copy behaves like prod without +/// holding anything of prod's: ten digits stay ten digits, a date stays a date in its format, a GUID +/// stays a GUID, and an address becomes a mock address no network can reach. The same value always +/// becomes the same stand-in under one key, so a code named in two places still matches, and copying +/// again changes nothing. +/// +public sealed class MirrorFaker(byte[] key) +{ + /// Where every mock address lives: a name reserved never to resolve (RFC 2606). + public const string MockDomain = "mirror.invalid"; + + /// What Bitween answers in place of a secret it holds; staging never sees what it stands for. + public const string Masked = "__private__"; + + // ------------------------------------------------------------------ values in context + + /// + /// An adapter's setting: how a flow behaves, so it is copied as it is - except an address, which + /// becomes a mock one, and a secret, which prod never showed and becomes a stand-in. + /// + public string Setting(string key, string value, string where) + { + if (string.IsNullOrEmpty(value)) return value; + if (value == Masked) return Secret(where); + if (IsConnectionStringKey(key) || LooksLikeConnectionString(value)) return ConnectionString(value); + if (IsAddressKey(key) && BareAddress(value) is { } bare) return bare; + return Addresses(value); + } + + /// + /// A partner's property or a global value: someone's data, so it is faked, unless it was asked to + /// be kept because it is a setting rather than data (a time zone, a date format). Addresses and + /// secrets are never kept. + /// + public string Datum(string key, string value, string where, bool keepReal = false) + { + if (string.IsNullOrEmpty(value)) return value; + if (value == Masked) return Secret(where); + if (IsConnectionStringKey(key) || LooksLikeConnectionString(value)) return ConnectionString(value); + if (IsAddressKey(key) && BareAddress(value) is { } bare) return bare; + // A port is nobody's data, and it is what a mock listens on. + if (IsPortKey(key)) return value; + return keepReal && !IsSecretLike(key) ? Addresses(value) : Fake(value); + } + + /// + /// A name a secret goes by, declared one or not: such a value is never copied as it is, whatever + /// the list of values to keep says. + /// + public static bool IsSecretLike(string key) + { + var k = (key ?? "").ToLowerInvariant().Replace("_", "").Replace("-", ""); + return k is "pass" or "key" or "pin" || SecretWords.Any(k.Contains); + } + + /// A stand-in for a secret Bitween wouldn't show: the same each time for the same place. + public string Secret(string where) + { + var bytes = Bytes("secret", where ?? "", 20); + var text = new StringBuilder("mirror-"); + foreach (var b in bytes) text.Append(Base36[b % Base36.Length]); + return text.ToString(); + } + + // ------------------------------------------------------------------ shape + + /// + /// The value with every letter and digit replaced, and everything else kept: punctuation, spaces, + /// case, length, {{tokens}}. JSON keeps its keys and structure; dates stay valid dates in their + /// format; GUIDs and hex stay hex; addresses inside become mock ones. + /// + public string Fake(string value) + { + if (string.IsNullOrEmpty(value)) return value; + var trimmed = value.Trim(); + if (KeptWords.Contains(trimmed)) return value; + if (trimmed.Length > 1 && trimmed[0] is '{' or '[' && Json(trimmed) is { } json) return json; + + var text = new StringBuilder(value.Length); + var at = 0; + foreach (var (start, length, kind) in Segments(value)) + { + if (start > at) text.Append(Plain(value[at..start])); + var part = value.Substring(start, length); + text.Append(kind switch + { + Segment.Token => part, + Segment.Url => Url(part, fakeQuery: true), + _ => Email(part), + }); + at = start + length; + } + if (at < value.Length) text.Append(Plain(value[at..])); + return text.ToString(); + } + + string Plain(string text) + { + if (text.Length == 0 || KeptWords.Contains(text.Trim())) return text; + if (IsoDate.Match(text) is { Success: true } date) return Date(text, date); + if (Hex.IsMatch(text) || Guid.IsMatch(text)) return HexShape(text); + return Shape(text); + } + + /// Letters stay letters of the same case, digits stay digits; a number keeps a leading digit that isn't 0. + string Shape(string text) + { + var bytes = Bytes("shape", text, text.Length); + var result = new char[text.Length]; + // Still in a number's leading zeros, which a padded number keeps, or at its first other digit. + var leading = true; + for (var i = 0; i < text.Length; i++) + { + var c = text[i]; + var b = bytes[i]; + if (i == 0 || !char.IsAsciiDigit(text[i - 1])) leading = true; + var first = leading && c != '0'; + if (char.IsAsciiDigit(c) && c != '0') leading = false; + result[i] = c switch + { + _ when char.IsAsciiDigit(c) => c == '0' && leading ? '0' + : first ? (char)('1' + b % 9) : (char)('0' + b % 10), + _ when char.IsAsciiLetterUpper(c) => (char)('A' + b % 26), + _ when char.IsAsciiLetterLower(c) => (char)('a' + b % 26), + // Another script's letters, which have no case to keep: a letter all the same. + _ when char.IsLetter(c) => (char)('a' + b % 26), + _ => c, + }; + } + return new string(result); + } + + string Digits(string number) + { + var bytes = Bytes("digits", number, number.Length); + return new string(number.Select((c, i) => !char.IsAsciiDigit(c) ? c + : i == 0 || !char.IsAsciiDigit(number[i - 1]) ? c == '0' ? '0' : (char)('1' + bytes[i] % 9) + : (char)('0' + bytes[i] % 10)).ToArray()); + } + + /// Hex stays hex, in its case; separators and braces stay where they are. + string HexShape(string text) + { + var bytes = Bytes("hex", text, text.Length); + var result = new char[text.Length]; + for (var i = 0; i < text.Length; i++) + { + var c = text[i]; + result[i] = char.IsAsciiHexDigit(c) + ? (char.IsAsciiLetterUpper(c) ? "0123456789ABCDEF" : "0123456789abcdef")[bytes[i] % 16] + : c; + } + return new string(result); + } + + /// A date moved by up to a year either way, written as it was; the time of day is kept. + string Date(string text, Match match) + { + if (!DateTime.TryParseExact(match.Value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var day)) + return Shape(text); + var shift = BitConverter.ToUInt16(Bytes("date", match.Value, 2)) % 731 - 365; + var rest = text[match.Length..]; + return day.AddDays(shift).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + (TimeOfDay.IsMatch(rest) ? rest : Shape(rest)); + } + + string Json(string text) + { + JsonNode node; + try { node = JsonNode.Parse(text); } + catch (JsonException) { return null; } + return node == null ? null : FakeNode(node)?.ToJsonString(new JsonSerializerOptions + { + WriteIndented = text.Contains('\n'), + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }); + } + + JsonNode FakeNode(JsonNode node) => node switch + { + JsonObject obj => new JsonObject(obj.Select(p => KeyValuePair.Create(p.Key, FakeNode(p.Value)))), + JsonArray list => new JsonArray(list.Select(FakeNode).ToArray()), + JsonValue v when v.TryGetValue(out var s) => JsonValue.Create(Fake(s)), + JsonValue v when v.TryGetValue(out _) => v.DeepClone(), + // A number: its digits only, so 1e5 stays a number. + JsonValue v => JsonNode.Parse(Digits(v.ToJsonString())), + _ => null, + }; + + // ------------------------------------------------------------------ addresses + + /// Every address in the text made a mock one; the rest of the text as it was. + public string Addresses(string text) + { + if (string.IsNullOrEmpty(text)) return text; + var result = new StringBuilder(text.Length); + var at = 0; + foreach (var (start, length, kind) in Segments(text).Where(s => s.Kind != Segment.Token)) + { + result.Append(text, at, start - at); + var part = text.Substring(start, length); + result.Append(kind == Segment.Url ? Url(part, fakeQuery: false) : Email(part)); + at = start + length; + } + result.Append(text, at, text.Length - at); + return result.ToString(); + } + + /// + /// A host, an IP, host:port or a list of them, as a setting named for an address holds: each made + /// a mock one. Null when the value isn't one, so it is read as text instead. + /// + public string BareAddress(string value) + { + var trimmed = value.Trim(); + if (trimmed.Contains("://")) return null; + var prefix = trimmed.StartsWith("tcp:", StringComparison.OrdinalIgnoreCase) ? trimmed[..4] : ""; + var parts = trimmed[prefix.Length..].Split(','); + var any = false; + var mocked = parts.Select(part => + { + var p = part.Trim(); + if (p.Length > 0 && p.All(char.IsAsciiDigit)) return part; // host,port as SQL Server writes it + var (host, port) = SplitPort(p); + if (!IsHost(host) && !IPAddress.TryParse(host, out _)) return null; + any = true; + return part.Replace(p, Host(host) + port); + }).ToList(); + return !any || mocked.Any(m => m == null) ? null : value.Replace(trimmed, prefix + string.Join(",", mocked)); + } + + /// A host's mock: the same each time. Loopback and names that resolve nowhere are left alone. + public string Host(string host) + { + if (string.IsNullOrEmpty(host) || host.Contains("{{")) return host; + var lower = host.Trim('[', ']').ToLowerInvariant(); + if (IPAddress.TryParse(lower, out var ip)) + { + if (IPAddress.IsLoopback(ip)) return host; + var n = 1 + Bytes("ip", lower, 1)[0] % 254; + // Addresses kept for documentation, which route nowhere (RFC 5737, RFC 3849). + return ip.AddressFamily == AddressFamily.InterNetworkV6 ? $"[2001:db8::{n:x}]" : $"192.0.2.{n}"; + } + if (Untouched(lower)) return host; + var hash = Bytes("host", lower, 4); + return $"m{Convert.ToHexString(hash).ToLowerInvariant()}.{MockDomain}"; + } + + /// A connection string: its host made a mock one, its password a stand-in, its user faked; settings kept. + public string ConnectionString(string value) + { + if (value.Contains("://")) return value.Replace(value.Trim(), Url(value.Trim(), fakeQuery: true)); + return string.Join(";", value.Split(';').Select(pair => + { + var eq = pair.IndexOf('='); + if (eq <= 0) return pair; + var name = pair[..eq]; + var v = pair[(eq + 1)..]; + var k = name.Trim().ToLowerInvariant().Replace(" ", ""); + return name + "=" + k switch + { + "host" or "server" or "datasource" or "address" or "addr" or "networkaddress" or "endpoint" or "hostname" + or "endpointsuffix" or "blobendpoint" or "queueendpoint" => BareAddress(v) ?? Addresses(v), + "password" or "pwd" or "accountkey" or "sharedaccesskey" or "sharedaccesssignature" or "secret" => Shape(v), + "userid" or "uid" or "user" or "username" or "accountname" or "sharedaccesskeyname" => Fake(v), + _ => v, + }; + })); + } + + string Url(string url, bool fakeQuery) + { + var m = UrlPattern.Match(url); + if (!m.Success) return url; + var authority = m.Groups["auth"].Value; + if (authority.Contains("{{")) return url; + + var user = ""; + var at = authority.LastIndexOf('@'); + if (at >= 0) + { + var info = authority[..at]; + var colon = info.IndexOf(':'); + // Who signs in and with what are prod's: the user faked, the password a stand-in of its shape. + user = (colon < 0 ? Fake(info) : Fake(info[..colon]) + ":" + Shape(info[(colon + 1)..])) + "@"; + authority = authority[(at + 1)..]; + } + var (host, port) = SplitPort(authority); + if (Untouched(host.ToLowerInvariant()) && user.Length == 0) return url; + var rest = m.Groups["rest"].Value; + return $"{m.Groups["scheme"].Value}://{user}{Host(host)}{port}{Query(rest, fakeQuery)}"; + } + + /// The path as it is; the query's values faked, where keys and tokens like ?apikey=... keep their place. + string Query(string rest, bool fake) + { + var q = rest.IndexOf('?'); + if (q < 0 || !fake) return rest; + var hash = rest.IndexOf('#', q); + var query = hash < 0 ? rest[(q + 1)..] : rest[(q + 1)..hash]; + var faked = string.Join("&", query.Split('&').Select(pair => + { + var eq = pair.IndexOf('='); + return eq < 0 ? pair : pair[..(eq + 1)] + Fake(pair[(eq + 1)..]); + })); + return rest[..(q + 1)] + faked + (hash < 0 ? "" : rest[hash..]); + } + + string Email(string email) + { + var at = email.LastIndexOf('@'); + var domain = email[(at + 1)..]; + if (Untouched(domain.ToLowerInvariant())) return email; + return Shape(email[..at]) + "@" + Host(domain); + } + + static (string Host, string Port) SplitPort(string authority) + { + if (authority.StartsWith('[')) + { + var close = authority.IndexOf(']'); + return close < 0 ? (authority, "") : (authority[..(close + 1)], authority[(close + 1)..]); + } + var colon = authority.LastIndexOf(':'); + return colon > 0 && authority.IndexOf(':') == colon && authority[(colon + 1)..].All(char.IsAsciiDigit) + ? (authority[..colon], authority[colon..]) + : (authority, ""); + } + + /// + /// Hosts no call goes to on purpose: loopback, names reserved for tests, and the schema hosts XML + /// and JSON name their namespaces after - a namespace is matched as text, so changing it would + /// change what a flow does. + /// + static bool Untouched(string host) => + host is "localhost" or "w3.org" or "www.w3.org" or "schemas.xmlsoap.org" or "schemas.microsoft.com" or "purl.org" + or "json-schema.org" or "schemas.openxmlformats.org" or "tempuri.org" or "schema.org" + || host.EndsWith("." + MockDomain, StringComparison.Ordinal) || host.EndsWith(".invalid", StringComparison.Ordinal) + || host.EndsWith(".localhost", StringComparison.Ordinal) || host.EndsWith(".test", StringComparison.Ordinal) + || host.EndsWith(".example", StringComparison.Ordinal) || host is "example.com" or "example.org" or "example.net"; + + // ------------------------------------------------------------------ reading values + + enum Segment { Token, Url, Email } + + /// Tokens, URLs and emails in the text, in order and apart: what isn't plain text. + static IEnumerable<(int Start, int Length, Segment Kind)> Segments(string text) + { + var found = new List<(int Start, int Length, Segment Kind)>(); + foreach (Match m in TokenPattern.Matches(text)) found.Add((m.Index, m.Length, Segment.Token)); + foreach (Match m in UrlPattern.Matches(text)) found.Add((m.Index, m.Length, Segment.Url)); + foreach (Match m in EmailPattern.Matches(text)) found.Add((m.Index, m.Length, Segment.Email)); + var end = 0; + foreach (var s in found.OrderBy(s => s.Start).ThenBy(s => s.Kind)) + { + if (s.Start < end) continue; + yield return s; + end = s.Start + s.Length; + } + } + + public static bool IsAddressKey(string key) + { + var k = (key ?? "").ToLowerInvariant(); + return AddressWords.Any(k.Contains); + } + + static bool IsPortKey(string key) => (key ?? "").EndsWith("port", StringComparison.OrdinalIgnoreCase); + + static bool IsConnectionStringKey(string key) + { + var k = (key ?? "").ToLowerInvariant().Replace("_", "").Replace("-", ""); + return k.Contains("connectionstring") || k.Contains("connstr") || k == "dsn"; + } + + /// name=value; pairs, one of them naming a server: Host=db;Port=5432;Password=... + static bool LooksLikeConnectionString(string value) => + value.Contains('=') && value.Contains(';') && + ConnectionHostPattern.IsMatch(value); + + static bool IsHost(string value) => HostPattern.IsMatch(value) && !value.Contains("{{"); + + // Deterministic bytes for a purpose and an input, as many as asked for. + byte[] Bytes(string purpose, string input, int count) + { + var result = new byte[count]; + var data = Encoding.UTF8.GetBytes(purpose + "\0" + input); + var filled = 0; + for (var block = 0; filled < count; block++) + { + using var hmac = new HMACSHA256(key); + hmac.TransformBlock(BitConverter.GetBytes(block), 0, 4, null, 0); + hmac.TransformFinalBlock(data, 0, data.Length); + var hash = hmac.Hash!; + var take = Math.Min(hash.Length, count - filled); + Array.Copy(hash, 0, result, filled, take); + filled += take; + } + return result; + } + + const string Base36 = "abcdefghijklmnopqrstuvwxyz0123456789"; + + static readonly HashSet KeptWords = new(StringComparer.OrdinalIgnoreCase) { "true", "false", "null", "none" }; + + static readonly string[] SecretWords = + ["password", "passwd", "pwd", "secret", "token", "credential", "apikey", "accesskey", "privatekey", "passphrase", "certificate", "signingkey"]; + + static readonly string[] AddressWords = ["url", "uri", "host", "server", "endpoint", "address", "domain", "webhook", "smtp", "imap", "ftp"]; + + static readonly Regex TokenPattern = new(@"\{\{[^}]*\}\}", RegexOptions.Compiled); + static readonly Regex UrlPattern = new(@"(?[A-Za-z][A-Za-z0-9+.\-]*)://(?[^\s/?#""'<>\\]+)(?[^\s""'<>\\]*)", RegexOptions.Compiled); + static readonly Regex EmailPattern = new(@"[A-Za-z0-9._%+\-]+@(?:[A-Za-z0-9\-]+\.)+[A-Za-z]{2,63}", RegexOptions.Compiled); + static readonly Regex HostPattern = new(@"^(?:[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z][A-Za-z0-9\-]{1,62}\.?$", RegexOptions.Compiled); + static readonly Regex ConnectionHostPattern = new(@"(^|;)\s*(host|server|data source|address|endpoint|accountname)\s*=", RegexOptions.Compiled | RegexOptions.IgnoreCase); + static readonly Regex IsoDate = new(@"^\d{4}-\d{2}-\d{2}(?=$|[T ])", RegexOptions.Compiled); + // At least one letter: digits alone are a number, and stay digits. + static readonly Regex Hex = new(@"^(?:(?=[0-9]*[a-f])[0-9a-f]{8,}|(?=[0-9]*[A-F])[0-9A-F]{8,})$", RegexOptions.Compiled); + static readonly Regex TimeOfDay = new(@"^([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$", RegexOptions.Compiled); + static readonly Regex Guid = new(@"^[{(]?[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}[)}]?$", RegexOptions.Compiled); +} diff --git a/SW.Bitween.Api/Services/Mirror/MirrorOptions.cs b/SW.Bitween.Api/Services/Mirror/MirrorOptions.cs index c6d47439..af66d8ae 100644 --- a/SW.Bitween.Api/Services/Mirror/MirrorOptions.cs +++ b/SW.Bitween.Api/Services/Mirror/MirrorOptions.cs @@ -1,8 +1,8 @@ namespace SW.Bitween.Services.Mirror; /// -/// Whether this Bitween is a staging that mirrors a prod's setup, with every value faked, to rehearse -/// an upgrade on. +/// Where this Bitween copies prod from, and whether it may: a staging that mirrors a prod's setup, +/// with every value faked, to rehearse an upgrade on. /// public class MirrorOptions { @@ -12,4 +12,22 @@ public class MirrorOptions /// overwritten by a click, whatever its settings say. /// public bool Target { get; set; } + + /// Prod's address, e.g. https://bitween.acme.com. + public string SourceUrl { get; set; } + + /// A service account token on prod with view-only permissions. A secret. + public string SourceToken { get; set; } + + /// For a prod too old to have service accounts: a member with the Viewer role. + public string SourceEmail { get; set; } + + /// That member's password. A secret. + public string SourcePassword { get; set; } + + /// + /// Partner properties and global values copied as they are, one per line, because they are + /// settings rather than data: partner:timeZone, or global:formats/date. Never an address or a secret. + /// + public string KeepRealValues { get; set; } } diff --git a/SW.Bitween.Api/Services/Mirror/MirrorService.cs b/SW.Bitween.Api/Services/Mirror/MirrorService.cs new file mode 100644 index 00000000..457af81b --- /dev/null +++ b/SW.Bitween.Api/Services/Mirror/MirrorService.cs @@ -0,0 +1,722 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using SW.Bitween.Cli; +using SW.Bitween.Cli.Configuration; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Services.Mirror; + +/// A prod the mirror can't copy, and why, in words for the page. +public class MirrorRefusal(string message) : Exception(message); + +public record MirrorSide(string Url, string Version, long? Exchanges); + +/// +/// One of prod's items and how staging's compares, with where each is so the page can open it: the +/// id on staging and on prod, and a subscription's type, which decides its page. A SQL statement opens +/// on its data source's page, so its ids are its data source's. +/// +/// inSync, differs, missing, waiting (for an adapter staging lacks) or extra (only on staging). +/// The last copy tried it and Bitween turned it down: copying again won't change that. +public record MirrorItem(string Key, string State, IReadOnlyList Fields, string Reason = null, bool Refused = false, + string StagingId = null, string ProdId = null, string Type = null); + +public record MirrorKind(string Section, string Noun, string Plural, int InSync, int Differs, int Missing, int Extra, string Skipped, IReadOnlyList Items); + +/// One of prod's adapters and what staging has of it. +public record MirrorAdapter(string Id, string Source, string ProdVersion, string StagingVersion, string State, int UsedBy); + +/// A field prod sends on items of one kind that this release doesn't know: from a newer or a changed build. Not copied. +public record MirrorUnknownField(string Section, string Plural, string Field, int Items, IReadOnlyList Examples); + +/// +/// One line on the page, in one of four groups: needsYou (only a person can do it), copy (copying +/// does it), notCovered (what the test can't include, and why) and onPurpose (how staging differs from +/// prod by design). Section and Key name the item it is about; Action, the page where it is done. +/// +public record MirrorNote(string Id, string Group, string Text, string Section = null, string Key = null, string Action = null); + +/// The page's answer: whether staging is ready to test on, and the counts behind it. +/// Prod's items staging has as prod has them. +/// Prod's items, of the kinds prod showed. +public record MirrorVerdict(bool Ready, int Matching, int Total, int NeedsYou, int ToCopy, int NotCovered); + +/// How close staging is to prod, and what is left to do: what the page shows. +public record MirrorParity( + DateTimeOffset CheckedOn, + MirrorSide Prod, + MirrorSide Staging, + MirrorVerdict Verdict, + IReadOnlyList Notes, + IReadOnlyList Kinds, + IReadOnlyList Adapters, + IReadOnlyList Addresses, + IReadOnlyList FakedKeys, + IReadOnlyList Unknown); + +public record MirrorFailure(string Section, string Noun, string Key, string Error); + +public record MirrorCopyResult(int Created, int Updated, int Removed, int AdapterSteps, IReadOnlyList Failed, MirrorParity After); + +/// +/// Copies prod's setup onto this staging, by name, through prod's own API and read-only: nothing on +/// prod changes and nothing needs installing there. Values are faked the moment they arrive (see +/// ), so this Bitween only ever stores stand-ins. Legacy setups are recreated +/// as prod has them (see ). Works with a prod on r10.0 or later; anything +/// older is refused with the reason. +/// +public sealed class MirrorService(BitweenOptions options, FileLinks links, IHttpContextAccessor httpContextAccessor, IConfiguration configuration, + NativeAdapterDiscoveryService nativeAdapters) +{ + /// The configuration name staging's copy is recorded under, so a later copy removes what prod no longer has. + public const string ConfigurationName = "prod-mirror"; + + static readonly string[] AdapterPrefixes = ["receivers", "validators", "mappers", "handlers"]; + + /// + /// What Bitween said of each item the last copy couldn't make, by kind and name, shown until a copy + /// makes it. One staging runs one node, and a restart forgets them until the next copy. + /// + static readonly System.Collections.Concurrent.ConcurrentDictionary<(string Section, string Key), string> LastFailures = new(); + + public async Task ParityAsync(CancellationToken cancellation = default) + { + using var comparison = await CompareAsync(removeExtras: false, copyKey: null); + return comparison.Parity; + } + + public async Task CopyAsync(bool removeExtras, CancellationToken cancellation = default) + { + if (!options.Mirror.Target) + throw new SWValidationException("MIRROR_NOT_TARGET", + "This Bitween isn't marked as a mirror, so nothing is copied onto it. Set Bitween__Mirror__Target=true in its deployment."); + + var key = MirrorCopyGate.OpenKey(); + List<(Change Change, string Error)> failed; + Plan plan; + try + { + using var comparison = await CompareAsync(removeExtras, key); + plan = comparison.Plan; + failed = await comparison.Planner.ApplyAllAsync(plan); + } + finally + { + MirrorCopyGate.CloseKey(key); + } + foreach (var change in plan.Changes) LastFailures.TryRemove((change.Section, change.Key), out _); + foreach (var (change, error) in failed) LastFailures[(change.Section, change.Key)] = error; + int Count(params Op[] ops) => plan.Changes.Count(c => ops.Contains(c.Op)) - failed.Count(f => ops.Contains(f.Change.Op)); + + var after = await ParityAsync(cancellation); + return new MirrorCopyResult(Count(Op.Create), Count(Op.Update), Count(Op.Delete), Count(Op.Install, Op.Promote), + failed.Select(f => new MirrorFailure(f.Change.Section, f.Change.Noun, f.Change.Key, f.Error)).ToList(), after); + } + + /// A comparison, and the two connections its planner carries out changes over until it is done with. + sealed record Comparison(MirrorParity Parity, Planner Planner, Plan Plan, BitweenApi Prod, BitweenApi Self) : IDisposable + { + public void Dispose() + { + Prod.Dispose(); + Self.Dispose(); + } + } + + async Task CompareAsync(bool removeExtras, string copyKey) + { + var prod = Connect(); + BitweenApi self = null; + try + { + return await CompareAsync(prod, () => self = SelfApi(copyKey), removeExtras); + } + catch + { + prod.Dispose(); + self?.Dispose(); + throw; + } + } + + async Task CompareAsync(BitweenApi prod, Func openSelf, bool removeExtras) + { + var prodVersion = await VersionAsync(prod); + + // ---- prod, read and faked + var prodCtx = new Context(prod); + var renamed = new List(); + var skipped = await ReadAsync(prodCtx, renamed); + var copy = MirrorSetup.Copy(prodCtx.Current, Faker(), KeepList()); + var used = AdapterUses(copy.Sections); + var prodAdapters = await AdaptersAsync(prod, used.Keys); + + // ---- staging, as it is + var self = openSelf(); + var selfCtx = new Context(self); + var stagingAdapters = await AdaptersAsync(self, used.Keys); + var adapters = CompareAdapters(prodAdapters, stagingAdapters, used, NativeIds()); + + var config = new ConfigFile(); + config.Rename(ConfigurationName); + var planner = new Planner(selfCtx, config); + await planner.ReadAsync(); + + // A subscription staging doesn't have yet, whose adapter it lacks and can't get from the + // marketplace, would only fail: it waits, and says for which adapter. One staging has already + // is copied over as usual, so a missing adapter never removes it. + var waiting = Waiting(copy.Sections, adapters, stagingAdapters) + .Where(w => !selfCtx.Has("subscriptions", w.Key)).ToDictionary(w => w.Key, w => w.Value, StringComparer.Ordinal); + foreach (var (section, items) in copy.Sections) + config.Sections[section] = items.Where(i => section != "subscriptions" || !waiting.ContainsKey(Kinds.Of(section).KeyOf(i))).ToList(); + config.Sections[Kinds.Adapters] = adapters.Where(a => a.State == "missing" && a.Source == "marketplace" && a.ProdVersion != null) + .Select(a => new JsonObject { ["install"] = a.Id, ["version"] = a.ProdVersion, ["current"] = true }).ToList(); + + var plan = await planner.PlanAsync(prune: true); + + var extras = Extras(selfCtx, copy.Sections, plan, skipped); + if (removeExtras) + plan.Changes.AddRange(extras.OrderByDescending(e => IndexOf(e.Section)) + .Select(e => new Change(Op.Delete, e.Section, e.Item.Key, Have: e.Item))); + + var kinds = Kinds.All.Select(kind => KindRow(kind, copy.Sections.GetValueOrDefault(kind.Section) ?? [], plan, extras, waiting, skipped, + prodCtx, selfCtx)).ToList(); + var unknown = Unknown(prodCtx, selfCtx, skipped); + var notes = Notes(kinds, adapters, plan, extras.Count, renamed, unknown, copy); + var parity = new MirrorParity( + DateTimeOffset.UtcNow, + new MirrorSide(prod.Url, prodVersion, await ExchangesAsync(prod)), + new MirrorSide(links.BaseUrl, BitweenInfo.Version(options).ToString(3), await ExchangesAsync(self)), + Verdict(kinds, notes), + notes, kinds, adapters, copy.Addresses, copy.FakedKeys, unknown); + return new Comparison(parity, planner, plan, prod, self); + } + + // ------------------------------------------------------------------ prod + + BitweenApi Connect() + { + var m = options.Mirror; + if (string.IsNullOrWhiteSpace(m.SourceUrl)) + throw new MirrorRefusal("Prod's address isn't set yet."); + if (!Uri.TryCreate(m.SourceUrl, UriKind.Absolute, out var uri) || uri.Scheme is not ("https" or "http")) + throw new MirrorRefusal($"{m.SourceUrl} isn't an address Bitween can call. Use one like https://bitween.acme.com."); + if (SameInstance(uri)) + throw new MirrorRefusal("That address is this Bitween. Set prod's address, not staging's."); + + // Prod is only ever read: a call that would change anything there is refused before it leaves. + var handler = new ReadOnly(new HttpClientHandler { UseCookies = false }); + if (!string.IsNullOrWhiteSpace(m.SourceToken)) + return new BitweenApi(m.SourceUrl, m.SourceToken.Trim(), insecure: false, handler); + if (!string.IsNullOrWhiteSpace(m.SourceEmail) && !string.IsNullOrEmpty(m.SourcePassword)) + return new BitweenApi(m.SourceUrl, m.SourceEmail.Trim(), m.SourcePassword, insecure: false, handler); + throw new MirrorRefusal("Set how staging signs in to prod: a service account token, or a Viewer member's email and password."); + } + + bool SameInstance(Uri prod) + { + foreach (var own in new[] { links.BaseUrl, links.InstanceUrl, options.PublicUrl }) + if (Uri.TryCreate(own, UriKind.Absolute, out var o) && + string.Equals(o.Host, prod.Host, StringComparison.OrdinalIgnoreCase) && o.Port == prod.Port) + return true; + return false; + } + + /// + /// Prod's release. r10.0 answered 1.0.0.0, as every build before it did, so 1.0.0.0 is told apart + /// from r8 by what only r10 has; Infolink (r6 and before) names its version differently. + /// + static async Task VersionAsync(BitweenApi prod) + { + JsonNode answer; + try { answer = await prod.GetAsync("api/settings/myversion"); } + catch (BitweenApiException ex) when (ex.Status is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + { + throw new MirrorRefusal($"Prod refused the sign-in: {ex.Message}"); + } + catch (Exception ex) when (ex is BitweenApiException or HttpRequestException or TaskCanceledException) + { + throw new MirrorRefusal($"Prod didn't answer: {ex.Message}"); + } + + if (answer?["infolinkApiVersion"] != null) + throw new MirrorRefusal("Prod runs Infolink (r6 or older). The mirror copies prods on r10.0 or later; move this client to r10 first."); + var version = VersionIn(answer?["bitweenApiVersion"]); + if (version is { Major: >= 10 }) return version.ToString(3); + + try { await prod.GetAsync("api/permissions"); } + catch (BitweenApiException ex) when (ex.Status == HttpStatusCode.NotFound) + { + throw new MirrorRefusal("Prod runs r8 or older. The mirror copies prods on r10.0 or later; move this client to r10 first."); + } + return "r10.0"; + } + + static Version VersionIn(JsonNode node) => node switch + { + JsonValue v when v.TryGetValue(out var s) && Version.TryParse(s, out var parsed) => parsed, + JsonObject o when (int?)o["major"] is { } major => new Version(major, (int?)o["minor"] ?? 0, Math.Max(0, (int?)o["build"] ?? 0)), + _ => null, + }; + + /// + /// Every kind prod has, each read on its own: a kind prod's release doesn't have, or the login + /// can't read, is left out and says why, rather than stopping the rest. + /// + static async Task> ReadAsync(Context ctx, List renamed) + { + var skipped = new Dictionary(StringComparer.Ordinal); + var raw = new Dictionary>(StringComparer.Ordinal); + foreach (var kind in Kinds.All) + { + try { raw[kind.Section] = await kind.ReadAsync(ctx); } + catch (BitweenApiException ex) when (ex.Status == HttpStatusCode.NotFound) + { + skipped[kind.Section] = NotOnProdsRelease; + raw[kind.Section] = []; + continue; + } + catch (BitweenApiException ex) when (ex.Status == HttpStatusCode.Forbidden) + { + skipped[kind.Section] = "Staging's login on prod can't read these."; + raw[kind.Section] = []; + continue; + } + renamed.AddRange(Distinguish(ctx, kind, raw[kind.Section])); + foreach (var (id, api) in raw[kind.Section]) ctx.Know(kind.Section, kind.KeyOfApi(ctx, api), id); + } + foreach (var kind in Kinds.All) + { + try { ctx.Current[kind.Section] = raw[kind.Section].Select(x => kind.ToExisting(ctx, x.Id, x.Api)).ToList(); } + catch (Exception ex) when (ex is InvalidOperationException or FormatException or ArgumentException) + { + // A shape this release didn't expect: the rest still copies. + skipped[kind.Section] = $"Prod answered in a form this release doesn't read ({ex.Message})."; + ctx.Current[kind.Section] = []; + } + } + return skipped; + } + + const string NotOnProdsRelease = "Prod's release doesn't have these."; + + /// + /// Prod can hold several of a kind under one name - five subscriptions called "test" - and a copy + /// goes by name, so each after the oldest is named apart: "test (2)", "test (3)". Every reference + /// to one still names the right one, since references are read by id. Says what it renamed. + /// + internal static IEnumerable Distinguish(Context ctx, Kind kind, List<(string Id, JsonObject Api)> items) + { + var field = kind.Section == "dataSourceStatements" ? "name" : kind.Fields.First(f => f.File == kind.KeyField).Api; + var taken = items.Select(i => kind.KeyOfApi(ctx, i.Api)).Where(k => k != null).ToHashSet(StringComparer.Ordinal); + var notes = new List(); + foreach (var group in items.GroupBy(i => kind.KeyOfApi(ctx, i.Api), StringComparer.Ordinal).Where(g => g.Key != null && g.Count() > 1)) + { + var ordered = group.OrderBy(i => long.TryParse(i.Id, out var n) ? n : long.MaxValue).ThenBy(i => i.Id, StringComparer.Ordinal).ToList(); + var names = new List(); + foreach (var (_, api) in ordered.Skip(1)) + { + var name = (string)api[field]; + var n = 2; + string unique; + do + { + unique = $"{name} ({n++})"; + api[field] = unique; + } while (!taken.Add(kind.KeyOfApi(ctx, api))); + names.Add(unique); + } + notes.Add($"Prod has {group.Count()} {Plural(kind.Noun)} named “{group.Key}”. On staging the oldest keeps the name and the others are {string.Join(", ", names.Select(x => $"“{x}”"))}."); + } + return notes; + } + + // ------------------------------------------------------------------ adapters + + sealed record AdapterInfo(string Id, bool Native, string Current, IReadOnlyCollection Versions, bool FromRegistry); + + /// + /// The adapters a Bitween lists, and for any one its items use that the lists leave out - a + /// database's, say - what versions it has of it. + /// + static async Task> AdaptersAsync(BitweenApi api, IEnumerable used) + { + var found = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var prefix in AdapterPrefixes) + { + JsonNode answer; + try { answer = await api.GetAsync($"api/adapters/catalog?prefix={prefix}"); } + catch (BitweenApiException ex) when (ex.Status is HttpStatusCode.NotFound or HttpStatusCode.Forbidden) { continue; } + foreach (var row in (answer as JsonArray ?? []).OfType()) + { + var id = (string)row["key"]; + if (string.IsNullOrEmpty(id)) continue; + var history = (row["versionHistory"] as JsonArray ?? []).OfType().ToList(); + found[id] = new AdapterInfo(id, (bool?)row["native"] ?? false, (string)row["currentVersion"], + (row["versions"] as JsonArray ?? []).Select(v => v?.ToString()).Where(v => v != null).ToList(), + history.Any(h => ((string)h["publishedBy"])?.StartsWith("registry:", StringComparison.Ordinal) == true)); + } + } + foreach (var id in used.Where(id => !found.ContainsKey(id) && !NativeAdapterDiscoveryService.IsNative(id))) + { + JsonNode listing; + try { listing = await api.GetAsync($"api/adapters/versions?adapterId={Uri.EscapeDataString(id)}"); } + catch (BitweenApiException ex) when (ex.Status is HttpStatusCode.NotFound or HttpStatusCode.Forbidden or HttpStatusCode.BadRequest) { continue; } + var versions = (listing?["versions"] as JsonArray ?? []).OfType().ToList(); + if (versions.Count == 0 && listing?["current"] == null) continue; + found[id] = new AdapterInfo(id, false, (string)listing?["current"], versions.Select(v => (string)v["version"]).Where(v => v != null).ToList(), + versions.Any(v => ((string)v["publishedBy"])?.StartsWith("registry:", StringComparison.Ordinal) == true)); + } + return found; + } + + /// Every adapter built into this release, retiring ones included: listings leave out a retiring one nothing uses yet. + HashSet NativeIds() => + AdapterPrefixes.SelectMany(p => nativeAdapters.GetNativeAdapters(p)).ToHashSet(StringComparer.OrdinalIgnoreCase); + + /// + /// Simplify9 publishes its database and broker adapters on the marketplace, so one missing on staging + /// is installed from there, whatever prod got it from. + /// + static bool PublishedBySimplify9(string id) => + id.StartsWith("bitween.db.", StringComparison.OrdinalIgnoreCase) || id.StartsWith("bitween.bus.", StringComparison.OrdinalIgnoreCase); + + static List CompareAdapters(Dictionary prod, Dictionary staging, + Dictionary uses, HashSet builtIn) + { + var rows = new List(); + foreach (var id in prod.Keys.Union(uses.Keys, StringComparer.OrdinalIgnoreCase).Order(StringComparer.OrdinalIgnoreCase)) + { + prod.TryGetValue(id, out var p); + staging.TryGetValue(id, out var s); + var used = uses.GetValueOrDefault(id); + if (p?.Native == true || NativeAdapterDiscoveryService.IsNative(id)) + { + // Built in: it comes with the release. One prod uses that this release lacks is a finding. + if (!builtIn.Contains(id) && used > 0) rows.Add(new MirrorAdapter(id, "builtin", "built in", null, "missing", used)); + continue; + } + var source = p?.FromRegistry == true || PublishedBySimplify9(id) ? "marketplace" : "custom"; + var want = p?.Current ?? p?.Versions.LastOrDefault(); + var state = s == null || (want != null && !s.Versions.Contains(want)) ? "missing" + : want != null && s.Current != want ? "different" + : "same"; + rows.Add(new MirrorAdapter(id, source, want, s?.Current, state, used)); + } + return rows; + } + + /// How many of prod's items use each adapter: subscriptions by role, channels, notifiers, data sources. + static Dictionary AdapterUses(Dictionary> sections) + { + var uses = new Dictionary(StringComparer.OrdinalIgnoreCase); + void Add(string id) { if (!string.IsNullOrEmpty(id)) uses[id] = uses.GetValueOrDefault(id) + 1; } + foreach (var s in sections.GetValueOrDefault("subscriptions") ?? []) + foreach (var role in new[] { "receiver", "validator", "mapper", "handler" }) + Add((string)s[role]?["adapter"]); + foreach (var c in (sections.GetValueOrDefault("notificationChannels") ?? []).Concat(sections.GetValueOrDefault("notifiers") ?? [])) + Add((string)c["handler"]); + foreach (var d in sections.GetValueOrDefault("dataSources") ?? []) Add((string)d["adapter"]); + return uses; + } + + /// + /// Subscriptions that name an adapter staging lacks, or a version of one it lacks, by name, with what + /// they wait for. One the copy will install from the marketplace at a known version doesn't wait. + /// + static Dictionary Waiting(Dictionary> sections, List adapters, + Dictionary staging) + { + var missing = adapters.Where(a => a.State == "missing" && !(a.Source == "marketplace" && a.ProdVersion != null)) + .ToDictionary(a => a.Id, StringComparer.OrdinalIgnoreCase); + var waiting = new Dictionary(StringComparer.Ordinal); + foreach (var s in sections.GetValueOrDefault("subscriptions") ?? []) + foreach (var role in new[] { "receiver", "validator", "mapper", "handler" }) + { + if ((string)s[role]?["adapter"] is not { } id) continue; + var pinned = (string)s[role]?["version"]; + if (missing.TryGetValue(id, out var a)) + waiting[(string)s["name"]] = $"Waits for the adapter {a.Id}{((pinned ?? a.ProdVersion) is { } v ? " " + v : "")} on staging."; + else if (pinned != null && staging.TryGetValue(id, out var has) && !has.Native && !has.Versions.Contains(pinned)) + waiting[(string)s["name"]] = $"Waits for version {pinned} of the adapter {id} on staging."; + } + return waiting; + } + + // ------------------------------------------------------------------ comparing + + /// + /// What staging has that prod doesn't: removed on a copy only when asked. A kind prod didn't show - + /// its release lacks it, or the login can't read it - says nothing about what prod has, so none of + /// staging's are counted. + /// + static List<(string Section, Existing Item)> Extras(Context self, Dictionary> sections, Plan plan, + Dictionary skipped) + { + var pruned = plan.Changes.Where(c => c.Op == Op.Delete).Select(c => (c.Section, c.Key)).ToHashSet(); + var extras = new List<(string, Existing)>(); + foreach (var kind in Kinds.All.Where(k => !skipped.ContainsKey(k.Section))) + { + var prodKeys = (sections.GetValueOrDefault(kind.Section) ?? []).Select(kind.KeyOf).ToHashSet(StringComparer.Ordinal); + extras.AddRange((self.Current.GetValueOrDefault(kind.Section) ?? []) + .Where(e => e.Key != null && !prodKeys.Contains(e.Key) && !pruned.Contains((kind.Section, e.Key))) + .Select(e => (kind.Section, e))); + } + return extras; + } + + static MirrorKind KindRow(Kind kind, List prodItems, Plan plan, List<(string Section, Existing Item)> extras, + Dictionary waiting, Dictionary skipped, Context prod, Context self) + { + var changes = plan.Changes.Where(c => c.Section == kind.Section).ToList(); + var prodIds = Ids(prod, kind.Section); + var stagingIds = Ids(self, kind.Section); + MirrorItem Located(MirrorItem item, string type = null) => + item with { ProdId = prodIds.GetValueOrDefault(item.Key), StagingId = stagingIds.GetValueOrDefault(item.Key), Type = type }; + + var items = new List(); + foreach (var item in prodItems) + { + var key = kind.KeyOf(item); + var type = kind.Section == "subscriptions" ? (string)item["type"] : null; + var change = changes.FirstOrDefault(c => c.Key == key && c.Op != Op.Delete); + var refused = change != null && LastFailures.TryGetValue((kind.Section, key), out var error) ? error : null; + items.Add(Located(waiting.TryGetValue(key, out var reason) && kind.Section == "subscriptions" + ? new MirrorItem(key, "waiting", [], reason) + : change == null ? new MirrorItem(key, "inSync", []) + : change.Op == Op.Create ? new MirrorItem(key, "missing", [], refused == null ? null : $"Staging couldn't create it. Bitween said: {refused}", refused != null) + : new MirrorItem(key, "differs", change.Fields ?? [], refused == null ? null : $"Staging couldn't update it. Bitween said: {refused}", refused != null), + type)); + } + var extraKeys = extras.Where(e => e.Section == kind.Section).Select(e => e.Item.Key).ToHashSet(StringComparer.Ordinal); + items.AddRange(changes.Where(c => c.Op == Op.Delete && !extraKeys.Contains(c.Key)) + .Select(c => Located(new MirrorItem(c.Key, "extra", [], "Prod no longer has it; copying removes it.")))); + items.AddRange(extraKeys.Order(StringComparer.Ordinal).Select(k => Located(new MirrorItem(k, "extra", [], "Only on staging.")))); + return new MirrorKind(kind.Section, kind.Noun, Plural(kind.Noun), + items.Count(i => i.State == "inSync"), items.Count(i => i.State == "differs"), + items.Count(i => i.State is "missing" or "waiting"), items.Count(i => i.State == "extra"), + skipped.GetValueOrDefault(kind.Section), items); + } + + /// Each item's id by key; a SQL statement's is its data source's, the page it is on. + static Dictionary Ids(Context ctx, string section) => + (ctx.Current.GetValueOrDefault(section) ?? []).Where(e => e.Key != null) + .GroupBy(e => e.Key, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => section == "dataSourceStatements" ? g.First().Api["dataSourceId"]?.ToString() : g.First().Id, StringComparer.Ordinal); + + /// + /// Fields prod sends on a kind's items, set on at least one, that this release never sends on the + /// same kind: what a changed or newer build added. The copy can't carry them, so the page says so + /// rather than dropping them unseen. Only kinds staging has items of can be compared. + /// + internal static List Unknown(Context prod, Context self, Dictionary skipped) + { + var found = new List(); + foreach (var kind in Kinds.All.Where(k => !skipped.ContainsKey(k.Section))) + { + var known = (self.Current.GetValueOrDefault(kind.Section) ?? []).SelectMany(e => e.Api.Select(p => p.Key)).ToHashSet(StringComparer.OrdinalIgnoreCase); + if (known.Count == 0) continue; + var set = (prod.Current.GetValueOrDefault(kind.Section) ?? []) + .SelectMany(e => e.Api.Where(p => !known.Contains(p.Key) && IsSet(p.Value)).Select(p => (Field: p.Key, e.Key))) + .GroupBy(x => x.Field, StringComparer.Ordinal); + found.AddRange(set.Select(g => new MirrorUnknownField(kind.Section, Plural(kind.Noun), g.Key, g.Count(), + g.Select(x => x.Key).Where(k => k != null).Order(StringComparer.Ordinal).Take(3).ToList()))); + } + return found; + + static bool IsSet(JsonNode value) => !Values.Empty(value) && !(value is JsonValue v && v.TryGetValue(out var b) && !b); + } + + /// "retry policy" as "retry policies", "address" as "addresses", "partner" as "partners". + internal static string Plural(string noun) => + noun.Length > 1 && noun[^1] == 'y' && !"aeiou".Contains(noun[^2]) ? noun[..^1] + "ies" + : noun.EndsWith('s') || noun.EndsWith('x') || noun.EndsWith("ch") || noun.EndsWith("sh") ? noun + "es" + : noun + "s"; + + /// "1 of prod's items uses", "3 of prod's items use". + static string Used(int n) => $"{n} of prod's items use{(n == 1 ? "s" : "")}"; + + static string Count(int n, string noun) => $"{n} {(n == 1 ? noun : Plural(noun))}"; + + static int IndexOf(string section) => Kinds.All.ToList().FindIndex(k => k.Section == section); + + // ------------------------------------------------------------------ what the page says + + static readonly Regex NamesWhatFailed = new("^There's no (?[a-zA-Z ]+) \"(?.+)\"\\.$", RegexOptions.Compiled); + + /// + /// Every line the page shows, in its group. Each says only what is known: a refusal is what + /// Bitween said when staging tried to create the item, never a forecast of what prod will do. + /// + static List Notes(List kinds, List adapters, Plan plan, int extras, List renamed, + List unknown, MirrorSetupCopy copy) + { + var notes = new List(); + + // ---- needs you + foreach (var a in adapters.Where(a => a.State == "missing" && a.Source != "builtin" && !(a.Source == "marketplace" && a.ProdVersion != null))) + { + var where = a.Source == "marketplace" + ? $"Install the adapter {a.Id} from the Marketplace tab of the Adapters page" + : $"Upload the adapter {a.Id}{(a.ProdVersion == null ? "" : $", version {a.ProdVersion},")} on the Adapters page"; + notes.Add(new MirrorNote($"adapter:{a.Id}", "needsYou", + $"{where}. {Used(a.UsedBy)} it; staging copies {(a.UsedBy == 1 ? "that one" : "them")} once it's there.", + Action: a.Source == "marketplace" ? "adapters-marketplace" : "adapters-installed")); + } + foreach (var a in adapters.Where(a => a.State == "different")) + notes.Add(new MirrorNote($"current:{a.Id}", "needsYou", + $"Make version {a.ProdVersion} of the adapter {a.Id} the current one on staging, as on prod. Staging runs {a.StagingVersion}.", + Action: "adapters-installed")); + foreach (var a in adapters.Where(a => a.State == "missing" && a.Source == "builtin")) + notes.Add(new MirrorNote($"builtin:{a.Id}", "needsYou", + $"This release no longer has the built-in adapter {a.Id}. {Used(a.UsedBy)} it, and would stop working after the upgrade.")); + foreach (var k in kinds.Where(k => k.Skipped != null && k.Skipped != NotOnProdsRelease)) + notes.Add(new MirrorNote($"skipped:{k.Section}", "needsYou", + $"{Capital(k.Plural)}: {k.Skipped} On prod, give its role permission to view {k.Plural}.")); + foreach (var problem in plan.Problems.Distinct()) + notes.Add(new MirrorNote($"problem:{problem}", "needsYou", $"On staging: {problem}")); + + // ---- copy does it + var creates = kinds.Sum(k => k.Items.Count(i => i.State == "missing" && !i.Refused)); + var updates = kinds.Sum(k => k.Items.Count(i => i.State == "differs" && !i.Refused)); + var installs = plan.Changes.Count(c => c.Op is Op.Install or Op.Promote); + var what = new[] + { + creates > 0 ? $"creates {Count(creates, "item")} staging doesn't have" : null, + updates > 0 ? $"updates {Count(updates, "item")} that {(updates == 1 ? "differs" : "differ")}" : null, + installs > 0 ? $"installs {Count(installs, "adapter")} from the marketplace" : null, + }.Where(x => x != null).ToList(); + if (what.Count > 0) notes.Add(new MirrorNote("copy", "copy", $"Copying {string.Join(", ", what)}.")); + if (extras > 0) + notes.Add(new MirrorNote("extras", "copy", + $"Staging has {Count(extras, "item")} prod doesn't. Copying removes {(extras == 1 ? "it" : "them")} only when you tick Also remove.")); + + // ---- not covered + foreach (var (k, i) in kinds.SelectMany(k => k.Items.Select(i => (k, i))).Where(x => x.i.Refused)) + { + var said = LastFailures.GetValueOrDefault((k.Section, i.Key)) ?? ""; + var named = NamesWhatFailed.Match(said); + notes.Add(new MirrorNote($"refused:{k.Section}:{i.Key}", "notCovered", named.Success + ? $"Staging couldn't create prod's {k.Noun} “{i.Key}”, because it uses the {named.Groups["noun"].Value} “{named.Groups["name"].Value}”, which staging couldn't create either." + : $"Staging couldn't {(i.State == "missing" ? "create" : "update")} prod's {k.Noun} “{i.Key}”, so the test won't cover it. Bitween said: {said}", + k.Section, i.Key)); + } + foreach (var (k, i) in kinds.SelectMany(k => k.Items.Select(i => (k, i))).Where(x => x.i.State == "waiting")) + notes.Add(new MirrorNote($"waiting:{k.Section}:{i.Key}", "notCovered", + $"Prod's {k.Noun} “{i.Key}” isn't on staging yet. {i.Reason}", k.Section, i.Key)); + foreach (var u in unknown) + notes.Add(new MirrorNote($"unknown:{u.Section}:{u.Field}", "notCovered", + $"Prod's {u.Plural} carry “{u.Field}”, which this release doesn't know, so it isn't copied. It is set on {u.Items} of them" + + $"{(u.Examples.Count > 0 ? ", such as " + string.Join(", ", u.Examples.Select(e => $"“{e}”")) : "")}.")); + + // ---- different on purpose + var subscriptions = copy.Sections.GetValueOrDefault("subscriptions")?.Count ?? 0; + if (subscriptions > 0) + notes.Add(new MirrorNote("paused", "onPurpose", $"{Capital(Count(subscriptions, "subscription"))} {(subscriptions == 1 ? "is" : "are")} paused on staging, so nothing runs on its own.")); + var dataSources = copy.Sections.GetValueOrDefault("dataSources")?.Count ?? 0; + var busGateways = copy.Sections.GetValueOrDefault("busGateways")?.Count ?? 0; + if (dataSources + busGateways > 0) + notes.Add(new MirrorNote("switchedOff", "onPurpose", + $"{Capital(string.Join(" and ", new[] { dataSources > 0 ? Count(dataSources, "data source") : null, busGateways > 0 ? Count(busGateways, "bus gateway") : null }.Where(x => x != null)))} {(dataSources + busGateways == 1 ? "is" : "are")} switched off on staging.")); + var fakedPartner = copy.FakedKeys.Count(f => !f.Kept && f.Id.StartsWith("partner:", StringComparison.Ordinal)); + var fakedGlobal = copy.FakedKeys.Count(f => !f.Kept && f.Id.StartsWith("global:", StringComparison.Ordinal)); + if (fakedPartner + fakedGlobal > 0) + notes.Add(new MirrorNote("faked", "onPurpose", + $"{Capital(string.Join(" and ", new[] { fakedPartner > 0 ? Count(fakedPartner, "partner property") : null, fakedGlobal > 0 ? Count(fakedGlobal, "global value") : null }.Where(x => x != null)))} " + + $"hold{(fakedPartner + fakedGlobal == 1 ? "s" : "")} a fake of the same shape. Keep a real one only if it's a setting, such as a time zone.", + Action: "faked")); + if (copy.Addresses.Count > 0) + notes.Add(new MirrorNote("addresses", "onPurpose", + $"{Capital(Count(copy.Addresses.Count, "address"))} prod uses lead{(copy.Addresses.Count == 1 ? "s" : "")} to a test address on staging, which goes nowhere.", + Action: "addresses")); + if ((copy.Sections.GetValueOrDefault("partners")?.Count ?? 0) > 0) + notes.Add(new MirrorNote("secrets", "onPurpose", "Secrets are stand-ins, and partners' API keys are staging's own.")); + notes.AddRange(renamed.Select(r => new MirrorNote($"renamed:{r}", "onPurpose", r))); + return notes; + } + + static string Capital(string text) => text.Length == 0 ? text : char.ToUpperInvariant(text[0]) + text[1..]; + + static MirrorVerdict Verdict(List kinds, List notes) + { + var compared = kinds.Where(k => k.Skipped == null).ToList(); + var toCopy = compared.Sum(k => k.Items.Count(i => i.State is "missing" or "differs" && !i.Refused)); + var needsYou = notes.Count(n => n.Group == "needsYou"); + return new MirrorVerdict(needsYou == 0 && toCopy == 0, + compared.Sum(k => k.InSync), compared.Sum(k => k.InSync + k.Differs + k.Missing), + needsYou, toCopy, notes.Count(n => n.Group == "notCovered")); + } + + static async Task ExchangesAsync(BitweenApi api) + { + try { return (long?)(await api.GetAsync("api/xchanges?page=0&size=1"))?["totalCount"]; } + catch (BitweenApiException) { return null; } + } + + // ------------------------------------------------------------------ this Bitween + + MirrorFaker Faker() => new(HKDF.DeriveKey(HashAlgorithmName.SHA256, + Encoding.UTF8.GetBytes(configuration["Token:Key"] ?? string.Empty), 32, info: Encoding.UTF8.GetBytes("bitween-mirror-fakes"))); + + HashSet KeepList() => + (options.Mirror.KeepRealValues ?? "").Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToHashSet(StringComparer.Ordinal); + + /// + /// This Bitween's own API, as the caller, the way the kit installer calls itself: where it listens. + /// During a copy each call carries the copy's key, so saves know they recreate prod. + /// + BitweenApi SelfApi(string copyKey) + { + var profile = new Profile { Url = SelfUrl, AccessToken = CallerToken() }; + return copyKey == null + ? new BitweenApi(null, null, profile) + : new BitweenApi(null, null, profile, new WithCopyKey(copyKey, new HttpClientHandler { UseCookies = false })); + } + + string SelfUrl => string.IsNullOrWhiteSpace(links.InstanceUrl) ? links.BaseUrl : links.InstanceUrl; + + string CallerToken() + { + var header = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString(); + return header?.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) == true + ? header[7..].Trim() + : throw new SWUnauthorizedException(); + } + + sealed class WithCopyKey(string key, HttpMessageHandler inner) : DelegatingHandler(inner) + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + request.Headers.Add(MirrorCopyGate.Header, key); + return base.SendAsync(request, cancellationToken); + } + } + + /// + /// Lets through only what reads: GETs, and the two sign-ins a read needs. Anything else is + /// refused here, before it reaches prod, whatever asked for it. + /// + sealed class ReadOnly(HttpMessageHandler inner) : DelegatingHandler(inner) + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri?.AbsolutePath.TrimEnd('/').ToLowerInvariant() ?? ""; + var signIn = request.Method == HttpMethod.Post && (path.EndsWith("/api/accounts/login") || path.EndsWith("/api/accounts/servicetoken")); + if (request.Method != HttpMethod.Get && !signIn) + throw new InvalidOperationException($"The mirror only reads prod; it refused {request.Method} {path}."); + return base.SendAsync(request, cancellationToken); + } + } +} diff --git a/SW.Bitween.Api/Services/Mirror/MirrorSetup.cs b/SW.Bitween.Api/Services/Mirror/MirrorSetup.cs new file mode 100644 index 00000000..a4909198 --- /dev/null +++ b/SW.Bitween.Api/Services/Mirror/MirrorSetup.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using SW.Bitween.Cli.Configuration; + +namespace SW.Bitween.Services.Mirror; + +/// An address on staging that stands in for one of prod's, and what uses it. +public record MirrorAddress(string Mock, string Kind, IReadOnlyList UsedBy); + +/// An item that uses something, and where in it: subscription "Push labels", handler, Url. +public record MirrorUse(string Section, string Noun, string Key, string Where); + +/// A partner property or global value that is faked, unless it is kept because it is a setting. +public record MirrorFakedKey(string Id, string Label, int Count, bool Kept); + +/// Prod's setup as staging gets it: every value handled, and what that turned up. +public record MirrorSetupCopy( + Dictionary> Sections, + IReadOnlyList Addresses, + IReadOnlyList FakedKeys); + +/// +/// Prod's setup, read as a configuration file would say it, with what staging must not hold taken out: +/// +/// how flows behave is copied as it is - formats, schedules, filters, mapping rules, tokens; +/// addresses become mock ones that resolve nowhere; +/// secrets prod wouldn't show become stand-ins, and partner keys new ones; +/// partner properties and global values, which are someone's data, are faked to the same shape, +/// unless a key is kept as a setting. +/// +/// Subscriptions arrive paused, and data sources and bus gateways switched off, so nothing on staging +/// runs on its own until a rehearsal runs it. +/// +public static class MirrorSetup +{ + public static MirrorSetupCopy Copy(IReadOnlyDictionary> prod, MirrorFaker faker, ISet keep) + { + var sections = new Dictionary>(StringComparer.Ordinal); + foreach (var (section, items) in prod) + sections[section] = items.Where(e => e.Key != null).OrderBy(e => e.Key, StringComparer.Ordinal) + .Select(e => Handle(section, e.Key, (JsonObject)e.File.DeepClone(), faker, keep)).ToList(); + + return new MirrorSetupCopy(sections, AddressesIn(sections), FakedKeys(prod, keep)); + } + + static JsonObject Handle(string section, string key, JsonObject item, MirrorFaker faker, ISet keep) + { + var where = $"{section}/{key}"; + switch (section) + { + case "partners": + Data(item["properties"] as JsonObject, $"{where}/properties", (k, _) => keep.Contains(PartnerKey(k)), faker); + if ((string)item["loginIdentity"] is { Length: > 0 } identity) + item["loginIdentity"] = faker.Datum("loginIdentity", identity, $"{where}/loginIdentity"); + // Prod keeps only a hash of each; staging gets keys of its own, the same each copy. + if (item["keys"] is JsonObject keys) + foreach (var name in keys.Select(k => k.Key).ToList()) + keys[name] = faker.Secret($"{where}/key/{name}"); + break; + + case "globalValues": + Data(item["values"] as JsonObject, $"{where}/values", (k, _) => keep.Contains(GlobalKey(key, k)), faker); + break; + + case "connections": + foreach (var field in new[] { "authorizeUrl", "tokenUrl" }) + if ((string)item[field] is { } url) item[field] = faker.Setting(field, url, $"{where}/{field}"); + foreach (var field in new[] { "clientId", "username" }) + if ((string)item[field] is { } value) item[field] = faker.Datum(field, value, $"{where}/{field}"); + foreach (var field in new[] { "clientSecret", "password" }) + if ((string)item[field] is { Length: > 0 } value) item[field] = faker.Setting(field, value, $"{where}/{field}"); + break; + + case "notificationChannels": + case "notifiers": + Settings(item["properties"] as JsonObject, $"{where}/properties", faker); + break; + + case "dataSources": + Settings(item["properties"] as JsonObject, $"{where}/properties", faker); + item["inactive"] = true; + break; + + case "busGateways": + Settings(item["endpointProperties"] as JsonObject, $"{where}/endpointProperties", faker); + item["inactive"] = true; + break; + + case "apiGateways": + // Its issuer is where partner logins are checked: an address like any other. + if (item["authentication"] is JsonObject auth) + foreach (var name in auth.Select(p => p.Key).ToList()) + if ((string)auth[name] is { } value && auth[name] is JsonValue) + auth[name] = faker.Setting(name, value, $"{where}/authentication/{name}"); + break; + + case "subscriptions": + foreach (var role in new[] { "receiver", "validator", "mapper", "handler" }) + Settings(item[role]?["properties"] as JsonObject, $"{where}/{role}", faker); + item["paused"] = true; + break; + } + return item; + } + + /// How an adapter behaves: copied, except addresses and secrets. + static void Settings(JsonObject properties, string where, MirrorFaker faker) + { + if (properties == null) return; + foreach (var name in properties.Select(p => p.Key).ToList()) + if (properties[name] is JsonValue v && v.TryGetValue(out var value)) + properties[name] = faker.Setting(name, value, $"{where}/{name}"); + } + + /// Someone's data: faked, unless kept. + static void Data(JsonObject values, string where, Func kept, MirrorFaker faker) + { + if (values == null) return; + foreach (var name in values.Select(p => p.Key).ToList()) + if (values[name] is JsonValue v && v.TryGetValue(out var value)) + values[name] = faker.Datum(name, value, $"{where}/{name}", kept(name, value)); + } + + public static string PartnerKey(string property) => $"partner:{property}"; + + public static string GlobalKey(string set, string key) => $"global:{set}/{key}"; + + /// + /// Every partner property and global value that gets faked, by key, and whether it is kept. A + /// secret or an address is never offered: neither can be kept, whatever the list says. + /// + static List FakedKeys(IReadOnlyDictionary> prod, ISet keep) + { + var rows = new List(); + var partnerKeys = (prod.GetValueOrDefault("partners") ?? []) + .SelectMany(p => Offered(p.File["properties"] as JsonObject, p.File["secretProperties"])) + .GroupBy(k => k, StringComparer.Ordinal); + foreach (var group in partnerKeys.OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase)) + rows.Add(new MirrorFakedKey(PartnerKey(group.Key), $"Partner property {group.Key}", group.Count(), keep.Contains(PartnerKey(group.Key)))); + + foreach (var set in (prod.GetValueOrDefault("globalValues") ?? []).OrderBy(s => s.Key, StringComparer.OrdinalIgnoreCase)) + foreach (var name in Offered(set.File["values"] as JsonObject, set.File["secretProperties"]).OrderBy(n => n, StringComparer.OrdinalIgnoreCase)) + rows.Add(new MirrorFakedKey(GlobalKey(set.Key, name), $"Global value {set.Key}.{name}", 1, keep.Contains(GlobalKey(set.Key, name)))); + return rows; + } + + static IEnumerable Offered(JsonObject values, JsonNode secretNames) + { + var secret = (secretNames as JsonArray ?? []).Select(x => x?.ToString()).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var (name, value) in values ?? []) + { + var text = value is JsonValue v && v.TryGetValue(out var s) ? s : null; + if (string.IsNullOrEmpty(text) || text == MirrorFaker.Masked || secret.Contains(name)) continue; + if (MirrorFaker.IsAddressKey(name) || MirrorFaker.IsSecretLike(name) || name.EndsWith("port", StringComparison.OrdinalIgnoreCase)) continue; + yield return name; + } + } + + // ------------------------------------------------------------------ the mock addresses it made + + static readonly Regex Mock = new(@"(?[A-Za-z][A-Za-z0-9+.\-]*://(?:[^@/\s]*@)?|@)?(?m[0-9a-f]{8}\." + Regex.Escape(MirrorFaker.MockDomain) + ")", RegexOptions.Compiled); + + static List AddressesIn(Dictionary> sections) + { + var found = new Dictionary UsedBy)>(StringComparer.Ordinal); + foreach (var (section, items) in sections) + foreach (var item in items) + { + var noun = Kinds.All.FirstOrDefault(k => k.Section == section); + var name = noun?.KeyOf(item) ?? ""; + Walk(item, [], (path, text) => + { + foreach (Match m in Mock.Matches(text)) + { + var host = m.Groups["host"].Value; + var kind = KindOf(m.Groups["before"].Value, path); + var entry = found.TryGetValue(host, out var e) ? e : found[host] = (kind, new List()); + if (entry.Kind == "host" && kind != "host") found[host] = entry = (kind, entry.UsedBy); + var use = new MirrorUse(section, noun?.Noun ?? section, name, string.Join(" · ", path.Where(p => p != "properties"))); + if (!entry.UsedBy.Contains(use)) entry.UsedBy.Add(use); + } + }); + } + return found.OrderBy(f => f.Value.Kind, StringComparer.Ordinal).ThenBy(f => f.Key, StringComparer.Ordinal) + .Select(f => new MirrorAddress(f.Key, f.Value.Kind, + f.Value.UsedBy.OrderBy(u => u.Noun, StringComparer.Ordinal).ThenBy(u => u.Key, StringComparer.Ordinal).ThenBy(u => u.Where, StringComparer.Ordinal).ToList())) + .ToList(); + } + + static string KindOf(string before, IReadOnlyList path) + { + if (before == "@") return "email"; + if (before.Contains("://")) return before[..before.IndexOf("://", StringComparison.Ordinal)].ToLowerInvariant(); + var key = path.LastOrDefault()?.ToLowerInvariant() ?? ""; + return key.Contains("sftp") ? "sftp" : key.Contains("ftp") ? "ftp" : key.Contains("smtp") ? "smtp" + : key.Contains("imap") ? "imap" : key.Contains("connection") ? "database" : "host"; + } + + static void Walk(JsonNode node, List path, Action, string> text) + { + switch (node) + { + case JsonObject obj: + foreach (var (name, value) in obj) Walk(value, [.. path, name], text); + break; + case JsonArray list: + foreach (var value in list) Walk(value, path, text); + break; + case JsonValue v when v.TryGetValue(out var s): + text(path, s); + break; + } + } +} diff --git a/SW.Bitween.Api/Services/Settings/SettingsCatalog.cs b/SW.Bitween.Api/Services/Settings/SettingsCatalog.cs index 3a6b4fa5..18100fda 100644 --- a/SW.Bitween.Api/Services/Settings/SettingsCatalog.cs +++ b/SW.Bitween.Api/Services/Settings/SettingsCatalog.cs @@ -364,6 +364,48 @@ public static class SettingsCatalog t => t.Bitween.MailServer.FromName, (t, v) => t.Bitween.MailServer.FromName = Trimmed(v)), + // --- Prod mirror --- + // Where this staging copies prod's setup from, faked, to rehearse an upgrade on. Whether it + // may be overwritten at all is the deployment's to say, never a setting's. + View("Bitween.Mirror.Target", "Prod mirror", "This Bitween is a mirror", + "Whether a copy of prod may replace this Bitween's setup. Set by the deployment (Bitween__Mirror__Target=true), so a prod can't be overwritten from here.", + SettingKind.Boolean, t => Str(t.Bitween.Mirror.Target)), + + new("Bitween.Mirror.SourceUrl", "Prod mirror", + "Prod's address", + "The prod this staging copies, e.g. https://bitween.acme.com. Read only, through its own API: nothing is installed or changed there.", + SettingKind.String, false, + t => t.Bitween.Mirror.SourceUrl, + (t, v) => t.Bitween.Mirror.SourceUrl = AbsoluteUrl(v)), + + new("Bitween.Mirror.SourceToken", "Prod mirror", + "Service account token", + "A token of a service account on prod whose role only views, such as the built-in Viewer. Used instead of an email and password when both are set.", + SettingKind.String, true, + t => t.Bitween.Mirror.SourceToken, + (t, v) => t.Bitween.Mirror.SourceToken = Trimmed(v)), + + new("Bitween.Mirror.SourceEmail", "Prod mirror", + "Viewer email", + "For a prod on r10.0, which has no service accounts: the email of a member with the Viewer role.", + SettingKind.String, false, + t => t.Bitween.Mirror.SourceEmail, + (t, v) => t.Bitween.Mirror.SourceEmail = Trimmed(v)), + + new("Bitween.Mirror.SourcePassword", "Prod mirror", + "Viewer password", + "That member's password.", + SettingKind.String, true, + t => t.Bitween.Mirror.SourcePassword, + (t, v) => t.Bitween.Mirror.SourcePassword = v), + + new("Bitween.Mirror.KeepRealValues", "Prod mirror", + "Values copied as they are", + "Partner properties and global values that are settings rather than data, such as a time zone, one per line: partner:timeZone or global:formats/date. Everything else of theirs is faked. Addresses and secrets are never copied.", + SettingKind.String, false, + t => t.Bitween.Mirror.KeepRealValues, + (t, v) => t.Bitween.Mirror.KeepRealValues = Trimmed(v)), + // --- Reliability & jobs --- new("Bitween.MaxRetryAfterSeconds", "Reliability & jobs", "Longest wait an adapter can ask for (seconds)", @@ -625,6 +667,16 @@ private static int Port(string value) => /// Empty clears it. private static string Trimmed(string value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + /// Empty clears it. Otherwise an absolute http(s) address, kept without a trailing slash. + private static string AbsoluteUrl(string value) + { + var url = Trimmed(value); + if (url == null) return null; + return Uri.TryCreate(url, UriKind.Absolute, out var parsed) && parsed.Scheme is "https" or "http" && parsed.Host.Length > 0 + ? url.TrimEnd('/') + : throw new FormatException($"'{url}' isn't an address. Use one like https://bitween.acme.com."); + } + /// Empty clears it. Otherwise one plain address, without the display name, which has a setting of its own. private static string EmailAddress(string value) { diff --git a/SW.Bitween.Sdk/Model/Permissions.cs b/SW.Bitween.Sdk/Model/Permissions.cs index 66c14d3f..bac6ea16 100644 --- a/SW.Bitween.Sdk/Model/Permissions.cs +++ b/SW.Bitween.Sdk/Model/Permissions.cs @@ -175,6 +175,16 @@ public static class Audit public const string View = "audit.view"; } + /// A staging that mirrors a prod, to rehearse upgrades on. + public static class Mirror + { + /// See how close this staging is to prod, and what is left to do. + public const string View = "mirror.view"; + + /// Copy prod's setup onto this staging, replacing what it has. + public const string Operate = "mirror.operate"; + } + public static class AdapterSource { /// Read the source an adapter version was published with, and compare versions. @@ -353,7 +363,13 @@ private static PermissionAreaModel Area(string id, string label, string group, s "The code each adapter version was published with, and adapters written in Bitween.", (View, "Read an adapter's source and compare its versions."), (Edit, "Write Python and JavaScript adapters in the editor: drafts, building, testing and trying them."), - (Operate, "Publish versions from the editor, and make a published version the one that runs.")) + (Operate, "Publish versions from the editor, and make a published version the one that runs.")), + + // Administration: a copy replaces this Bitween's whole setup. + Area("mirror", "Prod mirror", "Administration", + "A staging that copies a prod's setup, faked, to rehearse upgrades on.", + (View, "See how close staging is to prod, and what is left to do."), + (Operate, "Copy prod's setup onto this staging, replacing what it has.")) ]; /// Every valid permission key. diff --git a/SW.Bitween.UnitTests/MirrorFakerTests.cs b/SW.Bitween.UnitTests/MirrorFakerTests.cs new file mode 100644 index 00000000..021e629d --- /dev/null +++ b/SW.Bitween.UnitTests/MirrorFakerTests.cs @@ -0,0 +1,136 @@ +using System; +using System.Linq; +using System.Text; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SW.Bitween.Services.Mirror; + +namespace SW.Bitween.UnitTests; + +/// +/// What staging keeps of a prod value: something of the same shape, never the value itself, and +/// the same stand-in every time, so a copy can run twice and a code named in two places still matches. +/// +[TestClass] +public class MirrorFakerTests +{ + static readonly MirrorFaker Faker = new(Encoding.UTF8.GetBytes("a key only these tests use")); + + static bool SameShape(string a, string b) => + a.Length == b.Length && a.Zip(b).All(p => + char.IsAsciiDigit(p.First) ? char.IsAsciiDigit(p.Second) + : char.IsAsciiLetterUpper(p.First) ? char.IsAsciiLetterUpper(p.Second) + : char.IsAsciiLetterLower(p.First) ? char.IsAsciiLetterLower(p.Second) + : p.First == p.Second); + + [TestMethod] + public void A_value_keeps_its_shape_and_loses_its_content() + { + foreach (var value in new[] { "SO-1001-X", "ACME Ltd.", "0045 2231 8890", "traxis_prod" }) + { + var fake = Faker.Fake(value); + Assert.AreNotEqual(value, fake); + Assert.IsTrue(SameShape(value, fake), $"{value} became {fake}"); + } + // A number keeps its leading zeros and doesn't gain one. + Assert.IsTrue(Faker.Fake("00123").StartsWith("00")); + Assert.AreNotEqual('0', Faker.Fake("123456")[0]); + } + + [TestMethod] + public void The_same_value_always_gets_the_same_stand_in_and_another_key_another() + { + Assert.AreEqual(Faker.Fake("SO-1001"), Faker.Fake("SO-1001")); + Assert.AreNotEqual(Faker.Fake("SO-1001"), Faker.Fake("SO-1002")); + Assert.AreNotEqual(Faker.Fake("SO-1001"), new MirrorFaker(Encoding.UTF8.GetBytes("another")).Fake("SO-1001")); + Assert.AreEqual(Faker.Secret("partner/Acme/apiKey"), Faker.Secret("partner/Acme/apiKey")); + Assert.AreNotEqual(Faker.Secret("partner/Acme/apiKey"), Faker.Secret("partner/Zed/apiKey")); + } + + [TestMethod] + public void Tokens_words_dates_guids_and_json_stay_what_they_are() + { + Assert.AreEqual("Bearer {{globals.api.token}}".Length, Faker.Fake("Bearer {{globals.api.token}}").Length); + StringAssert.EndsWith(Faker.Fake("Bearer {{globals.api.token}}"), "{{globals.api.token}}"); + Assert.AreEqual("true", Faker.Fake("true")); + + var date = Faker.Fake("2026-02-28T10:15:00Z"); + Assert.IsTrue(DateTime.TryParse(date[..10], out _), date); + StringAssert.EndsWith(date, "T10:15:00Z"); + + var guid = Faker.Fake("3f2a9c1e-77b0-4c11-8a2e-0d9b5e6f7a10"); + Assert.IsTrue(Guid.TryParse(guid, out _), guid); + Assert.AreNotEqual("3f2a9c1e-77b0-4c11-8a2e-0d9b5e6f7a10", guid); + + var json = JsonNode.Parse(Faker.Fake("""{"account":"AC-778","limit":250,"live":true,"tags":["blue"]}"""))!; + Assert.AreEqual(250.ToString().Length, json["limit"]!.ToJsonString().Length); + Assert.AreEqual(true, (bool)json["live"]!); + Assert.AreNotEqual("AC-778", (string)json["account"]!); + Assert.AreEqual(1, json["tags"]!.AsArray().Count); + } + + [TestMethod] + public void Addresses_become_mock_ones_that_resolve_nowhere() + { + var url = Faker.Setting("BaseUrl", "https://ws05.ffdx.net:8443/api/v2/orders?apikey=XK99&mode=live", "x"); + StringAssert.StartsWith(url, "https://m"); + StringAssert.Contains(url, ".mirror.invalid:8443/api/v2/orders?apikey="); + Assert.IsFalse(url.Contains("ffdx"), url); + // A setting's query is how the flow behaves; only a datum's is faked. + StringAssert.EndsWith(url, "?apikey=XK99&mode=live"); + Assert.IsFalse(Faker.Datum("callback", "https://ws05.ffdx.net/cb?apikey=XK99", "x").Contains("XK99")); + + // The same host is the same mock wherever it appears. + var host = new Regex(@"m[0-9a-f]{8}\.mirror\.invalid").Match(url).Value; + Assert.AreEqual(host, Faker.Setting("SftpHost", "ws05.ffdx.net", "y")); + + var email = Faker.Setting("Recipients", "ops@traxis.app, Lina ", "z"); + Assert.IsFalse(email.Contains("traxis"), email); + StringAssert.Contains(email, ", Lina <"); + + StringAssert.StartsWith(Faker.Setting("Server", "10.20.30.40", "w"), "192.0.2."); + Assert.AreEqual("localhost:5000", Faker.Setting("Host", "localhost:5000", "w")); + } + + [TestMethod] + public void Text_that_only_looks_like_an_address_is_left_alone_in_a_setting() + { + // Named like an address, holding something else. + Assert.AreEqual("/inbound/orders", Faker.Setting("SftpRemoteFolder", "/inbound/orders", "x")); + Assert.AreEqual("30", Faker.Setting("ServerTimeoutSeconds", "30", "x")); + Assert.AreEqual("orders.json", Faker.Setting("FileName", "orders.json", "x")); + // XML names its namespaces after hosts; a namespace is matched as text. + Assert.AreEqual("http://www.w3.org/2001/XMLSchema", Faker.Setting("Namespace", "http://www.w3.org/2001/XMLSchema", "x")); + Assert.AreEqual("https://{{globals.api.host}}/orders", Faker.Setting("Url", "https://{{globals.api.host}}/orders", "x")); + } + + [TestMethod] + public void A_connection_string_keeps_its_settings_and_loses_its_server_and_password() + { + var cs = Faker.Setting("ConnectionString", "Host=db.traxis.app;Port=5432;Database=ship;Username=traxis;Password=S3cret!x", "x"); + StringAssert.Contains(cs, "Port=5432;Database=ship;"); + Assert.IsFalse(cs.Contains("traxis"), cs); + Assert.IsFalse(cs.Contains("S3cret"), cs); + + var uri = Faker.Setting("Broker", "amqp://bitween:hunter2@mq.traxis.app:5672/prod", "x"); + Assert.IsFalse(uri.Contains("hunter2") || uri.Contains("traxis"), uri); + StringAssert.EndsWith(uri, ":5672/prod"); + } + + [TestMethod] + public void A_secret_prod_would_not_show_becomes_a_stand_in() + { + StringAssert.StartsWith(Faker.Setting("Password", MirrorFaker.Masked, "subscription/1/handler/Password"), "mirror-"); + StringAssert.StartsWith(Faker.Datum("apiKey", MirrorFaker.Masked, "partner/Acme/apiKey"), "mirror-"); + } + + [TestMethod] + public void Data_is_faked_unless_kept_and_addresses_never_are() + { + Assert.AreNotEqual("Europe/Paris", Faker.Datum("timeZone", "Europe/Paris", "x")); + Assert.AreEqual("Europe/Paris", Faker.Datum("timeZone", "Europe/Paris", "x", keepReal: true)); + Assert.IsFalse(Faker.Datum("note", "mail ops@traxis.app", "x", keepReal: true).Contains("traxis")); + Assert.AreEqual("2222", Faker.Datum("SftpPort", "2222", "x")); + } +} diff --git a/SW.Bitween.UnitTests/MirrorSetupTests.cs b/SW.Bitween.UnitTests/MirrorSetupTests.cs new file mode 100644 index 00000000..3047553d --- /dev/null +++ b/SW.Bitween.UnitTests/MirrorSetupTests.cs @@ -0,0 +1,162 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json.Nodes; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SW.Bitween.Cli.Configuration; +using SW.Bitween.Services.Mirror; + +namespace SW.Bitween.UnitTests; + +/// +/// What each kind of prod's setup becomes on a staging that mirrors it: how flows behave kept, +/// addresses and secrets replaced, partners' and global data faked, and nothing left to run on its own. +/// +[TestClass] +public class MirrorSetupTests +{ + static readonly MirrorFaker Faker = new(Encoding.UTF8.GetBytes("a key only these tests use")); + + static Dictionary> Prod(params (string Section, string Key, string File)[] items) => + items.GroupBy(i => i.Section).ToDictionary(g => g.Key, + g => g.Select((i, n) => new Existing((n + 1).ToString(), i.Key, (JsonObject)JsonNode.Parse(i.File)!, new JsonObject())).ToList()); + + static JsonObject Only(MirrorSetupCopy copy, string section) => copy.Sections[section].Single(); + + [TestMethod] + public void A_subscription_keeps_how_it_behaves_and_arrives_paused() + { + var copy = MirrorSetup.Copy(Prod(("subscriptions", "Push labels", """ + { + "name": "Push labels", "type": "Internal", "paused": false, + "matchExpression": { "type": "one_of", "path": "$.country", "values": ["FR"] }, + "handler": { "adapter": "NativeHttpHandler", "properties": { + "Url": "https://api.carrier.com/v2/labels", "Method": "POST", "Password": "__private__", "Token": "{{globals.carrier.token}}" } } + } + """)), Faker, new HashSet()); + + var s = Only(copy, "subscriptions"); + Assert.AreEqual(true, (bool)s["paused"]!); + Assert.AreEqual("FR", (string)s["matchExpression"]!["values"]![0]!); + var p = s["handler"]!["properties"]!; + Assert.AreEqual("POST", (string)p["Method"]!); + Assert.AreEqual("{{globals.carrier.token}}", (string)p["Token"]!); + StringAssert.EndsWith((string)p["Url"]!, ".mirror.invalid/v2/labels"); + StringAssert.StartsWith((string)p["Password"]!, "mirror-"); + + var address = copy.Addresses.Single(); + Assert.AreEqual("https", address.Kind); + var use = address.UsedBy.Single(); + Assert.AreEqual(("subscriptions", "Push labels"), (use.Section, use.Key)); + StringAssert.Contains(use.Where, "Url"); + } + + [TestMethod] + public void A_partner_gets_fakes_of_its_data_and_keys_of_its_own() + { + var copy = MirrorSetup.Copy(Prod(("partners", "Acme", """ + { + "name": "Acme", "secretProperties": ["sftpPassword"], "loginIdentity": "acme-client-778", + "properties": { "accountNumber": "AC-1001", "timeZone": "Europe/Paris", "sftpPassword": "__private__", + "apiKey": "live_9f8e7d", "webhookUrl": "https://hooks.acme.com/in" }, + "keys": { "main": "abcde...(hidden)" } + } + """)), Faker, new HashSet { "partner:timeZone", "partner:apiKey", "partner:webhookUrl" }); + + var a = Only(copy, "partners"); + var p = a["properties"]!; + Assert.AreNotEqual("AC-1001", (string)p["accountNumber"]!); + Assert.AreEqual("AC-1001".Length, ((string)p["accountNumber"]!).Length); + // Kept, because it was asked for and it is a setting. + Assert.AreEqual("Europe/Paris", (string)p["timeZone"]!); + // Asked for, and never kept: a name a secret goes by, and an address. + Assert.AreNotEqual("live_9f8e7d", (string)p["apiKey"]!); + Assert.IsFalse(((string)p["webhookUrl"]!).Contains("acme.com")); + StringAssert.StartsWith((string)p["sftpPassword"]!, "mirror-"); + Assert.AreNotEqual("acme-client-778", (string)a["loginIdentity"]!); + StringAssert.StartsWith((string)a["keys"]!["main"]!, "mirror-"); + + // Offered for keeping: data that could be a setting, never a secret, an address or a secret's name. + CollectionAssert.AreEquivalent(new[] { "partner:accountNumber", "partner:timeZone" }, copy.FakedKeys.Select(k => k.Id).ToList()); + Assert.IsTrue(copy.FakedKeys.Single(k => k.Id == "partner:timeZone").Kept); + } + + [TestMethod] + public void Global_values_are_faked_and_data_sources_and_bus_gateways_arrive_switched_off() + { + var copy = MirrorSetup.Copy(Prod( + ("globalValues", "spaces", """{ "id": "spaces", "name": "Spaces", "values": { "bucket": "traxis-prod", "endpoint": "https://fra1.digitaloceanspaces.com" }, "secretProperties": [] }"""), + ("dataSources", "Warehouse", """{ "name": "Warehouse", "inactive": false, "properties": { "ConnectionString": "Host=db.traxis.app;Password=pw;Database=wh" } }"""), + ("busGateways", "Orders in", """{ "name": "Orders in", "inactive": false, "endpointProperties": { "Host": "mq.traxis.app" } }""")), + Faker, new HashSet()); + + var values = Only(copy, "globalValues")["values"]!; + Assert.AreNotEqual("traxis-prod", (string)values["bucket"]!); + Assert.IsFalse(((string)values["endpoint"]!).Contains("digitaloceanspaces")); + + var ds = Only(copy, "dataSources"); + Assert.AreEqual(true, (bool)ds["inactive"]!); + Assert.IsFalse(((string)ds["properties"]!["ConnectionString"]!).Contains("traxis")); + StringAssert.Contains((string)ds["properties"]!["ConnectionString"]!, "Database=wh"); + + var bus = Only(copy, "busGateways"); + Assert.AreEqual(true, (bool)bus["inactive"]!); + StringAssert.EndsWith((string)bus["endpointProperties"]!["Host"]!, ".mirror.invalid"); + } + + [TestMethod] + public void Several_of_one_name_are_told_apart_oldest_first() + { + var ctx = new Context(null); + var kind = Kinds.Of("subscriptions"); + var items = new List<(string Id, JsonObject Api)> + { + ("12", new JsonObject { ["name"] = "test" }), + ("3", new JsonObject { ["name"] = "test" }), + ("7", new JsonObject { ["name"] = "test (2)" }), + ("9", new JsonObject { ["name"] = "Orders" }), + }; + + var notes = MirrorService.Distinguish(ctx, kind, items).ToList(); + + // The oldest keeps the name; "test (2)" is taken already, so the next is "test (3)". + Assert.AreEqual("test", (string)items[1].Api["name"]!); + Assert.AreEqual("test (3)", (string)items[0].Api["name"]!); + Assert.AreEqual("test (2)", (string)items[2].Api["name"]!); + StringAssert.Contains(notes.Single(), "2 subscriptions named"); + } + + /// + /// A changed or newer build sends fields this release doesn't know. The copy can't carry them, so + /// they are named - only when set on some item, since a field always empty changes nothing. + /// + [TestMethod] + public void Fields_prod_sends_that_this_release_does_not_know_are_named_not_dropped() + { + static Existing Item(string key, string api) => new("1", key, new JsonObject(), (JsonObject)JsonNode.Parse(api)!); + var prod = new Context(null); + prod.Current["partners"] = + [ + Item("Acme", """{ "id": 1, "name": "Acme", "customerTier": "gold", "legacyCode": null, "archived": false }"""), + Item("Zed", """{ "id": 2, "name": "Zed", "customerTier": "", "legacyCode": null, "archived": false }"""), + ]; + var staging = new Context(null); + staging.Current["partners"] = [Item("Acme", """{ "id": 9, "name": "Acme" }""")]; + + var unknown = MirrorService.Unknown(prod, staging, new Dictionary()).Single(); + + Assert.AreEqual(("partners", "customerTier", 1), (unknown.Section, unknown.Field, unknown.Items)); + CollectionAssert.AreEqual(new[] { "Acme" }, unknown.Examples.ToList()); + // Nothing to compare with until staging has partners of its own. + Assert.AreEqual(0, MirrorService.Unknown(prod, new Context(null), new Dictionary()).Count); + } + + [TestMethod] + public void Nouns_read_as_plurals() + { + Assert.AreEqual("retry policies", MirrorService.Plural("retry policy")); + Assert.AreEqual("subscription categories", MirrorService.Plural("subscription category")); + Assert.AreEqual("bus gateways", MirrorService.Plural("bus gateway")); + Assert.AreEqual("addresses", MirrorService.Plural("address")); + } +} diff --git a/SW.Bitween.UnitTests/SettingsCatalogTests.cs b/SW.Bitween.UnitTests/SettingsCatalogTests.cs index 04c0810a..df810a8c 100644 --- a/SW.Bitween.UnitTests/SettingsCatalogTests.cs +++ b/SW.Bitween.UnitTests/SettingsCatalogTests.cs @@ -25,6 +25,7 @@ public void Sections_are_the_ones_the_page_lists_in_the_order_it_lists_them() "Adapters", "Marketplace", "Mail server", + "Prod mirror", "Reliability & jobs", "Messaging", "Database", diff --git a/SW.Bitween.Web/ClientApp/src/api/client.ts b/SW.Bitween.Web/ClientApp/src/api/client.ts index 7fd392e8..6bc10580 100644 --- a/SW.Bitween.Web/ClientApp/src/api/client.ts +++ b/SW.Bitween.Web/ClientApp/src/api/client.ts @@ -1,5 +1,8 @@ import type { AddBusRouteInput, AttachPartnerInput } from "./http/gateways"; import type { + MirrorCopyResult, + MirrorParityAnswer, + MirrorStatus, InviteResult, PasswordLinkInfo, AdapterInfo, @@ -655,6 +658,11 @@ export interface ApiClient { input: { subscriptionId: number; groupId: string } & RetryAlertConfig, ): Promise; + // - prod mirror - + getMirror(): Promise; + getMirrorParity(): Promise; + copyFromProd(removeExtras: boolean): Promise; + // - settings - listSettings(): Promise; getAbout(): Promise; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/httpClient.ts b/SW.Bitween.Web/ClientApp/src/api/http/httpClient.ts index 1e13c9b8..ee22aacb 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/httpClient.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/httpClient.ts @@ -22,6 +22,7 @@ import { queueHealthMethods } from "./queueHealth"; import { retryPolicyMethods } from "./retryPolicies"; import { sessionMethods } from "./session"; import { settingsMethods } from "./settings"; +import { mirrorMethods } from "./mirror"; import { serviceAccountMethods } from "./serviceAccounts"; import { teamMethods } from "./team"; import { workGroupMethods } from "./workGroups"; @@ -57,4 +58,5 @@ export const httpClient: ApiClient = { ...teamMethods, ...serviceAccountMethods, ...settingsMethods, + ...mirrorMethods, }; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/mirror.ts b/SW.Bitween.Web/ClientApp/src/api/http/mirror.ts new file mode 100644 index 00000000..d7ad9634 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/http/mirror.ts @@ -0,0 +1,19 @@ +import type { ApiClient } from "../client"; +import type { MirrorCopyResult, MirrorParityAnswer, MirrorStatus } from "../types"; +import { get, post } from "./request"; + +export const mirrorMethods = { + getMirror(): Promise { + return get("/mirror"); + }, + + /** Reads prod and staging now, which takes a few seconds; prod is only read. */ + getMirrorParity(): Promise { + return get("/mirror/parity"); + }, + + /** Copies prod's setup onto this staging, faked; `removeExtras` also removes what prod doesn't have. */ + copyFromProd(removeExtras: boolean): Promise { + return post("/mirror/copy", { removeExtras }); + }, +} satisfies Partial; diff --git a/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts b/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts index e591941e..736a7c93 100644 --- a/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts +++ b/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts @@ -213,6 +213,12 @@ export const keys = { insights: ["metrics", "insights"] as const, }, + mirror: { + all: ["mirror"] as const, + status: ["mirror", "status"] as const, + parity: ["mirror", "parity"] as const, + }, + settings: { all: ["settings"] as const, list: ["settings", "list"] as const, diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index 25dc82f1..1acd8993 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -2251,3 +2251,144 @@ export interface ConnectionInput { username?: string | null; password?: string | null; } + +// ── Prod mirror ── + +/** Whether this Bitween is a mirror and how it reaches prod - known without calling prod. */ +export interface MirrorStatus { + /** Set by the deployment: only a mirror can be copied onto. */ + target: boolean; + sourceUrl: string | null; + signIn: "token" | "member" | null; + configured: boolean; +} + +export interface MirrorSide { + url: string; + version: string; + exchanges: number | null; +} + +/** inSync, differs, missing; waiting for an adapter staging lacks; extra: only on staging. */ +export type MirrorItemState = "inSync" | "differs" | "missing" | "waiting" | "extra"; + +/** + * One of prod's items and how staging's compares, with where each is: its id on staging and on + * prod, and a subscription's type, which decides its page. A SQL statement's ids are its data + * source's, the page it is on. + */ +export interface MirrorItem { + key: string; + state: MirrorItemState; + fields: string[]; + reason: string | null; + /** The last copy tried it and Bitween turned it down: copying again won't change that. */ + refused: boolean; + stagingId: string | null; + prodId: string | null; + type: string | null; +} + +export interface MirrorKind { + section: string; + noun: string; + plural: string; + inSync: number; + differs: number; + missing: number; + extra: number; + /** Why prod's were not read: its release lacks them, or the login can't see them. */ + skipped: string | null; + items: MirrorItem[]; +} + +export interface MirrorAdapter { + id: string; + source: "builtin" | "marketplace" | "custom"; + prodVersion: string | null; + stagingVersion: string | null; + state: "same" | "missing" | "different"; + usedBy: number; +} + +/** + * One line on the page. needsYou: only a person can do it. copy: copying does it. notCovered: what + * the test can't include, and why. onPurpose: how staging differs from prod by design. + */ +export interface MirrorNote { + id: string; + group: "needsYou" | "copy" | "notCovered" | "onPurpose"; + text: string; + /** The item it is about, to open it. */ + section: string | null; + key: string | null; + /** Where it is done: adapters-installed, adapters-marketplace, faked or addresses. */ + action: string | null; +} + +export interface MirrorVerdict { + ready: boolean; + /** Prod's items staging has as prod has them. */ + matching: number; + total: number; + needsYou: number; + toCopy: number; + notCovered: number; +} + +/** An item that uses something, and where in it. */ +export interface MirrorUse { + section: string; + noun: string; + key: string; + where: string; +} + +/** A test address that stands in for one of prod's, and what uses it. */ +export interface MirrorAddress { + mock: string; + kind: string; + usedBy: MirrorUse[]; +} + +/** A partner property or global value that is faked, unless kept because it is a setting. */ +export interface MirrorFakedKey { + id: string; + label: string; + count: number; + kept: boolean; +} + +/** A field prod sends that this release doesn't know, from a newer or a changed build: not copied. */ +export interface MirrorUnknownField { + section: string; + plural: string; + field: string; + items: number; + examples: string[]; +} + +export interface MirrorParity { + checkedOn: string; + prod: MirrorSide; + staging: MirrorSide; + verdict: MirrorVerdict; + notes: MirrorNote[]; + kinds: MirrorKind[]; + adapters: MirrorAdapter[]; + addresses: MirrorAddress[]; + fakedKeys: MirrorFakedKey[]; + unknown: MirrorUnknownField[]; +} + +/** A prod that can't be reached, or is too old, answers with why. */ +export type MirrorParityAnswer = MirrorParity | { error: string }; + +export interface MirrorCopyResult { + created: number; + updated: number; + removed: number; + adapterSteps: number; + failed: { section: string; noun: string; key: string; error: string }[]; + after: MirrorParity; +} diff --git a/SW.Bitween.Web/ClientApp/src/pages.ts b/SW.Bitween.Web/ClientApp/src/pages.ts index d68eb047..a71057e3 100644 --- a/SW.Bitween.Web/ClientApp/src/pages.ts +++ b/SW.Bitween.Web/ClientApp/src/pages.ts @@ -7,6 +7,7 @@ import { Database, FileStack, FileText, + GitCompareArrows, KeyRound, LayoutDashboard, Handshake, @@ -447,6 +448,14 @@ export const PAGES = definePages({ nav: { group: "administration", icon: Server }, keywords: ["cluster", "servers"], }, + // On a staging: how close it is to the prod it mirrors, and the copy that closes the gap. + mirror: { + path: "mirror", + title: "Prod mirror", + permission: "mirror.view", + nav: { group: "administration", icon: GitCompareArrows }, + keywords: ["staging", "rehearsal", "upgrade", "copy", "parity", "prod"], + }, // Last: it reports on everything above it rather than configuring anything, and it is the one // page whose value is that nobody can quietly change it. audit: { diff --git a/SW.Bitween.Web/ClientApp/src/pages/mirror/MirrorPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/mirror/MirrorPage.tsx new file mode 100644 index 00000000..a137c36b --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/mirror/MirrorPage.tsx @@ -0,0 +1,768 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { CircleCheck, ChevronDown, ChevronRight, ExternalLink, GitCompareArrows, TriangleAlert } from "lucide-react"; +import { useState, type ReactNode } from "react"; +import { Link } from "react-router"; +import { + api, + type MirrorAdapter, + type MirrorAddress, + type MirrorCopyResult, + type MirrorFakedKey, + type MirrorItem, + type MirrorKind, + type MirrorNote, + type MirrorParity, + type MirrorStatus, + type SettingRow, + type SubscriptionType, +} from "../../api"; +import { keys } from "../../api/queryKeys"; +import { useSessionCan } from "../../auth/useSessionCan"; +import { PageHeader } from "../../components/layout/PageHeader"; +import { Badge, Button, EmptyState, FormError, InlineNotice, LoadError, LoadingBlock } from "../../components/ui/basics"; +import { Checkbox, Field, PasswordInput, TextInput } from "../../components/ui/forms"; +import { ConfirmDialog, Dialog } from "../../components/ui/overlays"; +import { Panel } from "../../components/ui/Panel"; +import { SegmentedControl } from "../../components/ui/SegmentedControl"; +import { MiniTable } from "../../components/ui/Table"; +import { timeAgo } from "../../lib/dates"; +import { whole } from "../../lib/numbers"; +import { subscriptionPath } from "../../lib/subscriptionPaths"; +import { pathOf, type PageId } from "../../pages"; + +const KEEP_SETTING = "Bitween.Mirror.KeepRealValues"; + +/** Where an item of each kind opens. Subscription categories have no page of their own. */ +const KIND_PAGE: Record = { + workGroups: "workGroup", + informationTypes: "informationType", + partners: "partner", + globalValues: "globalValueSet", + connections: "connection", + notificationChannels: "notificationChannel", + retryPolicies: "retryPolicy", + dataSources: "dataSource", + dataSourceStatements: "dataSource", + apiGateways: "apiGateway", + busGateways: "busGateway", + notifiers: "notifier", +}; + +/** The page an item opens on, from its id; null for a kind with no page, or an item not there. */ +function itemPath(section: string, id: string | null, type?: string | null): string | null { + if (!id) return null; + if (section === "subscriptions") return subscriptionPath(Number(id), (type ?? null) as SubscriptionType | null); + const page = KIND_PAGE[section]; + return page ? pathOf(page, { id }) : null; +} + +const STATE_LABEL: Record = { + inSync: { label: "Same", tone: "ok", title: "Staging has it as prod has it" }, + differs: { label: "Differs", tone: "warn", title: "Staging has it, but not as prod has it. Copying updates it." }, + missing: { label: "Missing", tone: "danger", title: "Prod has it and staging doesn't. Copying creates it." }, + waiting: { label: "Waiting", tone: "warn", title: "Staging can't create it yet: it uses an adapter staging lacks" }, + extra: { label: "Only on staging", tone: "neutral", title: "Staging has it and prod doesn't" }, +}; + +/** + * On a staging: whether it is ready to test an upgrade on, what is left to do to get there, what the + * test can't cover, and how staging differs from prod on purpose. Prod is only read, through its own + * API; every value that reaches staging is faked first. + */ +export function MirrorPage() { + const status = useQuery({ queryKey: keys.mirror.status, queryFn: () => api.getMirror() }); + const parity = useQuery({ + queryKey: keys.mirror.parity, + queryFn: () => api.getMirrorParity(), + enabled: status.data?.configured === true, + staleTime: 60_000, + }); + const [connecting, setConnecting] = useState(false); + + const header = ( + void parity.refetch()} title="Read prod and staging again and compare them"> + Check again + + ) + } + help={{ + title: "How the mirror works", + body: ( + <> +

+ Staging signs in to prod with a view-only login and reads its setup, by name: information types, partners, global + values, subscriptions, gateways, retry policies and the rest. Nothing is installed or changed on prod. +

+

+ Before anything is saved here, each value is handled. How a flow behaves (formats, schedules, filters, + mapping rules) is copied as it is. Addresses become test addresses that go nowhere.{" "} + Secrets, partner properties and global values become fakes of the same shape. Staging never holds a + real value of prod's. +

+

+ Legacy setups are copied as prod has them, such as a response handed to an Internal subscription. Bitween keeps those + on prod but won't let anyone make a new one, so only the copy may recreate them here. +

+ + ), + }} + /> + ); + + if (status.isPending) return
{header}
; + if (status.isError) return
{header} void status.refetch()} />
; + + const s = status.data; + return ( +
+ {header} + {!s.target && ( + + Copying is off: this deployment isn't marked as a mirror, so nothing can be copied onto it. Checking still works. To allow + copying, set Bitween__Mirror__Target=true in this staging's deployment. + + )} + + {!s.configured ? ( + } + title="Connect to prod" + action={} + > + Give prod's address and a view-only login. On prod, create a service account with the Viewer role and issue it a token; + a prod on r10.0, which has no service accounts, takes a Viewer member's email and password instead. + + ) : parity.isPending ? ( + + ) : parity.isError ? ( + void parity.refetch()} /> + ) : "error" in parity.data ? ( +
+ {parity.data.error} + +
+ ) : ( + setConnecting(true)} /> + )} + + {connecting && setConnecting(false)} />} +
+ ); +} + +type FoldId = "setup" | "adapters" | "faked" | "addresses"; + +function Comparison({ parity, status, onConnection }: { parity: MirrorParity; status: MirrorStatus; onConnection: () => void }) { + const [open, setOpen] = useState>(new Set(["setup"])); + const toggle = (id: FoldId) => + setOpen((now) => { + const next = new Set(now); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + // A note that points at a section below opens it and takes you there. + const show = (id: FoldId) => { + setOpen((now) => new Set(now).add(id)); + requestAnimationFrame(() => document.getElementById(`mirror-${id}`)?.scrollIntoView?.({ behavior: "smooth", block: "start" })); + }; + const items = new Map(parity.kinds.flatMap((k) => k.items.map((i) => [`${k.section}\u0000${i.key}`, i] as const))); + const notes = (group: MirrorNote["group"]) => parity.notes.filter((n) => n.group === group); + const context = { items, prodUrl: parity.prod.url, show }; + + return ( +
+ + +
+ Change} + /> + +
+ + + + + + + + + + + + + + a.state !== "same") ? `${parity.adapters.filter((a) => a.state !== "same").length} to look at` : "all the same"} + description="Prod's adapters and what staging has of each. Built-in ones come with the release and are listed only when this release lacks one prod uses."> + + + + !k.kept).length} faked, ${parity.fakedKeys.filter((k) => k.kept).length} kept`} + description="Partner properties and global values are someone's data, so staging gets fakes of the same shape. Keep the real value only for one that is a setting, such as a time zone or a date format."> + + + + + + +
+ ); +} + +/** The page's answer, first: ready or not, and what stands between. */ +function Verdict({ parity }: { parity: MirrorParity }) { + const v = parity.verdict; + const left = [ + v.needsYou > 0 && `${v.needsYou} ${v.needsYou === 1 ? "thing needs" : "things need"} you`, + v.toCopy > 0 && `${whole(v.toCopy)} ${v.toCopy === 1 ? "item is" : "items are"} left to copy`, + ].filter(Boolean); + return ( +
+ {v.ready ? : } +
+

{v.ready ? "Ready to test the upgrade" : "Not ready to test yet"}

+

+ Staging has {whole(v.matching)} of prod's {whole(v.total)} items as prod has them. + {left.length > 0 && ` ${left.join(", and ")}.`} + {v.notCovered > 0 && ` ${v.notCovered} ${v.notCovered === 1 ? "thing isn't" : "things aren't"} covered by the test; see below.`} +

+

Checked {timeAgo(parity.checkedOn)}

+
+
+ ); +} + +function SideCard({ title, side, note, action }: { title: string; side: MirrorParity["prod"]; note: string; action?: ReactNode }) { + return ( +
+
+

{title}

+ {action} +
+

{side.url}

+
+
Version
+
{side.version}
+
Exchanges
+
{side.exchanges == null ? "-" : whole(side.exchanges)}
+
+

{note}

+
+ ); +} + +interface NoteContext { + items: Map; + prodUrl: string; + show: (id: FoldId) => void; +} + +const GROUP_TONE = { + warn: "border-l-warn-400", + danger: "border-l-danger-600", + neutral: "border-l-ink-300", +} as const; + +function NoteGroup({ title, description, notes, tone, context }: { + title: string; + description: string; + notes: MirrorNote[]; + tone: keyof typeof GROUP_TONE; + context: NoteContext; +}) { + if (notes.length === 0) return null; + return ( +
+

+ {title} ({notes.length}) +

+

{description}

+
    + {notes.map((n) => ( +
  • + {n.text} + +
  • + ))} +
+
+ ); +} + +/** Where a note's subject is: the item on staging and on prod, or the page where it is dealt with. */ +function NoteLinks({ note, context }: { note: MirrorNote; context: NoteContext }) { + const item = note.section && note.key ? context.items.get(`${note.section}\u0000${note.key}`) : undefined; + const action = + note.action === "adapters-marketplace" ? Open the Marketplace + : note.action === "adapters-installed" ? Open Adapters + : note.action === "faked" ? + : note.action === "addresses" ? + : null; + if (!item && !action) return null; + return ( + + {item && note.section && } + {action} + + ); +} + +/** "On staging" and "On prod" links to one item, as far as each side has it. */ +function ItemLinks({ section, item, prodUrl }: { section: string; item: MirrorItem; prodUrl: string }) { + const staging = itemPath(section, item.stagingId, item.type); + const prod = itemPath(section, item.prodId, item.type); + return ( + + {staging && On staging} + {prod && ( + + On prod + + )} + + ); +} + +function CopyPanel({ parity, target, notes, context }: { parity: MirrorParity; target: boolean; notes: MirrorNote[]; context: NoteContext }) { + const canCopy = useSessionCan("mirror.operate"); + const queryClient = useQueryClient(); + const [removeExtras, setRemoveExtras] = useState(false); + const [confirming, setConfirming] = useState(false); + const [result, setResult] = useState(null); + const extras = parity.kinds.reduce((n, k) => n + k.extra, 0); + const toCopy = parity.verdict.toCopy; + + const copy = useMutation({ + mutationFn: () => api.copyFromProd(removeExtras), + onSuccess: (r) => { + setResult(r); + queryClient.setQueryData(keys.mirror.parity, r.after); + }, + }); + + return ( + +
+ {notes.length === 0 ?

Nothing to copy: staging already has prod's setup.

: notes.map((n) =>

{n.text}

)} +
+
+ + {extras > 0 && ( + setRemoveExtras(e.target.checked)} + /> + )} +
+ {copy.error instanceof Error ? copy.error.message : ""} + {result && ( +
+

+ Copied: created {whole(result.created)}, updated {whole(result.updated)}, removed {whole(result.removed)} + {result.adapterSteps > 0 && `, installed ${whole(result.adapterSteps)} adapter(s)`}. + {result.failed.length === 0 && " Nothing failed."} +

+ {result.failed.length > 0 && ( + <> +

Staging couldn't make {result.failed.length === 1 ? "this one" : `these ${result.failed.length}`}:

+
    + {result.failed.map((f) => { + const item = context.items.get(`${f.section}\u0000${f.key}`); + return ( +
  • + + {f.noun} {f.key}: {f.error} + + {item && } +
  • + ); + })} +
+ + )} +
+ )} + {confirming && ( + { + await copy.mutateAsync(); + }} + onClose={() => setConfirming(false)} + /> + )} +
+ ); +} + +function Fold({ id, open, onToggle, title, summary, description, children }: { + id: FoldId; + open: boolean; + onToggle: (id: FoldId) => void; + title: string; + summary: string; + description: string; + children: ReactNode; +}) { + return ( +
+ + {open && ( +
+

{description}

+ {children} +
+ )} +
+ ); +} + +function SetupList({ kinds, prodUrl }: { kinds: MirrorKind[]; prodUrl: string }) { + const [open, setOpen] = useState(null); + const [all, setAll] = useState(false); + return ( +
+
+ Kind + Same + Differs + Missing + Only here +
+ {kinds.map((k) => { + const total = k.inSync + k.differs + k.missing + k.extra; + const isOpen = open === k.section; + const shown = all ? k.items : k.items.filter((i) => i.state !== "inSync"); + return ( +
+ + {isOpen && ( +
+ {k.skipped &&

{k.skipped}

} + {k.inSync > 0 && ( + + )} + {shown.length === 0 && !k.skipped &&

All {k.inSync} are the same as on prod.

} + {shown.map((i) => ( +
+ + {i.refused ? "Couldn't copy" : STATE_LABEL[i.state].label} + + {i.key} + {i.fields.length > 0 && {i.fields.join(", ")}} + + {i.reason && {i.reason}} +
+ ))} +
+ )} +
+ ); + })} +
+ ); +} + +const Count = ({ n, tone }: { n: number; tone: string }) => ( + {n} +); + +const ADAPTER_SOURCE: Record = { builtin: "Built in", marketplace: "Marketplace", custom: "Custom" }; + +function AdaptersTable({ adapters }: { adapters: MirrorAdapter[] }) { + return ( + a.id} + empty="Staging has every adapter prod has, at the same versions." + columns={[ + { header: "Adapter", cell: (a) => {a.id} }, + { header: "From", headerTitle: "Built into Bitween, published on the marketplace, or uploaded", cell: (a) => ADAPTER_SOURCE[a.source] }, + { header: "Prod", headerTitle: "The version prod runs; - when prod doesn't say", cell: (a) => a.prodVersion ?? "-" }, + { header: "Staging", headerTitle: "The version staging runs", cell: (a) => a.stagingVersion ?? "-" }, + { + header: "State", + cell: (a) => ( + + {a.state === "same" ? "Same" : a.state === "missing" ? "Missing" : "Another version"} + + ), + }, + { header: "Used by", align: "right", headerTitle: "How many of prod's items use it", cell: (a) => {a.usedBy} }, + { + header: "", + align: "right", + cell: (a) => + a.state === "same" || a.source === "builtin" ? null : ( + + {a.source === "marketplace" ? "Open the Marketplace" : "Open Adapters"} + + ), + }, + ]} + /> + ); +} + +function KeptValues({ fakedKeys }: { fakedKeys: MirrorFakedKey[] }) { + const canEdit = useSessionCan("settings.edit"); + const queryClient = useQueryClient(); + const save = useMutation({ + mutationFn: (kept: string[]) => api.updateSetting(KEEP_SETTING, kept.length ? kept.join("\n") : null), + onSuccess: () => + Promise.all([ + queryClient.invalidateQueries({ queryKey: keys.mirror.parity }), + queryClient.invalidateQueries({ queryKey: keys.settings.all }), + ]), + }); + const toggle = (id: string, keep: boolean) => + save.mutate([...fakedKeys.filter((k) => (k.id === id ? keep : k.kept)).map((k) => k.id)]); + + return ( + <> + k.id} + empty="Prod's partners and global values hold nothing to fake." + search={{ text: (k) => k.label, noun: "values" }} + columns={[ + { header: "Value", cell: (k) => {k.label} }, + { header: "Partners", align: "right", headerTitle: "How many partners have it; 1 for a global value", cell: (k) => {k.count} }, + { + header: "Keep real", + headerTitle: "Copy prod's value as it is. Addresses, secrets and values named like secrets are never kept, and aren't listed.", + align: "right", + cell: (k) => ( + toggle(k.id, e.target.checked)} + /> + ), + }, + ]} + /> + {save.error instanceof Error ? save.error.message : ""} + {fakedKeys.some((k) => k.kept) &&

Kept values arrive with the next copy.

} + + ); +} + +function AddressesTable({ addresses, prodUrl, items }: { addresses: MirrorAddress[]; prodUrl: string; items: Map }) { + return ( + a.mock} + empty="Prod's setup uses no outside address." + search={{ text: (a) => `${a.mock} ${a.kind} ${a.usedBy.map((u) => `${u.noun} ${u.key} ${u.where}`).join(" ")}`, noun: "addresses" }} + columns={[ + { header: "Test address", headerTitle: "Goes nowhere: the name is reserved never to resolve", cell: (a) => {a.mock} }, + { header: "Kind", headerTitle: "What prod reaches at this address: from its scheme, or the setting's name", cell: (a) => a.kind }, + { + header: "Used by", + cell: (a) => ( +
    + {a.usedBy.map((u) => { + const item = items.get(`${u.section}\u0000${u.key}`); + return ( +
  • + + {u.noun} {u.key} + · {u.where} + + {item && } +
  • + ); + })} +
+ ), + }, + ]} + /> + ); +} + +type SignIn = "token" | "member"; + +const SETTING = { + url: "Bitween.Mirror.SourceUrl", + token: "Bitween.Mirror.SourceToken", + email: "Bitween.Mirror.SourceEmail", + password: "Bitween.Mirror.SourcePassword", +} as const; + +function ConnectionDialog({ status, onClose }: { status: MirrorStatus; onClose: () => void }) { + const canEdit = useSessionCan("settings.edit"); + const queryClient = useQueryClient(); + const settings = useQuery({ queryKey: keys.settings.list, queryFn: () => api.listSettings() }); + const row = (key: string): SettingRow | undefined => settings.data?.find((r) => r.key === key); + + const [url, setUrl] = useState(status.sourceUrl ?? ""); + const [signIn, setSignIn] = useState(status.signIn ?? "token"); + const [token, setToken] = useState(""); + const [email, setEmail] = useState(null); + const [password, setPassword] = useState(""); + + const save = useMutation({ + mutationFn: async () => { + await api.updateSetting(SETTING.url, url.trim() || null); + if (signIn === "token") { + if (token) await api.updateSetting(SETTING.token, token.trim()); + } else { + // The token is used whenever one is set, so signing in as a member clears it. + if (row(SETTING.token)?.hasValue) await api.updateSetting(SETTING.token, null); + if (email !== null) await api.updateSetting(SETTING.email, email.trim() || null); + if (password) await api.updateSetting(SETTING.password, password); + } + }, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: keys.mirror.all }), + queryClient.invalidateQueries({ queryKey: keys.settings.all }), + ]); + onClose(); + }, + }); + + const saved = (key: string) => (row(key)?.hasValue ? "Saved. Type a new one to replace it." : undefined); + return ( + + {settings.isPending ? ( + + ) : ( +
{ + e.preventDefault(); + save.mutate(); + }} + > + + setUrl(e.target.value)} placeholder="https://bitween.acme.com" required disabled={!canEdit} /> + + + label="Sign in with" + value={signIn} + onChange={setSignIn} + disabled={!canEdit} + options={[ + { value: "token", label: "Service account token", title: "For prod on a release with service accounts" }, + { value: "member", label: "Viewer member", title: "For prod on r10.0, which has no service accounts" }, + ]} + /> + {signIn === "token" ? ( + + setToken(e.target.value)} placeholder="bwt_…" autoComplete="off" disabled={!canEdit} /> + + ) : ( + <> + + setEmail(e.target.value)} + type="email" + autoComplete="off" + disabled={!canEdit} + /> + + + setPassword(e.target.value)} autoComplete="new-password" disabled={!canEdit} /> + + + )} + {!canEdit && Your role can't change settings, so the connection can't be changed here.} + {save.error instanceof Error ? save.error.message : ""} +
+ + +
+ + )} +
+ ); +} diff --git a/SW.Bitween.Web/ClientApp/src/pages/mirror/__tests__/MirrorPage.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/mirror/__tests__/MirrorPage.test.tsx new file mode 100644 index 00000000..e2db4b2e --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/mirror/__tests__/MirrorPage.test.tsx @@ -0,0 +1,182 @@ +import { screen, within } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; +import type { MirrorItem, MirrorParity } from "../../../api"; +import { apiPath, renderApp } from "../../../__tests__/support/renderApp"; + +const status = (over = {}) => + http.get(apiPath("/mirror"), () => + HttpResponse.json({ target: true, sourceUrl: "https://bitween.acme.test", signIn: "token", configured: true, ...over }), + ); + +const item = (over: Partial & Pick): MirrorItem => ({ + fields: [], + reason: null, + refused: false, + stagingId: null, + prodId: null, + type: null, + ...over, +}); + +const parity: MirrorParity = { + checkedOn: new Date().toISOString(), + prod: { url: "https://bitween.acme.test", version: "10.0.110", exchanges: 125000 }, + staging: { url: "https://staging.acme.test", version: "10.0.115", exchanges: 40 }, + verdict: { ready: false, matching: 40, total: 43, needsYou: 1, toCopy: 1, notCovered: 2 }, + notes: [ + { id: "adapter:acme.mapper", group: "needsYou", text: "Upload the adapter acme.mapper, version 1.4.2, on the Adapters page.", section: null, key: null, action: "adapters-installed" }, + { id: "copy", group: "copy", text: "Copying updates 1 item that differs.", section: null, key: null, action: null }, + { + id: "refused:subscriptions:Order intake", + group: "notCovered", + text: "Staging couldn't create prod's subscription “Order intake”, so the test won't cover it. Bitween said: nope.", + section: "subscriptions", + key: "Order intake", + action: null, + }, + { id: "waiting:subscriptions:SkyNet returns", group: "notCovered", text: "Prod's subscription “SkyNet returns” isn't on staging yet.", section: "subscriptions", key: "SkyNet returns", action: null }, + { id: "addresses", group: "onPurpose", text: "1 address prod uses leads to a test address on staging, which goes nowhere.", section: null, key: null, action: "addresses" }, + ], + kinds: [ + { + section: "subscriptions", + noun: "subscription", + plural: "subscriptions", + inSync: 40, + differs: 1, + missing: 2, + extra: 1, + skipped: null, + items: [ + item({ key: "Push labels", state: "differs", fields: ["handler"], stagingId: "77", prodId: "12", type: "Internal" }), + item({ key: "Order intake", state: "missing", refused: true, reason: "Staging couldn't create it. Bitween said: nope.", prodId: "31", type: "GatewayApiCall" }), + item({ key: "SkyNet returns", state: "waiting", reason: "Waits for the adapter acme.mapper 1.4.2 on staging.", prodId: "32", type: "Receiving" }), + item({ key: "zz old test", state: "extra", reason: "Only on staging.", stagingId: "90", type: "Internal" }), + ], + }, + { section: "connections", noun: "connection", plural: "connections", inSync: 0, differs: 0, missing: 0, extra: 0, skipped: "Prod's release doesn't have these.", items: [] }, + ], + adapters: [{ id: "acme.mapper", source: "custom", prodVersion: "1.4.2", stagingVersion: null, state: "missing", usedBy: 3 }], + addresses: [ + { mock: "m1a2b3c4d.mirror.invalid", kind: "https", usedBy: [{ section: "subscriptions", noun: "subscription", key: "Push labels", where: "handler · Url" }] }, + ], + fakedKeys: [{ id: "partner:timeZone", label: "Partner property timeZone", count: 4, kept: false }], + unknown: [], +}; + +const parityHandler = (answer: MirrorParity | { error: string } = parity) => http.get(apiPath("/mirror/parity"), () => HttpResponse.json(answer)); + +describe("prod mirror", () => { + it("asks for prod's address and a login before anything else", async () => { + renderApp("/mirror", { handlers: [status({ configured: false, sourceUrl: null, signIn: null, target: false })] }); + + expect(await screen.findByRole("button", { name: "Connect to prod" })).toBeVisible(); + // Not a mirror: copying is off, and the page says how to turn it on. + expect(screen.getByText(/isn't marked as a mirror/)).toBeVisible(); + }); + + it("answers first, then says what needs you, what the test can't cover and what differs on purpose", async () => { + renderApp("/mirror", { handlers: [status(), parityHandler()] }); + + const verdict = await screen.findByRole("status", { name: "Readiness" }); + expect(within(verdict).getByText("Not ready to test yet")).toBeVisible(); + expect(within(verdict).getByText(/40 of prod's 43 items as prod has them\. 1 thing needs you, and 1 item is left to copy/)).toBeVisible(); + + const needsYou = screen.getByRole("region", { name: "Needs you" }); + expect(within(needsYou).getByRole("link", { name: "Open Adapters" })).toHaveAttribute("href", "/adapters?tab=installed"); + + // An item staging couldn't create opens on prod, where it is, and not on staging, where it isn't. + const notCovered = screen.getByRole("region", { name: "Not covered by the test" }); + const refused = within(notCovered).getByText(/couldn't create prod's subscription “Order intake”/).closest("li")!; + expect(within(refused).getByRole("link", { name: /On prod/ })).toHaveAttribute("href", expect.stringMatching(/^https:\/\/bitween\.acme\.test\/.*31$/)); + expect(within(refused).queryByRole("link", { name: "On staging" })).toBeNull(); + + expect(screen.getByRole("region", { name: "Different on staging, on purpose" })).toBeVisible(); + }); + + it("opens the section a note points at", async () => { + const { user } = renderApp("/mirror", { handlers: [status(), parityHandler()] }); + + // Folded until asked for, so the page leads with what to do. + await screen.findByRole("status", { name: "Readiness" }); + expect(screen.queryByText("m1a2b3c4d.mirror.invalid")).toBeNull(); + await user.click(screen.getByRole("button", { name: "See the test addresses" })); + expect(screen.getByText("m1a2b3c4d.mirror.invalid")).toBeVisible(); + }); + + it("lists each kind's items with a way to each, on staging and on prod", async () => { + const { user } = renderApp("/mirror", { handlers: [status(), parityHandler()] }); + + await user.click(await screen.findByRole("button", { name: /^subscriptions: 40 same/ })); + const row = screen.getByText("Push labels").parentElement!; + expect(within(row).getByRole("link", { name: "On staging" })).toHaveAttribute("href", expect.stringMatching(/\/77$/)); + expect(within(row).getByRole("link", { name: /On prod/ })).toHaveAttribute("href", expect.stringMatching(/^https:\/\/bitween\.acme\.test\/.*12$/)); + expect(screen.getByTitle("Staging tried and couldn't make it")).toHaveTextContent("Couldn't copy"); + expect(screen.getByText("SkyNet returns")).toBeVisible(); + + // A kind prod's release doesn't have is said, not counted as missing. + await user.click(screen.getByRole("button", { name: "connections: not read from prod" })); + expect(screen.getByText("Prod's release doesn't have these.")).toBeVisible(); + }); + + it("copies, and says what staging couldn't make", async () => { + let asked: unknown = null; + const ready: MirrorParity = { ...parity, verdict: { ...parity.verdict, ready: true, needsYou: 0, toCopy: 0, matching: 41 }, notes: [] }; + const { user } = renderApp("/mirror", { + handlers: [ + status(), + parityHandler(), + http.post(apiPath("/mirror/copy"), async ({ request }) => { + asked = await request.json(); + return HttpResponse.json({ + created: 1, + updated: 1, + removed: 0, + adapterSteps: 0, + failed: [{ section: "subscriptions", noun: "subscription", key: "Order intake", error: "nope." }], + after: ready, + }); + }), + ], + }); + + await user.click(await screen.findByRole("button", { name: "Copy now" })); + + expect(await screen.findByText(/Copied: created 1, updated 1, removed 0/)).toBeVisible(); + expect(screen.getByText(/Staging couldn't make this one/)).toBeVisible(); + expect(asked).toEqual({ removeExtras: false }); + expect(await screen.findByText("Ready to test the upgrade")).toBeVisible(); + }); + + it("removes what only staging has only when ticked, and asks first", async () => { + let asked: unknown = null; + const { user } = renderApp("/mirror", { + handlers: [ + status(), + parityHandler(), + http.post(apiPath("/mirror/copy"), async ({ request }) => { + asked = await request.json(); + return HttpResponse.json({ created: 0, updated: 0, removed: 1, adapterSteps: 0, failed: [], after: parity }); + }), + ], + }); + + await user.click(await screen.findByRole("checkbox", { name: /Also remove the 1 item/ })); + await user.click(screen.getByRole("button", { name: "Copy now" })); + const dialog = await screen.findByRole("dialog", { name: "Copy and remove" }); + await user.click(within(dialog).getByRole("button", { name: "Copy and remove" })); + + expect(await screen.findByText(/removed 1/)).toBeVisible(); + expect(asked).toEqual({ removeExtras: true }); + }); + + it("says why prod can't be compared, with a way to fix the connection", async () => { + renderApp("/mirror", { + handlers: [status(), parityHandler({ error: "Prod runs r8 or older. The mirror copies prods on r10.0 or later; move this client to r10 first." })], + }); + + expect(await screen.findByText(/Prod runs r8 or older/)).toBeVisible(); + expect(screen.getByRole("button", { name: "Change the connection" })).toBeVisible(); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/routePages.tsx b/SW.Bitween.Web/ClientApp/src/routePages.tsx index 3499335f..8a4ab405 100644 --- a/SW.Bitween.Web/ClientApp/src/routePages.tsx +++ b/SW.Bitween.Web/ClientApp/src/routePages.tsx @@ -16,6 +16,7 @@ export const AdaptersPage = lazy(() => import("./pages/adapters/AdaptersPage").t export const ProfilePage = lazy(() => import("./pages/ProfilePage").then((m) => ({ default: m.ProfilePage }))); export const AuditPage = lazy(() => import("./pages/audit/AuditPage").then((m) => ({ default: m.AuditPage }))); export const NodesPage = lazy(() => import("./pages/nodes/NodesPage").then((m) => ({ default: m.NodesPage }))); +export const MirrorPage = lazy(() => import("./pages/mirror/MirrorPage").then((m) => ({ default: m.MirrorPage }))); export const SettingsPage = lazy(() => import("./pages/settings/SettingsPage").then((m) => ({ default: m.SettingsPage }))); export const DashboardPage = lazy(() => import("./pages/dashboard/DashboardPage").then((m) => ({ default: m.DashboardPage }))); export const ExchangeNewPage = lazy(() => import("./pages/exchanges/ExchangeNewPage").then((m) => ({ default: m.ExchangeNewPage }))); diff --git a/SW.Bitween.Web/ClientApp/src/router.tsx b/SW.Bitween.Web/ClientApp/src/router.tsx index f37cdd24..613b0cf1 100644 --- a/SW.Bitween.Web/ClientApp/src/router.tsx +++ b/SW.Bitween.Web/ClientApp/src/router.tsx @@ -46,6 +46,7 @@ import { NewResponseSubscriptionPage, NewScheduledJobPage, NodesPage, + MirrorPage, NotifierPage, NotifiersPage, PartnerPage, @@ -143,6 +144,7 @@ const PAGE_ELEMENTS: Record = { notificationChannelNew: , notificationChannel: , nodes: , + mirror: , audit: , profile: , }; diff --git a/SW.Bitween.Web/Startup.cs b/SW.Bitween.Web/Startup.cs index 6db077e6..19438e98 100644 --- a/SW.Bitween.Web/Startup.cs +++ b/SW.Bitween.Web/Startup.cs @@ -160,6 +160,7 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); services.AddSingleton(); services.AddScoped(); diff --git a/docs/admin-ui.md b/docs/admin-ui.md index cc0f6389..48c3113c 100644 --- a/docs/admin-ui.md +++ b/docs/admin-ui.md @@ -80,6 +80,7 @@ their next save. |---|---| | **Team** | Members: add with an invite (emailed when a mail server is set, otherwise a link to send) or a password, resend an expired invite, assign roles, set a password, unlock, disable and remove. Invited members show as *Invited* until they choose a password. Roles: a permission matrix with a live preview of the navigation the role would see. | | **Settings** | Runtime settings by section, including branding with an instant preview. Values owned by the environment are shown read-only. Documents & storage shows the bucket's deletion rules and what the retention settings do, and asks before saving a change to them. **Mail server** is the server Email channels send through. **Notification channels** lists channels and who uses each, and links to any legacy notifiers. Each channel opens on a page of its own: Email, Microsoft Teams and Slack first, any other adapter one click away, a test message, and its recent sends. A role with the notification permissions but not Settings, such as the built-in Member and Viewer, finds only the Notification channels section here. | +| **Prod mirror** | On a staging: how close it is to the prod it mirrors, what is left to do, and a copy of prod's setup with every value faked. See [Prod mirror](prod-mirror.md). | | **Audit trail** | Every configuration change with its old and new values. | The **Dashboard** opens from the logo, and includes failures still to act on and the retry chains that keep failing. **Profile** lets members change their display name and password. diff --git a/docs/api-reference.md b/docs/api-reference.md index 1ebc4b35..c888b475 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -345,6 +345,9 @@ See [Data sources](data-sources.md) and [Databases](databases.md). | `GET /api/retention?refresh=` | `settings.view`. What the retention settings do; `refresh=true` reads the bucket's rules again. | | `POST /api/retention/preview` | `settings.view`. The same for proposed values, without saving them. | | `GET /api/audit?offset=&limit=&entityName=&entityKey=&userId=&correlationId=&from=&to=` | `audit.view` | +| `GET /api/mirror` | `mirror.view`. Whether this Bitween is a mirror (`target`), prod's address and how it signs in, without calling prod. | +| `GET /api/mirror/parity` | `mirror.view`. Reads prod and this Bitween now: `verdict` (`ready`, `matching`, `total`, `needsYou`, `toCopy`, `notCovered`), `notes` (each in `needsYou`, `copy`, `notCovered` or `onPurpose`, with the item or page it is about), per kind what is the same, differs, is missing or is only here with each item's id on both sides, `adapters`, `addresses` (the test ones, with what uses each), `fakedKeys` and `unknown` (fields prod sends that this release doesn't know). A prod that can't be reached or is older than r10.0 answers `{ error }`. See [Prod mirror](prod-mirror.md). | +| `POST /api/mirror/copy` | `mirror.operate`, and edit rights on what it copies. `{ removeExtras }`. Copies prod's setup with every value faked and answers `{ created, updated, removed, adapterSteps, failed, after }`. Refused with `MIRROR_NOT_TARGET` unless the deployment sets `Bitween:Mirror:Target`. | ## Monitoring diff --git a/docs/configuration.md b/docs/configuration.md index 75f6a94b..b1a902c5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -97,6 +97,7 @@ applications the same `Token` values. Until they match, `Bus:OnInvalidUserToken` | `InboundMessagePruneCron` | `0 30 3 * * ?` | Quartz cron for deleting expired broker deduplication keys. | | `QueuePrefix` | `bitween` | Part of every queue name. Give each deployment sharing a broker its own value. | | `BusDefaultQueuePrefetch` | `12` | Default number of unacknowledged messages per consumer. Work groups can override it. | +| `Mirror:Target` | `false` | Whether a copy of a prod may replace this Bitween's setup, on a staging that mirrors one. Set it on the staging's deployment only. See [Prod mirror](prod-mirror.md). | | `BusDefaultConcurrency` | the prefetch | How many messages of a lane run at once on a node, for the Ungrouped lane and the bus's other consumers (bus-enabled information types, retry and auto-pause alerts). A work group sets its own with **Parallel exchanges**. `1` runs them one at a time, in order, as before. | | `RabbitMqManagementUrl`, `RabbitMqManagementUsername`, `RabbitMqManagementPassword` | | RabbitMQ management API, needed for queue health. | | `CorsOrigins` | empty | Browser origins allowed to call the API with credentials. Set as `Bitween__CorsOrigins__0`, `Bitween__CorsOrigins__1` and so on. | @@ -191,6 +192,9 @@ Settings are stored in the `Settings` table and edited on the Settings page. | `Bitween.RegistryUsageReports` | Marketplace | Yes | `false` | | `Bitween.MailServer.Host`, `Port`, `UseTls`, `CertificateThumbprint`, `Username`, `From`, `FromName` | Mail server | Yes | port `587`, `UseTls` `true`, the rest empty | | `Bitween.MailServer.Password` | Mail server | Yes, secret | empty | +| `Bitween.Mirror.Target` | Prod mirror | Read-only | `false` | +| `Bitween.Mirror.SourceUrl`, `SourceEmail`, `KeepRealValues` | Prod mirror | Yes | empty | +| `Bitween.Mirror.SourceToken`, `SourcePassword` | Prod mirror | Yes, secret | empty | | `Bitween.MaxRetryAfterSeconds` | Reliability & jobs | Yes | `21600`, six hours | | `Bitween.RetryJobCron` | Reliability & jobs | Yes | `0 * * * * ?` | | `Bitween.QueuePrefix`, `BusDefaultQueuePrefetch`, `BusDefaultConcurrency` | Messaging | Read-only | | diff --git a/docs/prod-mirror.md b/docs/prod-mirror.md new file mode 100644 index 00000000..5688a8f4 --- /dev/null +++ b/docs/prod-mirror.md @@ -0,0 +1,62 @@ +# Prod mirror + +A staging Bitween can copy a prod Bitween's setup, with every value faked, so an upgrade can be tried on the staging before it reaches prod. The **Prod mirror** page on the staging shows how close it is to prod, what is left to do, and copies prod's setup onto it. + +Prod is only read, through its own API, with a view-only login. Nothing is installed or changed on prod. + +## What it needs + +- **Prod on r10.0 or later.** A prod on r8, on Infolink (r6 and before), or on a build changed for one client is refused with the reason. Move such a client to r10 first. +- **A staging marked as a mirror.** Copying replaces the staging's setup, so it only runs on a Bitween whose deployment sets `Bitween__Mirror__Target=true`. A prod never sets it, so a prod can't be overwritten from its Settings page. Without it the page still compares, but doesn't copy. +- **A view-only login on prod**, one of: + - a service account with the built-in **Viewer** role, and a token issued to it (**Administration, Service accounts** on prod). Releases that have service accounts take this; + - on r10.0, which has none, the email and password of a member with the Viewer role. + +Enter prod's address and the login on the page (**Connect to prod**), or in **Settings, Prod mirror**. The token and password are stored encrypted, which needs `Bitween:SettingsEncryptionKey`. + +## What is copied, and how + +Everything a configuration file can hold is copied by name: subscription categories, work groups, information types, partners, global values, connections, notification channels, retry policies, data sources and their SQL statements, subscriptions, API gateways, bus gateways and legacy notifiers. A kind prod's release doesn't have is left out and says so. The copy goes through the same engine as [`bitween apply`](cli.md). + +Values are handled the moment they arrive, before anything is saved, so the staging never holds a real value of prod's: + +| Kind of value | Examples | On the staging | +|---|---|---| +| How a flow behaves | formats, schedules, filters and match expressions, mapping rules, methods, delimiters, file name patterns, `{{globals.…}}` tokens | Copied as it is | +| Addresses | hosts, URLs, email addresses, connection strings' servers, an API gateway's issuer | A mock address under `mirror.invalid`, which resolves nowhere. The same address is the same mock wherever prod names it | +| Secrets | anything prod shows as `__private__`, passwords in URLs and connection strings | A stand-in, the same on every copy | +| Partner properties and global values | account numbers, codes, names | A fake of the same shape: digits stay digits, letters keep their case, dates stay dates in their format, GUIDs stay GUIDs, JSON keeps its keys | +| Partner API keys | | New ones, the same on every copy | + +The same value always becomes the same fake, so a code named in two places still matches, and copying again changes nothing. A partner property or global value that is a setting rather than data, such as a time zone or a date format, can be kept as it is under **Faked values**. Addresses, secrets and values named like secrets (`apiKey`, `password`, `token` and similar) are never kept. + +Copied subscriptions arrive paused, and data sources and bus gateways switched off, so nothing on the staging runs on its own. + +Legacy setups are copied as prod has them: Internal and ApiCall subscriptions, legacy notifiers, and a response handed to an Internal or ApiCall subscription. Bitween keeps such a response link on an item that already has it but refuses a new one, and a copy is a new save. So the copy alone may recreate it: its calls carry a key it opens for the length of the copy, on a Bitween marked as a mirror. Someone editing on the staging never carries one and meets the same rule as on prod. + +A field prod sends that this release doesn't know, from a newer or a customised build, can't be copied. The page names each one and the items that have it set, rather than dropping it unseen. + +Prod may hold several items of a kind under one name. Each after the oldest is named apart on the staging: `test`, `test (2)`, `test (3)`. References to them still point at the right one. + +## Adapters + +Built-in adapters come with the release. One that prod uses and this release doesn't have is flagged, since it would stop working after the upgrade. Marketplace adapters are installed by the copy at the version prod runs. Simplify9's database and broker adapters (`bitween.db.…`, `bitween.bus.…`) are on the marketplace too; when prod doesn't say which version it runs, the page asks for one to be installed from the **Marketplace** tab. Custom adapters can't be read from prod: the page names each one, with the version prod runs, to upload on the **Adapters** page. Subscriptions that use one wait for it rather than fail. + +## The page + +It answers first: **Ready to test the upgrade**, or **Not ready to test yet**, with how many of prod's items the staging has as prod has them. Below it, everything is in one of four groups, and every item named opens on the staging or on prod: + +- **Needs you**: what only a person can do, such as installing an adapter, each with where it is done. +- **Copy prod's setup**: what copying will do, and the button. It creates what the staging lacks and updates what differs, carries on past an item that fails, and lists each one with Bitween's reason. Tick **Also remove** to delete what only the staging has. +- **Not covered by the test**: what is on prod but not on the staging, or not all of it: items waiting for an adapter, items the staging couldn't create, and fields this release doesn't know. +- **Different on staging, on purpose**: paused subscriptions, switched-off data sources and bus gateways, fake values, test addresses, stand-in secrets and names told apart. None of it is a problem. + +Then the details, folded: **Prod's setup, item by item** (per kind, what is the same, differs, is missing or is only on the staging), **Adapters**, **Fake values** (where a value can be kept as it is) and **Test addresses** (each with what uses it). + +When the staging can't create one of prod's items, the page says so, with what Bitween said. That is all it knows: an upgrade doesn't create items again, so a refusal here says nothing about how prod will behave. + +## Not covered yet + +- Running prod's flows on the staging against mock servers, and comparing the results. Until then the mock addresses lead nowhere. +- Exchanges and their files. Only the setup is copied. +- Runtime settings, roles, members and service accounts, which differ between environments on purpose. diff --git a/docs/security.md b/docs/security.md index a2b24410..24f01586 100644 --- a/docs/security.md +++ b/docs/security.md @@ -96,12 +96,14 @@ A permission is written `area.action`. A member holds the union of their roles' | Administration | `settings` | view, edit | | Administration | `audit` | view | | Administration | `adapter-source` | view, edit, operate | +| Administration | `mirror` | view, operate | - `exchanges.operate` covers retrying and resubmitting exchanges, including running a scheduled retry now. - `subscriptions.operate` covers pause, resume, receive now, roll up now and resetting a retry budget. - `data-sources.operate` covers testing a connection. `data-source-statements` lets people write SQL for a database without the right to change its credentials. - `documents` is the information types area. - The audit trail has only `view`, because nothing can change it. +- `mirror` is labelled "Prod mirror". `view` covers comparing a staging with the prod it mirrors; `operate` covers copying prod's setup onto it, which replaces what the staging has. See [Prod mirror](prod-mirror.md). - `adapter-source` is labelled "Adapter code". `view` covers reading the source a published adapter version carries and comparing versions. `edit` covers the adapter editor's drafts: starting, editing, deleting, building, checking and trying them, which runs their code on the Bitween server. `operate` covers publishing a version, from the editor or as a package through the API, making a version current and withdrawing one. Listing an adapter's versions needs only `subscriptions.view`. ### Built-in roles From d027dda5b76ee24574ecb3f4d2b70fd9912f8f8d Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 22 Oct 2026 14:26:46 +0300 Subject: [PATCH 04/16] feat: prod mirror takes samples of prod's exchanges, faked in their own format --- SW.Bitween.Api/Resources/Mirror/Mirror.cs | 34 ++ SW.Bitween.Api/Services/Mirror/MirrorFaker.cs | 124 ++++- SW.Bitween.Api/Services/Mirror/MirrorFiles.cs | 448 ++++++++++++++++++ .../Services/Mirror/MirrorSamples.cs | 97 ++++ .../Services/Mirror/MirrorService.Samples.cs | 294 ++++++++++++ .../Services/Mirror/MirrorService.cs | 28 +- SW.Bitween.Api/Services/Mirror/MirrorSetup.cs | 45 ++ SW.Bitween.UnitTests/MirrorFakerTests.cs | 44 ++ SW.Bitween.UnitTests/MirrorFilesTests.cs | 193 ++++++++ SW.Bitween.UnitTests/MirrorSamplesTests.cs | 89 ++++ SW.Bitween.Web/ClientApp/src/api/client.ts | 3 + .../ClientApp/src/api/http/mirror.ts | 11 +- SW.Bitween.Web/ClientApp/src/api/queryKeys.ts | 1 + SW.Bitween.Web/ClientApp/src/api/types.ts | 47 ++ .../ClientApp/src/pages/mirror/MirrorPage.tsx | 187 +++++++- .../mirror/__tests__/MirrorPage.test.tsx | 84 +++- SW.Bitween.Web/Startup.cs | 1 + docs/api-reference.md | 2 + docs/prod-mirror.md | 26 +- 19 files changed, 1720 insertions(+), 38 deletions(-) create mode 100644 SW.Bitween.Api/Services/Mirror/MirrorFiles.cs create mode 100644 SW.Bitween.Api/Services/Mirror/MirrorSamples.cs create mode 100644 SW.Bitween.Api/Services/Mirror/MirrorService.Samples.cs create mode 100644 SW.Bitween.UnitTests/MirrorFilesTests.cs create mode 100644 SW.Bitween.UnitTests/MirrorSamplesTests.cs diff --git a/SW.Bitween.Api/Resources/Mirror/Mirror.cs b/SW.Bitween.Api/Resources/Mirror/Mirror.cs index cb20fd72..3789fd69 100644 --- a/SW.Bitween.Api/Resources/Mirror/Mirror.cs +++ b/SW.Bitween.Api/Resources/Mirror/Mirror.cs @@ -46,6 +46,40 @@ public async Task Handle() } } +/// +/// The samples of prod's exchanges staging holds, faked, each file with a link that opens it, and how a +/// taking under way is going. +/// +[HandlerName("samples")] +public class Samples(BitweenDbContext dbContext, RequestContext requestContext, MirrorService mirror, MirrorSampler sampler) : IQueryHandler +{ + public async Task Handle() + { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Mirror.View); + return await mirror.SamplesAsync(sampler); + } +} + +public class MirrorTakeSamplesRequest; + +/// +/// Starts taking samples of prod's exchanges, in the background: a few recent ones for each +/// subscription, faked as they arrive. The page follows it through . Only on a +/// Bitween its deployment marks as a mirror. +/// +[HandlerName("takesamples")] +public class TakeSamples(BitweenDbContext dbContext, RequestContext requestContext, BitweenOptions options, MirrorService mirror, MirrorSampler sampler) + : ICommandHandler +{ + public async Task Handle(MirrorTakeSamplesRequest request) + { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Mirror.Operate); + if (!options.Mirror.Target) throw new SWValidationException("MIRROR_NOT_TARGET", MirrorService.NotTarget); + sampler.Start(); + return await mirror.SamplesAsync(sampler); + } +} + public class MirrorCopyRequest { /// Also remove what staging has and prod doesn't. Off unless asked, since it deletes. diff --git a/SW.Bitween.Api/Services/Mirror/MirrorFaker.cs b/SW.Bitween.Api/Services/Mirror/MirrorFaker.cs index 0df5f91b..2ce4594c 100644 --- a/SW.Bitween.Api/Services/Mirror/MirrorFaker.cs +++ b/SW.Bitween.Api/Services/Mirror/MirrorFaker.cs @@ -20,8 +20,15 @@ namespace SW.Bitween.Services.Mirror; /// becomes the same stand-in under one key, so a code named in two places still matches, and copying /// again changes nothing. /// -public sealed class MirrorFaker(byte[] key) +/// +/// Words staging's own copy of prod's setup already names - a filter's 'SHIPPED', a file pattern's ORD - +/// kept as they are wherever they turn up, so a faked value takes the path the real one took. Keeping +/// one tells staging nothing it doesn't hold already. +/// +public sealed class MirrorFaker(byte[] key, IEnumerable keep = null) { + readonly HashSet keep = new(keep ?? [], StringComparer.OrdinalIgnoreCase); + /// Where every mock address lives: a name reserved never to resolve (RFC 2606). public const string MockDomain = "mirror.invalid"; @@ -89,7 +96,7 @@ public string Fake(string value) { if (string.IsNullOrEmpty(value)) return value; var trimmed = value.Trim(); - if (KeptWords.Contains(trimmed)) return value; + if (Kept(trimmed)) return value; if (trimmed.Length > 1 && trimmed[0] is '{' or '[' && Json(trimmed) is { } json) return json; var text = new StringBuilder(value.Length); @@ -112,13 +119,40 @@ public string Fake(string value) string Plain(string text) { - if (text.Length == 0 || KeptWords.Contains(text.Trim())) return text; + if (text.Length == 0 || Kept(text.Trim())) return text; if (IsoDate.Match(text) is { Success: true } date) return Date(text, date); if (Hex.IsMatch(text) || Guid.IsMatch(text)) return HexShape(text); - return Shape(text); + return Words(text); + } + + /// + /// Each word on its own, so a word is the same stand-in wherever it stands - alone, in a sentence + /// or in a file name: one the setup names is kept, a date or a GUID keeps its form, the rest is + /// shaped. What is between words stays. + /// + string Words(string text) + { + var result = new StringBuilder(text.Length); + var at = 0; + foreach (Match m in WordPattern.Matches(text)) + { + result.Append(text, at, m.Index - at); + var word = m.Value; + result.Append( + Kept(word) ? word + : m.Groups["date"].Success ? Date(word, IsoDate.Match(word)) + : m.Groups["guid"].Success || Hex.IsMatch(word) ? HexShape(word) + : CompactDate(word) ?? Shape(word)); + at = m.Index + m.Length; + } + result.Append(text, at, text.Length - at); + return result.ToString(); } - /// Letters stay letters of the same case, digits stay digits; a number keeps a leading digit that isn't 0. + /// + /// Letters stay letters of the same case, digits stay digits of the same script; a number keeps a + /// leading digit that isn't 0. + /// string Shape(string text) { var bytes = Bytes("shape", text, text.Length); @@ -129,13 +163,20 @@ string Shape(string text) { var c = text[i]; var b = bytes[i]; - if (i == 0 || !char.IsAsciiDigit(text[i - 1])) leading = true; - var first = leading && c != '0'; - if (char.IsAsciiDigit(c) && c != '0') leading = false; + if (i == 0 || !char.IsDigit(text[i - 1])) leading = true; + if (char.IsDigit(c)) + { + // Any script's digits - Arabic-Indic ones too - counted from that script's zero. + var value = CharUnicodeInfo.GetDigitValue(c); + var zero = (char)(c - value); + result[i] = value == 0 && leading ? c + : leading ? (char)(zero + 1 + b % 9) + : (char)(zero + b % 10); + if (value != 0) leading = false; + continue; + } result[i] = c switch { - _ when char.IsAsciiDigit(c) => c == '0' && leading ? '0' - : first ? (char)('1' + b % 9) : (char)('0' + b % 10), _ when char.IsAsciiLetterUpper(c) => (char)('A' + b % 26), _ when char.IsAsciiLetterLower(c) => (char)('a' + b % 26), // Another script's letters, which have no case to keep: a letter all the same. @@ -146,6 +187,29 @@ _ when char.IsLetter(c) => (char)('a' + b % 26), return new string(result); } + /// + /// yyyyMMdd, with the time after it or not, as file names and fixed-width records write a date: + /// moved as the same day written 2026-10-20 is, so both forms stay the same day. Null when it isn't one. + /// + string CompactDate(string word) + { + if (word.Length is not (8 or 12 or 14) || !word.All(char.IsAsciiDigit)) return null; + if (!DateTime.TryParseExact(word[..8], "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var day) || + day.Year is < 1990 or > 2099) + return null; + return Shift(day).ToString("yyyyMMdd", CultureInfo.InvariantCulture) + word[8..]; + } + + /// A file's name: faked as any text is, with its extension kept, so ORD_*.csv still matches when the setup names ORD. + public string FileName(string name) + { + if (string.IsNullOrEmpty(name)) return name; + var dot = name.LastIndexOf('.'); + return dot > 0 ? Plain(name[..dot]) + name[dot..] : Plain(name); + } + + bool Kept(string word) => KeptWords.Contains(word) || keep.Contains(word); + string Digits(string number) { var bytes = Bytes("digits", number, number.Length); @@ -174,21 +238,36 @@ string Date(string text, Match match) { if (!DateTime.TryParseExact(match.Value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var day)) return Shape(text); - var shift = BitConverter.ToUInt16(Bytes("date", match.Value, 2)) % 731 - 365; var rest = text[match.Length..]; - return day.AddDays(shift).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + (TimeOfDay.IsMatch(rest) ? rest : Shape(rest)); + return Shift(day).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + (TimeOfDay.IsMatch(rest) ? rest : Shape(rest)); } + /// A day moved by up to a year either way: the same day always by the same amount. + DateTime Shift(DateTime day) => + day.AddDays(BitConverter.ToUInt16(Bytes("date", day.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), 2)) % 731 - 365); + + /// A JSON document faked as a value holding one is; null when the text isn't JSON. + public string FakeJson(string text) => Json(text.Trim()); + + /// A number's digits faked, its sign, point and exponent kept, as a spreadsheet holds one. + public string Number(string number) => string.IsNullOrEmpty(number) || Kept(number) ? number : Digits(number); + string Json(string text) { - JsonNode node; - try { node = JsonNode.Parse(text); } - catch (JsonException) { return null; } - return node == null ? null : FakeNode(node)?.ToJsonString(new JsonSerializerOptions + try + { + var node = JsonNode.Parse(text); + return node == null ? null : FakeNode(node)?.ToJsonString(new JsonSerializerOptions + { + WriteIndented = text.Contains('\n'), + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }); + } + // Not JSON after all, or JSON naming a key twice, which a JsonObject can't hold: read as text. + catch (Exception ex) when (ex is JsonException or ArgumentException or InvalidOperationException) { - WriteIndented = text.Contains('\n'), - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - }); + return null; + } } JsonNode FakeNode(JsonNode node) => node switch @@ -433,4 +512,11 @@ byte[] Bytes(string purpose, string input, int count) static readonly Regex Hex = new(@"^(?:(?=[0-9]*[a-f])[0-9a-f]{8,}|(?=[0-9]*[A-F])[0-9A-F]{8,})$", RegexOptions.Compiled); static readonly Regex TimeOfDay = new(@"^([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$", RegexOptions.Compiled); static readonly Regex Guid = new(@"^[{(]?[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}[)}]?$", RegexOptions.Compiled); + // A date with its time, a GUID, or a run of letters and digits: what Words fakes one at a time. + // Bounded by what can't continue them rather than \b, which an underscore defeats: ORD_2026-10-20. + static readonly Regex WordPattern = new( + @"(?(?(?One of prod's files as staging keeps it: faked in its own format, or not kept, and why. +/// The faked file; null when it isn't kept. +/// json, xml, html, csv, xlsx or text when it is kept; what it is when it isn't: pdf, zip, edi, binary... +/// Why it isn't kept, in words for the page; null when it is. +public record MirrorFakedFile(byte[] Content, string Name, string Format, string NotCopied); + +/// +/// Fakes one of prod's files in its own format, so staging reads it the way prod read the real one: +/// JSON keeps its keys, XML its elements, attribute names and namespaces, CSV its delimiter, quoting and +/// line endings, an Excel workbook its sheets, styles and formulas. Only values change, the way +/// changes any value. A file that can't be faked safely - a PDF, an archive, a +/// picture - isn't kept at all, so nothing of prod's is ever stored as it is. +/// +public static class MirrorFiles +{ + /// Larger files aren't kept: a sample needs the shape, not the volume. + public const int MaxBytes = 5 * 1024 * 1024; + + public static MirrorFakedFile Fake(MirrorFaker faker, byte[] content, string name) + { + var fakedName = faker.FileName(name); + if (content.Length > MaxBytes) return TooLarge(fakedName); + try + { + return Detect(content) switch + { + "xlsx" => Excel(faker, content, fakedName), + "docx" => Refused(fakedName, "docx", "Word and PowerPoint files aren't faked yet."), + "zip" => Refused(fakedName, "zip", "Archives aren't faked: what they hold could be anything."), + "pdf" => Refused(fakedName, "pdf", "PDFs can't be faked: their text is drawn on the page, not kept as values."), + "binary" => Refused(fakedName, "binary", "It isn't text, so it can't be faked."), + _ => Text(faker, content, fakedName), + }; + } + // Said in general words: a parser's own message can quote the file. + catch (Exception ex) when (ex is XmlException or JsonException or InvalidDataException or DecoderFallbackException or FormatException) + { + return Refused(fakedName, "unreadable", "It couldn't be read in its own format, so it wasn't kept."); + } + } + + public static MirrorFakedFile TooLarge(string name) => + Refused(name, "large", $"It is larger than {MaxBytes / 1024 / 1024} MB, and a sample needs the shape, not the size."); + + static MirrorFakedFile Kept(byte[] content, string name, string format) => new(content, name, format, null); + + static MirrorFakedFile Refused(string name, string format, string why) => new(null, name, format, why); + + // ------------------------------------------------------------------ what it is + + static string Detect(byte[] content) + { + var head = content.AsSpan(0, Math.Min(content.Length, 8192)); + if (head.StartsWith("PK\u0003\u0004"u8)) return Zip(content); + if (head.StartsWith("%PDF-"u8)) return "pdf"; + if (head.StartsWith(Utf16Le) || head.StartsWith(Utf16Be)) return "text"; + return head.IndexOf((byte)0) >= 0 ? "binary" : "text"; + } + + static string Zip(byte[] content) + { + try + { + using var zip = new ZipArchive(new MemoryStream(content), ZipArchiveMode.Read); + var names = zip.Entries.Select(e => e.FullName).ToList(); + if (names.Contains("[Content_Types].xml")) + { + if (names.Any(n => n.StartsWith("xl/", StringComparison.Ordinal))) return "xlsx"; + if (names.Any(n => n.StartsWith("word/", StringComparison.Ordinal) || n.StartsWith("ppt/", StringComparison.Ordinal))) return "docx"; + } + } + catch (InvalidDataException) { } + return "zip"; + } + + // ------------------------------------------------------------------ text + + static MirrorFakedFile Text(MirrorFaker faker, byte[] content, string name) + { + var (text, encoding) = Decode(content); + var start = text.TrimStart(); + if (start.StartsWith('<')) return Xml(faker, content, text, encoding, name); + if (start.StartsWith('{') || start.StartsWith('[')) + if (faker.FakeJson(text) is { } json) + return Kept(Encode(json, encoding), name, "json"); + if (start.StartsWith("UNA", StringComparison.Ordinal) || start.StartsWith("UNB", StringComparison.Ordinal) || + start.StartsWith("ISA", StringComparison.Ordinal)) + return Refused(name, "edi", "EDI files aren't faked yet: their codes would change along with their values."); + if (Csv.Delimiter(text) is { } delimiter) return Kept(Encode(Csv.Fake(faker, text, delimiter), encoding), name, "csv"); + return Kept(Encode(faker.Fake(text), encoding), name, "text"); + } + + static readonly byte[] Utf8Bom = [0xEF, 0xBB, 0xBF]; + static readonly byte[] Utf16Le = [0xFF, 0xFE]; + static readonly byte[] Utf16Be = [0xFE, 0xFF]; + static readonly UTF8Encoding StrictUtf8 = new(false, true); + + /// + /// Text as it was written: UTF-8 or UTF-16 by its byte order mark, UTF-8 when it reads as UTF-8, or + /// else one byte to a character, which puts every byte back where it was. Faked letters are ASCII, + /// so whatever the encoding, they fit it. + /// + static (string Text, Encoding Encoding) Decode(byte[] content) + { + var span = content.AsSpan(); + if (span.StartsWith(Utf8Bom)) return (StrictUtf8.GetString(content, 3, content.Length - 3), new UTF8Encoding(true)); + if (span.StartsWith(Utf16Le)) return (Encoding.Unicode.GetString(content, 2, content.Length - 2), new UnicodeEncoding(false, true)); + if (span.StartsWith(Utf16Be)) return (Encoding.BigEndianUnicode.GetString(content, 2, content.Length - 2), new UnicodeEncoding(true, true)); + try { return (StrictUtf8.GetString(content), new UTF8Encoding(false)); } + catch (DecoderFallbackException) { return (Encoding.Latin1.GetString(content), Encoding.Latin1); } + } + + static byte[] Encode(string text, Encoding encoding) => [.. encoding.GetPreamble(), .. encoding.GetBytes(text)]; + + // ------------------------------------------------------------------ XML + + static readonly XNamespace Xsi = "http://www.w3.org/2001/XMLSchema-instance"; + + static MirrorFakedFile Xml(MirrorFaker faker, byte[] content, string text, Encoding encoding, string name) + { + XDocument doc; + try + { + // No DTDs and nothing fetched: a file of prod's never reaches out on staging's behalf. + using var reader = XmlReader.Create(new MemoryStream(content), + new XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore, XmlResolver = null }); + doc = XDocument.Load(reader, LoadOptions.PreserveWhitespace); + } + catch (XmlException) + { + // HTML, or XML that isn't well-formed: its tags kept, the text between them faked. + return Kept(Encode(Markup(faker, text), encoding), name, "html"); + } + + FakeXml(faker, doc); + using var output = new MemoryStream(); + using (var writer = XmlWriter.Create(output, new XmlWriterSettings + { + Encoding = EncodingOf(doc.Declaration?.Encoding, content), + OmitXmlDeclaration = doc.Declaration == null, + NewLineHandling = NewLineHandling.None, + })) + doc.Save(writer); + return Kept(output.ToArray(), name, "xml"); + } + + static Encoding EncodingOf(string declared, byte[] content) + { + var span = content.AsSpan(); + if (span.StartsWith(Utf16Le)) return new UnicodeEncoding(false, true); + if (span.StartsWith(Utf16Be)) return new UnicodeEncoding(true, true); + var bom = span.StartsWith(Utf8Bom); + if (string.IsNullOrEmpty(declared) || declared.Equals("utf-8", StringComparison.OrdinalIgnoreCase)) return new UTF8Encoding(bom); + try { return Encoding.GetEncoding(declared); } + catch (ArgumentException) { return new UTF8Encoding(bom); } + } + + /// Every value faked - text, CDATA, comments, attributes - and every name kept: elements, attributes, namespaces, xsi:type. + static void FakeXml(MirrorFaker faker, XContainer root) + { + foreach (var node in root.DescendantNodes().ToList()) + { + switch (node) + { + case XCData cdata: + cdata.Value = Inner(faker, cdata.Value); + break; + case XText text when !string.IsNullOrWhiteSpace(text.Value): + text.Value = Inner(faker, text.Value); + break; + case XComment comment: + comment.Value = faker.Fake(comment.Value); + break; + case XElement element: + foreach (var attribute in element.Attributes().Where(a => !a.IsNamespaceDeclaration && a.Name.Namespace != Xsi)) + attribute.Value = faker.Fake(attribute.Value); + break; + } + } + } + + /// A value that is a document of its own, such as XML escaped into an element, faked as one; any other as a value. + static string Inner(MirrorFaker faker, string value) + { + if (value.TrimStart().StartsWith('<')) + { + try + { + var inner = XDocument.Parse(value, LoadOptions.PreserveWhitespace); + FakeXml(faker, inner); + return (inner.Declaration?.ToString() ?? "") + inner.ToString(SaveOptions.DisableFormatting); + } + catch (XmlException) { } + } + return faker.Fake(value); + } + + static readonly Regex Tag = new(@"|<[^<>]+>", RegexOptions.Compiled | RegexOptions.Singleline); + static readonly Regex AttributeValue = new(@"(?<=\s[\w:.\-]+\s*=\s*)(""[^""]*""|'[^']*')", RegexOptions.Compiled); + static readonly Regex Entity = new(@"&(?:#[0-9]+|#x[0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);", RegexOptions.Compiled); + + /// Markup that isn't well-formed XML: tags kept with their attributes' values faked, comments and the text between tags faked, entities kept. + static string Markup(MirrorFaker faker, string text) + { + var result = new StringBuilder(text.Length); + var at = 0; + foreach (Match tag in Tag.Matches(text)) + { + result.Append(Between(faker, text[at..tag.Index])); + result.Append(tag.Value.StartsWith("" + : AttributeValue.Replace(tag.Value, v => v.Value[0] + faker.Fake(v.Value[1..^1]) + v.Value[^1])); + at = tag.Index + tag.Length; + } + result.Append(Between(faker, text[at..])); + return result.ToString(); + } + + static string Between(MirrorFaker faker, string text) + { + if (string.IsNullOrWhiteSpace(text)) return text; + var result = new StringBuilder(text.Length); + var at = 0; + foreach (Match entity in Entity.Matches(text)) + { + result.Append(faker.Fake(text[at..entity.Index])).Append(entity.Value); + at = entity.Index + entity.Length; + } + return result.Append(faker.Fake(text[at..])).ToString(); + } + + // ------------------------------------------------------------------ Excel + + /// Parts of a workbook that hold what can't be faked, with what to call them. + static readonly (string Prefix, string What)[] Unkept = + [ + ("xl/media/", "pictures"), ("xl/drawings/", "drawings"), ("xl/charts/", "charts"), ("xl/embeddings/", "embedded files"), + ("xl/pivotCache/", "pivot tables"), ("xl/externalLinks/", "links to other workbooks"), ("customXml/", "custom data"), + ]; + + static MirrorFakedFile Excel(MirrorFaker faker, byte[] content, string name) + { + using var input = new ZipArchive(new MemoryStream(content), ZipArchiveMode.Read); + // A workbook is a zip: a small one can unpack to far more than any sample needs. + if (input.Entries.Sum(e => e.Length) > 20L * MaxBytes) + return Refused(name, "xlsx", $"It is larger than {20 * MaxBytes / 1024 / 1024} MB once unpacked, and a sample needs the shape, not the size."); + var unkept = Unkept.Where(u => input.Entries.Any(e => e.FullName.StartsWith(u.Prefix, StringComparison.Ordinal))).Select(u => u.What).ToList(); + if (unkept.Count > 0) return Refused(name, "xlsx", $"It holds {string.Join(" and ", unkept)}, which can't be faked."); + + using var output = new MemoryStream(); + using (var zip = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var entry in input.Entries) + { + var copy = zip.CreateEntry(entry.FullName, CompressionLevel.Optimal); + copy.LastWriteTime = entry.LastWriteTime; + using var source = entry.Open(); + using var sink = copy.Open(); + var part = entry.FullName; + if (part == "xl/sharedStrings.xml" || part.StartsWith("xl/comments", StringComparison.Ordinal) || + part.StartsWith("xl/threadedComments/", StringComparison.Ordinal) || part.StartsWith("docProps/", StringComparison.Ordinal)) + Save(Texts(faker, Load(source)), sink); + else if (part.StartsWith("xl/worksheets/", StringComparison.Ordinal) && part.EndsWith(".xml", StringComparison.Ordinal)) + Save(Sheet(faker, Load(source)), sink); + else if (part.EndsWith(".rels", StringComparison.Ordinal)) + Save(Links(faker, Load(source)), sink); + else + source.CopyTo(sink); + } + } + return Kept(output.ToArray(), name, "xlsx"); + } + + static XDocument Load(Stream part) + { + using var reader = XmlReader.Create(part, new XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore, XmlResolver = null }); + return XDocument.Load(reader, LoadOptions.PreserveWhitespace); + } + + static void Save(XDocument doc, Stream part) + { + using var writer = XmlWriter.Create(part, new XmlWriterSettings { Encoding = new UTF8Encoding(false), NewLineHandling = NewLineHandling.None }); + doc.Save(writer); + } + + /// Every element's own text faked: shared strings, comments, the workbook's author. + static XDocument Texts(MirrorFaker faker, XDocument doc) + { + foreach (var element in doc.Descendants().Where(e => !e.HasElements && !string.IsNullOrEmpty(e.Value))) + element.Value = faker.Fake(element.Value); + return doc; + } + + /// + /// A sheet's cells: numbers keep their form, text written into the cell is faked, a shared string is + /// faked where the strings are, and formulas, booleans and errors stay. + /// + static XDocument Sheet(MirrorFaker faker, XDocument doc) + { + foreach (var cell in doc.Descendants().Where(e => e.Name.LocalName == "c")) + { + var type = (string)cell.Attribute("t"); + var value = cell.Elements().FirstOrDefault(e => e.Name.LocalName == "v"); + switch (type) + { + case null or "n" when value != null: + value.Value = faker.Number(value.Value); + break; + case "str" or "d" when value != null: + value.Value = faker.Fake(value.Value); + break; + case "inlineStr": + foreach (var t in cell.Descendants().Where(e => e.Name.LocalName == "t")) + t.Value = faker.Fake(t.Value); + break; + } + } + foreach (var link in doc.Descendants().Where(e => e.Name.LocalName == "hyperlink")) + foreach (var attribute in new[] { "display", "tooltip" }.Select(a => link.Attribute(a)).Where(a => a != null)) + attribute.Value = faker.Fake(attribute.Value); + return doc; + } + + /// Links out of the workbook made mock ones, as any address is. + static XDocument Links(MirrorFaker faker, XDocument doc) + { + foreach (var relation in doc.Descendants().Where(e => e.Name.LocalName == "Relationship" && (string)e.Attribute("TargetMode") == "External")) + if (relation.Attribute("Target") is { } target) + target.Value = faker.Addresses(target.Value); + return doc; + } + + // ------------------------------------------------------------------ CSV + + /// + /// Delimited text read as written - RFC 4180 quoting, each field quoted or not as it was, each line's + /// own ending - so it is written back the same with only its values faked. + /// + internal static class Csv + { + static readonly char[] Candidates = [',', ';', '\t', '|']; + + record struct Field(string Value, bool Quoted); + + record Row(List Fields, string Ending); + + /// + /// The delimiter that splits every one of the first lines into the same number of fields, two or + /// more, over at least two lines; the one giving the most fields when several do. Null for prose. + /// + public static char? Delimiter(string text) + { + char? best = null; + var most = 1; + foreach (var candidate in Candidates) + { + var rows = Parse(text, candidate, limit: 20).Where(r => r.Fields.Count > 1 || r.Fields[0].Value.Length > 0).ToList(); + if (rows.Count < 2) continue; + var fields = rows[0].Fields.Count; + if (fields > most && rows.All(r => r.Fields.Count == fields)) + { + best = candidate; + most = fields; + } + } + return best; + } + + public static string Fake(MirrorFaker faker, string text, char delimiter) + { + var result = new StringBuilder(text.Length); + foreach (var row in Parse(text, delimiter)) + { + for (var i = 0; i < row.Fields.Count; i++) + { + if (i > 0) result.Append(delimiter); + var field = row.Fields[i]; + var value = faker.Fake(field.Value); + result.Append(field.Quoted ? "\"" + value.Replace("\"", "\"\"") + "\"" : value); + } + result.Append(row.Ending); + } + return result.ToString(); + } + + static List Parse(string text, char delimiter, int limit = int.MaxValue) + { + var rows = new List(); + var fields = new List(); + var value = new StringBuilder(); + bool quoted = false, inQuotes = false; + for (var i = 0; i < text.Length && rows.Count < limit; i++) + { + var c = text[i]; + if (inQuotes) + { + if (c != '"') value.Append(c); + else if (i + 1 < text.Length && text[i + 1] == '"') { value.Append('"'); i++; } + else inQuotes = false; + continue; + } + if (c == '"' && value.Length == 0 && !quoted) + { + inQuotes = quoted = true; + continue; + } + if (c == delimiter) + { + fields.Add(new Field(value.ToString(), quoted)); + value.Clear(); + quoted = false; + continue; + } + if (c is '\r' or '\n') + { + var ending = c == '\r' && i + 1 < text.Length && text[i + 1] == '\n' ? "\r\n" : c.ToString(); + i += ending.Length - 1; + fields.Add(new Field(value.ToString(), quoted)); + rows.Add(new Row(fields, ending)); + fields = []; + value.Clear(); + quoted = false; + continue; + } + value.Append(c); + } + if (rows.Count < limit && (value.Length > 0 || quoted || fields.Count > 0)) + { + fields.Add(new Field(value.ToString(), quoted)); + rows.Add(new Row(fields, "")); + } + return rows; + } + } +} diff --git a/SW.Bitween.Api/Services/Mirror/MirrorSamples.cs b/SW.Bitween.Api/Services/Mirror/MirrorSamples.cs new file mode 100644 index 00000000..fcd9fb34 --- /dev/null +++ b/SW.Bitween.Api/Services/Mirror/MirrorSamples.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace SW.Bitween.Services.Mirror; + +/// One of prod's exchange files as staging keeps it. +/// Where it is in staging's storage; null when it isn't kept. +/// Its name, faked as any text is. +/// json, xml, html, csv, xlsx or text when it is kept; what it is when it isn't. +/// Why it isn't kept, in words for the page; null when it is. +/// A link that opens it on staging, made when the page asks; never stored. +public record MirrorSampleFile(string Key, string Name, string Format, int Size, string NotCopied, string Url = null); + +/// +/// One of prod's exchanges, faked: what came in, what Bitween sent, what the partner answered, and how +/// it ended on prod. A rehearsal runs what came in on staging and compares the ending. +/// +/// The exchange on prod, so the page can open it there. +/// success, badResponse or failed: how it ended on prod. +/// The partner's HTTP status, where prod's error names one. +/// +/// The kinds of error prod's error names, outermost first: HttpRequestException, SocketException. Its +/// message isn't kept - it can hold prod's data - so the page opens the exchange on prod to read it. +/// +public record MirrorSample(string ProdExchangeId, DateTime StartedOn, string Outcome, int? Status, IReadOnlyList ErrorTypes, + MirrorSampleFile Input, MirrorSampleFile Output, MirrorSampleFile Response); + +/// A subscription's samples, or why it has none. +/// The subscription on prod. +public record MirrorSubscriptionSamples(string Subscription, string ProdId, IReadOnlyList Samples, string Reason); + +/// The samples staging holds, and how the last taking went: what the page shows. +/// none (never taken), running, done or failed. +/// Subscriptions read so far, of . +/// Why the last taking stopped, when it did. +public record MirrorSamples(string State, DateTimeOffset? StartedOn, DateTimeOffset? FinishedOn, int Done, int Total, string Error, + IReadOnlyList Subscriptions); + +/// How far a taking under way is. +public record MirrorSamplingProgress(DateTimeOffset StartedOn, int Done, int Total); + +/// +/// Takes prod's samples in the background, one taking at a time, so the page can follow it: on a big +/// prod it takes longer than a request may wait. How it is going is known to the node doing it, and a +/// staging runs one node. +/// +public sealed class MirrorSampler(IServiceScopeFactory scopes, ILogger logger) +{ + readonly Lock gate = new(); + Task running; + + /// The taking under way; null when none is. + public MirrorSamplingProgress Running { get; private set; } + + /// Why the last taking stopped, until another starts; null when it finished. + public string LastError { get; private set; } + + /// Starts a taking, unless one is under way. + public void Start() + { + lock (gate) + { + if (running is { IsCompleted: false }) return; + Running = new MirrorSamplingProgress(DateTimeOffset.UtcNow, 0, 0); + LastError = null; + running = Task.Run(RunAsync); + } + } + + async Task RunAsync() + { + var startedOn = Running.StartedOn; + try + { + using var scope = scopes.CreateScope(); + var mirror = scope.ServiceProvider.GetRequiredService(); + await mirror.TakeSamplesAsync((done, total) => Running = new MirrorSamplingProgress(startedOn, done, total)); + } + catch (MirrorRefusal refusal) + { + LastError = refusal.Message; + } + catch (Exception ex) + { + logger.LogError(ex, "Taking prod's samples stopped"); + LastError = $"Taking samples stopped: {ex.Message}"; + } + finally + { + Running = null; + } + } +} diff --git a/SW.Bitween.Api/Services/Mirror/MirrorService.Samples.cs b/SW.Bitween.Api/Services/Mirror/MirrorService.Samples.cs new file mode 100644 index 00000000..168460bc --- /dev/null +++ b/SW.Bitween.Api/Services/Mirror/MirrorService.Samples.cs @@ -0,0 +1,294 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using SW.Bitween.Cli; +using SW.Bitween.Cli.Configuration; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Services.Mirror; + +public sealed partial class MirrorService +{ + /// Where staging keeps prod's samples, faked, in its own storage. + public const string SamplesPrefix = "mirror/samples"; + + const string SamplesManifest = SamplesPrefix + "/samples.json"; + + static readonly JsonSerializerOptions ManifestJson = new(JsonSerializerDefaults.Web); + + static readonly MirrorSamples NoSamples = new("none", null, null, 0, 0, null, []); + + // How many of a subscription's exchanges are kept, by how they ended: most of them what worked, the + // path prod takes every day; one the partner answered badly; one that failed. + const int Worked = 3, AnsweredBadly = 1, Failed = 1, PerSubscription = 5; + + /// + /// Takes a few of prod's recent exchanges for each of its subscriptions - what came in, what Bitween + /// sent, what the partner answered, and how it ended - into staging's storage, every file faked the + /// moment it arrives. Prod is read gently: one subscription at a time, one page of its exchanges, + /// files one by one. Replaces the samples taken before. + /// + public async Task TakeSamplesAsync(Action progress, CancellationToken cancellation = default) + { + if (!options.Mirror.Target) throw new MirrorRefusal(NotTarget); + var startedOn = DateTimeOffset.UtcNow; + using var prod = Connect(); + await VersionAsync(prod); + var ctx = new Context(prod); + var skipped = await ReadAsync(ctx, []); + if (skipped.TryGetValue("subscriptions", out var why)) + throw new MirrorRefusal($"Prod's subscriptions couldn't be read. {why}"); + var (_, faker) = CopyOf(ctx); + + var subscriptions = (ctx.Current.GetValueOrDefault("subscriptions") ?? []).Where(s => s.Key != null) + .OrderBy(s => s.Key, StringComparer.Ordinal).ToList(); + using var files = new HttpClient(new ReadOnly(new HttpClientHandler { UseCookies = false })) { Timeout = TimeSpan.FromSeconds(60) }; + var taken = new List(); + progress?.Invoke(0, subscriptions.Count); + foreach (var subscription in subscriptions) + { + cancellation.ThrowIfCancellationRequested(); + taken.Add(await SamplesOfAsync(prod, files, subscription, faker, cancellation)); + progress?.Invoke(taken.Count, subscriptions.Count); + } + + var samples = new MirrorSamples("done", startedOn, DateTimeOffset.UtcNow, taken.Count, subscriptions.Count, null, taken); + await cloudFiles.WriteTextAsync(JsonSerializer.Serialize(samples, ManifestJson), + new WriteFileSettings { Key = SamplesManifest, ContentType = "application/json" }); + await RemoveUnusedSamplesAsync(samples); + return samples; + } + + /// The samples staging holds, each file with a link that opens it, and how a taking under way is going. + public async Task SamplesAsync(MirrorSampler sampler) + { + var samples = await ReadSamplesAsync(); + samples = samples with + { + Subscriptions = samples.Subscriptions.Select(s => s with { Samples = s.Samples.Select(Linked).ToList() }).ToList(), + }; + if (sampler.Running is { } running) + return samples with { State = "running", StartedOn = running.StartedOn, FinishedOn = null, Done = running.Done, Total = running.Total, Error = null }; + return sampler.LastError is { } error ? samples with { State = "failed", Error = error } : samples; + } + + async Task ReadSamplesAsync() + { + try + { + await using var stream = await cloudFiles.OpenReadAsync(SamplesManifest); + return await JsonSerializer.DeserializeAsync(stream, ManifestJson) ?? NoSamples; + } + catch (Exception ex) when (StorageErrors.IsNotFound(ex)) + { + return NoSamples; + } + } + + MirrorSample Linked(MirrorSample sample) => + sample with { ErrorTypes = sample.ErrorTypes ?? [], Input = Linked(sample.Input), Output = Linked(sample.Output), Response = Linked(sample.Response) }; + + MirrorSampleFile Linked(MirrorSampleFile file) => file?.Key == null ? file : file with { Url = links.LinkTo(file.Key) }; + + // ------------------------------------------------------------------ one subscription + + async Task SamplesOfAsync(BitweenApi prod, HttpClient files, Existing subscription, MirrorFaker faker, + CancellationToken cancellation) + { + List rows; + try + { + rows = await RecentAsync(prod, subscription.Id, DateTime.UtcNow.AddDays(-30)); + if (rows.Count == 0) rows = await RecentAsync(prod, subscription.Id, since: null); + } + catch (Exception ex) when (ex is BitweenApiException or HttpRequestException or TaskCanceledException) + { + return new MirrorSubscriptionSamples(subscription.Key, subscription.Id, [], $"Prod didn't list its exchanges: {Why(ex)}."); + } + + var chosen = Choose(rows); + if (chosen.Count == 0) + return new MirrorSubscriptionSamples(subscription.Key, subscription.Id, [], + rows.Count == 0 ? "Prod has no exchanges of it." : "Prod's exchanges of it are all still running or retries."); + var samples = new List(); + foreach (var row in chosen) + samples.Add(await SampleAsync(files, subscription.Key, row, faker, cancellation)); + return new MirrorSubscriptionSamples(subscription.Key, subscription.Id, samples, null); + } + + /// A page of a subscription's exchanges on prod, newest first: since a day, or ever. + static async Task> RecentAsync(BitweenApi prod, string subscriptionId, DateTime? since) + { + var query = $"filter={Uri.EscapeDataString($"SubscriptionId:1:{subscriptionId}")}"; + if (since is { } from) + query += $"&filter={Uri.EscapeDataString($"StartedOn:6:{from.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)}")}"; + var answer = await prod.GetAsync($"api/xchanges?{query}&page=0&size=20"); + return (answer?["result"] as JsonArray ?? []).OfType().ToList(); + } + + /// + /// Up to five of a subscription's finished exchanges, newest first and never a retry: up to three + /// that worked, one the partner answered badly and one that failed, topped up from the rest when one + /// kind is short. Nothing still running, which has no ending to compare with. + /// + internal static List Choose(IEnumerable rows) + { + var finished = rows.Where(r => r["status"] is JsonValue && string.IsNullOrEmpty((string)r["retryFor"])) + .OrderByDescending(StartedOn).ToList(); + var chosen = new List(); + foreach (var (outcome, most) in new[] { ("success", Worked), ("badResponse", AnsweredBadly), ("failed", Failed) }) + chosen.AddRange(finished.Where(r => Outcome(r) == outcome).Take(most)); + chosen.AddRange(finished.Except(chosen).Take(PerSubscription - chosen.Count)); + return chosen.OrderByDescending(StartedOn).ToList(); + } + + static DateTime StartedOn(JsonObject row) => + DateTime.TryParse((string)row["startedOn"], CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var on) + ? on + : default; + + internal static string Outcome(JsonObject row) => + (bool?)row["status"] == false ? "failed" : (bool?)row["responseBad"] == true ? "badResponse" : "success"; + + async Task SampleAsync(HttpClient files, string subscription, JsonObject row, MirrorFaker faker, CancellationToken cancellation) + { + var id = (string)row["id"] ?? ""; + // Named by staging, never by a path prod could choose: the subscription by a hash of its name, the + // exchange by its id when that is only letters and digits. + var folder = $"{SamplesPrefix}/{Folder(subscription)}/{(SafeId.IsMatch(id) ? id : Folder(id))}"; + var (status, errorTypes) = ErrorOf((string)row["exception"]); + return new MirrorSample(id, StartedOn(row), Outcome(row), status, errorTypes, + await FileAsync(files, (string)row["inputUrl"], (string)row["inputFileName"], $"{folder}/input", faker, cancellation), + await FileAsync(files, (string)row["outputUrl"], (string)row["outputFileName"], $"{folder}/output", faker, cancellation), + await FileAsync(files, (string)row["responseUrl"], (string)row["responseFileName"], $"{folder}/response", faker, cancellation)); + } + + static readonly Regex SafeId = new("^[A-Za-z0-9_-]{1,64}$", RegexOptions.Compiled); + + static string Folder(string name) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(name)))[..16].ToLowerInvariant(); + + static readonly Regex StatusPrefix = new(@"^(?[1-5][0-9]{2}): [A-Za-z]+: ", RegexOptions.Compiled); + + // Where an error names its kind: at the start, and after each "--->" of an inner one. Never inside + // the stack trace, whose frames name methods such as ThrowException. + static readonly Regex ErrorType = new(@"(?:^|--->)\s*(?:[A-Za-z_][A-Za-z0-9_]*\.)*(?[A-Z][A-Za-z0-9_]*(?:Exception|Error))\b", RegexOptions.Compiled); + + /// + /// How prod's exchange ended, from its error: the partner's HTTP status where Bitween wrote it first + /// ("500: InternalServerError: ..."), and the kinds of error it names, outermost first. Only names + /// from code: the message itself can hold prod's data, so none of it is kept. + /// + internal static (int? Status, IReadOnlyList Types) ErrorOf(string exception) + { + if (string.IsNullOrEmpty(exception)) return (null, []); + var m = StatusPrefix.Match(exception); + var types = ErrorType.Matches(exception[m.Length..]).Select(t => t.Groups["type"].Value) + .Distinct(StringComparer.Ordinal).Take(3).ToList(); + return (m.Success ? int.Parse(m.Groups["code"].Value, CultureInfo.InvariantCulture) : null, types); + } + + // ------------------------------------------------------------------ one file + + async Task FileAsync(HttpClient files, string url, string name, string key, MirrorFaker faker, CancellationToken cancellation) + { + if (string.IsNullOrEmpty(url)) return null; + byte[] content; + try + { + content = await DownloadAsync(files, FromProd(url), cancellation); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or InvalidOperationException or UriFormatException) + { + return new MirrorSampleFile(null, faker.FileName(name), null, 0, ex is HttpRequestException { StatusCode: System.Net.HttpStatusCode.NotFound } + ? "Prod doesn't have this file any more." + : $"Prod didn't hand it over: {Why(ex)}."); + } + if (content == null) return Kept(null, MirrorFiles.TooLarge(faker.FileName(name))); + + var faked = MirrorFiles.Fake(faker, content, name); + // Prod's file was only ever here, in memory, until it was faked. + Array.Clear(content); + if (faked.Content == null) return Kept(null, faked); + await cloudFiles.WriteAsync(new MemoryStream(faked.Content), new WriteFileSettings { Key = key, ContentType = ContentType(faked.Format) }); + return Kept(key, faked); + } + + static MirrorSampleFile Kept(string key, MirrorFakedFile faked) => + new(key, faked.Name, faked.Format, faked.Content?.Length ?? 0, faked.NotCopied); + + /// A file of prod's, or null when it is larger than a sample keeps, which is then left unread. + static async Task DownloadAsync(HttpClient files, string url, CancellationToken cancellation) + { + using var response = await files.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellation); + response.EnsureSuccessStatusCode(); + if (response.Content.Headers.ContentLength > MirrorFiles.MaxBytes) return null; + await using var stream = await response.Content.ReadAsStreamAsync(cancellation); + using var buffer = new MemoryStream(); + var chunk = new byte[81920]; + try + { + int read; + while ((read = await stream.ReadAsync(chunk, cancellation)) > 0) + { + if (buffer.Length + read > MirrorFiles.MaxBytes) return null; + buffer.Write(chunk, 0, read); + } + return buffer.ToArray(); + } + finally + { + Array.Clear(chunk); + Array.Clear(buffer.GetBuffer()); + } + } + + /// + /// One of prod's file links, through the address staging reaches prod on: prod builds them on its own + /// public address, which staging may reach another way. + /// + string FromProd(string url) + { + var source = options.Mirror.SourceUrl.TrimEnd('/'); + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return source + "/" + url.TrimStart('/'); + var at = uri.PathAndQuery.IndexOf("/" + FileLinks.RoutePrefix + "/", StringComparison.Ordinal); + return at < 0 ? url : source + uri.PathAndQuery[at..]; + } + + static string Why(Exception ex) => ex switch + { + BitweenApiException { Status: { } status } => $"it answered {(int)status}", + HttpRequestException { StatusCode: { } code } => $"it answered {(int)code}", + TaskCanceledException => "it took too long to answer", + _ => "it couldn't be reached", + }; + + static string ContentType(string format) => format switch + { + "json" => "application/json", + "xml" => "application/xml", + "html" => "text/html", + "csv" => "text/csv", + "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + _ => "text/plain", + }; + + /// Files of samples taken before that the new ones don't name: staging keeps only the latest. + async Task RemoveUnusedSamplesAsync(MirrorSamples samples) + { + var kept = samples.Subscriptions.SelectMany(s => s.Samples).SelectMany(s => new[] { s.Input, s.Output, s.Response }) + .Where(f => f?.Key != null).Select(f => f.Key).Append(SamplesManifest).ToHashSet(StringComparer.Ordinal); + foreach (var file in await cloudFiles.ListAsync(SamplesPrefix + "/")) + if (!kept.Contains(file.Key)) + await cloudFiles.DeleteAsync(file.Key); + } +} diff --git a/SW.Bitween.Api/Services/Mirror/MirrorService.cs b/SW.Bitween.Api/Services/Mirror/MirrorService.cs index 457af81b..300a6547 100644 --- a/SW.Bitween.Api/Services/Mirror/MirrorService.cs +++ b/SW.Bitween.Api/Services/Mirror/MirrorService.cs @@ -76,12 +76,15 @@ public record MirrorCopyResult(int Created, int Updated, int Removed, int Adapte /// as prod has them (see ). Works with a prod on r10.0 or later; anything /// older is refused with the reason. /// -public sealed class MirrorService(BitweenOptions options, FileLinks links, IHttpContextAccessor httpContextAccessor, IConfiguration configuration, - NativeAdapterDiscoveryService nativeAdapters) +public sealed partial class MirrorService(BitweenOptions options, FileLinks links, IHttpContextAccessor httpContextAccessor, IConfiguration configuration, + NativeAdapterDiscoveryService nativeAdapters, ICloudFilesService cloudFiles) { /// The configuration name staging's copy is recorded under, so a later copy removes what prod no longer has. public const string ConfigurationName = "prod-mirror"; + internal const string NotTarget = + "This Bitween isn't marked as a mirror, so nothing of prod's is copied onto it. Set Bitween__Mirror__Target=true in its deployment."; + static readonly string[] AdapterPrefixes = ["receivers", "validators", "mappers", "handlers"]; /// @@ -99,8 +102,7 @@ public async Task ParityAsync(CancellationToken cancellation = def public async Task CopyAsync(bool removeExtras, CancellationToken cancellation = default) { if (!options.Mirror.Target) - throw new SWValidationException("MIRROR_NOT_TARGET", - "This Bitween isn't marked as a mirror, so nothing is copied onto it. Set Bitween__Mirror__Target=true in its deployment."); + throw new SWValidationException("MIRROR_NOT_TARGET", NotTarget); var key = MirrorCopyGate.OpenKey(); List<(Change Change, string Error)> failed; @@ -158,7 +160,7 @@ async Task CompareAsync(BitweenApi prod, Func openSelf, var prodCtx = new Context(prod); var renamed = new List(); var skipped = await ReadAsync(prodCtx, renamed); - var copy = MirrorSetup.Copy(prodCtx.Current, Faker(), KeepList()); + var (copy, _) = CopyOf(prodCtx); var used = AdapterUses(copy.Sections); var prodAdapters = await AdaptersAsync(prod, used.Keys); @@ -666,8 +668,20 @@ static MirrorVerdict Verdict(List kinds, List notes) // ------------------------------------------------------------------ this Bitween - MirrorFaker Faker() => new(HKDF.DeriveKey(HashAlgorithmName.SHA256, - Encoding.UTF8.GetBytes(configuration["Token:Key"] ?? string.Empty), 32, info: Encoding.UTF8.GetBytes("bitween-mirror-fakes"))); + MirrorFaker Faker(IEnumerable keep = null) => new(HKDF.DeriveKey(HashAlgorithmName.SHA256, + Encoding.UTF8.GetBytes(configuration["Token:Key"] ?? string.Empty), 32, info: Encoding.UTF8.GetBytes("bitween-mirror-fakes")), keep); + + /// + /// Prod's setup as staging gets it, and the faker that made it, in two passes: the first fakes it to + /// find the words it names (see ), the second keeps those words + /// wherever prod's data has them. Exchanges' files are faked by the same faker, so they keep them too. + /// + (MirrorSetupCopy Copy, MirrorFaker Faker) CopyOf(Context prod) + { + var keep = KeepList(); + var faker = Faker(MirrorSetup.Words(MirrorSetup.Copy(prod.Current, Faker(), keep).Sections)); + return (MirrorSetup.Copy(prod.Current, faker, keep), faker); + } HashSet KeepList() => (options.Mirror.KeepRealValues ?? "").Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) diff --git a/SW.Bitween.Api/Services/Mirror/MirrorSetup.cs b/SW.Bitween.Api/Services/Mirror/MirrorSetup.cs index a4909198..10416d2a 100644 --- a/SW.Bitween.Api/Services/Mirror/MirrorSetup.cs +++ b/SW.Bitween.Api/Services/Mirror/MirrorSetup.cs @@ -124,6 +124,51 @@ static void Data(JsonObject values, string where, Func kep values[name] = faker.Datum(name, value, $"{where}/{name}", kept(name, value)); } + // ------------------------------------------------------------------ the words it names + + /// + /// Every word staging's copy of prod's setup names, outside the data in it - partners' properties, + /// global values, logins: a filter's 'SHIPPED', a file pattern's ORD, a global value's key, names. + /// Read from the copy, after its addresses and data are faked, so a word of it is one staging holds + /// already. Faking keeps these as they are (see ), so a value of prod's that + /// is one of them takes the path the real one took. + /// + public static HashSet Words(Dictionary> sections) + { + var words = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var (section, items) in sections) + foreach (var item in items) + Walk(item, [], (path, text) => + { + if (!IsData(section, path)) AddWords(words, text); + }); + // A global value's key is what a payload looks it up by. + foreach (var set in sections.GetValueOrDefault("globalValues") ?? []) + foreach (var (name, _) in set["values"] as JsonObject ?? []) + AddWords(words, name); + return words; + } + + static bool IsData(string section, IReadOnlyList path) => path.Count > 0 && section switch + { + "partners" => path[0] is "properties" or "loginIdentity" or "keys", + "globalValues" => path[0] == "values", + "connections" => path[0] is "clientId" or "username" or "clientSecret" or "password", + _ => false, + }; + + static void AddWords(HashSet words, string text) + { + var trimmed = text.Trim(); + if (trimmed.Length is > 0 and <= MaxWord) words.Add(trimmed); + foreach (Match m in QuotedPattern.Matches(text)) words.Add(m.Groups["q"].Value); + foreach (Match m in WordInPattern.Matches(text)) words.Add(m.Value); + } + + const int MaxWord = 64; + static readonly Regex QuotedPattern = new(@"'(?[^'\r\n]{1,64})'|""(?[^""\r\n]{1,64})""", RegexOptions.Compiled); + static readonly Regex WordInPattern = new(@"[\p{L}\p{M}\p{Nd}]{2,64}", RegexOptions.Compiled); + public static string PartnerKey(string property) => $"partner:{property}"; public static string GlobalKey(string set, string key) => $"global:{set}/{key}"; diff --git a/SW.Bitween.UnitTests/MirrorFakerTests.cs b/SW.Bitween.UnitTests/MirrorFakerTests.cs index 021e629d..6a4fdad2 100644 --- a/SW.Bitween.UnitTests/MirrorFakerTests.cs +++ b/SW.Bitween.UnitTests/MirrorFakerTests.cs @@ -125,6 +125,50 @@ public void A_secret_prod_would_not_show_becomes_a_stand_in() StringAssert.StartsWith(Faker.Datum("apiKey", MirrorFaker.Masked, "partner/Acme/apiKey"), "mirror-"); } + [TestMethod] + public void Words_the_setup_names_are_kept_wherever_they_turn_up() + { + var faker = new MirrorFaker(Encoding.UTF8.GetBytes("a key only these tests use"), ["SHIPPED", "ORD"]); + + Assert.AreEqual("SHIPPED", faker.Fake("SHIPPED")); + StringAssert.Contains(faker.Fake("Order SO-1001 SHIPPED today"), " SHIPPED "); + Assert.IsFalse(faker.Fake("Order SO-1001 SHIPPED today").Contains("SO-1001")); + + // A file pattern's word and extension stay, so ORD_*.csv still matches; the date stays a date. + var name = faker.FileName("ORD_2026-10-20_0042.csv"); + StringAssert.StartsWith(name, "ORD_"); + StringAssert.EndsWith(name, ".csv"); + Assert.IsTrue(DateTime.TryParse(name[4..14], out _), name); + Assert.AreNotEqual("ORD_2026-10-20_0042.csv", name); + } + + [TestMethod] + public void A_word_is_the_same_stand_in_alone_and_inside_a_text() + { + var alone = Faker.Fake("C123"); + Assert.AreEqual(alone, Faker.Fake("Ref C123").Split(' ')[1]); + Assert.AreEqual(alone + ".xml", Faker.FileName("C123.xml")); + } + + [TestMethod] + public void A_day_written_without_dashes_moves_with_the_same_day_written_with_them() + { + var dashed = Faker.Fake("2026-10-20"); + Assert.AreEqual(dashed.Replace("-", ""), Faker.Fake("20261020")); + Assert.AreEqual(dashed.Replace("-", "") + "101500", Faker.Fake("20261020101500")); + } + + [TestMethod] + public void Digits_of_any_script_are_faked_and_stay_that_script() + { + const string phone = "٠٧٩١٢٣٤٥٦٧"; + var fake = Faker.Fake(phone); + Assert.AreNotEqual(phone, fake); + Assert.AreEqual(phone.Length, fake.Length); + Assert.IsTrue(fake.All(c => c is >= '٠' and <= '٩'), fake); + Assert.AreEqual('٠', fake[0]); + } + [TestMethod] public void Data_is_faked_unless_kept_and_addresses_never_are() { diff --git a/SW.Bitween.UnitTests/MirrorFilesTests.cs b/SW.Bitween.UnitTests/MirrorFilesTests.cs new file mode 100644 index 00000000..6b2d9c1a --- /dev/null +++ b/SW.Bitween.UnitTests/MirrorFilesTests.cs @@ -0,0 +1,193 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Text.Json.Nodes; +using System.Xml.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SW.Bitween.Services.Mirror; + +namespace SW.Bitween.UnitTests; + +/// +/// Prod's exchange files as staging keeps them: faked in their own format, so they read the way the real +/// ones did, or not kept at all, with why. Never kept as they are. +/// +[TestClass] +public class MirrorFilesTests +{ + static readonly MirrorFaker Faker = new(Encoding.UTF8.GetBytes("a key only these tests use"), ["SHIPPED"]); + + static MirrorFakedFile Fake(string text, string name = "in.txt") => MirrorFiles.Fake(Faker, Encoding.UTF8.GetBytes(text), name); + + static string Text(MirrorFakedFile file) => Encoding.UTF8.GetString(file.Content); + + [TestMethod] + public void Json_keeps_its_keys_and_the_words_the_setup_names() + { + var file = Fake("""{"orderNo":"SO-1001","status":"SHIPPED","qty":3,"lines":[{"sku":"AB-77"}]}""", "order.json"); + + Assert.AreEqual("json", file.Format); + var json = JsonNode.Parse(Text(file))!; + Assert.AreEqual("SHIPPED", (string)json["status"]!); + Assert.AreNotEqual("SO-1001", (string)json["orderNo"]!); + Assert.AreEqual(1, json["qty"]!.ToJsonString().Length); + Assert.AreNotEqual("AB-77", (string)json["lines"]![0]!["sku"]!); + StringAssert.EndsWith(file.Name, ".json"); + } + + [TestMethod] + public void Xml_keeps_its_names_namespaces_types_and_encoding() + { + const string xml = """ + + + Jöhn Smith + + SHIPPED + + """; + var file = MirrorFiles.Fake(Faker, Encoding.Latin1.GetBytes(xml), "order.xml"); + + Assert.AreEqual("xml", file.Format); + var text = Encoding.Latin1.GetString(file.Content); + StringAssert.Contains(text, "encoding=\"iso-8859-1\""); + var doc = XDocument.Parse(text); + XNamespace o = "urn:acme:orders"; + Assert.AreEqual(o + "Order", doc.Root!.Name); + Assert.AreEqual("o:Sale", (string)doc.Root.Attribute(XNamespace.Get("http://www.w3.org/2001/XMLSchema-instance") + "type")); + Assert.AreNotEqual("SO-1001", (string)doc.Root.Attribute("ref")); + Assert.AreEqual("SHIPPED", (string)doc.Root.Element(o + "Status")); + Assert.IsFalse(text.Contains("Smith") || text.Contains("0791234567"), text); + } + + [TestMethod] + public void Xml_escaped_into_an_element_is_faked_as_xml() + { + var file = Fake("<Order><Id>SO-1001</Id></Order>", "soap.xml"); + + var inner = XDocument.Parse(XDocument.Parse(Text(file)).Root!.Element("Body")!.Value); + Assert.AreEqual("Order", inner.Root!.Name.LocalName); + Assert.AreNotEqual("SO-1001", (string)inner.Root.Element("Id")); + } + + [TestMethod] + public void Csv_keeps_its_delimiter_quoting_and_line_endings() + { + var file = Fake("id;name;note\r\n1;\"Smith; John\";\"said \"\"hi\"\"\"\r\n2;Doe;SHIPPED\r\n", "orders.csv"); + + Assert.AreEqual("csv", file.Format); + var lines = Text(file).Split("\r\n"); + Assert.AreEqual(4, lines.Length); + Assert.AreEqual("", lines[3]); + StringAssert.Matches(lines[1], new System.Text.RegularExpressions.Regex("^[0-9];\"[A-Za-z]+; [A-Za-z]+\";\"[a-z]+ \"\"[a-z]+\"\"\"$")); + StringAssert.EndsWith(lines[2], ";SHIPPED"); + Assert.IsFalse(Text(file).Contains("Smith")); + } + + [TestMethod] + public void A_workbook_keeps_its_sheets_and_formulas_and_loses_its_values() + { + var file = MirrorFiles.Fake(Faker, Workbook(), "report.xlsx"); + + Assert.AreEqual("xlsx", file.Format); + using var zip = new ZipArchive(new MemoryStream(file.Content)); + var strings = Read(zip, "xl/sharedStrings.xml"); + Assert.IsFalse(strings.Contains("John"), strings); + var sheet = XDocument.Parse(Read(zip, "xl/worksheets/sheet1.xml")); + var cells = sheet.Descendants().Where(e => e.Name.LocalName == "c").ToDictionary(c => (string)c.Attribute("r")!); + Assert.AreEqual("0", Value(cells["A1"])); + var number = Value(cells["B1"]); + Assert.IsTrue(double.TryParse(number, System.Globalization.CultureInfo.InvariantCulture, out _), number); + Assert.AreNotEqual("1234.5", number); + Assert.AreNotEqual("SO-1001", cells["C1"].Descendants().Single(e => e.Name.LocalName == "t").Value); + Assert.AreEqual("1", Value(cells["D1"])); + Assert.AreEqual("SUM(B1)", cells["E1"].Elements().Single(e => e.Name.LocalName == "f").Value); + } + + [TestMethod] + public void What_cant_be_faked_isnt_kept_and_says_why() + { + foreach (var (content, format) in new (byte[], string)[] + { + (Workbook(picture: true), "xlsx"), + (Encoding.ASCII.GetBytes("%PDF-1.7 ..."), "pdf"), + ([1, 0, 2, 3], "binary"), + (Encoding.ASCII.GetBytes("UNA:+.? 'UNB+UNOC:3+SENDER+RECEIVER'"), "edi"), + (new byte[MirrorFiles.MaxBytes + 1], "large"), + }) + { + var file = MirrorFiles.Fake(Faker, content, "file"); + Assert.AreEqual(format, file.Format); + Assert.IsNull(file.Content, format); + Assert.IsFalse(string.IsNullOrEmpty(file.NotCopied), format); + } + StringAssert.Contains(MirrorFiles.Fake(Faker, Workbook(picture: true), "report.xlsx").NotCopied, "pictures"); + } + + [TestMethod] + public void Html_keeps_its_tags_and_entities() + { + var file = Fake("

Dear John,
order SO-1001 & more

", "reply.html"); + + Assert.AreEqual("html", file.Format); + var text = Text(file); + StringAssert.Contains(text, "

"); + StringAssert.Contains(text, "&"); + Assert.IsFalse(text.Contains("John") || text.Contains("SO-1001"), text); + } + + [TestMethod] + public void Text_in_a_one_byte_encoding_keeps_every_byte_it_doesnt_fake() + { + var bytes = Encoding.Latin1.GetBytes("Café Müller, 12 rue d'Arc\n"); + var file = MirrorFiles.Fake(Faker, bytes, "note.txt"); + + Assert.AreEqual(bytes.Length, file.Content.Length); + var text = Encoding.Latin1.GetString(file.Content); + Assert.IsFalse(text.Contains("Müller"), text); + StringAssert.EndsWith(text, "\n"); + StringAssert.Contains(text, ", "); + } + + // ------------------------------------------------------------------ a workbook + + static byte[] Workbook(bool picture = false) + { + const string main = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + using var output = new MemoryStream(); + using (var zip = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true)) + { + Write(zip, "[Content_Types].xml", """"""); + Write(zip, "xl/workbook.xml", $""""""); + Write(zip, "xl/sharedStrings.xml", $"""John Smith"""); + Write(zip, "xl/worksheets/sheet1.xml", $""" + + 0 + 1234.5 + SO-1001 + 1 + SUM(B1)1234.5 + + """); + if (picture) Write(zip, "xl/media/image1.png", "not really a picture"); + } + return output.ToArray(); + } + + static void Write(ZipArchive zip, string name, string text) + { + using var writer = new StreamWriter(zip.CreateEntry(name).Open()); + writer.Write(text); + } + + static string Read(ZipArchive zip, string name) + { + using var reader = new StreamReader(zip.GetEntry(name)!.Open()); + return reader.ReadToEnd(); + } + + static string Value(XElement cell) => cell.Elements().Single(e => e.Name.LocalName == "v").Value; +} diff --git a/SW.Bitween.UnitTests/MirrorSamplesTests.cs b/SW.Bitween.UnitTests/MirrorSamplesTests.cs new file mode 100644 index 00000000..c1b88a00 --- /dev/null +++ b/SW.Bitween.UnitTests/MirrorSamplesTests.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json.Nodes; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SW.Bitween.Services.Mirror; + +namespace SW.Bitween.UnitTests; + +///

+/// Which of prod's exchanges become samples, and what staging keeps of how they ended: the words its +/// setup names, kept wherever prod's data has them, and never a value of the data itself. +/// +[TestClass] +public class MirrorSamplesTests +{ + static JsonObject Row(string id, int minute, bool? status, bool responseBad = false, string retryFor = null) => new() + { + ["id"] = id, + ["startedOn"] = $"2026-10-20T10:{minute:00}:00Z", + ["status"] = status, + ["responseBad"] = responseBad, + ["retryFor"] = retryFor, + }; + + [TestMethod] + public void Samples_are_mostly_what_worked_with_one_bad_answer_and_one_failure_never_a_retry_or_a_running_one() + { + var rows = new List + { + Row("w1", 1, true), Row("w2", 2, true), Row("w3", 3, true), Row("w4", 4, true), + Row("bad1", 5, true, responseBad: true), Row("bad2", 6, true, responseBad: true), + Row("f1", 7, false), Row("f2", 8, false), + Row("retry", 9, false, retryFor: "f1"), Row("running", 10, null), + }; + + var chosen = MirrorService.Choose(rows).Select(r => (string)r["id"]).ToList(); + + CollectionAssert.AreEqual(new[] { "f2", "bad2", "w4", "w3", "w2" }, chosen); + } + + [TestMethod] + public void A_kind_that_is_short_is_topped_up_from_the_rest() + { + var chosen = MirrorService.Choose([Row("f1", 1, false), Row("f2", 2, false), Row("f3", 3, false), Row("w1", 4, true)]); + + Assert.AreEqual(4, chosen.Count); + } + + [TestMethod] + public void Of_prods_error_only_the_partners_status_and_the_kinds_of_error_are_kept() + { + var (status, types) = MirrorService.ErrorOf("500: InternalServerError: {\"message\":\"Order SO-1001 not found\"}"); + Assert.AreEqual(500, status); + Assert.AreEqual(0, types.Count); + + (status, types) = MirrorService.ErrorOf( + "System.Net.Http.HttpRequestException: Connection refused (sftp.acme.com:22) ---> System.Net.Sockets.SocketException (61): Connection refused" + + "\n at System.ThrowHelper.ThrowException(ExceptionResource resource)"); + Assert.IsNull(status); + CollectionAssert.AreEqual(new[] { "HttpRequestException", "SocketException" }, types.ToList()); + + Assert.AreEqual(0, MirrorService.ErrorOf(null).Types.Count); + } + + [TestMethod] + public void The_words_kept_are_the_setups_and_never_its_data() + { + var sections = new Dictionary> + { + ["subscriptions"] = + [ + (JsonObject)JsonNode.Parse(""" + { "name": "Orders in", "receiver": { "properties": { "FilePattern": "ORD_*.csv" } }, + "matchExpression": "status == 'SHIPPED'" } + """)!, + ], + ["partners"] = [(JsonObject)JsonNode.Parse("""{ "name": "Acme", "properties": { "account": "AC-778" } }""")!], + ["globalValues"] = [(JsonObject)JsonNode.Parse("""{ "name": "countries", "values": { "JO": "Jordan" } }""")!], + }; + + var words = MirrorSetup.Words(sections); + + foreach (var kept in new[] { "SHIPPED", "ORD", "csv", "Orders in", "Acme", "JO", "countries" }) + Assert.IsTrue(words.Contains(kept), kept); + foreach (var data in new[] { "AC-778", "AC", "778", "Jordan" }) + Assert.IsFalse(words.Contains(data), data); + } +} diff --git a/SW.Bitween.Web/ClientApp/src/api/client.ts b/SW.Bitween.Web/ClientApp/src/api/client.ts index 6bc10580..0109e621 100644 --- a/SW.Bitween.Web/ClientApp/src/api/client.ts +++ b/SW.Bitween.Web/ClientApp/src/api/client.ts @@ -2,6 +2,7 @@ import type { AddBusRouteInput, AttachPartnerInput } from "./http/gateways"; import type { MirrorCopyResult, MirrorParityAnswer, + MirrorSamples, MirrorStatus, InviteResult, PasswordLinkInfo, @@ -662,6 +663,8 @@ export interface ApiClient { getMirror(): Promise; getMirrorParity(): Promise; copyFromProd(removeExtras: boolean): Promise; + getMirrorSamples(): Promise; + takeMirrorSamples(): Promise; // - settings - listSettings(): Promise; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/mirror.ts b/SW.Bitween.Web/ClientApp/src/api/http/mirror.ts index d7ad9634..a9f14618 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/mirror.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/mirror.ts @@ -1,5 +1,5 @@ import type { ApiClient } from "../client"; -import type { MirrorCopyResult, MirrorParityAnswer, MirrorStatus } from "../types"; +import type { MirrorCopyResult, MirrorParityAnswer, MirrorSamples, MirrorStatus } from "../types"; import { get, post } from "./request"; export const mirrorMethods = { @@ -16,4 +16,13 @@ export const mirrorMethods = { copyFromProd(removeExtras: boolean): Promise { return post("/mirror/copy", { removeExtras }); }, + + getMirrorSamples(): Promise { + return get("/mirror/samples"); + }, + + /** Starts taking samples of prod's exchanges in the background; follow it with getMirrorSamples. */ + takeMirrorSamples(): Promise { + return post("/mirror/takesamples", {}); + }, } satisfies Partial; diff --git a/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts b/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts index 736a7c93..3988f99f 100644 --- a/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts +++ b/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts @@ -217,6 +217,7 @@ export const keys = { all: ["mirror"] as const, status: ["mirror", "status"] as const, parity: ["mirror", "parity"] as const, + samples: ["mirror", "samples"] as const, }, settings: { diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index 1acd8993..1a7748d8 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -2392,3 +2392,50 @@ export interface MirrorCopyResult { failed: { section: string; noun: string; key: string; error: string }[]; after: MirrorParity; } + +/** One of prod's exchange files as staging keeps it: faked, or not kept and why. */ +export interface MirrorSampleFile { + /** Null when it isn't kept. */ + key: string | null; + /** Its name, faked. */ + name: string | null; + /** json, xml, html, csv, xlsx or text when kept; what it is when it isn't: pdf, zip, edi, binary, large... */ + format: string | null; + size: number; + notCopied: string | null; + /** Opens the faked file on staging. */ + url: string | null; +} + +/** One of prod's exchanges, faked, and how it ended on prod. */ +export interface MirrorSample { + prodExchangeId: string; + startedOn: string; + outcome: "success" | "badResponse" | "failed"; + /** The partner's HTTP status, when prod's error names one. */ + status: number | null; + /** The kinds of error prod's error names, outermost first. Its message isn't kept: it can hold prod's data. */ + errorTypes: string[]; + input: MirrorSampleFile | null; + output: MirrorSampleFile | null; + response: MirrorSampleFile | null; +} + +export interface MirrorSubscriptionSamples { + subscription: string; + prodId: string; + samples: MirrorSample[]; + /** Why it has none. */ + reason: string | null; +} + +/** The samples staging holds, and how the last taking went. */ +export interface MirrorSamples { + state: "none" | "running" | "done" | "failed"; + startedOn: string | null; + finishedOn: string | null; + done: number; + total: number; + error: string | null; + subscriptions: MirrorSubscriptionSamples[]; +} diff --git a/SW.Bitween.Web/ClientApp/src/pages/mirror/MirrorPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/mirror/MirrorPage.tsx index a137c36b..250c8005 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/mirror/MirrorPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/mirror/MirrorPage.tsx @@ -1,4 +1,4 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query"; import { CircleCheck, ChevronDown, ChevronRight, ExternalLink, GitCompareArrows, TriangleAlert } from "lucide-react"; import { useState, type ReactNode } from "react"; import { Link } from "react-router"; @@ -12,6 +12,9 @@ import { type MirrorKind, type MirrorNote, type MirrorParity, + type MirrorSample, + type MirrorSampleFile, + type MirrorSamples, type MirrorStatus, type SettingRow, type SubscriptionType, @@ -25,7 +28,7 @@ import { ConfirmDialog, Dialog } from "../../components/ui/overlays"; import { Panel } from "../../components/ui/Panel"; import { SegmentedControl } from "../../components/ui/SegmentedControl"; import { MiniTable } from "../../components/ui/Table"; -import { timeAgo } from "../../lib/dates"; +import { formatDateTime, timeAgo } from "../../lib/dates"; import { whole } from "../../lib/numbers"; import { subscriptionPath } from "../../lib/subscriptionPaths"; import { pathOf, type PageId } from "../../pages"; @@ -155,10 +158,16 @@ export function MirrorPage() { ); } -type FoldId = "setup" | "adapters" | "faked" | "addresses"; +type FoldId = "setup" | "samples" | "adapters" | "faked" | "addresses"; function Comparison({ parity, status, onConnection }: { parity: MirrorParity; status: MirrorStatus; onConnection: () => void }) { const [open, setOpen] = useState>(new Set(["setup"])); + // Followed every two seconds while a taking is under way, so the page shows how far it has got. + const samples = useQuery({ + queryKey: keys.mirror.samples, + queryFn: () => api.getMirrorSamples(), + refetchInterval: (q) => (q.state.data?.state === "running" ? 2000 : false), + }); const toggle = (id: FoldId) => setOpen((now) => { const next = new Set(now); @@ -193,6 +202,8 @@ function Comparison({ parity, status, onConnection }: { parity: MirrorParity; st + show("samples")} /> + + {samples.data && samples.data.subscriptions.length > 0 && ( + s.samples.length > 0).length)} of ${whole(samples.data.subscriptions.length)} subscriptions have samples`} + description="How each sample ended on prod, and its files as staging keeps them, faked. Open a file to see what staging holds, or the exchange on prod."> + + + )} + a.state !== "same") ? `${parity.adapters.filter((a) => a.state !== "same").length} to look at` : "all the same"} description="Prod's adapters and what staging has of each. Built-in ones come with the release and are listed only when this release lacks one prod uses."> @@ -441,6 +460,168 @@ function CopyPanel({ parity, target, notes, context }: { parity: MirrorParity; t ); } +const plural = (n: number, one: string, many: string) => `${whole(n)} ${n === 1 ? one : many}`; + +/** Takes a few of prod's exchanges for each subscription, faked, and says how far a taking under way has got. */ +function SamplesPanel({ samples, target, onShow }: { samples: UseQueryResult; target: boolean; onShow: () => void }) { + const canTake = useSessionCan("mirror.operate"); + const queryClient = useQueryClient(); + const take = useMutation({ + mutationFn: () => api.takeMirrorSamples(), + onSuccess: (r) => queryClient.setQueryData(keys.mirror.samples, r), + }); + const s = samples.data; + const running = s?.state === "running"; + const subscriptions = s?.subscriptions ?? []; + const taken = subscriptions.filter((x) => x.samples.length > 0); + const count = taken.reduce((n, x) => n + x.samples.length, 0); + const notKept = subscriptions + .flatMap((x) => x.samples) + .flatMap((x) => [x.input, x.output, x.response]) + .filter((f) => f?.notCopied).length; + + return ( + + {samples.isPending ? ( + + ) : samples.isError ? ( + void samples.refetch()} /> + ) : ( +
+ {running ? ( +

+ Taking samples: {whole(s.done)} of {whole(s.total)} subscriptions read. Prod is read one subscription at a time. +

+ ) : !s || s.state === "none" || subscriptions.length === 0 ? ( +

No samples yet.

+ ) : ( +

+ {plural(count, "sample", "samples")} from {whole(taken.length)} of prod's {plural(subscriptions.length, "subscription", "subscriptions")} + {s?.finishedOn && `, taken ${timeAgo(s.finishedOn)}`}. + {subscriptions.length > taken.length && ` ${plural(subscriptions.length - taken.length, "subscription has", "subscriptions have")} none; the list says why for each.`} + {notKept > 0 && ` ${plural(notKept, "file isn't", "files aren't")} kept; the list says why.`}{" "} + +

+ )} + {s?.state === "failed" && s.error && {s.error}} +
+ )} +
+ +
+ {take.error instanceof Error ? take.error.message : ""} +
+ ); +} + +const OUTCOME: Record = { + success: { label: "Worked", tone: "ok", title: "It worked on prod" }, + badResponse: { label: "Bad answer", tone: "warn", title: "It was delivered on prod, and the partner answered with an error" }, + failed: { label: "Failed", tone: "danger", title: "It failed on prod" }, +}; + +function SamplesList({ samples, prodUrl, items }: { samples: MirrorSamples; prodUrl: string; items: Map }) { + const [open, setOpen] = useState(null); + const [all, setAll] = useState(false); + const none = samples.subscriptions.filter((s) => s.samples.length === 0); + const shown = all ? samples.subscriptions : samples.subscriptions.filter((s) => s.samples.length > 0); + const exchanges = pathOf("exchanges"); + return ( +
+ {none.length > 0 && ( + + )} + {shown.map((s) => { + const item = items.get(`subscriptions\u0000${s.subscription}`); + const isOpen = open === s.subscription; + const ended = (Object.keys(OUTCOME) as MirrorSample["outcome"][]) + .map((o) => [o, s.samples.filter((x) => x.outcome === o).length] as const) + .filter(([, n]) => n > 0); + return ( +
+
+ + {s.samples.length > 0 ? ( + {ended.map(([o, n]) => `${n} ${OUTCOME[o].label.toLowerCase()}`).join(", ")} + ) : ( + {s.reason} + )} + {item && } +
+ {isOpen && ( +
    + {s.samples.map((x) => ( +
  • + + {OUTCOME[x.outcome].label} + {x.status != null && ` ${x.status}`} + + {formatDateTime(x.startedOn)} + + + + {exchanges && ( + + On prod + + )} + {x.errorTypes.length > 0 && ( + + {x.errorTypes.join(" → ")} + + )} +
  • + ))} +
+ )} +
+ ); + })} +
+ ); +} + +/** One of a sample's files: a link that opens staging's faked copy, or why it wasn't kept. */ +function SampleFile({ label, what, file }: { label: string; what: string; file: MirrorSampleFile | null }) { + if (!file) return null; + if (!file.url) + return ( + + {label}: not kept{file.format ? ` (${file.format})` : ""} + + ); + return ( + + {label} ({file.format}) + + ); +} + function Fold({ id, open, onToggle, title, summary, description, children }: { id: FoldId; open: boolean; diff --git a/SW.Bitween.Web/ClientApp/src/pages/mirror/__tests__/MirrorPage.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/mirror/__tests__/MirrorPage.test.tsx index e2db4b2e..1793f03a 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/mirror/__tests__/MirrorPage.test.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/mirror/__tests__/MirrorPage.test.tsx @@ -1,7 +1,7 @@ import { screen, within } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { describe, expect, it } from "vitest"; -import type { MirrorItem, MirrorParity } from "../../../api"; +import type { MirrorItem, MirrorParity, MirrorSamples } from "../../../api"; import { apiPath, renderApp } from "../../../__tests__/support/renderApp"; const status = (over = {}) => @@ -67,6 +67,39 @@ const parity: MirrorParity = { const parityHandler = (answer: MirrorParity | { error: string } = parity) => http.get(apiPath("/mirror/parity"), () => HttpResponse.json(answer)); +const noSamples: MirrorSamples = { state: "none", startedOn: null, finishedOn: null, done: 0, total: 0, error: null, subscriptions: [] }; + +const samplesHandler = (answer: MirrorSamples = noSamples) => http.get(apiPath("/mirror/samples"), () => HttpResponse.json(answer)); + +const taken: MirrorSamples = { + state: "done", + startedOn: new Date().toISOString(), + finishedOn: new Date().toISOString(), + done: 2, + total: 2, + error: null, + subscriptions: [ + { + subscription: "Push labels", + prodId: "12", + reason: null, + samples: [ + { + prodExchangeId: "a1b2", + startedOn: "2026-10-20T10:00:00Z", + outcome: "failed", + status: 500, + errorTypes: ["HttpRequestException"], + input: { key: "mirror/samples/x/a1b2/input", name: "ORD_2025-04-11.xml", format: "xml", size: 120, notCopied: null, url: "https://staging.acme.test/api/files/s/in" }, + output: null, + response: { key: null, name: "label.pdf", format: "pdf", size: 0, notCopied: "PDFs can't be faked.", url: null }, + }, + ], + }, + { subscription: "Nightly report", prodId: "13", reason: "Prod has no exchanges of it.", samples: [] }, + ], +}; + describe("prod mirror", () => { it("asks for prod's address and a login before anything else", async () => { renderApp("/mirror", { handlers: [status({ configured: false, sourceUrl: null, signIn: null, target: false })] }); @@ -77,7 +110,7 @@ describe("prod mirror", () => { }); it("answers first, then says what needs you, what the test can't cover and what differs on purpose", async () => { - renderApp("/mirror", { handlers: [status(), parityHandler()] }); + renderApp("/mirror", { handlers: [status(), parityHandler(), samplesHandler()] }); const verdict = await screen.findByRole("status", { name: "Readiness" }); expect(within(verdict).getByText("Not ready to test yet")).toBeVisible(); @@ -96,7 +129,7 @@ describe("prod mirror", () => { }); it("opens the section a note points at", async () => { - const { user } = renderApp("/mirror", { handlers: [status(), parityHandler()] }); + const { user } = renderApp("/mirror", { handlers: [status(), parityHandler(), samplesHandler()] }); // Folded until asked for, so the page leads with what to do. await screen.findByRole("status", { name: "Readiness" }); @@ -106,7 +139,7 @@ describe("prod mirror", () => { }); it("lists each kind's items with a way to each, on staging and on prod", async () => { - const { user } = renderApp("/mirror", { handlers: [status(), parityHandler()] }); + const { user } = renderApp("/mirror", { handlers: [status(), parityHandler(), samplesHandler()] }); await user.click(await screen.findByRole("button", { name: /^subscriptions: 40 same/ })); const row = screen.getByText("Push labels").parentElement!; @@ -127,6 +160,7 @@ describe("prod mirror", () => { handlers: [ status(), parityHandler(), + samplesHandler(), http.post(apiPath("/mirror/copy"), async ({ request }) => { asked = await request.json(); return HttpResponse.json({ @@ -155,6 +189,7 @@ describe("prod mirror", () => { handlers: [ status(), parityHandler(), + samplesHandler(), http.post(apiPath("/mirror/copy"), async ({ request }) => { asked = await request.json(); return HttpResponse.json({ created: 0, updated: 0, removed: 1, adapterSteps: 0, failed: [], after: parity }); @@ -171,9 +206,48 @@ describe("prod mirror", () => { expect(asked).toEqual({ removeExtras: true }); }); + it("takes samples, follows the taking and lists each one with its faked files and the exchange on prod", async () => { + let samples: MirrorSamples = noSamples; + let asked = 0; + const { user } = renderApp("/mirror", { + handlers: [ + status(), + parityHandler(), + http.get(apiPath("/mirror/samples"), () => HttpResponse.json(samples)), + http.post(apiPath("/mirror/takesamples"), () => { + asked++; + samples = { ...noSamples, state: "running", done: 1, total: 2 }; + return HttpResponse.json(samples); + }), + ], + }); + + expect(await screen.findByText("No samples yet.")).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Take samples" })); + expect(await screen.findByText(/Taking samples: 1 of 2 subscriptions read/)).toBeVisible(); + expect(asked).toBe(1); + + // The taking ends; the page follows it on its own. + samples = taken; + expect(await screen.findByText(/1 sample from 1 of prod's 2 subscriptions/, {}, { timeout: 4000 })).toBeVisible(); + + await user.click(screen.getByRole("button", { name: "See the samples" })); + await user.click(screen.getByRole("button", { name: "Push labels: 1 samples" })); + expect(screen.getByText("Failed 500")).toBeVisible(); + expect(screen.getByText("HttpRequestException")).toBeVisible(); + expect(screen.getByRole("link", { name: "In (xml)" })).toHaveAttribute("href", "https://staging.acme.test/api/files/s/in"); + expect(screen.getByTitle("PDFs can't be faked.")).toHaveTextContent("Answer: not kept (pdf)"); + const onProd = screen.getAllByRole("link", { name: /On prod/ }).find((a) => a.getAttribute("href")?.includes("ids=a1b2")); + expect(onProd).toHaveAttribute("href", "https://bitween.acme.test/exchanges?ids=a1b2"); + + // A subscription without samples says why, when asked to show it. + await user.click(screen.getByRole("checkbox", { name: /without samples too/ })); + expect(screen.getByText("Prod has no exchanges of it.")).toBeVisible(); + }); + it("says why prod can't be compared, with a way to fix the connection", async () => { renderApp("/mirror", { - handlers: [status(), parityHandler({ error: "Prod runs r8 or older. The mirror copies prods on r10.0 or later; move this client to r10 first." })], + handlers: [status(), samplesHandler(), parityHandler({ error: "Prod runs r8 or older. The mirror copies prods on r10.0 or later; move this client to r10 first." })], }); expect(await screen.findByText(/Prod runs r8 or older/)).toBeVisible(); diff --git a/SW.Bitween.Web/Startup.cs b/SW.Bitween.Web/Startup.cs index 19438e98..c972a7bb 100644 --- a/SW.Bitween.Web/Startup.cs +++ b/SW.Bitween.Web/Startup.cs @@ -162,6 +162,7 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddScoped(); services.AddSingleton(); diff --git a/docs/api-reference.md b/docs/api-reference.md index c888b475..39b5f0b1 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -347,6 +347,8 @@ See [Data sources](data-sources.md) and [Databases](databases.md). | `GET /api/audit?offset=&limit=&entityName=&entityKey=&userId=&correlationId=&from=&to=` | `audit.view` | | `GET /api/mirror` | `mirror.view`. Whether this Bitween is a mirror (`target`), prod's address and how it signs in, without calling prod. | | `GET /api/mirror/parity` | `mirror.view`. Reads prod and this Bitween now: `verdict` (`ready`, `matching`, `total`, `needsYou`, `toCopy`, `notCovered`), `notes` (each in `needsYou`, `copy`, `notCovered` or `onPurpose`, with the item or page it is about), per kind what is the same, differs, is missing or is only here with each item's id on both sides, `adapters`, `addresses` (the test ones, with what uses each), `fakedKeys` and `unknown` (fields prod sends that this release doesn't know). A prod that can't be reached or is older than r10.0 answers `{ error }`. See [Prod mirror](prod-mirror.md). | +| `GET /api/mirror/samples` | `mirror.view`. The samples of prod's exchanges this Bitween holds: `state` (`none`, `running`, `done` or `failed`), `done` of `total` subscriptions read, and per subscription its samples, each with how it ended on prod (`outcome`, `status`, `errorTypes`) and its `input`, `output` and `response` files: `format`, a faked `name`, a `url` that opens the faked file, or `notCopied` and why. A subscription without samples has a `reason`. | +| `POST /api/mirror/takesamples` | `mirror.operate`. Starts taking samples in the background and answers as `GET /api/mirror/samples` does. Refused with `MIRROR_NOT_TARGET` unless the deployment sets `Bitween:Mirror:Target`. | | `POST /api/mirror/copy` | `mirror.operate`, and edit rights on what it copies. `{ removeExtras }`. Copies prod's setup with every value faked and answers `{ created, updated, removed, adapterSteps, failed, after }`. Refused with `MIRROR_NOT_TARGET` unless the deployment sets `Bitween:Mirror:Target`. | ## Monitoring diff --git a/docs/prod-mirror.md b/docs/prod-mirror.md index 5688a8f4..f00e7608 100644 --- a/docs/prod-mirror.md +++ b/docs/prod-mirror.md @@ -38,6 +38,26 @@ A field prod sends that this release doesn't know, from a newer or a customised Prod may hold several items of a kind under one name. Each after the oldest is named apart on the staging: `test`, `test (2)`, `test (3)`. References to them still point at the right one. +## Samples of prod's traffic + +**Take samples** takes a few of prod's recent exchanges for each subscription: up to five, mostly ones that worked, with one the partner answered badly and one that failed when prod has them, never a retry or one still running. For each it keeps what came in, what Bitween sent and what the partner answered, and how the exchange ended. The rehearsal runs them through the staging's flows. + +Each file is faked the moment it arrives, in its own format, before it is saved: + +| Format | Kept as it was | Faked | +|---|---|---| +| JSON | keys, structure, `true`, `false`, `null` | strings, and numbers' digits | +| XML | element and attribute names, namespaces, `xsi:type`, the declared encoding | text, CDATA, comments, attribute values; XML escaped inside an element is faked as XML | +| CSV | the delimiter, quoting, line endings | every field | +| Excel | sheets, styles, formulas, booleans | shared strings, numbers, text in cells, comments, the author | +| HTML and other text | tags, entities, punctuation, spacing | the words between them | + +Values are faked the way partner properties are, so the same value is the same fake in a file and in the setup. A word the staging's setup itself names stays as it is wherever it turns up: a filter's `'SHIPPED'`, the `ORD` of a file pattern `ORD_*.csv`, a global value's key. So a faked file takes the path the real one took, and keeping such a word tells the staging nothing it doesn't already hold. File names are faked too, keeping their extension. + +A file that can't be faked safely isn't kept, and the page says why: PDFs, archives, Word files, EDI files, workbooks with pictures, charts or pivot tables, binary files and files over 5 MB. Of prod's error only the partner's HTTP status and the kinds of error it names, such as `HttpRequestException`, are kept: the message can hold prod's data, so the page links to the exchange on prod to read it. + +Prod is read gently: one subscription at a time, one page of its recent exchanges, then each file. Taking samples again replaces the ones taken before. + ## Adapters Built-in adapters come with the release. One that prod uses and this release doesn't have is flagged, since it would stop working after the upgrade. Marketplace adapters are installed by the copy at the version prod runs. Simplify9's database and broker adapters (`bitween.db.…`, `bitween.bus.…`) are on the marketplace too; when prod doesn't say which version it runs, the page asks for one to be installed from the **Marketplace** tab. Custom adapters can't be read from prod: the page names each one, with the version prod runs, to upload on the **Adapters** page. Subscriptions that use one wait for it rather than fail. @@ -48,15 +68,15 @@ It answers first: **Ready to test the upgrade**, or **Not ready to test yet**, w - **Needs you**: what only a person can do, such as installing an adapter, each with where it is done. - **Copy prod's setup**: what copying will do, and the button. It creates what the staging lacks and updates what differs, carries on past an item that fails, and lists each one with Bitween's reason. Tick **Also remove** to delete what only the staging has. +- **Samples of prod's traffic**: how many samples the staging holds and when they were taken, and the button that takes them again. While it runs, how far it has got. - **Not covered by the test**: what is on prod but not on the staging, or not all of it: items waiting for an adapter, items the staging couldn't create, and fields this release doesn't know. - **Different on staging, on purpose**: paused subscriptions, switched-off data sources and bus gateways, fake values, test addresses, stand-in secrets and names told apart. None of it is a problem. -Then the details, folded: **Prod's setup, item by item** (per kind, what is the same, differs, is missing or is only on the staging), **Adapters**, **Fake values** (where a value can be kept as it is) and **Test addresses** (each with what uses it). +Then the details, folded: **Prod's setup, item by item** (per kind, what is the same, differs, is missing or is only on the staging), **Samples, subscription by subscription** (how each ended on prod, a link to each faked file and to the exchange on prod), **Adapters**, **Fake values** (where a value can be kept as it is) and **Test addresses** (each with what uses it). When the staging can't create one of prod's items, the page says so, with what Bitween said. That is all it knows: an upgrade doesn't create items again, so a refusal here says nothing about how prod will behave. ## Not covered yet -- Running prod's flows on the staging against mock servers, and comparing the results. Until then the mock addresses lead nowhere. -- Exchanges and their files. Only the setup is copied. +- Running prod's flows on the staging against mock servers, and comparing the results. Until then the mock addresses lead nowhere, and the samples are only kept. - Runtime settings, roles, members and service accounts, which differ between environments on purpose. From 04f3d0e905fd910e578c1f7599d1f16df2e3e76d Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 22 Oct 2026 15:09:35 +0300 Subject: [PATCH 05/16] feat: prod mirror answers the calls staging shouldn't make --- SW.Bitween.Api/Resources/Mirror/Mirror.cs | 20 + .../Services/Connections/ConnectionTokens.cs | 10 +- .../Services/Mirror/MirrorCatcher.cs | 594 ++++++++++++++++++ SW.Bitween.Api/Services/Mirror/MirrorFaker.cs | 57 ++ SW.Bitween.Api/Services/Mirror/MirrorMock.cs | 324 ++++++++++ .../Services/Mirror/MirrorOptions.cs | 7 + .../Services/Mirror/MirrorService.Samples.cs | 2 +- .../Services/Mirror/MirrorService.cs | 8 +- .../Services/Settings/SettingsCatalog.cs | 7 + SW.Bitween.UnitTests/MirrorCatchingTests.cs | 230 +++++++ SW.Bitween.Web/ClientApp/src/api/client.ts | 2 + .../ClientApp/src/api/http/mirror.ts | 7 +- SW.Bitween.Web/ClientApp/src/api/queryKeys.ts | 1 + SW.Bitween.Web/ClientApp/src/api/types.ts | 30 + .../ClientApp/src/pages/mirror/MirrorPage.tsx | 45 +- .../mirror/__tests__/MirrorPage.test.tsx | 29 + SW.Bitween.Web/Startup.cs | 6 + docs/api-reference.md | 1 + docs/configuration.md | 4 +- docs/prod-mirror.md | 18 +- 20 files changed, 1393 insertions(+), 9 deletions(-) create mode 100644 SW.Bitween.Api/Services/Mirror/MirrorCatcher.cs create mode 100644 SW.Bitween.Api/Services/Mirror/MirrorMock.cs create mode 100644 SW.Bitween.UnitTests/MirrorCatchingTests.cs diff --git a/SW.Bitween.Api/Resources/Mirror/Mirror.cs b/SW.Bitween.Api/Resources/Mirror/Mirror.cs index 3789fd69..13716dc5 100644 --- a/SW.Bitween.Api/Resources/Mirror/Mirror.cs +++ b/SW.Bitween.Api/Resources/Mirror/Mirror.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Threading.Tasks; using SW.Bitween.Services.Mirror; using SW.PrimitiveTypes; @@ -80,6 +81,25 @@ public async Task Handle(MirrorTakeSamplesRequest request) } } +/// +/// The calls staging answered in place of the addresses they were for, since this node started: by +/// host, and the latest ones. Empty on a Bitween that isn't a mirror, which answers none. +/// +[HandlerName("calls")] +public class Calls(BitweenDbContext dbContext, RequestContext requestContext, BitweenOptions options, MirrorMock mock) : IQueryHandler +{ + public async Task Handle() + { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Mirror.View); + return new + { + Catching = options.Mirror.Target, + Hosts = options.Mirror.Target ? await mock.HostsAsync() : [], + Recent = mock.Recent.Take(50).ToList(), + }; + } +} + public class MirrorCopyRequest { /// Also remove what staging has and prod doesn't. Off unless asked, since it deletes. diff --git a/SW.Bitween.Api/Services/Connections/ConnectionTokens.cs b/SW.Bitween.Api/Services/Connections/ConnectionTokens.cs index b2799212..91dcd1e7 100644 --- a/SW.Bitween.Api/Services/Connections/ConnectionTokens.cs +++ b/SW.Bitween.Api/Services/Connections/ConnectionTokens.cs @@ -57,7 +57,15 @@ public class ConnectionTokens(IServiceScopeFactory scopes, SealedState sealedSta /// For tests: answers in place of the network, as 's does. internal static Func HandlerOverride { get; set; } - static HttpClient Client => HandlerOverride is { } handler ? new HttpClient(handler()) : Http; + static HttpClient caught; + + /// + /// On a staging that mirrors prod, token requests go through , which + /// answers those for test addresses itself (see ). + /// + public static void CatchWith(HttpMessageHandler handler) => caught = new HttpClient(handler) { Timeout = Http.Timeout }; + + static HttpClient Client => HandlerOverride is { } handler ? new HttpClient(handler()) : caught ?? Http; /// Whether any of refers to a connection. public static bool Refers(IEnumerable values) => values?.Any(v => v != null && Reference.IsMatch(v)) == true; diff --git a/SW.Bitween.Api/Services/Mirror/MirrorCatcher.cs b/SW.Bitween.Api/Services/Mirror/MirrorCatcher.cs new file mode 100644 index 00000000..51d75285 --- /dev/null +++ b/SW.Bitween.Api/Services/Mirror/MirrorCatcher.cs @@ -0,0 +1,594 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Security; +using System.Security.Authentication; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SW.Bitween.NativeAdapters; +using SW.Bitween.Services.Connections; + +namespace SW.Bitween.Services.Mirror; + +/// +/// Answers, in place of the network, what Bitween's own adapters call that the mirror catches (see +/// ), and sends the rest on. +/// +public sealed class MirrorCatchingHandler(MirrorMock mock) : DelegatingHandler +{ + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.RequestUri is not { IsAbsoluteUri: true } url || !await mock.CatchesAsync(url.Host)) + return await base.SendAsync(request, cancellationToken); + + var body = request.Content == null ? [] : await request.Content.ReadAsByteArrayAsync(cancellationToken); + var answer = await mock.AnswerAsync(new MirrorRequest(request.Method.Method, url, request.Content?.Headers.ContentType?.ToString(), body, "Bitween")); + var response = new HttpResponseMessage((HttpStatusCode)answer.Status) { RequestMessage = request, Content = new ByteArrayContent(answer.Body) }; + if (answer.ContentType != null) response.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(answer.ContentType); + return response; + } +} + +/// +/// A certificate authority made for one run of this Bitween, and the certificates it gives the hosts +/// the catcher answers for, so an adapter's TLS to a test address succeeds. Adapters' processes are +/// told to trust it; nothing else is. A new run makes a new one, and its processes start again with it. +/// +public sealed class MirrorAuthority : IDisposable +{ + readonly X509Certificate2 authority; + readonly ConcurrentDictionary issued = new(StringComparer.OrdinalIgnoreCase); + + public MirrorAuthority() + { + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var request = new CertificateRequest("CN=Bitween prod mirror, O=Test addresses only", key, HashAlgorithmName.SHA256); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, true, 0, true)); + request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign | X509KeyUsageFlags.DigitalSignature, true)); + request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); + authority = Reloaded(request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(5))); + } + + public string Pem => authority.ExportCertificatePem(); + + public X509Certificate2 Certificate => authority; + + /// A certificate for , made the first time it is asked for. + public X509Certificate2 For(string host) => issued.GetOrAdd(host, Issue); + + X509Certificate2 Issue(string host) + { + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var request = new CertificateRequest($"CN={host}", key, HashAlgorithmName.SHA256); + var names = new SubjectAlternativeNameBuilder(); + if (IPAddress.TryParse(host, out var ip)) names.AddIpAddress(ip); + else names.AddDnsName(host); + request.CertificateExtensions.Add(names.Build()); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); + request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, true)); + request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension([new Oid("1.3.6.1.5.5.7.3.1")], false)); + request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); + // Strict verifiers, Python's among them, want the issuer named this way. + request.CertificateExtensions.Add(X509AuthorityKeyIdentifierExtension.CreateFromCertificate(authority, true, false)); + var serial = RandomNumberGenerator.GetBytes(16); + serial[0] &= 0x7F; + using var signed = request.Create(authority, DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(365), serial); + using var withKey = signed.CopyWithPrivateKey(key); + return Reloaded(withKey); + } + + // Through PKCS #12, so SslStream on every platform can use the private key. + static X509Certificate2 Reloaded(X509Certificate2 certificate) + { + using (certificate) return X509CertificateLoader.LoadPkcs12(certificate.Export(X509ContentType.Pkcs12), null); + } + + public void Dispose() + { + authority.Dispose(); + foreach (var c in issued.Values) c.Dispose(); + } +} + +/// +/// Where adapters' own processes send their web calls on a mirror. What the mirror catches (see +/// ) is answered by staging, over TLS made with ; +/// anything else is passed on untouched, through the proxy Bitween itself would use, if any. It +/// listens on loopback only, so only processes in this container can reach it. +/// +/// The processes find it the way every HTTP stack does, through HTTP_PROXY and HTTPS_PROXY, and trust +/// the authority through SSL_CERT_FILE and its kin. They inherit these from Bitween, which sets them +/// before any adapter starts, having first fixed its own proxy as it was, so Bitween's own calls - +/// to prod, to storage - don't change. +/// +/// +public sealed class MirrorCatcher : IDisposable +{ + const int MaxHead = 32 * 1024; + const int MaxBody = 32 * 1024 * 1024; + static readonly TimeSpan Idle = TimeSpan.FromMinutes(2); + + readonly TcpListener listener; + readonly MirrorAuthority authority; + readonly IWebProxy onward; + MirrorMock mock; + ILogger logger; + + MirrorCatcher(TcpListener listener, MirrorAuthority authority, IWebProxy onward) + { + this.listener = listener; + this.authority = authority; + this.onward = onward; + } + + /// The port it listens on, on 127.0.0.1. + public int Port => ((IPEndPoint)listener.LocalEndpoint).Port; + + public MirrorAuthority Authority => authority; + + /// A catcher listening on loopback, answering nothing until . + /// The proxy calls it passes on go through, if any: Bitween's own, as it was before the catcher. + public static MirrorCatcher Listen(IWebProxy onward = null) + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return new MirrorCatcher(listener, new MirrorAuthority(), onward ?? HttpClient.DefaultProxy); + } + + /// + /// On a staging marked as a mirror: Bitween's own adapters' calls go through + /// , and adapters' processes are pointed at a catcher - before + /// anything starts one, since a process reads where to send its calls when it starts. + /// + public static void Install(IServiceCollection services, BitweenOptions options) + { + services.AddTransient(); + services.AddHttpClient(DynamicHttpProxy.ClientName).AddHttpMessageHandler(); + + var catcher = Listen(); + catcher.TellAdapterProcesses(Uri.TryCreate(options.PublicUrl, UriKind.Absolute, out var own) ? [own.Host] : []); + services.AddSingleton(catcher); + services.AddHostedService(); + } + + /// + /// Points the adapter processes Bitween starts from now on at this catcher, and has them trust its + /// authority, through variables they inherit. Bitween's own proxy is fixed first as it is now, so + /// its own calls don't go through the catcher. are hosts processes reach + /// without it: Bitween's own public address, where adapters fetch their files. + /// + public void TellAdapterProcesses(IEnumerable direct) + { + HttpClient.DefaultProxy = HttpClient.DefaultProxy; + + var folder = Path.Combine(Path.GetTempPath(), $"bitween-mirror-{Environment.ProcessId}"); + Directory.CreateDirectory(folder); + var authorityFile = Path.Combine(folder, "authority.pem"); + File.WriteAllText(authorityFile, authority.Pem); + // SSL_CERT_FILE replaces the trusted roots rather than adding to them, so the bundle carries + // the system's too: calls the catcher passes on still meet their real certificates. + var bundle = Path.Combine(folder, "trust.pem"); + File.WriteAllText(bundle, SystemRoots() + "\n" + authority.Pem); + + var proxy = $"http://127.0.0.1:{Port}"; + var bypass = string.Join(",", new[] { Environment.GetEnvironmentVariable("NO_PROXY"), "localhost", "127.0.0.1", "::1" } + .Concat(direct).Where(h => !string.IsNullOrWhiteSpace(h)).Distinct(StringComparer.OrdinalIgnoreCase)); + foreach (var (name, value) in new (string, string)[] + { + ("HTTP_PROXY", proxy), ("HTTPS_PROXY", proxy), ("http_proxy", proxy), ("https_proxy", proxy), + ("NO_PROXY", bypass), ("no_proxy", bypass), + // OpenSSL - .NET on Linux, Python, curl - Python's requests, and Node. + ("SSL_CERT_FILE", bundle), ("REQUESTS_CA_BUNDLE", bundle), ("CURL_CA_BUNDLE", bundle), + ("NODE_EXTRA_CA_CERTS", authorityFile), ("NODE_USE_ENV_PROXY", "1"), + }) + Environment.SetEnvironmentVariable(name, value); + } + + static string SystemRoots() + { + foreach (var file in new[] + { + Environment.GetEnvironmentVariable("SSL_CERT_FILE"), "/etc/ssl/certs/ca-certificates.crt", + "/etc/pki/tls/certs/ca-bundle.crt", "/etc/ssl/ca-bundle.pem", "/etc/ssl/cert.pem", + }) + if (!string.IsNullOrEmpty(file) && File.Exists(file)) + return File.ReadAllText(file); + using var store = new X509Store(StoreName.Root, StoreLocation.LocalMachine); + store.Open(OpenFlags.ReadOnly); + return string.Join("\n", store.Certificates.Select(c => c.ExportCertificatePem())); + } + + /// Answers connections until , catching what catches. + public async Task RunAsync(MirrorMock mirror, ILogger log, CancellationToken stopping) + { + mock = mirror; + logger = log; + logger.LogInformation("Prod mirror: adapters' processes send their web calls through 127.0.0.1:{Port}", Port); + while (!stopping.IsCancellationRequested) + { + TcpClient client; + try { client = await listener.AcceptTcpClientAsync(stopping); } + catch (OperationCanceledException) { break; } + catch (SocketException ex) + { + logger.LogWarning(ex, "The prod mirror's catcher couldn't accept a connection"); + continue; + } + _ = Task.Run(() => ServeAsync(client, stopping), CancellationToken.None); + } + } + + public void Dispose() + { + listener.Stop(); + authority.Dispose(); + } + + // ------------------------------------------------------------------ one connection + + async Task ServeAsync(TcpClient client, CancellationToken stopping) + { + using var _ = client; + using var idle = CancellationTokenSource.CreateLinkedTokenSource(stopping); + idle.CancelAfter(Idle); + var ct = idle.Token; + try + { + var wire = new Wire(client.GetStream()); + var head = await wire.ReadHeadAsync(ct); + if (head == null) return; + var request = Head.Parse(head); + + if (request.Method == "CONNECT") + { + var (host, port) = HostAndPort(request.Target, 443); + if (await mock.CatchesAsync(host)) + { + await wire.WriteAsync("HTTP/1.1 200 Connection Established\r\n\r\n", ct); + await using var tls = new SslStream(wire.AsStream()); + try + { + await tls.AuthenticateAsServerAsync(new SslServerAuthenticationOptions + { + ServerCertificate = authority.For(host), + ApplicationProtocols = [SslApplicationProtocol.Http11], + }, ct); + } + catch (Exception ex) when (ex is AuthenticationException or IOException) + { + // Most often the adapter doesn't trust the catcher's authority: said where the page shows it. + mock.Refused(host, ex.InnerException?.Message ?? ex.Message); + logger.LogDebug(ex, "An adapter's TLS to the prod mirror's catcher failed for {Host}", host); + return; + } + if (!await AnswerAsync(new Wire(tls), "https", port == 443 ? host : $"{host}:{port}", null, idle, ct)) + // Some TLS stacks finish the handshake and only then check the certificate, and hang up. + mock.Refused(host, "it closed the connection without asking anything, which is how most adapters refuse a certificate they don't trust"); + } + else + { + using var onwardClient = await ConnectAsync(host, port, ct); + await wire.WriteAsync("HTTP/1.1 200 Connection Established\r\n\r\n", ct); + await PipeAsync(wire, onwardClient.GetStream(), ct); + } + return; + } + + if (!request.Target.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || !Uri.TryCreate(request.Target, UriKind.Absolute, out var url)) + { + await wire.WriteAsync("HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", ct); + return; + } + if (await mock.CatchesAsync(url.Host)) + { + await AnswerAsync(wire, "http", url.Authority, request, idle, ct); + return; + } + + // Passed on as it came, one request to the connection, which the far side closes after answering. + using var server = await ConnectAsync(url.Host, url.Port, ct); + var serverStream = server.GetStream(); + await serverStream.WriteAsync(Encoding.Latin1.GetBytes(request.Rewritten(url.PathAndQuery) + "\r\n\r\n"), ct); + await PipeAsync(wire, serverStream, ct); + } + catch (Exception ex) when (ex is IOException or SocketException or OperationCanceledException or InvalidDataException or FormatException) + { + logger.LogDebug(ex, "A connection to the prod mirror's catcher ended early"); + } + catch (Exception ex) + { + logger.LogWarning(ex, "The prod mirror's catcher failed on a connection"); + } + } + + /// Requests on one connection, each answered by the mock, until the client closes it. False when it closed before asking anything. + async Task AnswerAsync(Wire wire, string scheme, string authorityPart, Head first, CancellationTokenSource idle, CancellationToken ct) + { + var request = first; + var answered = false; + while (true) + { + if (request == null) + { + var head = await wire.ReadHeadAsync(ct); + if (head == null) return answered; + request = Head.Parse(head); + } + if (request.ExpectsContinue) await wire.WriteAsync("HTTP/1.1 100 Continue\r\n\r\n", ct); + var length = request.ContentLength ?? 0; + if (length > MaxBody) throw new InvalidDataException("A body larger than the catcher reads."); + var body = request.Chunked ? await wire.ReadChunkedAsync(MaxBody, ct) : length > 0 ? await wire.ReadBytesAsync((int)length, ct) : []; + // Only a target that names its scheme is absolute: on Linux and macOS a path such as + // /v2/labels would otherwise read as a file's address. + var url = request.Target.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || request.Target.StartsWith("https://", StringComparison.OrdinalIgnoreCase) + ? new Uri(request.Target) + : new Uri($"{scheme}://{authorityPart}{request.Target}"); + var answer = await mock.AnswerAsync(new MirrorRequest(request.Method, url, request.ContentType, body, "An adapter's process")); + + var reply = new StringBuilder() + .Append("HTTP/1.1 ").Append(answer.Status).Append(' ').Append(Reason(answer.Status)).Append("\r\n") + .Append("Content-Length: ").Append(answer.Body.Length).Append("\r\n"); + if (answer.ContentType != null) reply.Append("Content-Type: ").Append(answer.ContentType).Append("\r\n"); + // Plain HTTP comes to the catcher itself, and a client sends its next call to any host down the + // same connection: each is answered on its own, so the next can be passed on if it isn't caught. + var keepAlive = request.KeepAlive && scheme == "https"; + reply.Append("Connection: ").Append(keepAlive ? "keep-alive" : "close").Append("\r\n\r\n"); + await wire.WriteAsync(reply.ToString(), ct); + if (request.Method != "HEAD") await wire.Stream.WriteAsync(answer.Body, ct); + await wire.Stream.FlushAsync(ct); + answered = true; + if (!keepAlive) return true; + idle.CancelAfter(Idle); + request = null; + } + } + + static string Reason(int status) => status switch + { + 200 => "OK", 201 => "Created", 202 => "Accepted", 204 => "No Content", 400 => "Bad Request", 401 => "Unauthorized", + 403 => "Forbidden", 404 => "Not Found", 409 => "Conflict", 422 => "Unprocessable Entity", 429 => "Too Many Requests", + 500 => "Internal Server Error", 502 => "Bad Gateway", 503 => "Service Unavailable", + _ => "Answer", + }; + + /// A connection to where a passed-on call goes: straight there, or through Bitween's own proxy when it has one for that address. + async Task ConnectAsync(string host, int port, CancellationToken ct) + { + var target = new Uri($"https://{(host.Contains(':') ? $"[{host}]" : host)}:{port}"); + var via = onward != null && !onward.IsBypassed(target) ? onward.GetProxy(target) : null; + var client = new TcpClient(); + try + { + if (via == null || via == target) + { + await client.ConnectAsync(host, port, ct); + return client; + } + await client.ConnectAsync(via.Host, via.Port, ct); + var wire = new Wire(client.GetStream()); + await wire.WriteAsync($"CONNECT {target.Authority} HTTP/1.1\r\nHost: {target.Authority}\r\n\r\n", ct); + var answer = await wire.ReadHeadAsync(ct); + if (answer == null || !answer.StartsWith("HTTP/1.1 200", StringComparison.Ordinal) && !answer.StartsWith("HTTP/1.0 200", StringComparison.Ordinal)) + throw new IOException($"Bitween's proxy wouldn't connect to {target.Authority}."); + return client; + } + catch + { + client.Dispose(); + throw; + } + } + + /// Both ways at once, until either side closes. + static async Task PipeAsync(Wire client, Stream far, CancellationToken ct) + { + using var done = CancellationTokenSource.CreateLinkedTokenSource(ct); + var up = client.PipeToAsync(far, done.Token); + var down = far.CopyToAsync(client.Stream, done.Token); + await Task.WhenAny(up, down); + await done.CancelAsync(); + try { await Task.WhenAll(up, down); } + catch (Exception ex) when (ex is OperationCanceledException or IOException or ObjectDisposedException) { } + } + + static (string Host, int Port) HostAndPort(string authorityPart, int defaultPort) + { + if (authorityPart.StartsWith('[')) + { + var close = authorityPart.IndexOf(']'); + var host6 = authorityPart[1..close]; + return (host6, authorityPart.Length > close + 2 && int.TryParse(authorityPart[(close + 2)..], out var p6) ? p6 : defaultPort); + } + var colon = authorityPart.LastIndexOf(':'); + return colon > 0 && int.TryParse(authorityPart[(colon + 1)..], out var port) ? (authorityPart[..colon], port) : (authorityPart, defaultPort); + } + + // ------------------------------------------------------------------ HTTP/1.1, as much as a catcher needs + + sealed class Head + { + public string Method { get; private init; } + public string Target { get; private init; } + public List<(string Name, string Value)> Headers { get; } = []; + + string Header(string name) => Headers.LastOrDefault(h => string.Equals(h.Name, name, StringComparison.OrdinalIgnoreCase)).Value; + + public long? ContentLength => long.TryParse(Header("Content-Length"), out var n) ? n : null; + public bool Chunked => Header("Transfer-Encoding")?.Contains("chunked", StringComparison.OrdinalIgnoreCase) == true; + public bool ExpectsContinue => string.Equals(Header("Expect"), "100-continue", StringComparison.OrdinalIgnoreCase); + public string ContentType => Header("Content-Type"); + public bool KeepAlive => !string.Equals(Header("Connection") ?? Header("Proxy-Connection"), "close", StringComparison.OrdinalIgnoreCase); + + public static Head Parse(string head) + { + var lines = head.Split("\r\n"); + var first = lines[0].Split(' '); + if (first.Length < 3) throw new InvalidDataException("Not an HTTP request."); + var parsed = new Head { Method = first[0].ToUpperInvariant(), Target = first[1] }; + foreach (var line in lines.Skip(1)) + { + var colon = line.IndexOf(':'); + if (colon > 0) parsed.Headers.Add((line[..colon].Trim(), line[(colon + 1)..].Trim())); + } + return parsed; + } + + /// The request as a server is sent it: its own path, its proxy headers gone, the connection closed after. + public string Rewritten(string pathAndQuery) => + string.Join("\r\n", new[] { $"{Method} {pathAndQuery} HTTP/1.1" } + .Concat(Headers.Where(h => !h.Name.Equals("Proxy-Connection", StringComparison.OrdinalIgnoreCase) && + !h.Name.Equals("Proxy-Authorization", StringComparison.OrdinalIgnoreCase) && + !h.Name.Equals("Connection", StringComparison.OrdinalIgnoreCase)) + .Select(h => $"{h.Name}: {h.Value}")) + .Append("Connection: close")); + } + + /// A stream read a head, a line or a body at a time, with what it read ahead kept for the next read. + sealed class Wire(Stream stream) + { + readonly byte[] buffer = new byte[MaxHead]; + int start, end; + + public Stream Stream => stream; + + public Task WriteAsync(string text, CancellationToken ct) => stream.WriteAsync(Encoding.Latin1.GetBytes(text), ct).AsTask(); + + async Task FillAsync(CancellationToken ct) + { + if (start == end) start = end = 0; + if (end == buffer.Length) + { + if (start == 0) throw new InvalidDataException("A head larger than the catcher reads."); + Buffer.BlockCopy(buffer, start, buffer, 0, end - start); + end -= start; + start = 0; + } + var read = await stream.ReadAsync(buffer.AsMemory(end), ct); + end += read; + return read > 0; + } + + /// Up to the blank line that ends a head; null when the other side closed first. + public async Task ReadHeadAsync(CancellationToken ct) + { + while (true) + { + var at = buffer.AsSpan(start, end - start).IndexOf("\r\n\r\n"u8); + if (at >= 0) + { + var head = Encoding.Latin1.GetString(buffer, start, at); + start += at + 4; + return head; + } + if (!await FillAsync(ct)) return null; + } + } + + async Task ReadLineAsync(CancellationToken ct) + { + while (true) + { + var at = buffer.AsSpan(start, end - start).IndexOf("\r\n"u8); + if (at >= 0) + { + var line = Encoding.Latin1.GetString(buffer, start, at); + start += at + 2; + return line; + } + if (!await FillAsync(ct)) throw new IOException("The connection closed mid-request."); + } + } + + public async Task ReadBytesAsync(int count, CancellationToken ct) + { + var result = new byte[count]; + var have = 0; + while (have < count) + { + if (start == end && !await FillAsync(ct)) throw new IOException("The connection closed mid-body."); + var take = Math.Min(count - have, end - start); + Buffer.BlockCopy(buffer, start, result, have, take); + start += take; + have += take; + } + return result; + } + + public async Task ReadChunkedAsync(int max, CancellationToken ct) + { + using var body = new MemoryStream(); + while (true) + { + var size = Convert.ToInt32((await ReadLineAsync(ct)).Split(';')[0].Trim(), 16); + if (size == 0) break; + if (body.Length + size > max) throw new InvalidDataException("A body larger than the catcher reads."); + body.Write(await ReadBytesAsync(size, ct)); + await ReadLineAsync(ct); + } + while ((await ReadLineAsync(ct)).Length > 0) { } + return body.ToArray(); + } + + /// The stream, read from what was read ahead first. + public Stream AsStream() => end > start ? new Ahead(this) : stream; + + sealed class Ahead(Wire wire) : Stream + { + public override bool CanRead => true; + public override bool CanWrite => true; + public override bool CanSeek => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + + public override async ValueTask ReadAsync(Memory into, CancellationToken ct = default) + { + if (wire.end <= wire.start) return await wire.Stream.ReadAsync(into, ct); + var take = Math.Min(into.Length, wire.end - wire.start); + wire.buffer.AsMemory(wire.start, take).CopyTo(into); + wire.start += take; + return take; + } + + public override int Read(byte[] into, int offset, int count) => ReadAsync(into.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + public override ValueTask WriteAsync(ReadOnlyMemory from, CancellationToken ct = default) => wire.Stream.WriteAsync(from, ct); + public override void Write(byte[] from, int offset, int count) => wire.Stream.Write(from, offset, count); + public override Task FlushAsync(CancellationToken ct) => wire.Stream.FlushAsync(ct); + public override void Flush() => wire.Stream.Flush(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + } + + /// What was read ahead, then the rest of the stream, to . + public async Task PipeToAsync(Stream to, CancellationToken ct) + { + if (end > start) + { + await to.WriteAsync(buffer.AsMemory(start, end - start), ct); + start = end = 0; + } + await stream.CopyToAsync(to, ct); + } + } +} + +/// Serves once the mirror's mock exists, and sends connections' token requests through it too. +public sealed class MirrorCatcherService(MirrorCatcher catcher, MirrorMock mock, ILogger logger) : BackgroundService +{ + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + ConnectionTokens.CatchWith(new MirrorCatchingHandler(mock) { InnerHandler = new SocketsHttpHandler() }); + return catcher.RunAsync(mock, logger, stoppingToken); + } +} diff --git a/SW.Bitween.Api/Services/Mirror/MirrorFaker.cs b/SW.Bitween.Api/Services/Mirror/MirrorFaker.cs index 2ce4594c..4ba68512 100644 --- a/SW.Bitween.Api/Services/Mirror/MirrorFaker.cs +++ b/SW.Bitween.Api/Services/Mirror/MirrorFaker.cs @@ -459,6 +459,63 @@ public static bool IsAddressKey(string key) return AddressWords.Any(k.Contains); } + /// + /// Every host a setting names, in lower case and without its port: in URLs and email addresses + /// anywhere in it, by the server a connection string names, and as a bare host, IP or list of them + /// where the setting is named for an address. A host built from a {{token}} isn't one yet. + /// + public static IEnumerable HostsIn(string key, string value) => AllHostsIn(key, value).Distinct(); + + static IEnumerable AllHostsIn(string key, string value) + { + if (string.IsNullOrEmpty(value)) yield break; + foreach (Match m in UrlPattern.Matches(value)) + { + var authority = m.Groups["auth"].Value; + if (authority.Contains("{{")) continue; + if (authority.LastIndexOf('@') is var at and >= 0) authority = authority[(at + 1)..]; + if (Bare(SplitPort(authority).Host) is { } host) yield return host; + } + foreach (Match m in EmailPattern.Matches(value)) + yield return m.Value[(m.Value.LastIndexOf('@') + 1)..].ToLowerInvariant(); + if (value.Contains("://")) yield break; + + IEnumerable parts = + LooksLikeConnectionString(value) ? value.Split(';').Select(p => p.Split('=', 2)).Where(p => p.Length == 2 && ConnectionHostKeys.Contains(p[0].Trim().ToLowerInvariant().Replace(" ", ""))).SelectMany(p => p[1].Split(',')) + : IsAddressKey(key) ? value.Split(',', ';') + : []; + foreach (var part in parts) + { + var p = part.Trim(); + if (p.StartsWith("tcp:", StringComparison.OrdinalIgnoreCase)) p = p[4..]; + if (Bare(SplitPort(p).Host) is { } host) yield return host; + } + + static string Bare(string host) + { + var h = host.Trim().Trim('[', ']').ToLowerInvariant(); + return h.Length > 0 && !h.Contains("{{") && (IsHost(h) || IPAddress.TryParse(h, out _)) ? h : null; + } + } + + /// + /// One of the addresses a copy puts in place of prod's: a name under , or an + /// IP kept for documentation (192.0.2.0/24, 2001:db8::/32), which routes nowhere. + /// + public static bool IsMockHost(string host) + { + var h = (host ?? "").Trim().Trim('[', ']').TrimEnd('.').ToLowerInvariant(); + if (h.EndsWith("." + MockDomain, StringComparison.Ordinal)) return true; + if (!IPAddress.TryParse(h, out var ip)) return false; + var bytes = ip.GetAddressBytes(); + return ip.AddressFamily == AddressFamily.InterNetwork + ? bytes[0] == 192 && bytes[1] == 0 && bytes[2] == 2 + : bytes[0] == 0x20 && bytes[1] == 0x01 && bytes[2] == 0x0d && bytes[3] == 0xb8; + } + + static readonly HashSet ConnectionHostKeys = + ["host", "server", "datasource", "address", "addr", "networkaddress", "endpoint", "hostname"]; + static bool IsPortKey(string key) => (key ?? "").EndsWith("port", StringComparison.OrdinalIgnoreCase); static bool IsConnectionStringKey(string key) diff --git a/SW.Bitween.Api/Services/Mirror/MirrorMock.cs b/SW.Bitween.Api/Services/Mirror/MirrorMock.cs new file mode 100644 index 00000000..e1bab567 --- /dev/null +++ b/SW.Bitween.Api/Services/Mirror/MirrorMock.cs @@ -0,0 +1,324 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using SW.Bitween.Domain; +using SW.Bitween.Domain.Connections; +using SW.Bitween.Domain.DataSources; +using SW.Bitween.Domain.Gateway; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Services.Mirror; + +/// A call as it reached the mirror: from one of Bitween's own adapters, or an adapter's own process. +public record MirrorRequest(string Method, Uri Url, string ContentType, byte[] Body, string From); + +/// What staging answered a call with, and why that. +public record MirrorAnswer(int Status, string ContentType, byte[] Body, string Why); + +/// One call staging answered. +public record MirrorCall(DateTimeOffset At, string Method, string Host, string Path, int Status, string Why, string From); + +/// The calls to one host staging answered, as the page shows them. +/// One of prod's addresses as the copy faked it, rather than one staging's setup doesn't name. +public record MirrorHostCalls(string Host, bool Test, int Calls, DateTimeOffset LastAt, string LastWhy, IReadOnlyList UsedBy); + +/// +/// What staging's own setup names, read now and then: the hosts it may call for real, which +/// subscriptions use each test address, and where each login's reply keeps its token. +/// +public sealed record MirrorSetupHosts( + IReadOnlySet Named, + IReadOnlyDictionary> UsedBy, + IReadOnlyDictionary TokenPaths, + IReadOnlySet Logins) +{ + public static readonly MirrorSetupHosts None = new(new HashSet(), new Dictionary>(), + new Dictionary(), new HashSet()); + + public static async Task ReadAsync(BitweenDbContext db) + { + var named = new HashSet(StringComparer.OrdinalIgnoreCase); + var usedBy = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var tokenPaths = new Dictionary(StringComparer.OrdinalIgnoreCase); + var logins = new HashSet(StringComparer.OrdinalIgnoreCase); + + void Add(IEnumerable> values, string subscription = null) + { + foreach (var (key, value) in values ?? []) + foreach (var host in MirrorFaker.HostsIn(key, value)) + { + if (!MirrorFaker.IsMockHost(host)) named.Add(host); + else if (subscription != null) + (usedBy.TryGetValue(host, out var names) ? names : usedBy[host] = new SortedSet(StringComparer.Ordinal)).Add(subscription); + } + } + + void Login(string url, string tokenPath = null) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var login)) return; + logins.Add(login.Host + login.AbsolutePath.TrimEnd('/')); + if (!string.IsNullOrWhiteSpace(tokenPath)) tokenPaths[login.Host] = tokenPath.Trim(); + } + + foreach (var s in await db.Subscriptions().AsNoTracking() + .Select(s => new { s.Name, s.HandlerProperties, s.ReceiverProperties, s.MapperProperties, s.ValidatorProperties }).ToListAsync()) + { + foreach (var properties in new[] { s.HandlerProperties, s.ReceiverProperties, s.MapperProperties, s.ValidatorProperties }) + { + Add(properties, s.Name); + if (Get(properties, "LoginUrl") is { } login) Login(login, Get(properties, "LoginTokenPath")); + } + } + foreach (var n in await db.Set().AsNoTracking().Select(n => n.HandlerProperties).ToListAsync()) Add(n); + foreach (var c in await db.Set().AsNoTracking().Select(c => c.HandlerProperties).ToListAsync()) Add(c); + foreach (var c in await db.Set().AsNoTracking().Select(c => new { c.AuthorizeUrl, c.TokenUrl }).ToListAsync()) + { + Add([new("authorizeUrl", c.AuthorizeUrl), new("tokenUrl", c.TokenUrl)]); + Login(c.TokenUrl); + } + foreach (var d in await db.Set().AsNoTracking().Select(d => d.Properties).ToListAsync()) Add(d); + foreach (var b in await db.Set().AsNoTracking().Select(b => b.EndpointProperties).ToListAsync()) Add(b); + // What a {{globals...}} or {{partner...}} token stands for is an address like any written out. + foreach (var g in await db.Set().AsNoTracking().Select(g => g.Values).ToListAsync()) Add(g); + foreach (var p in await db.Set().AsNoTracking().Select(p => p.AdapterProperties).ToListAsync()) Add(p); + + return new MirrorSetupHosts(named, usedBy.ToDictionary(u => u.Key, u => (IReadOnlyList)u.Value.ToList(), StringComparer.OrdinalIgnoreCase), + tokenPaths, logins); + + static string Get(IReadOnlyDictionary properties, string name) => + properties?.FirstOrDefault(p => string.Equals(p.Key, name, StringComparison.OrdinalIgnoreCase)).Value is { Length: > 0 } value ? value : null; + } +} + +/// +/// On a mirror, answers the calls flows make that staging must not send on: to a test address - one +/// of prod's, as the copy faked it - and to any host staging's own setup doesn't name, such as one +/// written inside an adapter's code. A host the setup names, like a team's own staging API, is called +/// for real, and so is one listed in Bitween:Mirror:PassHosts. +/// +/// A login is answered with a token of staging's own, where the login's settings say its reply keeps +/// one. Anything else gets the newest answer prod's partner gave a subscription that uses the address, +/// as staging keeps it in the samples - faked - or an empty one when there is none. Every answer is +/// counted, so the page can show what was caught. +/// +/// +public sealed class MirrorMock(IServiceScopeFactory scopes, BitweenOptions options, ILogger logger) +{ + static readonly TimeSpan Fresh = TimeSpan.FromMinutes(1); + const int Kept = 200; + + readonly SemaphoreSlim reading = new(1, 1); + MirrorSetupHosts setup; + MirrorSamples samples; + DateTimeOffset readOn; + + readonly ConcurrentDictionary answers = new(StringComparer.Ordinal); + readonly ConcurrentQueue recent = new(); + readonly ConcurrentDictionary byHost = new(StringComparer.OrdinalIgnoreCase); + + /// Overrides the setup and samples it reads, for tests. + internal Func> ReadOverride { get; set; } + + /// Overrides how a sample's file is read from storage, for tests. + internal Func> FileOverride { get; set; } + + /// Whether staging answers a call to itself rather than sending it on. + public async Task CatchesAsync(string host) + { + var h = (host ?? "").Trim().Trim('[', ']').TrimEnd('.').ToLowerInvariant(); + if (MirrorFaker.IsMockHost(h)) return true; + if (h is "localhost" || h.EndsWith(".localhost", StringComparison.Ordinal) || IPAddress.TryParse(h, out var ip) && IPAddress.IsLoopback(ip)) + return false; + if (Own(h) || PassHosts().Contains(h)) return false; + return !(await ReadAsync()).Setup.Named.Contains(h); + } + + public async Task AnswerAsync(MirrorRequest request) + { + MirrorAnswer answer; + try + { + var (setup, samples) = await ReadAsync(); + var host = request.Url.Host.ToLowerInvariant(); + answer = AsksForToken(request, setup) ? Token(setup.TokenPaths.GetValueOrDefault(host)) + : await SampleAnswerAsync(host, setup, samples) ?? new MirrorAnswer(200, null, [], + "Nothing of prod's to answer with, so an empty reply."); + } + catch (Exception ex) + { + logger.LogWarning(ex, "The prod mirror couldn't answer a call to {Host}", request.Url.Host); + answer = new MirrorAnswer(500, "text/plain", Encoding.UTF8.GetBytes("The prod mirror couldn't answer this call."), + "Staging couldn't answer it: see the log."); + } + Record(request, answer); + return answer; + } + + /// The calls answered since this Bitween started, by host, busiest first. + public async Task> HostsAsync() + { + var setup = (await ReadAsync()).Setup; + return byHost.Select(h => new MirrorHostCalls(h.Key, MirrorFaker.IsMockHost(h.Key), h.Value.Calls, h.Value.LastAt, h.Value.LastWhy, + setup.UsedBy.GetValueOrDefault(h.Key) ?? [])) + .OrderByDescending(h => h.Calls).ThenBy(h => h.Host, StringComparer.Ordinal).ToList(); + } + + /// The latest calls answered, newest first. + public IReadOnlyList Recent => recent.Reverse().ToList(); + + // ------------------------------------------------------------------ answers + + /// + /// A login: a URL staging's setup logs in at, or a POST that reads like one - to a path ending in + /// token or login, or carrying a grant type. + /// + internal static bool AsksForToken(MirrorRequest request, MirrorSetupHosts setup) + { + if (!string.Equals(request.Method, "POST", StringComparison.OrdinalIgnoreCase)) return false; + var path = request.Url.AbsolutePath.TrimEnd('/'); + if (setup.Logins.Contains(request.Url.Host + path)) return true; + var last = path[(path.LastIndexOf('/') + 1)..].ToLowerInvariant(); + if (last is "token" or "login" or "signin" or "authenticate" or "auth") return true; + var body = request.Body is { Length: > 0 and <= 16 * 1024 } ? Encoding.UTF8.GetString(request.Body) : ""; + return body.Contains("grant_type", StringComparison.OrdinalIgnoreCase); + } + + /// + /// A token of staging's own, under every name a login's reply commonly gives it, and at the path the + /// login's settings read it from when they name one ("data.session.token"). + /// + internal static MirrorAnswer Token(string path) + { + var token = "mirror-" + Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(); + var reply = new JsonObject + { + ["access_token"] = token, ["token_type"] = "Bearer", ["expires_in"] = 3600, + ["token"] = token, ["accessToken"] = token, ["jwt"] = token, + }; + var segments = (path ?? "").Trim().TrimStart('$').TrimStart('.').Split('.', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length > 0 && segments.All(s => !s.Contains('['))) + { + var at = reply; + foreach (var segment in segments[..^1]) + at = at[segment] as JsonObject ?? (JsonObject)(at[segment] = new JsonObject()); + at[segments[^1]] = token; + } + return new MirrorAnswer(200, "application/json", Encoding.UTF8.GetBytes(reply.ToJsonString()), "A login, answered with a token of staging's own."); + } + + /// The newest answer prod's partner gave, faked, to a subscription that uses the address and worked on prod. + async Task SampleAnswerAsync(string host, MirrorSetupHosts setup, MirrorSamples samples) + { + var users = setup.UsedBy.GetValueOrDefault(host); + if (users == null || samples == null) return null; + var candidate = samples.Subscriptions.Where(s => users.Contains(s.Subscription)) + .SelectMany(s => s.Samples.Select(x => (s.Subscription, Sample: x))) + .Where(x => x.Sample.Outcome == "success" && x.Sample.Response?.Key != null) + .OrderByDescending(x => x.Sample.StartedOn) + .FirstOrDefault(); + if (candidate.Sample == null) return null; + + var file = candidate.Sample.Response; + if (!answers.TryGetValue(file.Key, out var cached)) + { + var body = await ReadFileAsync(file.Key); + cached = (body, ContentTypeOf(file.Format)); + if (answers.Count < Kept) answers[file.Key] = cached; + } + return new MirrorAnswer(200, cached.ContentType, cached.Body, $"Prod's answer to “{candidate.Subscription}”, faked."); + } + + static string ContentTypeOf(string format) => format switch + { + "json" => "application/json", + "xml" => "application/xml", + "html" => "text/html", + "csv" => "text/csv", + _ => "text/plain", + }; + + async Task ReadFileAsync(string key) + { + if (FileOverride != null) return await FileOverride(key); + using var scope = scopes.CreateScope(); + await using var stream = await scope.ServiceProvider.GetRequiredService().OpenReadAsync(key); + using var buffer = new MemoryStream(); + await stream.CopyToAsync(buffer); + return buffer.ToArray(); + } + + // ------------------------------------------------------------------ what it knows + + async Task<(MirrorSetupHosts Setup, MirrorSamples Samples)> ReadAsync() + { + if (setup != null && DateTimeOffset.UtcNow - readOn < Fresh) return (setup, samples); + await reading.WaitAsync(); + try + { + if (setup != null && DateTimeOffset.UtcNow - readOn < Fresh) return (setup, samples); + if (ReadOverride != null) (setup, samples) = await ReadOverride(); + else + { + using var scope = scopes.CreateScope(); + setup = await MirrorSetupHosts.ReadAsync(scope.ServiceProvider.GetRequiredService()); + samples = await ReadSamplesAsync(scope.ServiceProvider.GetRequiredService()); + } + readOn = DateTimeOffset.UtcNow; + answers.Clear(); + return (setup, samples); + } + finally + { + reading.Release(); + } + } + + static async Task ReadSamplesAsync(ICloudFilesService files) + { + try + { + await using var stream = await files.OpenReadAsync(MirrorService.SamplesManifest); + return await JsonSerializer.DeserializeAsync(stream, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + } + catch (Exception ex) when (StorageErrors.IsNotFound(ex)) + { + return null; + } + } + + /// The hosts this Bitween is reached on, which adapters call it back on. + bool Own(string host) => + Uri.TryCreate(options.PublicUrl, UriKind.Absolute, out var own) && string.Equals(own.Host, host, StringComparison.OrdinalIgnoreCase); + + HashSet PassHosts() => + (options.Mirror.PassHosts ?? "").Split(['\n', ',', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(h => h.Trim('[', ']').ToLowerInvariant()).ToHashSet(StringComparer.OrdinalIgnoreCase); + + /// + /// A call to that failed before staging could answer it, at TLS: most often + /// the adapter doesn't trust staging's test authority, or checks certificates its own way. + /// + public void Refused(string host, string why) => + Record(new MirrorRequest("CONNECT", new Uri($"https://{host ?? "unknown"}/"), null, [], "An adapter's process"), + new MirrorAnswer(0, null, [], $"The adapter's call failed before staging could answer: {why}.")); + + void Record(MirrorRequest request, MirrorAnswer answer) + { + var now = DateTimeOffset.UtcNow; + recent.Enqueue(new MirrorCall(now, request.Method, request.Url.Host, request.Url.AbsolutePath, answer.Status, answer.Why, request.From)); + while (recent.Count > Kept && recent.TryDequeue(out _)) { } + byHost.AddOrUpdate(request.Url.Host, _ => (1, now, answer.Why), (_, h) => (h.Calls + 1, now, answer.Why)); + } +} diff --git a/SW.Bitween.Api/Services/Mirror/MirrorOptions.cs b/SW.Bitween.Api/Services/Mirror/MirrorOptions.cs index af66d8ae..88e3741d 100644 --- a/SW.Bitween.Api/Services/Mirror/MirrorOptions.cs +++ b/SW.Bitween.Api/Services/Mirror/MirrorOptions.cs @@ -30,4 +30,11 @@ public class MirrorOptions /// settings rather than data: partner:timeZone, or global:formats/date. Never an address or a secret. /// public string KeepRealValues { get; set; } + + /// + /// Hosts staging's flows may call for real although its setup doesn't name them, one per line: an + /// address written inside an adapter that is safe to reach from staging. Everything else the setup + /// doesn't name is answered by staging itself. + /// + public string PassHosts { get; set; } } diff --git a/SW.Bitween.Api/Services/Mirror/MirrorService.Samples.cs b/SW.Bitween.Api/Services/Mirror/MirrorService.Samples.cs index 168460bc..a85d9788 100644 --- a/SW.Bitween.Api/Services/Mirror/MirrorService.Samples.cs +++ b/SW.Bitween.Api/Services/Mirror/MirrorService.Samples.cs @@ -22,7 +22,7 @@ public sealed partial class MirrorService /// Where staging keeps prod's samples, faked, in its own storage. public const string SamplesPrefix = "mirror/samples"; - const string SamplesManifest = SamplesPrefix + "/samples.json"; + internal const string SamplesManifest = SamplesPrefix + "/samples.json"; static readonly JsonSerializerOptions ManifestJson = new(JsonSerializerDefaults.Web); diff --git a/SW.Bitween.Api/Services/Mirror/MirrorService.cs b/SW.Bitween.Api/Services/Mirror/MirrorService.cs index 300a6547..79854949 100644 --- a/SW.Bitween.Api/Services/Mirror/MirrorService.cs +++ b/SW.Bitween.Api/Services/Mirror/MirrorService.cs @@ -195,7 +195,7 @@ async Task CompareAsync(BitweenApi prod, Func openSelf, var kinds = Kinds.All.Select(kind => KindRow(kind, copy.Sections.GetValueOrDefault(kind.Section) ?? [], plan, extras, waiting, skipped, prodCtx, selfCtx)).ToList(); var unknown = Unknown(prodCtx, selfCtx, skipped); - var notes = Notes(kinds, adapters, plan, extras.Count, renamed, unknown, copy); + var notes = Notes(kinds, adapters, plan, extras.Count, renamed, unknown, copy, catching: options.Mirror.Target); var parity = new MirrorParity( DateTimeOffset.UtcNow, new MirrorSide(prod.Url, prodVersion, await ExchangesAsync(prod)), @@ -562,7 +562,7 @@ internal static string Plural(string noun) => /// Bitween said when staging tried to create the item, never a forecast of what prod will do. /// static List Notes(List kinds, List adapters, Plan plan, int extras, List renamed, - List unknown, MirrorSetupCopy copy) + List unknown, MirrorSetupCopy copy, bool catching) { var notes = new List(); @@ -642,6 +642,10 @@ static List Notes(List kinds, List adapte notes.Add(new MirrorNote("addresses", "onPurpose", $"{Capital(Count(copy.Addresses.Count, "address"))} prod uses lead{(copy.Addresses.Count == 1 ? "s" : "")} to a test address on staging, which goes nowhere.", Action: "addresses")); + if (catching) + notes.Add(new MirrorNote("calls", "onPurpose", + "Staging answers calls to test addresses itself, and calls to any host its setup doesn't name, such as one written inside an adapter. A host the setup names, like your own staging API, is called for real.", + Action: "calls")); if ((copy.Sections.GetValueOrDefault("partners")?.Count ?? 0) > 0) notes.Add(new MirrorNote("secrets", "onPurpose", "Secrets are stand-ins, and partners' API keys are staging's own.")); notes.AddRange(renamed.Select(r => new MirrorNote($"renamed:{r}", "onPurpose", r))); diff --git a/SW.Bitween.Api/Services/Settings/SettingsCatalog.cs b/SW.Bitween.Api/Services/Settings/SettingsCatalog.cs index 18100fda..6f9e06e4 100644 --- a/SW.Bitween.Api/Services/Settings/SettingsCatalog.cs +++ b/SW.Bitween.Api/Services/Settings/SettingsCatalog.cs @@ -406,6 +406,13 @@ public static class SettingsCatalog t => t.Bitween.Mirror.KeepRealValues, (t, v) => t.Bitween.Mirror.KeepRealValues = Trimmed(v)), + new("Bitween.Mirror.PassHosts", "Prod mirror", + "Hosts called for real", + "Hosts staging's flows may reach although its setup doesn't name them, one per line: an address written inside an adapter that is safe to call from staging. Staging answers every other call its setup doesn't name, and every call to a test address, itself.", + SettingKind.String, false, + t => t.Bitween.Mirror.PassHosts, + (t, v) => t.Bitween.Mirror.PassHosts = Trimmed(v)), + // --- Reliability & jobs --- new("Bitween.MaxRetryAfterSeconds", "Reliability & jobs", "Longest wait an adapter can ask for (seconds)", diff --git a/SW.Bitween.UnitTests/MirrorCatchingTests.cs b/SW.Bitween.UnitTests/MirrorCatchingTests.cs new file mode 100644 index 00000000..b1b441b3 --- /dev/null +++ b/SW.Bitween.UnitTests/MirrorCatchingTests.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SW.Bitween.Services.Mirror; + +namespace SW.Bitween.UnitTests; + +/// +/// What a mirror does with the calls flows make: a test address, or a host staging's own setup doesn't +/// name, is answered by staging; a host the setup names is called for real. For Bitween's own adapters +/// and for adapters' own processes, which reach staging through the catcher. +/// +[TestClass] +public class MirrorCatchingTests +{ + const string Test = "m1a2b3c4d.mirror.invalid"; + + static readonly MirrorSetupHosts Setup = new( + new HashSet(StringComparer.OrdinalIgnoreCase) { "api.staging.acme.test" }, + new Dictionary>(StringComparer.OrdinalIgnoreCase) { [Test] = ["Push labels"] }, + new Dictionary(StringComparer.OrdinalIgnoreCase) { ["login.mirror.invalid"] = "data.session.key" }, + new HashSet(StringComparer.OrdinalIgnoreCase) { "login.mirror.invalid/v1/session" }); + + static readonly MirrorSamples Samples = new("done", null, null, 1, 1, null, + [ + new MirrorSubscriptionSamples("Push labels", "12", [ + new MirrorSample("x1", new DateTime(2026, 10, 20), "success", null, [], null, null, + new MirrorSampleFile("mirror/samples/a/x1/response", "label.json", "json", 20, null)), + ], null), + ]); + + static MirrorMock Mock(string passHosts = null) + { + var options = new BitweenOptions(); + options.Mirror.PassHosts = passHosts; + return new MirrorMock(null!, options, NullLogger.Instance) + { + ReadOverride = () => Task.FromResult((Setup, Samples)), + FileOverride = _ => Task.FromResult(Encoding.UTF8.GetBytes("""{"labelId":"QX-0042"}""")), + }; + } + + [TestMethod] + public void A_setting_names_its_hosts_and_test_addresses_are_known_as_such() + { + CollectionAssert.AreEquivalent(new[] { "api.acme.com", "acme.com" }, + MirrorFaker.HostsIn("Url", "https://user:pw@api.acme.com:8443/v1?cb=x, mail ops@acme.com").ToList()); + CollectionAssert.AreEquivalent(new[] { "sftp.acme.com", "10.0.0.5" }, MirrorFaker.HostsIn("SftpHost", "sftp.acme.com:22, 10.0.0.5").ToList()); + CollectionAssert.AreEquivalent(new[] { "db.acme.com" }, MirrorFaker.HostsIn("Conn", "Host=db.acme.com;Port=5432;Database=x").ToList()); + Assert.AreEqual(0, MirrorFaker.HostsIn("Url", "https://{{globals.api.host}}/v1").Count()); + Assert.AreEqual(0, MirrorFaker.HostsIn("Folder", "/inbound/orders").Count()); + + Assert.IsTrue(MirrorFaker.IsMockHost(Test)); + Assert.IsTrue(MirrorFaker.IsMockHost("192.0.2.17")); + Assert.IsTrue(MirrorFaker.IsMockHost("[2001:db8::a]")); + Assert.IsFalse(MirrorFaker.IsMockHost("api.acme.com")); + } + + [TestMethod] + public async Task Staging_answers_test_addresses_and_hosts_its_setup_doesnt_name() + { + var mock = Mock(passHosts: "rates.example.org"); + + Assert.IsTrue(await mock.CatchesAsync(Test)); + Assert.IsTrue(await mock.CatchesAsync("api.zohoapis.eu"), "written inside an adapter, named nowhere in the setup"); + Assert.IsFalse(await mock.CatchesAsync("api.staging.acme.test"), "the team's own staging API, named in the setup"); + Assert.IsFalse(await mock.CatchesAsync("rates.example.org"), "let through on purpose"); + Assert.IsFalse(await mock.CatchesAsync("localhost")); + Assert.IsFalse(await mock.CatchesAsync("127.0.0.1")); + } + + [TestMethod] + public async Task A_login_gets_a_token_where_its_settings_read_it_and_a_call_gets_prods_answer() + { + var mock = Mock(); + + var login = await mock.AnswerAsync(new MirrorRequest("POST", new Uri("https://login.mirror.invalid/v1/session"), "application/json", + Encoding.UTF8.GetBytes("""{"user":"x","pass":"y"}"""), "Bitween")); + var reply = JsonNode.Parse(login.Body)!; + StringAssert.StartsWith((string)reply["data"]!["session"]!["key"]!, "mirror-"); + Assert.AreEqual((string)reply["access_token"], (string)reply["jwt"]); + + var oauth = await mock.AnswerAsync(new MirrorRequest("POST", new Uri($"https://{Test}/oauth2/token"), "application/x-www-form-urlencoded", + Encoding.UTF8.GetBytes("grant_type=client_credentials"), "Bitween")); + Assert.IsNotNull(JsonNode.Parse(oauth.Body)!["access_token"]); + + var label = await mock.AnswerAsync(new MirrorRequest("POST", new Uri($"https://{Test}/v2/labels"), "application/json", [], "Bitween")); + Assert.AreEqual(200, label.Status); + Assert.AreEqual("""{"labelId":"QX-0042"}""", Encoding.UTF8.GetString(label.Body)); + StringAssert.Contains(label.Why, "Push labels"); + + var unknown = await mock.AnswerAsync(new MirrorRequest("GET", new Uri("https://api.zohoapis.eu/crm/v2"), null, [], "An adapter's process")); + Assert.AreEqual(200, unknown.Status); + Assert.AreEqual(0, unknown.Body.Length); + + var hosts = await mock.HostsAsync(); + Assert.AreEqual(2, hosts.Single(h => h.Host == Test).Calls); + Assert.IsFalse(hosts.Single(h => h.Host == "api.zohoapis.eu").Test); + Assert.AreEqual(4, mock.Recent.Count); + } + + [TestMethod] + public async Task Bitweens_own_adapters_are_answered_without_the_network() + { + var handler = new MirrorCatchingHandler(Mock()) { InnerHandler = new Unreachable() }; + using var client = new HttpClient(handler); + + var answer = await client.PostAsync($"https://{Test}/v2/labels", new StringContent("{}")); + Assert.AreEqual(HttpStatusCode.OK, answer.StatusCode); + Assert.AreEqual("application/json", answer.Content.Headers.ContentType!.MediaType); + Assert.AreEqual("""{"labelId":"QX-0042"}""", await answer.Content.ReadAsStringAsync()); + + // A host the setup names goes on to the network. + await Assert.ThrowsExceptionAsync(() => client.GetAsync("https://api.staging.acme.test/ping")); + } + + sealed class Unreachable : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + throw new HttpRequestException($"sent on to {request.RequestUri!.Host}"); + } + + [TestMethod] + public async Task An_adapters_process_is_answered_over_tls_by_the_catcher_and_the_rest_is_passed_on() + { + var mock = Mock(); + using var catcher = MirrorCatcher.Listen(new WebProxy()); + using var stop = new CancellationTokenSource(); + var running = catcher.RunAsync(mock, NullLogger.Instance, stop.Token); + + using var client = new HttpClient(new SocketsHttpHandler + { + Proxy = new WebProxy($"http://127.0.0.1:{catcher.Port}") { BypassProxyOnLocal = false }, + UseProxy = true, + // As an adapter's process does, given the bundle the catcher writes: its authority trusted. + SslOptions = new SslClientAuthenticationOptions { RemoteCertificateValidationCallback = (_, certificate, _, errors) => + Trusted(catcher.Authority.Certificate, (X509Certificate2)certificate!, errors) }, + }); + + var tls = await client.PostAsync($"https://{Test}/v2/labels", new StringContent("""{"order":"A1"}""")); + Assert.AreEqual("""{"labelId":"QX-0042"}""", await tls.Content.ReadAsStringAsync()); + var again = await client.PostAsync($"https://{Test}/oauth/token", new FormUrlEncodedContent([new("grant_type", "client_credentials")])); + Assert.IsNotNull(JsonNode.Parse(await again.Content.ReadAsStringAsync())!["access_token"]); + var plain = await client.GetAsync($"http://{Test}/status"); + Assert.AreEqual(HttpStatusCode.OK, plain.StatusCode); + + // Loopback isn't caught: what the server there says comes back through the catcher. + var (server, port) = Serve("HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello"); + Assert.AreEqual("hello", await client.GetStringAsync($"http://127.0.0.1:{port}/ping")); + await server; + + // A client that doesn't trust the authority fails, and the page hears why. + using var stranger = new HttpClient(new SocketsHttpHandler { Proxy = new WebProxy($"http://127.0.0.1:{catcher.Port}"), UseProxy = true }); + await Assert.ThrowsExceptionAsync(() => stranger.GetAsync($"https://{Test}/v2/labels")); + for (var i = 0; i < 50 && !mock.Recent.Any(c => c.Status == 0); i++) await Task.Delay(20); + StringAssert.Contains(mock.Recent.First(c => c.Status == 0).Why, "failed before staging could answer"); + + await stop.CancelAsync(); + await running; + } + + [TestMethod] + public async Task A_tunnel_to_a_host_staging_doesnt_catch_carries_what_is_sent_through_it() + { + using var catcher = MirrorCatcher.Listen(new WebProxy()); + using var stop = new CancellationTokenSource(); + var running = catcher.RunAsync(Mock(), NullLogger.Instance, stop.Token); + var (server, port) = Serve("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"); + + using var tcp = new TcpClient(); + await tcp.ConnectAsync(IPAddress.Loopback, catcher.Port); + var stream = tcp.GetStream(); + await stream.WriteAsync(Encoding.ASCII.GetBytes($"CONNECT 127.0.0.1:{port} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n")); + StringAssert.StartsWith(await ReadUntilAsync(stream, "\r\n\r\n"), "HTTP/1.1 200"); + await stream.WriteAsync(Encoding.ASCII.GetBytes("GET / HTTP/1.1\r\nHost: x\r\n\r\n")); + StringAssert.EndsWith(await ReadUntilAsync(stream, "ok"), "ok"); + await server; + + await stop.CancelAsync(); + await running; + } + + static bool Trusted(X509Certificate2 authority, X509Certificate2 certificate, SslPolicyErrors errors) + { + if ((errors & SslPolicyErrors.RemoteCertificateNameMismatch) != 0) return false; + using var chain = new X509Chain(); + chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; + chain.ChainPolicy.CustomTrustStore.Add(authority); + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + return chain.Build(certificate); + } + + /// A server on loopback that answers one connection with . + static (Task Served, int Port) Serve(string answer) + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + return (Task.Run(async () => + { + using var _ = listener.Server; + using var client = await listener.AcceptTcpClientAsync(); + var stream = client.GetStream(); + await ReadUntilAsync(stream, "\r\n\r\n"); + await stream.WriteAsync(Encoding.ASCII.GetBytes(answer)); + listener.Stop(); + }), port); + } + + static async Task ReadUntilAsync(NetworkStream stream, string end) + { + var read = new StringBuilder(); + var buffer = new byte[1]; + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + while (!read.ToString().EndsWith(end, StringComparison.Ordinal) && await stream.ReadAsync(buffer, timeout.Token) > 0) + read.Append((char)buffer[0]); + return read.ToString(); + } +} diff --git a/SW.Bitween.Web/ClientApp/src/api/client.ts b/SW.Bitween.Web/ClientApp/src/api/client.ts index 0109e621..6efc5536 100644 --- a/SW.Bitween.Web/ClientApp/src/api/client.ts +++ b/SW.Bitween.Web/ClientApp/src/api/client.ts @@ -1,5 +1,6 @@ import type { AddBusRouteInput, AttachPartnerInput } from "./http/gateways"; import type { + MirrorCalls, MirrorCopyResult, MirrorParityAnswer, MirrorSamples, @@ -665,6 +666,7 @@ export interface ApiClient { copyFromProd(removeExtras: boolean): Promise; getMirrorSamples(): Promise; takeMirrorSamples(): Promise; + getMirrorCalls(): Promise; // - settings - listSettings(): Promise; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/mirror.ts b/SW.Bitween.Web/ClientApp/src/api/http/mirror.ts index a9f14618..bb8a5e9d 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/mirror.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/mirror.ts @@ -1,5 +1,5 @@ import type { ApiClient } from "../client"; -import type { MirrorCopyResult, MirrorParityAnswer, MirrorSamples, MirrorStatus } from "../types"; +import type { MirrorCalls, MirrorCopyResult, MirrorParityAnswer, MirrorSamples, MirrorStatus } from "../types"; import { get, post } from "./request"; export const mirrorMethods = { @@ -21,6 +21,11 @@ export const mirrorMethods = { return get("/mirror/samples"); }, + /** The calls staging answered in place of the network, since this node started. */ + getMirrorCalls(): Promise { + return get("/mirror/calls"); + }, + /** Starts taking samples of prod's exchanges in the background; follow it with getMirrorSamples. */ takeMirrorSamples(): Promise { return post("/mirror/takesamples", {}); diff --git a/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts b/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts index 3988f99f..3b41d761 100644 --- a/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts +++ b/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts @@ -218,6 +218,7 @@ export const keys = { status: ["mirror", "status"] as const, parity: ["mirror", "parity"] as const, samples: ["mirror", "samples"] as const, + calls: ["mirror", "calls"] as const, }, settings: { diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index 1a7748d8..2c60a3c0 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -2429,6 +2429,36 @@ export interface MirrorSubscriptionSamples { reason: string | null; } +/** The calls to one host staging answered in place of the network. */ +export interface MirrorHostCalls { + host: string; + /** One of prod's addresses as the copy faked it; otherwise a host staging's setup doesn't name. */ + test: boolean; + calls: number; + lastAt: string; + lastWhy: string; + /** Subscriptions on staging that use it. */ + usedBy: string[]; +} + +export interface MirrorCall { + at: string; + method: string; + host: string; + path: string; + /** 0 when the call failed before staging could answer. */ + status: number; + why: string; + from: string; +} + +export interface MirrorCalls { + /** Whether this Bitween is a mirror, and so answers calls. */ + catching: boolean; + hosts: MirrorHostCalls[]; + recent: MirrorCall[]; +} + /** The samples staging holds, and how the last taking went. */ export interface MirrorSamples { state: "none" | "running" | "done" | "failed"; diff --git a/SW.Bitween.Web/ClientApp/src/pages/mirror/MirrorPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/mirror/MirrorPage.tsx index 250c8005..df939d9b 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/mirror/MirrorPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/mirror/MirrorPage.tsx @@ -8,6 +8,7 @@ import { type MirrorAddress, type MirrorCopyResult, type MirrorFakedKey, + type MirrorHostCalls, type MirrorItem, type MirrorKind, type MirrorNote, @@ -158,7 +159,7 @@ export function MirrorPage() { ); } -type FoldId = "setup" | "samples" | "adapters" | "faked" | "addresses"; +type FoldId = "setup" | "samples" | "calls" | "adapters" | "faked" | "addresses"; function Comparison({ parity, status, onConnection }: { parity: MirrorParity; status: MirrorStatus; onConnection: () => void }) { const [open, setOpen] = useState>(new Set(["setup"])); @@ -168,6 +169,13 @@ function Comparison({ parity, status, onConnection }: { parity: MirrorParity; st queryFn: () => api.getMirrorSamples(), refetchInterval: (q) => (q.state.data?.state === "running" ? 2000 : false), }); + // Read while the list is open, and again every ten seconds: calls come in as flows run. + const calls = useQuery({ + queryKey: keys.mirror.calls, + queryFn: () => api.getMirrorCalls(), + enabled: open.has("calls"), + refetchInterval: 10_000, + }); const toggle = (id: FoldId) => setOpen((now) => { const next = new Set(now); @@ -234,6 +242,15 @@ function Comparison({ parity, status, onConnection }: { parity: MirrorParity; st )} + {status.target && ( + n + h.calls, 0), "call", "calls")} to ${plural(calls.data.hosts.length, "host", "hosts")}` : "since this staging started"} + description="What flows on staging called and staging answered itself: test addresses, and hosts its setup doesn't name, such as one written inside an adapter. To let a host through for real, add it in Settings, under Prod mirror, Hosts called for real."> + {calls.isPending ? : calls.isError ? void calls.refetch()} /> + : } + + )} + a.state !== "same") ? `${parity.adapters.filter((a) => a.state !== "same").length} to look at` : "all the same"} description="Prod's adapters and what staging has of each. Built-in ones come with the release and are listed only when this release lacks one prod uses."> @@ -347,6 +364,7 @@ function NoteLinks({ note, context }: { note: MirrorNote; context: NoteContext } : note.action === "adapters-installed" ? Open Adapters : note.action === "faked" ? : note.action === "addresses" ? + : note.action === "calls" ? : null; if (!item && !action) return null; return ( @@ -606,6 +624,31 @@ function SamplesList({ samples, prodUrl, items }: { samples: MirrorSamples; prod ); } +/** Each host staging answered calls to, and what it answered last. */ +function CallsList({ hosts, prodUrl, items }: { hosts: MirrorHostCalls[]; prodUrl: string; items: Map }) { + if (hosts.length === 0) return

No calls yet. They come in as flows run on staging.

; + return ( +
    + {hosts.map((h) => ( +
  • + {h.host} + + {h.test ? "Test address" : "Not in staging's setup"} + + + {plural(h.calls, "call", "calls")}, last {timeAgo(h.lastAt)} + + {h.usedBy.map((name) => { + const item = items.get(`subscriptions\u0000${name}`); + return item ? : null; + })} + {h.lastWhy} +
  • + ))} +
+ ); +} + /** One of a sample's files: a link that opens staging's faked copy, or why it wasn't kept. */ function SampleFile({ label, what, file }: { label: string; what: string; file: MirrorSampleFile | null }) { if (!file) return null; diff --git a/SW.Bitween.Web/ClientApp/src/pages/mirror/__tests__/MirrorPage.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/mirror/__tests__/MirrorPage.test.tsx index 1793f03a..8a0df6cd 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/mirror/__tests__/MirrorPage.test.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/mirror/__tests__/MirrorPage.test.tsx @@ -245,6 +245,35 @@ describe("prod mirror", () => { expect(screen.getByText("Prod has no exchanges of it.")).toBeVisible(); }); + it("lists the calls staging answered, each with what it answered last", async () => { + const withCalls: MirrorParity = { + ...parity, + notes: [...parity.notes, { id: "calls", group: "onPurpose", text: "Staging answers calls to test addresses itself.", section: null, key: null, action: "calls" }], + }; + const { user } = renderApp("/mirror", { + handlers: [ + status(), + parityHandler(withCalls), + samplesHandler(), + http.get(apiPath("/mirror/calls"), () => + HttpResponse.json({ + catching: true, + recent: [], + hosts: [ + { host: "m1a2b3c4d.mirror.invalid", test: true, calls: 3, lastAt: new Date().toISOString(), lastWhy: "Prod's answer to “Push labels”, faked.", usedBy: ["Push labels"] }, + { host: "api.zohoapis.eu", test: false, calls: 1, lastAt: new Date().toISOString(), lastWhy: "Nothing of prod's to answer with, so an empty reply.", usedBy: [] }, + ], + }), + ), + ], + }); + + await user.click(await screen.findByRole("button", { name: "See the calls it answered" })); + expect(await screen.findByText("m1a2b3c4d.mirror.invalid")).toBeVisible(); + expect(screen.getByText("Prod's answer to “Push labels”, faked.")).toBeVisible(); + expect(screen.getByTitle(/Staging's setup doesn't name it/)).toHaveTextContent("Not in staging's setup"); + }); + it("says why prod can't be compared, with a way to fix the connection", async () => { renderApp("/mirror", { handlers: [status(), samplesHandler(), parityHandler({ error: "Prod runs r8 or older. The mirror copies prods on r10.0 or later; move this client to r10 first." })], diff --git a/SW.Bitween.Web/Startup.cs b/SW.Bitween.Web/Startup.cs index c972a7bb..bcf87d8e 100644 --- a/SW.Bitween.Web/Startup.cs +++ b/SW.Bitween.Web/Startup.cs @@ -615,6 +615,12 @@ public void ConfigureServices(IServiceCollection services) sp => sp.GetRequiredService().MailServer, sp => sp.GetRequiredService().PrimaryColor); + // On a staging that mirrors prod, what flows call is caught: here, before anything starts + // an adapter's process, which reads where to send its calls as it starts. + services.AddSingleton(); + if (bitweenOptions.Mirror.Target) + SW.Bitween.Services.Mirror.MirrorCatcher.Install(services, bitweenOptions); + // services.AddScoped(); // services.AddScoped(); diff --git a/docs/api-reference.md b/docs/api-reference.md index 39b5f0b1..061ddc0d 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -349,6 +349,7 @@ See [Data sources](data-sources.md) and [Databases](databases.md). | `GET /api/mirror/parity` | `mirror.view`. Reads prod and this Bitween now: `verdict` (`ready`, `matching`, `total`, `needsYou`, `toCopy`, `notCovered`), `notes` (each in `needsYou`, `copy`, `notCovered` or `onPurpose`, with the item or page it is about), per kind what is the same, differs, is missing or is only here with each item's id on both sides, `adapters`, `addresses` (the test ones, with what uses each), `fakedKeys` and `unknown` (fields prod sends that this release doesn't know). A prod that can't be reached or is older than r10.0 answers `{ error }`. See [Prod mirror](prod-mirror.md). | | `GET /api/mirror/samples` | `mirror.view`. The samples of prod's exchanges this Bitween holds: `state` (`none`, `running`, `done` or `failed`), `done` of `total` subscriptions read, and per subscription its samples, each with how it ended on prod (`outcome`, `status`, `errorTypes`) and its `input`, `output` and `response` files: `format`, a faked `name`, a `url` that opens the faked file, or `notCopied` and why. A subscription without samples has a `reason`. | | `POST /api/mirror/takesamples` | `mirror.operate`. Starts taking samples in the background and answers as `GET /api/mirror/samples` does. Refused with `MIRROR_NOT_TARGET` unless the deployment sets `Bitween:Mirror:Target`. | +| `GET /api/mirror/calls` | `mirror.view`. The calls this node answered in place of the network since it started: `catching` (whether it is a mirror), `hosts` (each with `test` for a test address, `calls`, `lastAt`, `lastWhy` and the subscriptions that use it) and the 50 most `recent`. A call with `status` 0 failed before the staging could answer it. | | `POST /api/mirror/copy` | `mirror.operate`, and edit rights on what it copies. `{ removeExtras }`. Copies prod's setup with every value faked and answers `{ created, updated, removed, adapterSteps, failed, after }`. Refused with `MIRROR_NOT_TARGET` unless the deployment sets `Bitween:Mirror:Target`. | ## Monitoring diff --git a/docs/configuration.md b/docs/configuration.md index b1a902c5..4160eb0a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -97,7 +97,7 @@ applications the same `Token` values. Until they match, `Bus:OnInvalidUserToken` | `InboundMessagePruneCron` | `0 30 3 * * ?` | Quartz cron for deleting expired broker deduplication keys. | | `QueuePrefix` | `bitween` | Part of every queue name. Give each deployment sharing a broker its own value. | | `BusDefaultQueuePrefetch` | `12` | Default number of unacknowledged messages per consumer. Work groups can override it. | -| `Mirror:Target` | `false` | Whether a copy of a prod may replace this Bitween's setup, on a staging that mirrors one. Set it on the staging's deployment only. See [Prod mirror](prod-mirror.md). | +| `Mirror:Target` | `false` | Whether a copy of a prod may replace this Bitween's setup, on a staging that mirrors one. It also has the staging answer the calls its flows make to test addresses, and to hosts its setup doesn't name, itself. Set it on the staging's deployment only. See [Prod mirror](prod-mirror.md). | | `BusDefaultConcurrency` | the prefetch | How many messages of a lane run at once on a node, for the Ungrouped lane and the bus's other consumers (bus-enabled information types, retry and auto-pause alerts). A work group sets its own with **Parallel exchanges**. `1` runs them one at a time, in order, as before. | | `RabbitMqManagementUrl`, `RabbitMqManagementUsername`, `RabbitMqManagementPassword` | | RabbitMQ management API, needed for queue health. | | `CorsOrigins` | empty | Browser origins allowed to call the API with credentials. Set as `Bitween__CorsOrigins__0`, `Bitween__CorsOrigins__1` and so on. | @@ -193,7 +193,7 @@ Settings are stored in the `Settings` table and edited on the Settings page. | `Bitween.MailServer.Host`, `Port`, `UseTls`, `CertificateThumbprint`, `Username`, `From`, `FromName` | Mail server | Yes | port `587`, `UseTls` `true`, the rest empty | | `Bitween.MailServer.Password` | Mail server | Yes, secret | empty | | `Bitween.Mirror.Target` | Prod mirror | Read-only | `false` | -| `Bitween.Mirror.SourceUrl`, `SourceEmail`, `KeepRealValues` | Prod mirror | Yes | empty | +| `Bitween.Mirror.SourceUrl`, `SourceEmail`, `KeepRealValues`, `PassHosts` | Prod mirror | Yes | empty | | `Bitween.Mirror.SourceToken`, `SourcePassword` | Prod mirror | Yes, secret | empty | | `Bitween.MaxRetryAfterSeconds` | Reliability & jobs | Yes | `21600`, six hours | | `Bitween.RetryJobCron` | Reliability & jobs | Yes | `0 * * * * ?` | diff --git a/docs/prod-mirror.md b/docs/prod-mirror.md index f00e7608..7c6b3337 100644 --- a/docs/prod-mirror.md +++ b/docs/prod-mirror.md @@ -58,6 +58,21 @@ A file that can't be faked safely isn't kept, and the page says why: PDFs, archi Prod is read gently: one subscription at a time, one page of its recent exchanges, then each file. Taking samples again replaces the ones taken before. +## Calls the staging answers + +On a mirror, the staging answers some of the calls its flows make itself, so nothing on the staging reaches prod's partners: + +- **Test addresses**: every address the copy put in place of one of prod's. +- **Hosts the staging's setup doesn't name**: an address written inside an adapter's code, say. It may be one of prod's partners, so it is answered rather than reached. + +A host the setup names, such as a team's own staging API, is called for real. So is any host listed under **Settings, Prod mirror, Hosts called for real**, for an address inside an adapter that is safe to reach from the staging. + +A login (an OAuth token request, or a login URL a subscription signs in at) gets a token of the staging's own, where the subscription's settings say its reply keeps one. Any other call gets the newest answer prod's partner gave a subscription that uses the address, as the samples keep it, faked; with no sample, an empty `200`. **Calls staging answered** on the page lists each host, how many calls it answered and what it answered last. + +Bitween's own adapters are answered inside Bitween. Adapters that run in their own process reach the staging through a catcher it runs on `127.0.0.1`: when it starts, the staging sets `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` for the processes it starts, and points `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE` and `NODE_EXTRA_CA_CERTS` at a certificate authority it makes for that run, so .NET, Python, Node and curl trust the answers. Bitween's own calls, to prod and to its storage, don't go through the catcher. Nothing needs setting up. + +What the catcher can't reach: an adapter that turns its HTTP proxy off, or checks certificates its own way, fails its calls to test addresses instead of being answered, and the page lists the call as failed. Calls that aren't HTTP, such as SFTP or mail written inside an adapter, aren't caught yet. + ## Adapters Built-in adapters come with the release. One that prod uses and this release doesn't have is flagged, since it would stop working after the upgrade. Marketplace adapters are installed by the copy at the version prod runs. Simplify9's database and broker adapters (`bitween.db.…`, `bitween.bus.…`) are on the marketplace too; when prod doesn't say which version it runs, the page asks for one to be installed from the **Marketplace** tab. Custom adapters can't be read from prod: the page names each one, with the version prod runs, to upload on the **Adapters** page. Subscriptions that use one wait for it rather than fail. @@ -78,5 +93,6 @@ When the staging can't create one of prod's items, the page says so, with what B ## Not covered yet -- Running prod's flows on the staging against mock servers, and comparing the results. Until then the mock addresses lead nowhere, and the samples are only kept. +- Running prod's flows on the staging and comparing the results with prod's. Until then the samples are only kept. +- SFTP, FTP and mail test servers. Until then those addresses lead nowhere. - Runtime settings, roles, members and service accounts, which differ between environments on purpose. From ebd330751978737cd49802544f4e0d4e32b8270e Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 25 Oct 2026 10:35:21 +0300 Subject: [PATCH 06/16] feat: prod mirror rehearses prod's arrivals through their own entry points Each sampled arrival enters staging the way it came in on prod (gateway as the partner, partner API, document routing, aggregation) and staging runs the whole chain. Every exchange is compared with its prod twin: changes first, three verdict levels. --- .../Controllers/GatewayController.cs | 7 +- SW.Bitween.Api/Interfaces/IInfolinkCache.cs | 9 + SW.Bitween.Api/Resources/Mirror/Mirror.cs | 49 +- SW.Bitween.Api/Resources/Xchanges/Update.cs | 7 +- SW.Bitween.Api/Services/AggregationJob.cs | 9 +- SW.Bitween.Api/Services/BusService.cs | 8 +- .../Services/Caching/InMemoryInfolinkCache.cs | 7 +- SW.Bitween.Api/Services/FilterService.cs | 7 +- SW.Bitween.Api/Services/GatewayIssuers.cs | 10 +- .../Services/Mirror/MirrorCatcher.cs | 18 +- .../Services/Mirror/MirrorCompare.cs | 375 +++++++++++ SW.Bitween.Api/Services/Mirror/MirrorFiles.cs | 40 ++ SW.Bitween.Api/Services/Mirror/MirrorMock.cs | 188 +++++- .../Services/Mirror/MirrorRehearsal.cs | 65 ++ .../Services/Mirror/MirrorSamples.cs | 98 ++- .../Mirror/MirrorService.Rehearsal.cs | 597 ++++++++++++++++++ .../Services/Mirror/MirrorService.Samples.cs | 302 ++++++++- .../Services/Mirror/MirrorService.cs | 20 +- SW.Bitween.Api/Services/Mirror/MirrorSetup.cs | 12 +- .../Services/Mirror/MirrorTraffic.cs | 112 ++++ SW.Bitween.Api/Services/XchangeService.cs | 27 +- SW.Bitween.Configuration/BitweenApi.cs | 6 +- SW.Bitween.UnitTests/MirrorCatchingTests.cs | 13 +- SW.Bitween.UnitTests/MirrorRehearsalTests.cs | 234 +++++++ SW.Bitween.UnitTests/MirrorSetupTests.cs | 14 + SW.Bitween.Web/ClientApp/src/api/client.ts | 3 + .../ClientApp/src/api/http/mirror.ts | 11 +- SW.Bitween.Web/ClientApp/src/api/queryKeys.ts | 1 + SW.Bitween.Web/ClientApp/src/api/types.ts | 118 +++- .../src/pages/mirror/FileCompare.tsx | 90 +++ .../ClientApp/src/pages/mirror/MirrorPage.tsx | 250 +------- .../ClientApp/src/pages/mirror/Rehearsal.tsx | 591 +++++++++++++++++ .../mirror/__tests__/MirrorPage.test.tsx | 201 +++++- .../ClientApp/src/pages/mirror/shared.tsx | 75 +++ SW.Bitween.Web/Startup.cs | 2 +- docs/api-reference.md | 4 +- docs/prod-mirror.md | 45 +- 37 files changed, 3250 insertions(+), 375 deletions(-) create mode 100644 SW.Bitween.Api/Services/Mirror/MirrorCompare.cs create mode 100644 SW.Bitween.Api/Services/Mirror/MirrorRehearsal.cs create mode 100644 SW.Bitween.Api/Services/Mirror/MirrorService.Rehearsal.cs create mode 100644 SW.Bitween.Api/Services/Mirror/MirrorTraffic.cs create mode 100644 SW.Bitween.UnitTests/MirrorRehearsalTests.cs create mode 100644 SW.Bitween.Web/ClientApp/src/pages/mirror/FileCompare.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/mirror/Rehearsal.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/mirror/shared.tsx diff --git a/SW.Bitween.Api/Controllers/GatewayController.cs b/SW.Bitween.Api/Controllers/GatewayController.cs index b20249c7..56512b94 100644 --- a/SW.Bitween.Api/Controllers/GatewayController.cs +++ b/SW.Bitween.Api/Controllers/GatewayController.cs @@ -194,8 +194,11 @@ await xchangeService.RunValidator(subscription.ValidatorRef, validatorProperties var xchangeId = string.IsNullOrEmpty(idempotencyKey) ? null : await IdempotencyGuard.FindAsync(dbContext, idempotencyScope, idempotencyKey); + // A mirror's rehearsal calls as prod's partner did, under a correlation of its own, and sends + // what the rehearsal before it sent. + var rehearsal = Services.Mirror.MirrorTraffic.CorrelationFor(options, Request.Headers); // Not for a replayed call: its payload is a repeat by definition, and it is answered already. - var duplicate = xchangeId is null + var duplicate = xchangeId is null && rehearsal is null ? await new DuplicateArrivals(dbContext).CheckAsync(document, partner.Id, xchangeFile) : null; @@ -217,7 +220,7 @@ await xchangeService.RunValidator(subscription.ValidatorRef, validatorProperties // The sets already read at the top of the request, from the cache. var xchange = await xchangeService.CreateXchange(subscription, xchangeFile, xchangeReferences.ToArray(), - Guid.NewGuid().ToString("N"), partner, globalAdapterValuesSet); + rehearsal ?? Guid.NewGuid().ToString("N"), partner, globalAdapterValuesSet); if (resultSync) { // Before the save publishes the exchange, so its result can't arrive unannounced. The diff --git a/SW.Bitween.Api/Interfaces/IInfolinkCache.cs b/SW.Bitween.Api/Interfaces/IInfolinkCache.cs index 7d7a741b..1d9a4798 100644 --- a/SW.Bitween.Api/Interfaces/IInfolinkCache.cs +++ b/SW.Bitween.Api/Interfaces/IInfolinkCache.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Threading.Tasks; using SW.Bitween.Domain; using SW.Bitween.Domain.Gateway; @@ -8,6 +9,14 @@ public interface IInfolinkCache { public Task ListSubscriptionsByDocumentAsync(int documentId); public Task ListBusGatewayRoutesByDocumentAsync(int documentId); + + /// + /// The routes, and those of the switched-off gateways in too: a + /// mirror's copy switches off gateways prod has on, so nothing runs on its own, and its rehearsal's + /// traffic still goes where prod's would. + /// + public Task ListBusGatewayRoutesByDocumentAsync(int documentId, IReadOnlySet alsoSwitchedOff) => + ListBusGatewayRoutesByDocumentAsync(documentId); public Task ListNotifiersAsync(); public Task NotificationChannelByIdAsync(int channelId); diff --git a/SW.Bitween.Api/Resources/Mirror/Mirror.cs b/SW.Bitween.Api/Resources/Mirror/Mirror.cs index 13716dc5..caad7cb7 100644 --- a/SW.Bitween.Api/Resources/Mirror/Mirror.cs +++ b/SW.Bitween.Api/Resources/Mirror/Mirror.cs @@ -52,12 +52,12 @@ public async Task Handle() /// taking under way is going. /// [HandlerName("samples")] -public class Samples(BitweenDbContext dbContext, RequestContext requestContext, MirrorService mirror, MirrorSampler sampler) : IQueryHandler +public class Samples(BitweenDbContext dbContext, RequestContext requestContext, MirrorService mirror, MirrorWork work) : IQueryHandler { public async Task Handle() { await requestContext.EnsurePermission(dbContext, Model.Permissions.Mirror.View); - return await mirror.SamplesAsync(sampler); + return await mirror.SamplesAsync(work); } } @@ -66,18 +66,55 @@ public class MirrorTakeSamplesRequest; /// /// Starts taking samples of prod's exchanges, in the background: a few recent ones for each /// subscription, faked as they arrive. The page follows it through . Only on a -/// Bitween its deployment marks as a mirror. +/// Bitween its deployment marks as a mirror, and not while a rehearsal runs on the samples there are. /// [HandlerName("takesamples")] -public class TakeSamples(BitweenDbContext dbContext, RequestContext requestContext, BitweenOptions options, MirrorService mirror, MirrorSampler sampler) +public class TakeSamples(BitweenDbContext dbContext, RequestContext requestContext, BitweenOptions options, MirrorService mirror, MirrorWork work) : ICommandHandler { public async Task Handle(MirrorTakeSamplesRequest request) { await requestContext.EnsurePermission(dbContext, Model.Permissions.Mirror.Operate); if (!options.Mirror.Target) throw new SWValidationException("MIRROR_NOT_TARGET", MirrorService.NotTarget); - sampler.Start(); - return await mirror.SamplesAsync(sampler); + if (work.Start(MirrorWork.Sampling, (m, progress, ct) => m.TakeSamplesAsync(progress, ct)) == MirrorWork.Rehearsing) + throw new SWValidationException("MIRROR_BUSY", "A rehearsal is running on the samples there are. Take samples once it finishes."); + return await mirror.SamplesAsync(work); + } +} + +/// +/// The last rehearsal: each of prod's samples run on staging, with the exchange it made and how it +/// compares with prod's. Followed by the page while one runs. +/// +[HandlerName("rehearsal")] +public class Rehearsal(BitweenDbContext dbContext, RequestContext requestContext, MirrorService mirror, MirrorWork work) : IQueryHandler +{ + public async Task Handle() + { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Mirror.View); + return await mirror.RehearsalAsync(work); + } +} + +public class MirrorRehearseRequest; + +/// +/// Starts a rehearsal, in the background: every sample run on staging as a real exchange, then compared +/// with how prod's went. Only on a Bitween its deployment marks as a mirror, and not while samples are +/// being taken. +/// +[HandlerName("rehearse")] +public class Rehearse(BitweenDbContext dbContext, RequestContext requestContext, BitweenOptions options, MirrorService mirror, MirrorWork work) + : ICommandHandler +{ + public async Task Handle(MirrorRehearseRequest request) + { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Mirror.Operate); + if (!options.Mirror.Target) throw new SWValidationException("MIRROR_NOT_TARGET", MirrorService.NotTarget); + var token = mirror.CallerToken(); + if (work.Start(MirrorWork.Rehearsing, (m, progress, ct) => m.RehearseAsync(progress, token, ct)) == MirrorWork.Sampling) + throw new SWValidationException("MIRROR_BUSY", "Samples are being taken. Rehearse once that finishes."); + return await mirror.RehearsalAsync(work); } } diff --git a/SW.Bitween.Api/Resources/Xchanges/Update.cs b/SW.Bitween.Api/Resources/Xchanges/Update.cs index bf72add1..69afc15c 100644 --- a/SW.Bitween.Api/Resources/Xchanges/Update.cs +++ b/SW.Bitween.Api/Resources/Xchanges/Update.cs @@ -106,8 +106,11 @@ public async Task Handle(string documentIdOrName, dynamic request) var xchangeId = string.IsNullOrEmpty(idempotencyKey) ? null : await IdempotencyGuard.FindAsync(dbContext, idempotencyScope, idempotencyKey); + // A mirror's rehearsal calls as prod's partner did, under a correlation of its own, and + // sends what the rehearsal before it sent. + var rehearsal = Services.Mirror.MirrorTraffic.CorrelationFor(BitweenSettings, httpContextAccessor?.HttpContext?.Request.Headers); // Not for a replayed call: its payload is a repeat by definition, and it is answered already. - var duplicate = xchangeId is null + var duplicate = xchangeId is null && rehearsal is null ? await new DuplicateArrivals(dbContext).CheckAsync(document, par.Partner.Id, xchangeFile) : null; if (duplicate?.Drop == true) @@ -120,7 +123,7 @@ public async Task Handle(string documentIdOrName, dynamic request) if (duplicate != null && duplicate.FitsWith(xchangeReferences)) xchangeReferences.Add(duplicate.Reference); var subscription = await cache.SubscriptionByIdAsync(sub.Id); var xchange = await xchangeService.CreateXchange(subscription, xchangeFile, - xchangeReferences.ToArray(), Guid.NewGuid().ToString("N")); + xchangeReferences.ToArray(), rehearsal ?? Guid.NewGuid().ToString("N")); if (string.IsNullOrEmpty(idempotencyKey)) { await dbContext.SaveChangesAsync(); diff --git a/SW.Bitween.Api/Services/AggregationJob.cs b/SW.Bitween.Api/Services/AggregationJob.cs index dd16d052..073e8b09 100644 --- a/SW.Bitween.Api/Services/AggregationJob.cs +++ b/SW.Bitween.Api/Services/AggregationJob.cs @@ -11,7 +11,8 @@ namespace SW.Bitween; -public record AggregationJobParams(int SubscriptionId, string? CronExpression); +/// The roll-up's correlation id: a mirror's rehearsal names one, so the roll-up runs as its traffic. +public record AggregationJobParams(int SubscriptionId, string? CronExpression, string? Correlation = null); [ScheduleConfig(AllowConcurrentExecution = false, MisfireInstructions = MisfireInstructions.Skip)] public class AggregationJob( @@ -34,7 +35,7 @@ public async Task Execute(AggregationJobParams jobParams) if (!await runFlagUpdater.MarkAsRunning(aggSub.Id)) return; try { - await RollUpAsync(aggSub); + await RollUpAsync(aggSub, jobParams.Correlation); } finally { @@ -43,7 +44,7 @@ public async Task Execute(AggregationJobParams jobParams) } } - async Task RollUpAsync(Subscription aggSub) + async Task RollUpAsync(Subscription aggSub, string correlationId) { var startedOn = DateTime.UtcNow; @@ -78,7 +79,7 @@ orderby xchange.StartedOn var urlList = targetXchangeList.Select(x => xchangeService.AdapterFileUrl(x.Id, x.FilesPrefix, aggSub.AggregationTarget)).ToList(); var xchangeAggregationFile = new XchangeFile(JsonConvert.SerializeObject(urlList)); - var aggXchange = await xchangeService.CreateXchange(aggSub, xchangeAggregationFile); + var aggXchange = await xchangeService.CreateXchange(aggSub, xchangeAggregationFile, correlationId: correlationId); dbContext.Add(aggXchange); targetXchangeList.ForEach(x => dbContext.Add(new XchangeAggregation(x.Id, aggXchange.Id))); outcome = ReceiveOutcome.Received; diff --git a/SW.Bitween.Api/Services/BusService.cs b/SW.Bitween.Api/Services/BusService.cs index 1efd418f..412f9292 100644 --- a/SW.Bitween.Api/Services/BusService.cs +++ b/SW.Bitween.Api/Services/BusService.cs @@ -8,7 +8,7 @@ namespace SW.Bitween { public class BusService(XchangeService xchangeService, IInfolinkCache cache, - RequestContext requestContext) : IConsume + RequestContext requestContext, BitweenOptions options) : IConsume { private const string MessageTypeNameToDocumentId = "MessageTypeNameToDocumentId"; @@ -31,7 +31,11 @@ public async Task Process(string messageTypeName, string message) var sourceXchangeId = requestContext.GetValueOf(StartupValuesFiller.SourceXchangeBusValue, RequestValueType.ServiceBusValue); - await xchangeService.SubmitFilterXchange(map[messageTypeName], xf, null, requestContext.CorrelationId, + // A mirror's rehearsal keeps its correlation across the bus, which would start a new one. + var rehearsal = requestContext.GetValueOf(Services.Mirror.MirrorTraffic.BusValue, RequestValueType.ServiceBusValue); + var correlationId = Services.Mirror.MirrorTraffic.IsRehearsal(options, rehearsal) ? rehearsal : requestContext.CorrelationId; + + await xchangeService.SubmitFilterXchange(map[messageTypeName], xf, null, correlationId, string.IsNullOrWhiteSpace(sourceXchangeId) ? null : new XchangeSource(sourceXchangeId)); } diff --git a/SW.Bitween.Api/Services/Caching/InMemoryInfolinkCache.cs b/SW.Bitween.Api/Services/Caching/InMemoryInfolinkCache.cs index c5efe9cb..3b585b9b 100644 --- a/SW.Bitween.Api/Services/Caching/InMemoryInfolinkCache.cs +++ b/SW.Bitween.Api/Services/Caching/InMemoryInfolinkCache.cs @@ -132,7 +132,10 @@ public async Task ApiGatewayByUrlNameAsync(string urlName) ?? gateways.FirstOrDefault(g => string.Equals(g.UrlName, urlName, StringComparison.OrdinalIgnoreCase)); } - public async Task ListBusGatewayRoutesByDocumentAsync(int documentId) + public Task ListBusGatewayRoutesByDocumentAsync(int documentId) => + ListBusGatewayRoutesByDocumentAsync(documentId, null); + + public async Task ListBusGatewayRoutesByDocumentAsync(int documentId, IReadOnlySet alsoSwitchedOff) { if (!_cache.TryGetValue(nameof(BusGateway), out BusGateway[] cachedBusGateways)) { @@ -144,7 +147,7 @@ public async Task ListBusGatewayRoutesByDocumentAsync(int doc // one means on the bus side: the message still publishes, this gateway just stops // being one of the places it lands. return cachedBusGateways - .Where(g => g.DocumentId == documentId && !g.Inactive) + .Where(g => g.DocumentId == documentId && (!g.Inactive || alsoSwitchedOff?.Contains(g.Id) == true)) .SelectMany(g => g.Routes ?? Enumerable.Empty()) .ToArray(); } diff --git a/SW.Bitween.Api/Services/FilterService.cs b/SW.Bitween.Api/Services/FilterService.cs index 6d914c01..ffb5340e 100644 --- a/SW.Bitween.Api/Services/FilterService.cs +++ b/SW.Bitween.Api/Services/FilterService.cs @@ -13,7 +13,8 @@ public class FilterService(IInfolinkCache cache) { readonly IInfolinkCache _cache = cache; - public async Task Filter(int documentId, XchangeFile xchangeFile) + /// A mirror's rehearsal traffic, which also takes the routes of gateways the copy switched off. + public async Task Filter(int documentId, XchangeFile xchangeFile, bool rehearsing = false) { if (xchangeFile is null) throw new ArgumentNullException(nameof(xchangeFile)); @@ -113,7 +114,9 @@ or SubscriptionType.ApiCall // Bus-gateway routes: run the assigned subscription (optionally with a partner's values) // for every route whose filter matches. A null filter matches all messages on the doc. - var routes = await _cache.ListBusGatewayRoutesByDocumentAsync(documentId); + var routes = rehearsing && Services.Mirror.MirrorTraffic.SwitchedOffGateways is { } switchedOff + ? await _cache.ListBusGatewayRoutesByDocumentAsync(documentId, switchedOff) + : await _cache.ListBusGatewayRoutesByDocumentAsync(documentId); foreach (var route in routes) { if (route.MatchExpression == null || (propReader != null && route.MatchExpression.IsMatch(propReader))) diff --git a/SW.Bitween.Api/Services/GatewayIssuers.cs b/SW.Bitween.Api/Services/GatewayIssuers.cs index f6a1d9f2..63cb4f99 100644 --- a/SW.Bitween.Api/Services/GatewayIssuers.cs +++ b/SW.Bitween.Api/Services/GatewayIssuers.cs @@ -25,13 +25,21 @@ public class OpenIdGatewayIssuers : IGatewayIssuers // connection, rather than refused in a few. static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(10) }; + static HttpClient caught; + + /// + /// On a staging that mirrors prod, login servers' keys are read through , + /// which answers for test addresses itself (see ). + /// + public static void CatchWith(HttpMessageHandler handler) => caught = new HttpClient(handler) { Timeout = Http.Timeout }; + readonly ConcurrentDictionary byIssuer = new(); public BaseConfigurationManager For(string issuer) => byIssuer.GetOrAdd(issuer, _ => new ConfigurationManager( $"{issuer.TrimEnd('/')}/.well-known/openid-configuration", new OpenIdConnectConfigurationRetriever(), - new HttpDocumentRetriever(Http) { RequireHttps = !IsLoopback(issuer) }) + new HttpDocumentRetriever(caught ?? Http) { RequireHttps = !IsLoopback(issuer) }) { // Left on, a key that once checked a token keeps being accepted for an hour after the // login server stops publishing it. A key dropped that suddenly is usually one that diff --git a/SW.Bitween.Api/Services/Mirror/MirrorCatcher.cs b/SW.Bitween.Api/Services/Mirror/MirrorCatcher.cs index 51d75285..9986a762 100644 --- a/SW.Bitween.Api/Services/Mirror/MirrorCatcher.cs +++ b/SW.Bitween.Api/Services/Mirror/MirrorCatcher.cs @@ -30,11 +30,17 @@ public sealed class MirrorCatchingHandler(MirrorMock mock) : DelegatingHandler { protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - if (request.RequestUri is not { IsAbsoluteUri: true } url || !await mock.CatchesAsync(url.Host)) + if (request.RequestUri is not { IsAbsoluteUri: true } url) return await base.SendAsync(request, cancellationToken); + if (await mock.PassesAsync(url.Host) is { } why) + { + mock.Passed(request.Method.Method, url.Host, url.AbsolutePath, why, "Bitween"); + return await base.SendAsync(request, cancellationToken); + } var body = request.Content == null ? [] : await request.Content.ReadAsByteArrayAsync(cancellationToken); var answer = await mock.AnswerAsync(new MirrorRequest(request.Method.Method, url, request.Content?.Headers.ContentType?.ToString(), body, "Bitween")); + if (answer.Fail) throw new HttpRequestException(answer.Why); var response = new HttpResponseMessage((HttpStatusCode)answer.Status) { RequestMessage = request, Content = new ByteArrayContent(answer.Body) }; if (answer.ContentType != null) response.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(answer.ContentType); return response; @@ -254,7 +260,8 @@ async Task ServeAsync(TcpClient client, CancellationToken stopping) if (request.Method == "CONNECT") { var (host, port) = HostAndPort(request.Target, 443); - if (await mock.CatchesAsync(host)) + var passes = await mock.PassesAsync(host); + if (passes == null) { await wire.WriteAsync("HTTP/1.1 200 Connection Established\r\n\r\n", ct); await using var tls = new SslStream(wire.AsStream()); @@ -279,6 +286,7 @@ await tls.AuthenticateAsServerAsync(new SslServerAuthenticationOptions } else { + mock.Passed("CONNECT", host, "", passes, "An adapter's process"); using var onwardClient = await ConnectAsync(host, port, ct); await wire.WriteAsync("HTTP/1.1 200 Connection Established\r\n\r\n", ct); await PipeAsync(wire, onwardClient.GetStream(), ct); @@ -291,11 +299,12 @@ await tls.AuthenticateAsServerAsync(new SslServerAuthenticationOptions await wire.WriteAsync("HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", ct); return; } - if (await mock.CatchesAsync(url.Host)) + if (await mock.PassesAsync(url.Host) is not { } why) { await AnswerAsync(wire, "http", url.Authority, request, idle, ct); return; } + mock.Passed(request.Method, url.Host, url.AbsolutePath, why, "An adapter's process"); // Passed on as it came, one request to the connection, which the far side closes after answering. using var server = await ConnectAsync(url.Host, url.Port, ct); @@ -336,6 +345,8 @@ async Task AnswerAsync(Wire wire, string scheme, string authorityPart, Hea ? new Uri(request.Target) : new Uri($"{scheme}://{authorityPart}{request.Target}"); var answer = await mock.AnswerAsync(new MirrorRequest(request.Method, url, request.ContentType, body, "An adapter's process")); + // Closed without a word, as a call that never got through. + if (answer.Fail) return true; var reply = new StringBuilder() .Append("HTTP/1.1 ").Append(answer.Status).Append(' ').Append(Reason(answer.Status)).Append("\r\n") @@ -589,6 +600,7 @@ public sealed class MirrorCatcherService(MirrorCatcher catcher, MirrorMock mock, protected override Task ExecuteAsync(CancellationToken stoppingToken) { ConnectionTokens.CatchWith(new MirrorCatchingHandler(mock) { InnerHandler = new SocketsHttpHandler() }); + OpenIdGatewayIssuers.CatchWith(new MirrorCatchingHandler(mock) { InnerHandler = new SocketsHttpHandler() }); return catcher.RunAsync(mock, logger, stoppingToken); } } diff --git a/SW.Bitween.Api/Services/Mirror/MirrorCompare.cs b/SW.Bitween.Api/Services/Mirror/MirrorCompare.cs new file mode 100644 index 00000000..1000f765 --- /dev/null +++ b/SW.Bitween.Api/Services/Mirror/MirrorCompare.cs @@ -0,0 +1,375 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Xml; +using System.Xml.Linq; + +namespace SW.Bitween.Services.Mirror; + +/// +/// Compares what a rehearsed exchange did on staging with what prod's did, as staging keeps it: faked. +/// Both went through the same faker, so a value staging copied from its input reads as prod's did, and +/// one a mapping works out from faked values reads differently but keeps its look. So a difference is: +/// +/// different when how it ended changed, or the file's format, its fields, how many of them, +/// or how a value is written: a date in another format, a number turned text, a value left empty; +/// check when only a value's text changed and its look didn't, which faked data explains +/// when a mapping works values out (a sum, a cut, a lookup) - and a wrong field taken doesn't; +/// same otherwise. +/// +/// +public static class MirrorCompare +{ + public const string Different = "different", Check = "check", Same = "same", Info = "info", Faked = "faked"; + + /// Fields listed per file: the ones that differ first. + const int MaxFields = 40; + + const int MaxValue = 120; + + /// How a rehearsed exchange compares: by how it ended and what it sent and got back. What it used only explains. + public static string Verdict(IEnumerable diffs) + { + var levels = diffs.Where(d => d.Part is "result" or "output" or "answer").Select(d => d.Level).ToList(); + return levels.Contains(Different) ? Different : levels.Contains(Check) ? Check : Same; + } + + // ------------------------------------------------------------------ how it ended + + static readonly HashSet Unreached = new(StringComparer.Ordinal) + { + "HttpRequestException", "SocketException", "IOException", "TimeoutException", "TaskCanceledException", + "OperationCanceledException", "AuthenticationException", "WebException", + }; + + /// An error that says the call never got through: refused, cut, timed out. + internal static bool Unreachable(IEnumerable types) => types.Any(Unreached.Contains); + + public static MirrorDiff Result(MirrorSample prod, string outcome, int? status, IReadOnlyList errorTypes) + { + var prodTypes = prod.ErrorTypes ?? []; + errorTypes ??= []; + var level = prod.Outcome != outcome ? Different + : outcome == "failed" && (prod.Status != status || !SameFailure(prodTypes, errorTypes)) ? Check + : Same; + var note = level switch + { + Different when prod.Outcome == "success" => "It worked on prod and doesn't on staging.", + Different when outcome == "success" => "It works on staging where it didn't on prod.", + Check => "Both failed, in different ways.", + _ => null, + }; + return new MirrorDiff("result", "Result", level, Ending(prod.Outcome, prod.Status, prodTypes), Ending(outcome, status, errorTypes), note); + } + + /// The same failure, or both a call that never got through - which is how staging replays one of prod's that didn't. + static bool SameFailure(IReadOnlyList prod, IReadOnlyList staging) => + prod.SequenceEqual(staging) || Unreachable(prod) && Unreachable(staging); + + internal static string Ending(string outcome, int? status, IReadOnlyList types) => outcome switch + { + "success" => "Worked", + "badResponse" => "Answered badly", + "failed" => string.Join(" · ", new[] { "Failed", status is { } s ? $"HTTP {s}" : null, types.Count > 0 ? string.Join(" → ", types) : null } + .Where(p => p != null)), + _ => "Didn't finish", + }; + + // ------------------------------------------------------------------ files + + /// A leaf value of a file: where it is, the field it is a value of, and what kind of value. + internal record Leaf(string Field, string Path, string Value, string Kind); + + /// How many times something repeats in one place: a list's items, an element's same-named children, a file's rows. + internal record Count(string Field, string Path, int N); + + internal record Read(string Format, List Leaves, List Counts); + + /// + /// One of the exchange's files, prod's beside staging's: is output or answer. + /// Null when neither has one. + /// + public static MirrorDiff File(string part, string name, MirrorSampleFile prodFile, byte[] prod, byte[] staging) + { + if (prodFile == null && staging == null) return null; + if (prodFile == null) + return new MirrorDiff(part, name, Different, "None", Describe(Parse(staging)), "Staging made one where prod made none."); + if (prodFile.Key == null || prod == null) + return new MirrorDiff(part, name, Info, prodFile.Format == null ? "Not kept" : $"Not kept ({FormatName(prodFile.Format)})", + staging == null ? "None" : Describe(Parse(staging)), + $"Prod's isn't kept, so it isn't compared. {prodFile.NotCopied}".Trim()); + var a = Parse(prod); + if (staging == null) + return new MirrorDiff(part, name, Different, Describe(a), "None", "Prod made one; staging didn't."); + var b = Parse(staging); + if (a.Format != b.Format) + return new MirrorDiff(part, name, Different, Describe(a), Describe(b), $"Written as {FormatName(b.Format)} where prod's was {FormatName(a.Format)}."); + if (a.Leaves == null) + return prod.AsSpan().SequenceEqual(staging) + ? new MirrorDiff(part, name, Same, Describe(a), Describe(b)) + : new MirrorDiff(part, name, Check, Describe(a), Describe(b), $"{FormatName(a.Format)} files aren't compared value by value yet: open both to compare."); + + var (level, note, fields) = Compare(a, b); + return new MirrorDiff(part, name, level, Describe(a), Describe(b), note, fields); + } + + internal static (string Level, string Note, List Fields) Compare(Read prod, Read staging) + { + var fields = new List(); + int missing = 0, added = 0, counted = 0, written = 0, changed = 0; + + // How often things repeat: a list two items shorter shows once, not once per field of its items. + var prodCounts = prod.Counts.GroupBy(c => c.Field).ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal); + foreach (var group in staging.Counts.GroupBy(c => c.Field)) + { + if (!prodCounts.TryGetValue(group.Key, out var p)) continue; + foreach (var (pc, sc) in p.Zip(group)) + if (pc.N != sc.N) + { + counted++; + fields.Add(new MirrorFieldDiff(sc.Path, Different, Times(pc.N, prod.Format), Times(sc.N, staging.Format))); + } + } + + var listsDiffer = counted > 0; + var prodBy = prod.Leaves.GroupBy(l => l.Field).ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal); + var stagingBy = staging.Leaves.GroupBy(l => l.Field).ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal); + var order = prod.Leaves.Select(l => l.Field).Concat(staging.Leaves.Select(l => l.Field)).Distinct(StringComparer.Ordinal); + foreach (var field in order) + { + var inProd = prodBy.TryGetValue(field, out var p); + var inStaging = stagingBy.TryGetValue(field, out var s); + if (!inStaging) + { + missing++; + fields.Add(new MirrorFieldDiff(p[0].Path, Different, Cut(p[0].Value), "Missing")); + continue; + } + if (!inProd) + { + added++; + fields.Add(new MirrorFieldDiff(s[0].Path, Different, "Missing", Cut(s[0].Value))); + continue; + } + // Repeats are already said by the counts above; a field missing from some of a list's items isn't. + if (!listsDiffer && p.Count != s.Count) + { + counted++; + fields.Add(new MirrorFieldDiff(s[0].Path, Different, Times(p.Count, prod.Format), Times(s.Count, staging.Format))); + } + foreach (var (pv, sv) in p.Zip(s)) + { + if (pv.Kind == sv.Kind && pv.Value == sv.Value) continue; + if (pv.Kind != sv.Kind || Look(pv.Value) != Look(sv.Value)) + { + written++; + fields.Add(new MirrorFieldDiff(sv.Path, Different, Shown(pv), Shown(sv))); + } + else + { + changed++; + fields.Add(new MirrorFieldDiff(sv.Path, Check, Shown(pv), Shown(sv))); + } + } + } + + var level = missing + added + counted + written > 0 ? Different : changed > 0 ? Check : Same; + var parts = new[] + { + missing > 0 ? $"{Many(missing, "field")} missing" : null, + added > 0 ? $"{Many(added, "new field")}" : null, + counted > 0 ? $"{Many(counted, "list")} of another length" : null, + written > 0 ? $"{Many(written, "value")} written another way" : null, + changed > 0 ? $"{Many(changed, "value")} with other text but the same look, which fake data can explain" : null, + }.Where(x => x != null).ToList(); + var listed = fields.OrderBy(f => f.Level == Different ? 0 : 1).Take(MaxFields).ToList(); + var note = parts.Count == 0 ? null : string.Join("; ", parts) + "." + (fields.Count > MaxFields ? $" The first {MaxFields} are listed." : ""); + return (level, note is null ? null : char.ToUpperInvariant(note[0]) + note[1..], listed); + } + + internal static string Many(int n, string noun) => $"{n} {noun}{(n == 1 ? "" : "s")}"; + + static string Times(int n, string format) => format switch + { + "csv" => Many(n, "row"), + "text" or "html" => Many(n, "line"), + _ => Many(n, "item"), + }; + + static string Shown(Leaf leaf) => leaf.Kind switch + { + "string" => $"\"{Cut(leaf.Value)}\"", + _ => Cut(leaf.Value), + }; + + static string Cut(string value) => value == null ? null : value.Length <= MaxValue ? value : value[..MaxValue] + "…"; + + /// + /// How a value looks: letters as A or a, digits as 9, the rest as it is, each run as one. Faking keeps it + /// (2026-03-14 and 2031-07-22 both look 9-9-9), and a change in how a value is written changes it. + /// + internal static string Look(string value) + { + if (string.IsNullOrEmpty(value)) return ""; + var look = new StringBuilder(); + foreach (var c in value) + { + var k = char.IsDigit(c) ? '9' : char.IsUpper(c) ? 'A' : char.IsLetter(c) ? 'a' : char.IsWhiteSpace(c) ? ' ' : c; + if (look.Length == 0 || look[^1] != k || k is not ('9' or 'A' or 'a' or ' ')) look.Append(k); + } + return look.ToString(); + } + + // ------------------------------------------------------------------ reading a file + + internal static Read Parse(byte[] content) + { + var format = MirrorFiles.FormatOf(content); + var leaves = new List(); + var counts = new List(); + try + { + switch (format) + { + case "json": + Json(JsonNode.Parse(MirrorFiles.TextOf(content)), "", "", leaves, counts); + break; + case "xml": + using (var reader = XmlReader.Create(new MemoryStream(content), new XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore, XmlResolver = null })) + { + var root = XDocument.Load(reader).Root; + if (root != null) Xml(root, root.Name.LocalName, root.Name.LocalName, leaves, counts); + } + break; + case "csv": + var text = MirrorFiles.TextOf(content); + var rows = MirrorFiles.Csv.Rows(text, MirrorFiles.Csv.Delimiter(text) ?? ','); + counts.Add(new Count("rows", "Rows", rows.Count)); + for (var r = 0; r < rows.Count; r++) + for (var c = 0; c < rows[r].Count; c++) + leaves.Add(new Leaf($"column {c + 1}", $"row {r + 1} · column {c + 1}", rows[r][c], "text")); + break; + case "text": + case "html": + var lines = MirrorFiles.TextOf(content).Split('\n').Select(l => l.TrimEnd('\r')).ToList(); + if (lines.Count > 1 && lines[^1].Length == 0) lines.RemoveAt(lines.Count - 1); + counts.Add(new Count("lines", "Lines", lines.Count)); + for (var i = 0; i < lines.Count; i++) leaves.Add(new Leaf("line", $"line {i + 1}", lines[i], "text")); + break; + default: + return new Read(format, null, null); + } + } + catch (Exception ex) when (ex is JsonException or XmlException or DecoderFallbackException or InvalidOperationException) + { + return new Read(format, null, null); + } + return new Read(format, leaves, counts); + } + + static void Json(JsonNode node, string path, string field, List leaves, List counts) + { + var at = path.Length == 0 ? "(the whole file)" : path; + switch (node) + { + case null: + leaves.Add(new Leaf(field, at, "null", "null")); + break; + case JsonObject { Count: 0 }: + leaves.Add(new Leaf(field, at, "{}", "object")); + break; + case JsonObject o: + foreach (var (key, value) in o) + Json(value, path.Length == 0 ? key : $"{path}.{key}", field.Length == 0 ? key : $"{field}.{key}", leaves, counts); + break; + case JsonArray a: + counts.Add(new Count(field + "[]", at, a.Count)); + if (a.Count == 0) leaves.Add(new Leaf(field + "[]", at, "[]", "array")); + for (var i = 0; i < a.Count; i++) Json(a[i], $"{path}[{i}]", field + "[]", leaves, counts); + break; + case JsonValue v: + var kind = v.GetValueKind(); + leaves.Add(new Leaf(field, at, kind == JsonValueKind.String ? v.GetValue() : v.ToJsonString(), kind switch + { + JsonValueKind.String => "string", + JsonValueKind.Number => "number", + JsonValueKind.True or JsonValueKind.False => "bool", + _ => "null", + })); + break; + } + } + + static void Xml(XElement element, string path, string field, List leaves, List counts) + { + foreach (var attribute in element.Attributes().Where(a => !a.IsNamespaceDeclaration)) + leaves.Add(new Leaf($"{field}/@{attribute.Name.LocalName}", $"{path}/@{attribute.Name.LocalName}", attribute.Value, "text")); + var children = element.Elements().ToList(); + if (children.Count == 0) + { + leaves.Add(new Leaf(field, path, element.Value, "text")); + return; + } + var named = children.GroupBy(c => c.Name.LocalName).ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal); + foreach (var (name, n) in named) counts.Add(new Count($"{field}/{name}", $"{path}/{name}", n)); + var seen = new Dictionary(StringComparer.Ordinal); + foreach (var child in children) + { + var name = child.Name.LocalName; + var i = seen[name] = seen.GetValueOrDefault(name) + 1; + Xml(child, named[name] > 1 ? $"{path}/{name}[{i}]" : $"{path}/{name}", $"{field}/{name}", leaves, counts); + } + } + + static string Describe(Read read) => read.Leaves == null ? FormatName(read.Format) : read.Format switch + { + "csv" => $"CSV · {Many(read.Counts[0].N, "row")}", + "text" or "html" => $"{FormatName(read.Format)} · {Many(read.Counts[0].N, "line")}", + _ => $"{FormatName(read.Format)} · {Many(read.Leaves.Select(l => l.Field).Distinct(StringComparer.Ordinal).Count(), "field")}", + }; + + internal static string FormatName(string format) => format switch + { + "json" => "JSON", + "xml" => "XML", + "csv" => "CSV", + "html" => "HTML", + "xlsx" => "Excel", + "text" => "Text", + "docx" => "Word", + null => "A file", + _ => format.ToUpperInvariant(), + }; + + // ------------------------------------------------------------------ setup + + /// + /// A setup item's fields, prod's beside staging's, where they differ: nested values field by field + /// (handler · properties · Url), so a long block shows only what changed in it. + /// + internal static List SetupFields(JsonObject want, JsonObject have, IEnumerable fields) + { + var diffs = new List(); + foreach (var field in fields) + { + var a = new List(); + var b = new List(); + Json(want?[field]?.DeepClone(), field, field, a, []); + Json(have?[field]?.DeepClone(), field, field, b, []); + var prodBy = a.GroupBy(l => l.Path).ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); + var stagingBy = b.GroupBy(l => l.Path).ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); + foreach (var path in prodBy.Keys.Concat(stagingBy.Keys).Distinct(StringComparer.Ordinal)) + { + prodBy.TryGetValue(path, out var p); + stagingBy.TryGetValue(path, out var s); + if (p != null && s != null && p.Kind == s.Kind && p.Value == s.Value) continue; + diffs.Add(new MirrorFieldDiff(path.Replace(".", " · "), Info, p == null ? "Not set" : Shown(p), s == null ? "Not set" : Shown(s))); + } + } + return diffs.Take(MaxFields).ToList(); + } +} diff --git a/SW.Bitween.Api/Services/Mirror/MirrorFiles.cs b/SW.Bitween.Api/Services/Mirror/MirrorFiles.cs index bca8129d..285285bd 100644 --- a/SW.Bitween.Api/Services/Mirror/MirrorFiles.cs +++ b/SW.Bitween.Api/Services/Mirror/MirrorFiles.cs @@ -61,6 +61,41 @@ public static MirrorFakedFile TooLarge(string name) => // ------------------------------------------------------------------ what it is + /// What a file is, read the way reads it: json, xml, html, csv, text, xlsx, docx, zip, pdf, edi or binary. + internal static string FormatOf(byte[] content) + { + var detected = Detect(content); + if (detected != "text") return detected; + var text = TextOf(content); + var start = text.TrimStart(); + if (start.StartsWith('<')) + { + try + { + using var reader = XmlReader.Create(new StringReader(text), new XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore, XmlResolver = null }); + while (reader.Read()) { } + return "xml"; + } + catch (XmlException) { return "html"; } + } + if (start.StartsWith('{') || start.StartsWith('[')) + { + try + { + using var _ = JsonDocument.Parse(text); + return "json"; + } + catch (JsonException) { } + } + if (start.StartsWith("UNA", StringComparison.Ordinal) || start.StartsWith("UNB", StringComparison.Ordinal) || + start.StartsWith("ISA", StringComparison.Ordinal)) + return "edi"; + return Csv.Delimiter(text) != null ? "csv" : "text"; + } + + /// A text file's text, in the encoding it was written in. + internal static string TextOf(byte[] content) => Decode(content).Text; + static string Detect(byte[] content) { var head = content.AsSpan(0, Math.Min(content.Length, 8192)); @@ -379,6 +414,11 @@ record Row(List Fields, string Ending); return best; } + /// Each row's values, as read. + public static List> Rows(string text, char delimiter) => + Parse(text, delimiter).Where(r => r.Fields.Count > 1 || r.Fields[0].Value.Length > 0) + .Select(r => r.Fields.Select(f => f.Value).ToList()).ToList(); + public static string Fake(MirrorFaker faker, string text, char delimiter) { var result = new StringBuilder(text.Length); diff --git a/SW.Bitween.Api/Services/Mirror/MirrorMock.cs b/SW.Bitween.Api/Services/Mirror/MirrorMock.cs index e1bab567..ba1524f5 100644 --- a/SW.Bitween.Api/Services/Mirror/MirrorMock.cs +++ b/SW.Bitween.Api/Services/Mirror/MirrorMock.cs @@ -13,6 +13,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; using SW.Bitween.Domain; using SW.Bitween.Domain.Connections; using SW.Bitween.Domain.DataSources; @@ -25,10 +27,17 @@ namespace SW.Bitween.Services.Mirror; public record MirrorRequest(string Method, Uri Url, string ContentType, byte[] Body, string From); /// What staging answered a call with, and why that. -public record MirrorAnswer(int Status, string ContentType, byte[] Body, string Why); +/// The call fails as if it never got through, as prod's did in an exchange being rehearsed. +public record MirrorAnswer(int Status, string ContentType, byte[] Body, string Why, bool Fail = false); -/// One call staging answered. -public record MirrorCall(DateTimeOffset At, string Method, string Host, string Path, int Status, string Why, string From); +/// One call staging answered, or sent on for real while a rehearsal ran an exchange. +/// Sent on for real rather than answered: only noted for a rehearsal. +/// The rehearsal's exchange that made it, where staging can tell. +public record MirrorCall(DateTimeOffset At, string Method, string Host, string Path, int Status, string Why, string From, bool Real = false, + string Exchange = null); + +/// What prod's partner answered one of a family's exchanges, for staging to answer its twin with. +public record MirrorReplayAnswer(string Subscription, MirrorAnswer Answer); /// The calls to one host staging answered, as the page shows them. /// One of prod's addresses as the copy faked it, rather than one staging's setup doesn't name. @@ -42,7 +51,8 @@ public sealed record MirrorSetupHosts( IReadOnlySet Named, IReadOnlyDictionary> UsedBy, IReadOnlyDictionary TokenPaths, - IReadOnlySet Logins) + IReadOnlySet Logins, + IReadOnlyDictionary Subscriptions = null) { public static readonly MirrorSetupHosts None = new(new HashSet(), new Dictionary>(), new Dictionary(), new HashSet()); @@ -72,9 +82,11 @@ void Login(string url, string tokenPath = null) if (!string.IsNullOrWhiteSpace(tokenPath)) tokenPaths[login.Host] = tokenPath.Trim(); } + var names = new Dictionary(); foreach (var s in await db.Subscriptions().AsNoTracking() - .Select(s => new { s.Name, s.HandlerProperties, s.ReceiverProperties, s.MapperProperties, s.ValidatorProperties }).ToListAsync()) + .Select(s => new { s.Id, s.Name, s.HandlerProperties, s.ReceiverProperties, s.MapperProperties, s.ValidatorProperties }).ToListAsync()) { + names[s.Id] = s.Name; foreach (var properties in new[] { s.HandlerProperties, s.ReceiverProperties, s.MapperProperties, s.ValidatorProperties }) { Add(properties, s.Name); @@ -95,7 +107,7 @@ void Login(string url, string tokenPath = null) foreach (var p in await db.Set().AsNoTracking().Select(p => p.AdapterProperties).ToListAsync()) Add(p); return new MirrorSetupHosts(named, usedBy.ToDictionary(u => u.Key, u => (IReadOnlyList)u.Value.ToList(), StringComparer.OrdinalIgnoreCase), - tokenPaths, logins); + tokenPaths, logins, names); static string Get(IReadOnlyDictionary properties, string name) => properties?.FirstOrDefault(p => string.Equals(p.Key, name, StringComparison.OrdinalIgnoreCase)).Value is { Length: > 0 } value ? value : null; @@ -109,9 +121,10 @@ static string Get(IReadOnlyDictionary properties, string name) = /// for real, and so is one listed in Bitween:Mirror:PassHosts. /// /// A login is answered with a token of staging's own, where the login's settings say its reply keeps -/// one. Anything else gets the newest answer prod's partner gave a subscription that uses the address, -/// as staging keeps it in the samples - faked - or an empty one when there is none. Every answer is -/// counted, so the page can show what was caught. +/// one. While a rehearsal runs one of prod's exchanges, its subscription's calls get what prod's partner +/// answered in that exchange. Anything else gets the newest answer prod's partner gave a subscription +/// that uses the address, as staging keeps it in the samples - faked - or an empty one when there is +/// none. Every answer is counted, so the page can show what was caught. /// /// public sealed class MirrorMock(IServiceScopeFactory scopes, BitweenOptions options, ILogger logger) @@ -128,6 +141,59 @@ public sealed class MirrorMock(IServiceScopeFactory scopes, BitweenOptions optio readonly ConcurrentQueue recent = new(); readonly ConcurrentDictionary byHost = new(StringComparer.OrdinalIgnoreCase); + sealed class Replaying(string correlation, IReadOnlyList answers) + { + public string Correlation { get; } = correlation; + public ConcurrentQueue Calls { get; } = new(); + + readonly Dictionary byExchange = new(StringComparer.Ordinal); + readonly HashSet given = []; + + /// + /// The answer for a staging exchange of : the first of prod's twins + /// of it not given to another yet, and the same one for every call it makes. + /// + public MirrorReplayAnswer For(string exchange, string subscription) + { + lock (given) + { + if (byExchange.TryGetValue(exchange, out var known)) return known; + var next = answers.FirstOrDefault(a => a.Subscription == subscription && !given.Contains(a)); + if (next != null) given.Add(next); + return byExchange[exchange] = next; + } + } + + public IEnumerable Of(string subscription) => answers.Where(a => a.Subscription == subscription); + } + + volatile Replaying replaying; + + /// + /// While a rehearsal runs one of prod's families on staging, under : + /// each exchange's calls are answered with what prod's partner answered its twin - the exchange of + /// the same subscription, in the same order. A call to an address only other subscriptions use keeps + /// its usual answer. Every call is noted, those sent on for real too, until . + /// + public void Replay(string correlation, IReadOnlyList answers) => replaying = new Replaying(correlation, answers); + + /// Ends a replay, with the calls made during it. + public IReadOnlyList EndReplay() => Interlocked.Exchange(ref replaying, null)?.Calls.ToList() ?? []; + + /// + /// The rehearsal exchange a call is for: the one Bitween runs the adapter of itself, or else, for an + /// adapter's own process, the one the replay's family is running - by the address, when several are. + /// + static MirrorTraffic.Running Making(Replaying r, string host, MirrorSetupHosts setup) + { + if (MirrorTraffic.Current is { } current && current.Correlation == r.Correlation) return current; + var now = MirrorTraffic.Now.Where(x => x.Correlation == r.Correlation).ToList(); + if (now.Count <= 1) return now.FirstOrDefault(); + var users = setup.UsedBy.GetValueOrDefault(host) ?? []; + return now.FirstOrDefault(x => x.SubscriptionId is { } id && setup.Subscriptions?.GetValueOrDefault(id) is { } name && users.Contains(name)) + ?? now[0]; + } + /// Overrides the setup and samples it reads, for tests. internal Func> ReadOverride { get; set; } @@ -135,14 +201,26 @@ public sealed class MirrorMock(IServiceScopeFactory scopes, BitweenOptions optio internal Func> FileOverride { get; set; } /// Whether staging answers a call to itself rather than sending it on. - public async Task CatchesAsync(string host) + public async Task CatchesAsync(string host) => await PassesAsync(host) == null; + + /// Why a call to is sent on for real, in words; null when staging answers it. + public async Task PassesAsync(string host) { var h = (host ?? "").Trim().Trim('[', ']').TrimEnd('.').ToLowerInvariant(); - if (MirrorFaker.IsMockHost(h)) return true; + if (MirrorFaker.IsMockHost(h)) return null; if (h is "localhost" || h.EndsWith(".localhost", StringComparison.Ordinal) || IPAddress.TryParse(h, out var ip) && IPAddress.IsLoopback(ip)) - return false; - if (Own(h) || PassHosts().Contains(h)) return false; - return !(await ReadAsync()).Setup.Named.Contains(h); + return "it is this machine"; + if (Own(h)) return "it is staging itself"; + if (PassHosts().Contains(h)) return "it is in Hosts called for real"; + return (await ReadAsync()).Setup.Named.Contains(h) ? "staging's setup names it" : null; + } + + /// A call sent on for real: noted while a rehearsal runs an exchange, where it explains what came back. + public void Passed(string method, string host, string path, string why, string from) + { + if (replaying is { } r) + r.Calls.Enqueue(new MirrorCall(DateTimeOffset.UtcNow, method, host, path, 0, $"Sent for real: {why}.", from, Real: true, + Exchange: Making(r, host, setup ?? MirrorSetupHosts.None)?.Id)); } public async Task AnswerAsync(MirrorRequest request) @@ -152,8 +230,8 @@ public async Task AnswerAsync(MirrorRequest request) { var (setup, samples) = await ReadAsync(); var host = request.Url.Host.ToLowerInvariant(); - answer = AsksForToken(request, setup) ? Token(setup.TokenPaths.GetValueOrDefault(host)) - : await SampleAnswerAsync(host, setup, samples) ?? new MirrorAnswer(200, null, [], + answer = LoginServer(request) ?? (AsksForToken(request, setup) ? Token(setup.TokenPaths.GetValueOrDefault(host)) : null) + ?? Replayed(host, setup) ?? await SampleAnswerAsync(host, setup, samples) ?? new MirrorAnswer(200, null, [], "Nothing of prod's to answer with, so an empty reply."); } catch (Exception ex) @@ -218,14 +296,79 @@ internal static MirrorAnswer Token(string path) return new MirrorAnswer(200, "application/json", Encoding.UTF8.GetBytes(reply.ToJsonString()), "A login, answered with a token of staging's own."); } + MirrorAnswer Replayed(string host, MirrorSetupHosts setup) + { + if (replaying is not { } r) return null; + var making = Making(r, host, setup); + var name = making?.SubscriptionId is { } id ? setup.Subscriptions?.GetValueOrDefault(id) : null; + if (name == null) return null; + // An address only other subscriptions use keeps its usual answer. + if (setup.UsedBy.TryGetValue(host, out var users) && users.Count > 0 && !users.Contains(name)) return null; + return r.For(making.Id, name)?.Answer; + } + + // ------------------------------------------------------------------ a login server for gateways + + const string DiscoveryPath = "/.well-known/openid-configuration", KeysPath = "/.well-known/mirror-keys", KeyId = "bitween-mirror"; + + readonly RSA signing = RSA.Create(2048); + + /// + /// A token as a partner's login server issues it, for a gateway that checks tokens: signed with a key + /// of staging's own, which staging hands out as the keys of any test address. So a rehearsal calls a + /// JWT gateway as prod's partner did, and the gateway checks the token as it does on prod. + /// + public string SignToken(string issuer, string audience, string claim, string value) => + new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor + { + Issuer = issuer, + Audience = audience, + Claims = new Dictionary { [string.IsNullOrWhiteSpace(claim) ? "sub" : claim] = value }, + Expires = DateTime.UtcNow.AddMinutes(10), + SigningCredentials = new SigningCredentials(new RsaSecurityKey(signing) { KeyId = KeyId }, SecurityAlgorithms.RsaSha256), + }); + + /// A login server's settings and keys, for a test address a gateway trusts; null for any other call. + MirrorAnswer LoginServer(MirrorRequest request) + { + if (!string.Equals(request.Method, "GET", StringComparison.OrdinalIgnoreCase)) return null; + var path = request.Url.AbsolutePath; + JsonObject reply; + if (path.EndsWith(DiscoveryPath, StringComparison.Ordinal)) + { + var issuer = request.Url.GetLeftPart(UriPartial.Authority) + path[..^DiscoveryPath.Length]; + reply = new JsonObject + { + ["issuer"] = issuer, ["jwks_uri"] = issuer + KeysPath, + ["id_token_signing_alg_values_supported"] = new JsonArray("RS256"), + ["response_types_supported"] = new JsonArray("code"), ["subject_types_supported"] = new JsonArray("public"), + }; + } + else if (path.EndsWith(KeysPath, StringComparison.Ordinal)) + { + var key = signing.ExportParameters(false); + reply = new JsonObject + { + ["keys"] = new JsonArray(new JsonObject + { + ["kty"] = "RSA", ["use"] = "sig", ["alg"] = "RS256", ["kid"] = KeyId, + ["n"] = Base64UrlEncoder.Encode(key.Modulus), ["e"] = Base64UrlEncoder.Encode(key.Exponent), + }), + }; + } + else return null; + return new MirrorAnswer(200, "application/json", Encoding.UTF8.GetBytes(reply.ToJsonString()), + "A login server's keys, answered by staging, which signs the tokens a rehearsal calls gateways with."); + } + /// The newest answer prod's partner gave, faked, to a subscription that uses the address and worked on prod. async Task SampleAnswerAsync(string host, MirrorSetupHosts setup, MirrorSamples samples) { var users = setup.UsedBy.GetValueOrDefault(host); if (users == null || samples == null) return null; - var candidate = samples.Subscriptions.Where(s => users.Contains(s.Subscription)) - .SelectMany(s => s.Samples.Select(x => (s.Subscription, Sample: x))) - .Where(x => x.Sample.Outcome == "success" && x.Sample.Response?.Key != null) + var candidate = (samples.Families ?? []).SelectMany(f => f.Members) + .Where(x => x.Subscription != null && users.Contains(x.Subscription) && x.Outcome == "success" && x.Response?.Key != null) + .Select(x => (x.Subscription, Sample: x)) .OrderByDescending(x => x.Sample.StartedOn) .FirstOrDefault(); if (candidate.Sample == null) return null; @@ -317,7 +460,12 @@ public void Refused(string host, string why) => void Record(MirrorRequest request, MirrorAnswer answer) { var now = DateTimeOffset.UtcNow; - recent.Enqueue(new MirrorCall(now, request.Method, request.Url.Host, request.Url.AbsolutePath, answer.Status, answer.Why, request.From)); + var r = replaying; + var making = r == null ? null : Making(r, request.Url.Host, setup ?? MirrorSetupHosts.None); + var call = new MirrorCall(now, request.Method, request.Url.Host, request.Url.AbsolutePath, answer.Status, answer.Why, request.From, + Exchange: making?.Id); + recent.Enqueue(call); + r?.Calls.Enqueue(call); while (recent.Count > Kept && recent.TryDequeue(out _)) { } byHost.AddOrUpdate(request.Url.Host, _ => (1, now, answer.Why), (_, h) => (h.Calls + 1, now, answer.Why)); } diff --git a/SW.Bitween.Api/Services/Mirror/MirrorRehearsal.cs b/SW.Bitween.Api/Services/Mirror/MirrorRehearsal.cs new file mode 100644 index 00000000..024b1027 --- /dev/null +++ b/SW.Bitween.Api/Services/Mirror/MirrorRehearsal.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; + +namespace SW.Bitween.Services.Mirror; + +/// One line of a rehearsed exchange's compare: something of prod's beside staging's. +/// +/// result, output, answer, calls, input or used: the part of the exchange it is about; entry or family for +/// a family's: how it came in, and which exchanges it led to. +/// +/// What is compared, in words: Result, What it sent, Handler, Partner “Acme”. +/// +/// different or check (see ); info, a difference that explains rather than +/// counts, such as the upgrade itself; faked, the same as prod's but with faked values; or same. +/// +/// How it is on prod, as staging can show it: faked, like everything of prod's. +/// How it is on staging. +/// What explains it, when something does. +/// Field by field, where there is more than one thing to show. +/// For a setup item: its kind, so the page can open it. +/// For a setup item: its id on staging. +/// For a subscription: its type, which decides its page. +public record MirrorDiff(string Part, string Name, string Level, string Prod, string Staging, string Note = null, + IReadOnlyList Fields = null, string Section = null, string StagingId = null, string Type = null); + +/// One field of a compared file or setup item. +/// Where it is: order.lines[2].sku, Order/@id, row 3 · column 2, handler · properties · Url. +public record MirrorFieldDiff(string Path, string Level, string Prod, string Staging); + +/// One exchange of a rehearsed family: prod's, the twin staging ran, and how the two compare. +/// Prod's exchange; null for one staging ran that prod's family didn't. +/// Its subscription, by name, or the information type of a document being routed. +/// The twin staging ran; null when it didn't run one. +/// How it ended on staging: success, badResponse or failed; null when it didn't run or finish. +/// The HTTP status staging's error names, where it names one. +/// same, check or different; null when it didn't run or finish. +/// Why it didn't run, or didn't finish, in words for the page. +/// What staging's exchange sent. +/// What staging's exchange got back. +public record MirrorRun(string ProdExchangeId, string Subscription, string StagingExchangeId, DateTimeOffset RanOn, string Outcome, + int? Status, IReadOnlyList ErrorTypes, string Verdict, string NotRun, MirrorSampleFile Output, MirrorSampleFile Response, + IReadOnlyList Diffs); + +/// +/// One of prod's families, sent in on staging the way it came in on prod - through the gateway as the +/// partner, as a document routed, as a file received, as an aggregation - and every exchange staging +/// ran for it, each compared with its twin on prod. +/// +/// The family: its first exchange's id on prod. +/// The correlation id the family ran under on staging; null when it didn't run. +/// same, check or different: the worst of its exchanges and how it came in; null when it didn't run. +/// Why it didn't run, in words for the page. +/// The family's own lines: how it came in, and exchanges one side ran and the other didn't. +public record MirrorFamilyRun(string FamilyId, string Correlation, DateTimeOffset RanOn, string Verdict, string NotRun, + IReadOnlyList Diffs, IReadOnlyList Members); + +/// The last rehearsal, and how one under way is going: what the page shows. +/// none (never run), running, done or failed. +/// Families run so far, of . +/// Why the last rehearsal stopped, when it did. +/// Prod's release when it ran; null when prod couldn't be read. +/// Staging's release when it ran: the one being tried. +/// Why what each exchange used couldn't be compared with prod, when it couldn't. +public record MirrorRehearsal(string State, DateTimeOffset? StartedOn, DateTimeOffset? FinishedOn, int Done, int Total, string Error, + string ProdVersion, string StagingVersion, string SetupError, IReadOnlyList Families); diff --git a/SW.Bitween.Api/Services/Mirror/MirrorSamples.cs b/SW.Bitween.Api/Services/Mirror/MirrorSamples.cs index fcd9fb34..a4625d21 100644 --- a/SW.Bitween.Api/Services/Mirror/MirrorSamples.cs +++ b/SW.Bitween.Api/Services/Mirror/MirrorSamples.cs @@ -17,81 +17,125 @@ public record MirrorSampleFile(string Key, string Name, string Format, int Size, /// /// One of prod's exchanges, faked: what came in, what Bitween sent, what the partner answered, and how -/// it ended on prod. A rehearsal runs what came in on staging and compares the ending. +/// it ended on prod. A rehearsal runs it on staging, as part of its family, and compares the ending. /// /// The exchange on prod, so the page can open it there. +/// Its subscription, by name; null for a document being routed. +/// The type of what came in, by name. +/// The exchange on prod whose answer, or whose message, started it; null for the one that came in. /// success, badResponse or failed: how it ended on prod. /// The partner's HTTP status, where prod's error names one. /// /// The kinds of error prod's error names, outermost first: HttpRequestException, SocketException. Its /// message isn't kept - it can hold prod's data - so the page opens the exchange on prod to read it. /// +/// The partner it ran as, by name: a partner calling an API gateway, or the subscription's own. +/// The handler it ran, as prod recorded it: the adapter, and its version where one was pinned. +/// The mapper it ran, the same way. +/// For an aggregation: a few of the files it rolled up, faked, for staging to roll up the like. public record MirrorSample(string ProdExchangeId, DateTime StartedOn, string Outcome, int? Status, IReadOnlyList ErrorTypes, - MirrorSampleFile Input, MirrorSampleFile Output, MirrorSampleFile Response); + MirrorSampleFile Input, MirrorSampleFile Output, MirrorSampleFile Response, + string Partner = null, string Handler = null, string Mapper = null, + string Subscription = null, string InformationType = null, string Parent = null, + IReadOnlyList Rolled = null); -/// A subscription's samples, or why it has none. +/// +/// One thing that came in on prod and every exchange it led to: a document routed to the subscriptions +/// that wanted it, a partner's call through a gateway, a file a receiver read, an aggregation - then the +/// answers those handed on, to response subscriptions or as messages to bus gateways. A rehearsal sends +/// the first in the way it came in, and staging runs the rest by itself. +/// +/// The first exchange's id on prod. +/// How it came in: document, gateway, api (the partner API), receiver, aggregation or direct. +/// What it came in through, by name: the information type, the gateway, the receiving subscription. +/// The partner who called, for a gateway or the partner API. +/// Its exchanges, in the order they started on prod; the first is the one that came in. +/// How many more exchanges prod's family had than the sample keeps. +/// +/// Prod doesn't say which subscriptions a document of it went to - it came in without a correlation +/// id - so the subscriptions staging routes it to are run, but not counted as more than prod's. +/// +public record MirrorFamily(string Id, string Entry, string Door, string Partner, IReadOnlyList Members, int Cut = 0, + bool Unlinked = false); + +/// A subscription's place in the samples: the families it takes part in, or why there are none. /// The subscription on prod. -public record MirrorSubscriptionSamples(string Subscription, string ProdId, IReadOnlyList Samples, string Reason); +public record MirrorSubscriptionSamples(string Subscription, string ProdId, IReadOnlyList Families, string Reason); /// The samples staging holds, and how the last taking went: what the page shows. /// none (never taken), running, done or failed. /// Subscriptions read so far, of . /// Why the last taking stopped, when it did. public record MirrorSamples(string State, DateTimeOffset? StartedOn, DateTimeOffset? FinishedOn, int Done, int Total, string Error, - IReadOnlyList Subscriptions); + IReadOnlyList Subscriptions, IReadOnlyList Families = null); -/// How far a taking under way is. -public record MirrorSamplingProgress(DateTimeOffset StartedOn, int Done, int Total); +/// How far the mirror's work under way is. +/// samples or rehearsal. +/// Done so far, of : subscriptions read, or samples run. +public record MirrorProgress(string Kind, DateTimeOffset StartedOn, int Done, int Total); /// -/// Takes prod's samples in the background, one taking at a time, so the page can follow it: on a big -/// prod it takes longer than a request may wait. How it is going is known to the node doing it, and a -/// staging runs one node. +/// Runs the mirror's long work in the background - taking prod's samples, or rehearsing them - one at a +/// time, so the page can follow it: on a big prod it takes longer than a request may wait, and a +/// rehearsal running while its samples are replaced would compare the wrong files. How it is going is +/// known to the node doing it, and a staging runs one node. /// -public sealed class MirrorSampler(IServiceScopeFactory scopes, ILogger logger) +public sealed class MirrorWork(IServiceScopeFactory scopes, ILogger logger) { + public const string Sampling = "samples", Rehearsing = "rehearsal"; + readonly Lock gate = new(); + readonly Dictionary lastErrors = new(StringComparer.Ordinal); Task running; - /// The taking under way; null when none is. - public MirrorSamplingProgress Running { get; private set; } + /// The work under way; null when none is. + public MirrorProgress Running { get; private set; } - /// Why the last taking stopped, until another starts; null when it finished. - public string LastError { get; private set; } + /// Why the last work of a kind stopped, until it starts again; null when it finished. + public string LastError(string kind) + { + lock (gate) return lastErrors.GetValueOrDefault(kind); + } - /// Starts a taking, unless one is under way. - public void Start() + /// Starts work of a kind, unless work is under way: then says which kind is, and starts nothing. + public string Start(string kind, Func, CancellationToken, Task> work) { lock (gate) { - if (running is { IsCompleted: false }) return; - Running = new MirrorSamplingProgress(DateTimeOffset.UtcNow, 0, 0); - LastError = null; - running = Task.Run(RunAsync); + if (running is { IsCompleted: false }) return Running?.Kind; + Running = new MirrorProgress(kind, DateTimeOffset.UtcNow, 0, 0); + lastErrors.Remove(kind); + running = Task.Run(() => RunAsync(kind, work)); + return null; } } - async Task RunAsync() + async Task RunAsync(string kind, Func, CancellationToken, Task> work) { var startedOn = Running.StartedOn; + string error = null; try { using var scope = scopes.CreateScope(); var mirror = scope.ServiceProvider.GetRequiredService(); - await mirror.TakeSamplesAsync((done, total) => Running = new MirrorSamplingProgress(startedOn, done, total)); + await work(mirror, (done, total) => Running = new MirrorProgress(kind, startedOn, done, total), CancellationToken.None); } catch (MirrorRefusal refusal) { - LastError = refusal.Message; + error = refusal.Message; } catch (Exception ex) { - logger.LogError(ex, "Taking prod's samples stopped"); - LastError = $"Taking samples stopped: {ex.Message}"; + logger.LogError(ex, "The prod mirror's {Kind} stopped", kind); + error = $"{(kind == Sampling ? "Taking samples" : "The rehearsal")} stopped: {ex.Message}"; } finally { - Running = null; + lock (gate) + { + if (error != null) lastErrors[kind] = error; + Running = null; + } } } } diff --git a/SW.Bitween.Api/Services/Mirror/MirrorService.Rehearsal.cs b/SW.Bitween.Api/Services/Mirror/MirrorService.Rehearsal.cs new file mode 100644 index 00000000..c15cbe79 --- /dev/null +++ b/SW.Bitween.Api/Services/Mirror/MirrorService.Rehearsal.cs @@ -0,0 +1,597 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SW.Bitween.Cli; +using SW.Bitween.Cli.Configuration; +using SW.Bitween.Domain; +using SW.Bitween.Model; +using SW.PrimitiveTypes; +using SW.Serverless.Contract.Catalog; +using static SW.Bitween.Services.Mirror.MirrorCompare; + +namespace SW.Bitween.Services.Mirror; + +public sealed partial class MirrorService +{ + internal const string RehearsalManifest = "mirror/rehearsal/rehearsal.json"; + + static readonly MirrorRehearsal NoRehearsal = new("none", null, null, 0, 0, null, null, null, null, []); + + /// How long one exchange may take on staging before the rehearsal moves on without it. + static readonly TimeSpan RunTimeout = TimeSpan.FromMinutes(2); + + /// Prod's setup beside staging's, compared once for every exchange of a rehearsal. + sealed record Setup(MirrorParity Parity, Plan Plan, MirrorSetupCopy Copy); + + /// + /// Sends each of prod's families in on staging the way it came in on prod - a partner's call through + /// the gateway, a document routed, a file a receiver read, an aggregation - with prod's input as + /// staging keeps it, faked; staging runs the rest by itself, as prod did. Each exchange it runs is + /// compared with its twin on prod. A partner an exchange calls is answered by staging with what + /// prod's partner answered its twin (see ), so both get the same + /// answer: one family at a time, which that needs. Replaces the last rehearsal; the page follows this + /// one as it goes. Staging's setup is read as whoever started it, with , + /// since nobody's request is around by then. + /// + public async Task RehearseAsync(Action progress, string selfToken, CancellationToken cancellation = default) + { + if (!options.Mirror.Target) throw new MirrorRefusal(NotTarget); + // Aggregations last: they roll up what the rest ran. + var families = ((await ReadSamplesAsync()).Families ?? []) + .OrderBy(f => f.Entry == "aggregation" ? 1 : 0).ThenBy(f => f.Members[0].StartedOn).ToList(); + if (families.Count == 0) throw new MirrorRefusal("There are no samples to rehearse yet: take samples of prod's exchanges first."); + progress?.Invoke(0, families.Count); + + Setup setup = null; + string setupError = null; + try + { + using var comparison = await CompareAsync(removeExtras: false, copyKey: null, selfToken); + setup = new Setup(comparison.Parity, comparison.Plan, comparison.Copy); + } + catch (MirrorRefusal refusal) + { + setupError = $"What each exchange used isn't compared with prod's setup: {refusal.Message}"; + } + catch (BitweenApiException ex) when (ex.Status == null) + { + setupError = $"What each exchange used isn't compared with prod's setup: {ex.Message}"; + } + catch (Exception ex) when (ex is BitweenApiException or HttpRequestException or TaskCanceledException) + { + setupError = $"What each exchange used isn't compared with prod's setup: prod couldn't be read, as {Why(ex)}."; + } + + var rehearsal = new MirrorRehearsal("running", DateTimeOffset.UtcNow, null, 0, families.Count, null, setup?.Parity.Prod.Version, + BitweenInfo.Version(options).ToString(3), setupError, []); + var runs = new List(); + var unfinished = 0; + var key = MirrorTraffic.OpenKey(); + using var doors = new HttpClient(new HttpClientHandler { UseCookies = false }) + { + BaseAddress = new Uri(SelfUrl.TrimEnd('/') + "/"), Timeout = TimeSpan.FromMinutes(2), + }; + // The bus gateways prod has on, which the copy switched off so nothing runs on its own: the + // rehearsal's traffic goes through them, as prod's does. + MirrorTraffic.RouteThrough(await SwitchedOffGatewaysAsync(setup)); + try + { + foreach (var family in families) + { + cancellation.ThrowIfCancellationRequested(); + var run = await RunAsync(family, setup, new Doors(doors, key, Faker()), cancellation); + runs.Add(run); + progress?.Invoke(runs.Count, families.Count); + // Written as it goes, so the page shows each family as soon as it is compared. + rehearsal = rehearsal with { Done = runs.Count, Families = runs.ToList() }; + await SaveAsync(rehearsal); + unfinished = run.Members.Any(m => m.StagingExchangeId != null && m.Outcome == null) ? unfinished + 1 : 0; + if (unfinished == 2) + throw new MirrorRefusal($"Two families in a row didn't finish on staging within {RunTimeout.TotalMinutes:0} minutes, so the rehearsal stopped. " + + "Open their exchanges on the exchanges page to see where they are."); + } + } + finally + { + MirrorTraffic.RouteThrough(null); + MirrorTraffic.CloseKey(key); + } + rehearsal = rehearsal with { State = "done", FinishedOn = DateTimeOffset.UtcNow }; + await SaveAsync(rehearsal); + return rehearsal; + } + + async Task> SwitchedOffGatewaysAsync(Setup setup) + { + const string prefix = "busGateways/"; + var names = (setup?.Copy.SwitchedOff ?? new HashSet()).Where(k => k.StartsWith(prefix, StringComparison.Ordinal)) + .Select(k => k[prefix.Length..]).ToList(); + if (names.Count == 0) return new HashSet(); + using var scope = scopes.CreateScope(); + return (await scope.ServiceProvider.GetRequiredService().Set().AsNoTracking() + .Where(g => names.Contains(g.Name)).Select(g => g.Id).ToListAsync()).ToHashSet(); + } + + Task SaveAsync(MirrorRehearsal rehearsal) => + cloudFiles.WriteTextAsync(JsonSerializer.Serialize(rehearsal, ManifestJson), new WriteFileSettings { Key = RehearsalManifest, ContentType = "application/json" }); + + /// The last rehearsal, each staging file with a link that opens it, and how one under way is going. + public async Task RehearsalAsync(MirrorWork work) + { + var rehearsal = await ReadRehearsalAsync(); + rehearsal = rehearsal with + { + Families = (rehearsal.Families ?? []).Select(f => f with + { + Diffs = f.Diffs ?? [], + Members = (f.Members ?? []).Select(r => r with { Output = Linked(r.Output), Response = Linked(r.Response), ErrorTypes = r.ErrorTypes ?? [], Diffs = r.Diffs ?? [] }).ToList(), + }).ToList(), + }; + if (work.Running is { Kind: MirrorWork.Rehearsing } running) + return rehearsal with { State = "running", StartedOn = running.StartedOn, FinishedOn = null, Done = running.Done, Total = running.Total, Error = null }; + if (work.LastError(MirrorWork.Rehearsing) is { } error) return rehearsal with { State = "failed", Error = error }; + // Saved as running, and nothing runs it: staging restarted part way. + return rehearsal.State == "running" ? rehearsal with { State = "failed", Error = "The rehearsal stopped part way: staging restarted while it ran." } : rehearsal; + } + + async Task ReadRehearsalAsync() + { + try + { + await using var stream = await cloudFiles.OpenReadAsync(RehearsalManifest); + return await JsonSerializer.DeserializeAsync(stream, ManifestJson) ?? NoRehearsal; + } + catch (Exception ex) when (StorageErrors.IsNotFound(ex)) + { + return NoRehearsal; + } + } + + // ------------------------------------------------------------------ one exchange + + /// What a rehearsal calls staging's doors with: the client, the key that marks its calls, and the faker partner keys come from. + sealed record Doors(HttpClient Client, string Key, MirrorFaker Faker); + + /// How a family got in: the door's line, whether the door turned it away, or why it couldn't be sent in at all. + /// For an aggregation: what staging rolled up, said the way the first exchange's input line says it. + sealed record Entered(MirrorDiff Door, bool Refused = false, string NotRun = null, string Rolled = null); + + /// + /// One family: sent in the way it came in on prod, run by staging under a correlation of its own, and + /// each exchange staging ran paired with its twin on prod - the exchange of the same subscription, in + /// the same order - and compared with it. + /// + async Task RunAsync(MirrorFamily family, Setup setup, Doors doors, CancellationToken cancellation) + { + var first = family.Members[0]; + if (family.Entry != "aggregation" && first.Input?.Key == null) + return new MirrorFamilyRun(family.Id, null, DateTimeOffset.UtcNow, null, + first.Input == null ? "Prod's first exchange had nothing come in to send." : $"Prod's input isn't kept. {first.Input.NotCopied}", [], []); + + var correlation = MirrorTraffic.NewCorrelation(); + var answers = new List(); + foreach (var member in family.Members.Where(m => m.Subscription != null)) + answers.Add(new MirrorReplayAnswer(member.Subscription, await ReplayAsync(member))); + var since = DateTime.UtcNow.AddSeconds(-1); + mock.Replay(correlation, answers); + Entered entered; + List<(Xchange X, XchangeResult R)> ran = []; + var settled = true; + IReadOnlyList calls; + try + { + entered = await SendInAsync(family, first, correlation, doors, cancellation); + if (entered.NotRun == null && !entered.Refused) (ran, settled) = await SettledAsync(correlation, since, cancellation); + } + finally + { + calls = mock.EndReplay(); + } + if (entered.NotRun != null) return new MirrorFamilyRun(family.Id, null, DateTimeOffset.UtcNow, null, entered.NotRun, [], []); + return await CompareFamilyAsync(family, setup, correlation, entered, ran, settled, calls); + } + + static string NoSubscription(string name) => $"Staging doesn't have the subscription “{name}”. Copy prod's setup; the page says why when it can't."; + + /// The first exchange's input, sent in through the same door it came in through on prod. + async Task SendInAsync(MirrorFamily family, MirrorSample first, string correlation, Doors doors, CancellationToken cancellation) + { + using var scope = scopes.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var input = first.Input?.Key == null ? null : await ReadAsync(first.Input.Key); + var file = input == null ? null : new XchangeFile(MirrorFiles.TextOf(input), first.Input.Name); + switch (family.Entry) + { + case "document": + { + var type = await db.Set().AsNoTracking().FirstOrDefaultAsync(d => d.Name == family.Door, cancellation); + if (type == null) return new Entered(null, NotRun: $"Staging doesn't have the information type “{family.Door}”. Copy prod's setup."); + await scope.ServiceProvider.GetRequiredService().SubmitFilterXchange(type.Id, file, ["rehearsal"], correlation); + return new Entered(new MirrorDiff("entry", "How it came in", Same, $"A document of “{family.Door}”", "The same document, routed by staging")); + } + case "gateway": + case "api": + return await CallAsync(family, first, input, correlation, doors, db, cancellation); + case "aggregation": + { + var subscription = await db.Subscriptions().AsNoTracking().FirstOrDefaultAsync(s => s.Name == family.Door, cancellation); + if (subscription == null) return new Entered(null, NotRun: NoSubscription(family.Door)); + // The way prod does it: rolling up what staging ran of the subscription it aggregates. + await ActivatorUtilities.CreateInstance(scope.ServiceProvider) + .Execute(new AggregationJobParams(subscription.Id, null, correlation)); + if (await db.Set().AnyAsync(x => x.CorrelationId == correlation, cancellation)) + return new Entered(new MirrorDiff("entry", "How it came in", Same, "Prod's exchanges, rolled up", "Staging's own, rolled up", + "It rolls up what staging ran in this rehearsal, so what came in lists other files than prod's."), Rolled: "Staging's own, listed"); + // Staging ran nothing of it: the files prod rolled up instead, faked, served by staging. + var rolled = (first.Rolled ?? []).Where(f => f.Key != null).ToList(); + if (rolled.Count == 0) + return new Entered(null, NotRun: "Staging had nothing to roll up: none of the exchanges it aggregates ran in this rehearsal, and none of the files prod rolled up is kept."); + var list = JsonSerializer.Serialize(rolled.Select(f => links.LinkTo(f.Key, forAdapter: true)).ToList()); + await scope.ServiceProvider.GetRequiredService().CreateXchange(subscription, new XchangeFile(list), ["rehearsal"], correlation); + await db.SaveChangesAsync(cancellation); + return new Entered(new MirrorDiff("entry", "How it came in", Info, "Prod's exchanges, rolled up", $"{rolled.Count} of the files prod rolled up, faked", + "Staging ran none of the exchanges it aggregates in this rehearsal, so it rolls up a few of the files prod did, as staging keeps them."), + Rolled: $"{rolled.Count} of prod's, faked, listed"); + } + default: + { + var subscription = await db.Subscriptions().FirstOrDefaultAsync(s => s.Name == family.Door, cancellation); + if (subscription == null) return new Entered(null, NotRun: NoSubscription(family.Door)); + var partnerName = family.Partner ?? first.Partner; + Partner partner = null; + if (partnerName != null && (partner = await db.Set().FirstOrDefaultAsync(p => p.Name == partnerName, cancellation)) == null) + return new Entered(null, NotRun: $"Staging doesn't have the partner “{partnerName}”. Copy prod's setup."); + await scope.ServiceProvider.GetRequiredService().CreateXchange(subscription, file, ["rehearsal"], correlation, partner); + await db.SaveChangesAsync(cancellation); + return new Entered(family.Entry == "receiver" + ? new MirrorDiff("entry", "How it came in", Info, "A file its receiver read", "The same file, handed straight to it", + "Reading it from the server comes with the test servers.") + : new MirrorDiff("entry", "How it came in", Same, "Started by hand", "Started the same way")); + } + } + } + + /// + /// A partner's call through one of staging's doors - an API gateway, or the partner API - as prod's + /// partner made it: with staging's key for the partner, or a token staging signs for it. + /// + async Task CallAsync(MirrorFamily family, MirrorSample first, byte[] input, string correlation, Doors doors, BitweenDbContext db, + CancellationToken cancellation) + { + var name = family.Partner ?? first.Partner; + var partner = name == null ? null : await db.Set().AsNoTracking().Where(p => p.Name == name) + .Select(p => new { p.Name, p.LoginIdentity, Keys = p.ApiCredentials.Select(c => c.Name).ToList() }) + .FirstOrDefaultAsync(cancellation); + if (partner == null) + return new Entered(null, NotRun: name == null ? "Prod's exchange doesn't say which partner called." : $"Staging doesn't have the partner “{name}”. Copy prod's setup."); + + using var request = new HttpRequestMessage(HttpMethod.Post, (Uri)null); + string said; + if (family.Entry == "gateway") + { + var gateway = family.Door == null ? null + : await db.Set().AsNoTracking().FirstOrDefaultAsync(g => g.UrlName == family.Door, cancellation); + if (gateway == null) + return new Entered(null, NotRun: family.Door == null ? "Prod's setup doesn't say which gateway it came through." : $"Staging doesn't have the gateway “{family.Door}”. Copy prod's setup."); + request.RequestUri = new Uri($"api/gateway/{gateway.UrlName}/async", UriKind.Relative); + said = $"A call to the gateway “{gateway.UrlName}” from “{name}”"; + if (gateway.AuthMethod == GatewayAuthMethod.Jwt) + { + if (string.IsNullOrEmpty(partner.LoginIdentity)) + return new Entered(null, NotRun: $"Staging's partner “{name}” has no login identity for the gateway's tokens. Copy prod's setup."); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", + mock.SignToken(gateway.JwtIssuer, gateway.JwtAudience, gateway.JwtPartnerClaim, partner.LoginIdentity)); + } + } + else + { + request.RequestUri = new Uri($"api/xchanges/{Uri.EscapeDataString(family.Door ?? "")}", UriKind.Relative); + said = $"A call to the partner API from “{name}”"; + } + if (request.Headers.Authorization == null) + { + if (partner.Keys.Count == 0) return new Entered(null, NotRun: $"Staging's partner “{name}” has no key to call with. Copy prod's setup."); + // The copy gives each of a partner's keys the same stand-in every time, which is how the rehearsal knows it. + request.Headers.Add(PartnerKeyHeaders.Default, doors.Faker.Secret($"partners/{partner.Name}/key/{partner.Keys[0]}")); + } + request.Headers.Add(MirrorTraffic.Header, $"{doors.Key} {correlation}"); + request.Content = new ByteArrayContent(input ?? []); + request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(ContentType(first.Input?.Format)); + + using var response = await doors.Client.SendAsync(request, cancellation); + var status = (int)response.StatusCode; + if (status is >= 200 and < 300) + return new Entered(new MirrorDiff("entry", "How it came in", Same, said, $"Taken: staging answered {status}")); + var body = (await response.Content.ReadAsStringAsync(cancellation)).Trim(); + return new Entered(new MirrorDiff("entry", "How it came in", Different, said, $"Refused: staging answered {status}", + $"Prod took this call, and staging turned it away{(body.Length == 0 ? "." : $": {(body.Length > 300 ? body[..300] + "…" : body)}")}"), Refused: true); + } + + /// + /// Each of staging's exchanges paired with its twin on prod - the same subscription, in the order they + /// started - and compared; an exchange one side ran and the other didn't is a difference of its own. + /// + async Task CompareFamilyAsync(MirrorFamily family, Setup setup, string correlation, Entered entered, + List<(Xchange X, XchangeResult R)> ran, bool settled, IReadOnlyList calls) + { + using var scope = scopes.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var xchanges = scope.ServiceProvider.GetRequiredService(); + var subscriptions = await db.Subscriptions().AsNoTracking().Select(s => new { s.Id, s.Name }).ToDictionaryAsync(s => s.Id, s => s.Name); + var types = await db.Set().AsNoTracking().Select(d => new { d.Id, d.Name }).ToDictionaryAsync(d => d.Id, d => d.Name); + string NameOf(Xchange x) => x.SubscriptionId is { } id ? subscriptions.GetValueOrDefault(id) : types.GetValueOrDefault(x.DocumentId); + // A document being routed has no subscription: it pairs with one of the same type. + string TwinOf(Xchange x) => x.SubscriptionId == null ? "\u0000" + NameOf(x) : NameOf(x); + static string Twin(MirrorSample p) => p.Subscription ?? "\u0000" + p.InformationType; + + var pool = ran.ToList(); + var members = new List(); + for (var i = 0; i < family.Members.Count; i++) + { + var p = family.Members[i]; + var at = pool.FindIndex(s => TwinOf(s.X) == Twin(p)); + if (at < 0) + { + members.Add(Missing(p, settled && !entered.Refused, entered.Refused)); + continue; + } + var (x, r) = pool[at]; + pool.RemoveAt(at); + members.Add(await MemberAsync(p, i == 0, x, r, setup, xchanges, calls.Where(c => c.Exchange == x.Id).ToList(), entered.Rolled)); + } + foreach (var (x, r) in pool) + members.Add(Extra(x, r, NameOf(x), family.Cut > 0 || family.Unlinked)); + + var diffs = new List(); + if (entered.Door != null) diffs.Add(entered.Door); + if (setup != null) + { + var (prod, staging) = (setup.Parity.Prod.Version, setup.Parity.Staging.Version); + diffs.Add(new MirrorDiff("family", "Bitween", prod == staging ? Same : Info, prod, staging, prod == staging ? null : "The release being tried.")); + } + if (family.Cut > 0) + diffs.Add(new MirrorDiff("family", "Prod's family", Info, $"More than {family.Members.Count} exchanges", $"{ran.Count} exchanges", + $"The sample keeps {family.Members.Count} of prod's family's exchanges, so staging's others aren't compared.")); + else if (family.Unlinked) + diffs.Add(new MirrorDiff("family", "Where it went", Info, "Not recorded", $"{ran.Count} exchanges", + "It came in on prod without a correlation id, so prod doesn't say which subscriptions it went to: staging's are run, but not counted as more than prod's.")); + var untied = calls.Where(c => c.Exchange == null).ToList(); + if (Calls(untied, untied.Where(c => c.Real).Select(c => c.Host).Distinct(StringComparer.OrdinalIgnoreCase).ToList()) is { } loose) + diffs.Add(loose with { Part = "family", Name = "Calls staging couldn't tie to an exchange" }); + + var levels = members.Select(m => m.Verdict).Append(entered.Door?.Level).ToList(); + var verdict = levels.Contains(Different) ? Different : levels.Contains(Check) ? Check : Same; + return new MirrorFamilyRun(family.Id, correlation, DateTimeOffset.UtcNow, verdict, null, diffs, members); + } + + static string OutcomeOf(XchangeResult r) => !r.Success ? "failed" : r.ResponseBad ? "badResponse" : "success"; + + /// One of prod's exchanges staging ran no twin of. + static MirrorRun Missing(MirrorSample p, bool settled, bool refused) => + new(p.ProdExchangeId, p.Subscription ?? p.InformationType, null, DateTimeOffset.UtcNow, null, null, [], + settled || refused ? Different : null, + refused ? "Staging turned the call away, so it didn't run." + : settled ? "Staging didn't run it, where prod's family did." + : $"Staging hadn't run it when the rehearsal stopped waiting, after {RunTimeout.TotalMinutes:0} minutes.", + null, null, + settled && !refused + ? [new MirrorDiff("result", "Result", Different, Ending(p.Outcome, p.Status, p.ErrorTypes ?? []), "Not run", + "Staging didn't run it: its routing, or an answer before it, went another way than on prod.")] + : []); + + /// One of staging's exchanges that prod's family had no twin of. + static MirrorRun Extra(Xchange x, XchangeResult r, string name, bool cut) + { + var (status, types) = ErrorOf(r?.Exception); + var outcome = r == null ? null : OutcomeOf(r); + return new MirrorRun(null, name, x.Id, DateTimeOffset.UtcNow, outcome, status, types, cut ? Same : Different, null, null, null, + [new MirrorDiff("result", "Result", cut ? Info : Different, "Not run", outcome == null ? "Didn't finish" : Ending(outcome, status, types), + cut ? "Prod's family was larger than the sample keeps, so it may well have run this too." + : "Prod's family didn't run it: staging's routing, or an answer before it, took it somewhere prod didn't.")]); + } + + /// One of the family's exchanges on staging, beside its twin on prod. + async Task MemberAsync(MirrorSample p, bool first, Xchange x, XchangeResult r, Setup setup, XchangeService xchanges, + IReadOnlyList calls, string rolled) + { + var name = p.Subscription ?? p.InformationType; + if (r == null) + return new MirrorRun(p.ProdExchangeId, name, x.Id, DateTimeOffset.UtcNow, null, null, [], null, + $"It didn't finish on staging within {RunTimeout.TotalMinutes:0} minutes.", null, null, []); + + var (status, types) = ErrorOf(r.Exception); + var outcome = OutcomeOf(r); + var (outputFile, output) = await StagingFileAsync(xchanges, x, r.OutputName, r.OutputSize, XchangeFileType.Output); + var (responseFile, response) = await StagingFileAsync(xchanges, x, r.ResponseName, r.ResponseSize, XchangeFileType.Response); + var real = calls.Where(c => c.Real).Select(c => c.Host).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var answer = File("answer", "What it got back", p.Response, await SampleFileAsync(p.Response), response); + // A host called for real answered, not prod's partner: a different answer then says nothing of the release. + if (real.Count > 0 && response != null && answer?.Level is Different or Check) + answer = answer with { Level = Info, Note = $"It called {string.Join(", ", real)} for real, so this is that host's answer, not prod's partner's. {answer.Note}".Trim() }; + + var diffs = new List + { + Result(p, outcome, status, types), + File("output", "What it sent", p.Output, await SampleFileAsync(p.Output), output), + answer, + Calls(calls, real), + }; + if (!first) + { + // Handed on by the exchange before it, so it differs where that one's answer did. + var (_, input) = await StagingFileAsync(xchanges, x, x.InputName, x.InputSize, XchangeFileType.Input); + diffs.Add(File("input", "What was handed on to it", p.Input, await SampleFileAsync(p.Input), input)); + } + else if (rolled != null) + diffs.Add(new MirrorDiff("input", "What came in", Info, "Prod's exchanges, listed", rolled)); + else + diffs.Add(new MirrorDiff("input", "What came in", Faked, $"{FormatName(p.Input?.Format)} · prod's, faked", "The same file")); + if (p.Subscription != null) diffs.AddRange(Used(setup, p.Subscription, p, x, r)); + diffs.RemoveAll(d => d == null); + return new MirrorRun(p.ProdExchangeId, name, x.Id, DateTimeOffset.UtcNow, outcome, status, types, MirrorCompare.Verdict(diffs), null, + outputFile, responseFile, diffs); + } + + /// The calls the exchange made: answered by staging, or sent on for real, which explains what came back. + static MirrorDiff Calls(IReadOnlyList calls, IReadOnlyList real) + { + if (calls.Count == 0) return null; + return new MirrorDiff("calls", "Calls it made", real.Count > 0 ? Info : Same, null, + real.Count == 0 ? $"{Many(calls.Count, "call")} answered by staging" : $"{Many(calls.Count(c => c.Real), "call")} sent for real, to {string.Join(", ", real)}", + real.Count == 0 ? null : "Staging calls for real a host its setup names, staging itself and this machine.", + calls.Take(20).Select(c => new MirrorFieldDiff($"{c.Method} {c.Host}{c.Path}", c.Real ? Info : Same, null, c.Why)).ToList()); + } + + /// + /// What staging answers the exchange's calls with: what prod's partner answered in it, faked. A failure + /// prod's error names a status for is that status; a call that never got through on prod doesn't on + /// staging either. Null for a failure before any call, which leaves staging's usual answer. + /// + async Task ReplayAsync(MirrorSample sample) + { + const string because = "Prod's partner's answer in the exchange being rehearsed, faked."; + return sample.Outcome switch + { + "success" or "badResponse" => new MirrorAnswer(sample.Outcome == "success" ? 200 : 400, + sample.Response?.Key == null ? null : ContentType(sample.Response.Format), + sample.Response?.Key == null ? [] : await ReadAsync(sample.Response.Key), because), + "failed" when sample.Status is { } status => new MirrorAnswer(status, null, [], $"Prod's partner answered {status} in the exchange being rehearsed."), + "failed" when Unreachable(sample.ErrorTypes ?? []) => new MirrorAnswer(0, null, [], + "Prod's call didn't get through in the exchange being rehearsed, so this one doesn't either.", Fail: true), + _ => null, + }; + } + + /// + /// Staging's exchanges of a family, once each has finished and nothing new has started for a few + /// seconds: an answer handed on, or a message across the bus, starts after its exchange is saved. + /// What there is when runs out, with whether it settled. + /// + async Task<(List<(Xchange X, XchangeResult R)> Ran, bool Settled)> SettledAsync(string correlation, DateTime since, CancellationToken cancellation) + { + var until = DateTime.UtcNow + RunTimeout; + List<(Xchange X, XchangeResult R)> ran = []; + int quiet = 0, last = -1; + while (DateTime.UtcNow < until) + { + await Task.Delay(500, cancellation); + using var scope = scopes.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var rows = await (from x in db.Set().AsNoTracking() + where x.StartedOn >= since && x.CorrelationId == correlation + join r in db.Set().AsNoTracking() on x.Id equals r.Id into results + from r in results.DefaultIfEmpty() + select new { x, r }).ToListAsync(cancellation); + ran = rows.OrderBy(a => a.x.StartedOn).Select(a => (a.x, a.r)).ToList(); + var done = ran.Count > 0 && ran.All(a => a.Item2 != null); + quiet = done && ran.Count == last ? quiet + 1 : 0; + last = ran.Count; + if (quiet >= 6) return (ran, true); + } + return (ran, false); + } + + async Task<(MirrorSampleFile File, byte[] Content)> StagingFileAsync(XchangeService xchanges, Xchange xchange, string name, int size, XchangeFileType type) + { + if (xchanges.FileKey(xchange.Id, xchange.FilesPrefix, size, type) is not { } key) return (null, null); + var content = await ReadAsync(key); + return (new MirrorSampleFile(key, name, MirrorFiles.FormatOf(content), content.Length, null), content); + } + + async Task SampleFileAsync(MirrorSampleFile file) => file?.Key == null ? null : await ReadAsync(file.Key); + + async Task ReadAsync(string key) + { + await using var stream = await cloudFiles.OpenReadAsync(key); + using var buffer = new MemoryStream(); + await stream.CopyToAsync(buffer); + return buffer.ToArray(); + } + + // ------------------------------------------------------------------ what it used + + /// + /// What the exchange used, prod's beside staging's: the release, the subscription, its information + /// type, the partner it ran as, the global values it names, and the adapters it ran. Prod's is its + /// setup today, faked as the copy fakes it; its adapters are the ones its exchange recorded. + /// + static IEnumerable Used(Setup setup, string name, MirrorSample sample, Xchange xchange, XchangeResult result) + { + if (setup != null) + { + var item = (setup.Copy.Sections.GetValueOrDefault("subscriptions") ?? []).FirstOrDefault(i => (string)i["name"] == name); + yield return Item(setup, "subscriptions", name, "Subscription"); + if ((string)item?["informationType"] is { } type) yield return Item(setup, "informationTypes", type, "Information type"); + if ((sample.Partner ?? (string)item?["partner"]) is { } partner) yield return Item(setup, "partners", partner, "Partner"); + foreach (var set in GlobalSetsIn(setup, item)) yield return Item(setup, "globalValues", set, "Global values"); + } + yield return Adapter(setup, "Mapper", sample.Mapper, xchange.MapperId, result.MapperVersion); + yield return Adapter(setup, "Handler", sample.Handler, xchange.HandlerId, result.HandlerVersion); + } + + static MirrorDiff Item(Setup setup, string section, string key, string noun) + { + var row = setup.Parity.Kinds.FirstOrDefault(k => k.Section == section)?.Items.FirstOrDefault(i => i.Key == key); + var change = setup.Plan.Changes.FirstOrDefault(c => c.Section == section && c.Key == key && c.Op != Op.Delete); + var name = $"{noun} “{key}”"; + if (row == null) + return new MirrorDiff("used", name, Info, "Not on prod any more", "On staging", "Prod's setup no longer has it: the samples are older than that."); + if (row.State is "missing" or "waiting" || change?.Op == Op.Create) + return new MirrorDiff("used", name, Info, "Has it", "Doesn't have it", row.Reason ?? "Copy prod's setup to bring it over.", null, section, row.StagingId, row.Type); + if (change?.Op == Op.Update) + { + var fields = SetupFields(change.Want, change.Have?.File, change.Fields ?? []); + return new MirrorDiff("used", name, Info, $"{fields.Count} different", $"{fields.Count} different", + "Staging's differs from prod's setup as copied today; copying again brings it in line.", fields, section, row.StagingId, row.Type); + } + var copied = (setup.Copy.Sections.GetValueOrDefault(section) ?? []).FirstOrDefault(i => Kinds.Of(section).KeyOf(i) == key); + var faked = section switch + { + "partners" when copied?["properties"] is JsonObject { Count: > 0 } => "Its properties are faked.", + "globalValues" => "Its values are faked.", + "subscriptions" when setup.Copy.Addresses.Any(a => a.UsedBy.Any(u => u.Section == section && u.Key == key)) => "Its addresses are test addresses.", + _ => null, + }; + return new MirrorDiff("used", name, faked == null ? Same : Faked, "Same", "Same", faked, null, section, row.StagingId, row.Type); + } + + /// The global values sets a subscription's adapters name in {{globals.set.key}}, as the copy names them. + static IEnumerable GlobalSetsIn(Setup setup, JsonObject subscription) + { + var values = new[] { "receiver", "validator", "mapper", "handler" } + .SelectMany(role => subscription?[role]?["properties"] as JsonObject ?? []) + .Select(p => p.Value is JsonValue v && v.TryGetValue(out var s) ? s : null); + var sets = (setup.Copy.Sections.GetValueOrDefault("globalValues") ?? []).Select(Kinds.Of("globalValues").KeyOf).Where(k => k != null).ToList(); + return StartupValuesFiller.ReferencesIn(values).Globals + .Select(g => sets.FirstOrDefault(k => string.Equals(k, g.SetId, StringComparison.OrdinalIgnoreCase))) + .Where(k => k != null).Distinct(StringComparer.Ordinal); + } + + /// + /// An adapter the exchange ran, prod's beside staging's: the version prod pinned, or else the one it has + /// as current today, against the one staging ran. A built-in one comes with the release. + /// + static MirrorDiff Adapter(Setup setup, string role, string prodRef, string stagingRef, string stagingVersion) + { + if (prodRef == null && stagingRef == null) return null; + var (prodId, pinned) = prodRef == null ? (null, null) : AdapterCatalogPaths.Split(prodRef); + var (stagingId, _) = stagingRef == null ? (null, null) : AdapterCatalogPaths.Split(stagingRef); + var builtIn = NativeAdapterDiscoveryService.IsNative(prodId ?? stagingId); + var prodVersion = pinned ?? setup?.Parity.Adapters.FirstOrDefault(a => string.Equals(a.Id, prodId, StringComparison.OrdinalIgnoreCase))?.ProdVersion; + var same = string.Equals(prodId, stagingId, StringComparison.OrdinalIgnoreCase) && + (builtIn || prodVersion == null || stagingVersion == null || prodVersion == stagingVersion); + string Shown(string id, string version) => + id == null ? "None" : NativeAdapterDiscoveryService.IsNative(id) ? $"{id} (built in)" : version == null ? id : $"{id} {version}"; + return new MirrorDiff("used", role, same ? Same : Info, Shown(prodId, builtIn ? null : prodVersion), Shown(stagingId, builtIn ? null : stagingVersion), + same || pinned != null || prodVersion == null ? null : "Prod's version is the one it has as current today."); + } +} diff --git a/SW.Bitween.Api/Services/Mirror/MirrorService.Samples.cs b/SW.Bitween.Api/Services/Mirror/MirrorService.Samples.cs index a85d9788..f234d89c 100644 --- a/SW.Bitween.Api/Services/Mirror/MirrorService.Samples.cs +++ b/SW.Bitween.Api/Services/Mirror/MirrorService.Samples.cs @@ -33,10 +33,11 @@ public sealed partial class MirrorService const int Worked = 3, AnsweredBadly = 1, Failed = 1, PerSubscription = 5; /// - /// Takes a few of prod's recent exchanges for each of its subscriptions - what came in, what Bitween - /// sent, what the partner answered, and how it ended - into staging's storage, every file faked the - /// moment it arrives. Prod is read gently: one subscription at a time, one page of its exchanges, - /// files one by one. Replaces the samples taken before. + /// Takes a few of prod's recent exchanges for each of its subscriptions, each with its family - what + /// came in, and every exchange that led to - into staging's storage: what came in, what Bitween sent, + /// what the partner answered, and how it ended, every file faked the moment it arrives. Prod is read + /// gently: one subscription at a time, one page of its exchanges, a family by the indexed time it + /// started, files one by one. Replaces the samples taken before. /// public async Task TakeSamplesAsync(Action progress, CancellationToken cancellation = default) { @@ -53,16 +54,24 @@ public async Task TakeSamplesAsync(Action progress, Can var subscriptions = (ctx.Current.GetValueOrDefault("subscriptions") ?? []).Where(s => s.Key != null) .OrderBy(s => s.Key, StringComparer.Ordinal).ToList(); using var files = new HttpClient(new ReadOnly(new HttpClientHandler { UseCookies = false })) { Timeout = TimeSpan.FromSeconds(60) }; + var taking = new Taking(prod, ctx, files, faker); var taken = new List(); progress?.Invoke(0, subscriptions.Count); foreach (var subscription in subscriptions) { cancellation.ThrowIfCancellationRequested(); - taken.Add(await SamplesOfAsync(prod, files, subscription, faker, cancellation)); + taken.Add(await SamplesOfAsync(taking, subscription, cancellation)); progress?.Invoke(taken.Count, subscriptions.Count); } - var samples = new MirrorSamples("done", startedOn, DateTimeOffset.UtcNow, taken.Count, subscriptions.Count, null, taken); + // A subscription takes part in every family it has an exchange in, not only those its own exchanges led to. + var families = taking.Families.Values.OrderBy(f => f.Members[0].StartedOn).ToList(); + taken = taken.Select(t => + { + var all = families.Where(f => f.Members.Any(m => m.Subscription == t.Subscription)).Select(f => f.Id).ToList(); + return all.Count == 0 ? t : t with { Families = all, Reason = null }; + }).ToList(); + var samples = new MirrorSamples("done", startedOn, DateTimeOffset.UtcNow, taken.Count, subscriptions.Count, null, taken, families); await cloudFiles.WriteTextAsync(JsonSerializer.Serialize(samples, ManifestJson), new WriteFileSettings { Key = SamplesManifest, ContentType = "application/json" }); await RemoveUnusedSamplesAsync(samples); @@ -70,16 +79,17 @@ await cloudFiles.WriteTextAsync(JsonSerializer.Serialize(samples, ManifestJson), } /// The samples staging holds, each file with a link that opens it, and how a taking under way is going. - public async Task SamplesAsync(MirrorSampler sampler) + public async Task SamplesAsync(MirrorWork work) { var samples = await ReadSamplesAsync(); samples = samples with { - Subscriptions = samples.Subscriptions.Select(s => s with { Samples = s.Samples.Select(Linked).ToList() }).ToList(), + Subscriptions = samples.Subscriptions.Select(s => s with { Families = s.Families ?? [] }).ToList(), + Families = (samples.Families ?? []).Select(f => f with { Members = f.Members.Select(Linked).ToList() }).ToList(), }; - if (sampler.Running is { } running) + if (work.Running is { Kind: MirrorWork.Sampling } running) return samples with { State = "running", StartedOn = running.StartedOn, FinishedOn = null, Done = running.Done, Total = running.Total, Error = null }; - return sampler.LastError is { } error ? samples with { State = "failed", Error = error } : samples; + return work.LastError(MirrorWork.Sampling) is { } error ? samples with { State = "failed", Error = error } : samples; } async Task ReadSamplesAsync() @@ -96,20 +106,44 @@ async Task ReadSamplesAsync() } MirrorSample Linked(MirrorSample sample) => - sample with { ErrorTypes = sample.ErrorTypes ?? [], Input = Linked(sample.Input), Output = Linked(sample.Output), Response = Linked(sample.Response) }; + sample with + { + ErrorTypes = sample.ErrorTypes ?? [], Input = Linked(sample.Input), Output = Linked(sample.Output), Response = Linked(sample.Response), + Rolled = sample.Rolled?.Select(Linked).ToList(), + }; MirrorSampleFile Linked(MirrorSampleFile file) => file?.Key == null ? file : file with { Url = links.LinkTo(file.Key) }; // ------------------------------------------------------------------ one subscription - async Task SamplesOfAsync(BitweenApi prod, HttpClient files, Existing subscription, MirrorFaker faker, - CancellationToken cancellation) + /// One taking: what it reads prod with, and the families it has found so far, by their first exchange. + sealed class Taking(BitweenApi prod, Context ctx, HttpClient files, MirrorFaker faker) + { + public BitweenApi Prod { get; } = prod; + public Context Ctx { get; } = ctx; + public HttpClient Files { get; } = files; + public MirrorFaker Faker { get; } = faker; + public Dictionary Families { get; } = new(StringComparer.Ordinal); + + /// Each exchange already in a family, with the family's id. + public Dictionary FamilyOf { get; } = new(StringComparer.Ordinal); + + /// Prod's subscriptions as the copy reads them, by their id on prod. + public Dictionary Subscriptions { get; } = (ctx.Current.GetValueOrDefault("subscriptions") ?? []) + .Where(s => s.Id != null).GroupBy(s => s.Id).ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); + + /// The same, by name. + public Dictionary ByName { get; } = (ctx.Current.GetValueOrDefault("subscriptions") ?? []) + .Where(s => s.Key != null).GroupBy(s => s.Key).ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); + } + + async Task SamplesOfAsync(Taking taking, Existing subscription, CancellationToken cancellation) { List rows; try { - rows = await RecentAsync(prod, subscription.Id, DateTime.UtcNow.AddDays(-30)); - if (rows.Count == 0) rows = await RecentAsync(prod, subscription.Id, since: null); + rows = await RecentAsync(taking.Prod, subscription.Id, DateTime.UtcNow.AddDays(-30)); + if (rows.Count == 0) rows = await RecentAsync(taking.Prod, subscription.Id, since: null); } catch (Exception ex) when (ex is BitweenApiException or HttpRequestException or TaskCanceledException) { @@ -120,10 +154,152 @@ async Task SamplesOfAsync(BitweenApi prod, HttpClient if (chosen.Count == 0) return new MirrorSubscriptionSamples(subscription.Key, subscription.Id, [], rows.Count == 0 ? "Prod has no exchanges of it." : "Prod's exchanges of it are all still running or retries."); - var samples = new List(); + var families = new List(); foreach (var row in chosen) - samples.Add(await SampleAsync(files, subscription.Key, row, faker, cancellation)); - return new MirrorSubscriptionSamples(subscription.Key, subscription.Id, samples, null); + { + if (!taking.FamilyOf.TryGetValue(Id(row), out var family)) + { + if (await FamilyAsync(taking, row, cancellation) is not { } found) continue; + taking.Families[found.Id] = found; + foreach (var member in found.Members) taking.FamilyOf.TryAdd(member.ProdExchangeId, found.Id); + family = found.Id; + } + if (!families.Contains(family)) families.Add(family); + } + return new MirrorSubscriptionSamples(subscription.Key, subscription.Id, families, + families.Count == 0 ? "Its handler reads values from the exchange that started it, and prod's exchanges of it don't say which." : null); + } + + static string Id(JsonObject row) => (string)row["id"] ?? ""; + + /// How many of a family's exchanges a sample keeps: a document can go to many subscriptions. + const int FamilyMembers = 25; + + /// A family's exchanges start this close to its first one: answers and messages are handed on at once. + static readonly TimeSpan Before = TimeSpan.FromMinutes(1), After = TimeSpan.FromMinutes(15); + + /// + /// The exchange on prod and its family, by the links prod records: up to the exchange whose answer or + /// message started it, then down to what each one started - the subscriptions a document was routed + /// to, an answer handed on, a message a bus gateway took - never a retry. A document's subscriptions + /// share its correlation id and its input, and start after it; an answer to a response subscription, + /// and a message, name the exchange that handed them on; an answer to a legacy target shares its + /// correlation id. Each search is bound to the minutes around the exchange it starts from, which + /// prod has indexed, so a big prod isn't searched end to end. Null for an exchange whose handler reads + /// values from the exchange that started it, when prod doesn't say which. + /// + async Task FamilyAsync(Taking taking, JsonObject sampled, CancellationToken cancellation) + { + var top = sampled; + for (var depth = 0; depth < 4 && (string)top["sourceXchangeId"] is { Length: > 0 } parentId; depth++) + { + if (await ByIdAsync(taking.Prod, parentId) is not { } parent) break; + top = parent; + } + if (SubscriptionOf(taking, top) is { } first && (string)first.File["type"] is "Response" or "BusGateway" && ReadsSource(first)) return null; + + var rows = new List(); + var seen = new HashSet(StringComparer.Ordinal); + var frontier = new Queue(); + frontier.Enqueue(top); + var (cut, unlinked) = (0, false); + while (frontier.Count > 0) + { + cancellation.ThrowIfCancellationRequested(); + var row = frontier.Dequeue(); + if (!seen.Add(Id(row))) continue; + if (rows.Count == FamilyMembers) + { + cut++; + continue; + } + rows.Add(row); + var around = StartedOn(row); + var correlation = (string)row["correlationId"]; + var subscription = SubscriptionOf(taking, row); + if (subscription == null) + { + // A document being routed: the subscriptions it went to. + if (string.IsNullOrEmpty(correlation)) unlinked = true; + else + foreach (var hit in await NearAsync(taking.Prod, $"CorrelationId:1:{correlation}", around)) + if (hit["subscriptionId"] != null && string.IsNullOrEmpty((string)hit["sourceXchangeId"]) && + StartedOn(hit) >= around && (int?)hit["inputFileSize"] == (int?)row["inputFileSize"]) + frontier.Enqueue(hit); + continue; + } + if (!string.IsNullOrEmpty((string)subscription.File["responseMessageTypeName"]) || + (string)subscription.File["responseSubscription"] is { } target && TypeOf(taking, target) == "Response") + foreach (var next in await NearAsync(taking.Prod, $"SourceXchangeId:1:{Id(row)}", around)) + frontier.Enqueue(next); + else if ((string)subscription.File["responseSubscription"] is { } legacy && !string.IsNullOrEmpty(correlation) && + taking.ByName.GetValueOrDefault(legacy)?.Id is { } legacyId) + foreach (var next in await NearAsync(taking.Prod, $"CorrelationId:1:{correlation}", around)) + if (next["subscriptionId"]?.ToString() == legacyId && StartedOn(next) >= around) + frontier.Enqueue(next); + } + rows = rows.OrderBy(StartedOn).ToList(); + var kept = rows.Select(Id).ToHashSet(StringComparer.Ordinal); + + var members = new List(); + foreach (var row in rows) + members.Add(await SampleAsync(taking, row, kept, cancellation)); + var (entry, door, partner) = EntryOf(taking, rows[0], members[0]); + return new MirrorFamily(Id(rows[0]), entry, door, partner, members, cut, unlinked); + } + + /// Whether a subscription's handler reads values from the exchange that started it: {{source.…}}. + static bool ReadsSource(Existing subscription) => + StartupValuesFiller.SourcePathsIn((subscription.File["handler"]?["properties"] as JsonObject ?? []).Select(p => p.Value?.ToString())).Any(); + + static Existing SubscriptionOf(Taking taking, JsonObject row) => + row["subscriptionId"]?.ToString() is { } id ? taking.Subscriptions.GetValueOrDefault(id) : null; + + static string TypeOf(Taking taking, string subscriptionKey) => (string)taking.ByName.GetValueOrDefault(subscriptionKey)?.File["type"]; + + /// + /// How a family came in on prod, what through, and who called: a document routed (one that isn't + /// kept as an exchange is seen only in the subscriptions it reached), a call to a gateway or to the + /// partner API, a file a receiver read, an aggregation, or an exchange started by hand. + /// + static (string Entry, string Door, string Partner) EntryOf(Taking taking, JsonObject first, MirrorSample member) + { + if (member.Subscription == null) return ("document", member.InformationType, null); + var subscription = taking.Subscriptions.GetValueOrDefault(first["subscriptionId"]?.ToString() ?? ""); + switch ((string)subscription?.File["type"]) + { + case "GatewayApiCall": + var gateway = (taking.Ctx.Current.GetValueOrDefault("apiGateways") ?? []) + .FirstOrDefault(g => (g.File["partners"] as JsonArray ?? []).OfType() + .Any(p => (string)p["subscription"] == member.Subscription && (member.Partner == null || (string)p["partner"] == member.Partner))); + return ("gateway", gateway?.Key, member.Partner); + case "ApiCall": + return ("api", member.InformationType, member.Partner); + case "Receiving": + return ("receiver", member.Subscription, null); + case "Aggregation": + return ("aggregation", member.Subscription, null); + case "Internal" when !string.IsNullOrEmpty((string)first["correlationId"]): + return ("document", member.InformationType, null); + default: + return ("direct", member.Subscription, member.Partner); + } + } + + static async Task ByIdAsync(BitweenApi prod, string id) + { + var answer = await prod.GetAsync($"api/xchanges?filter={Uri.EscapeDataString($"Id:1:{id}")}&page=0&size=1"); + return (answer?["result"] as JsonArray ?? []).OfType().FirstOrDefault(); + } + + /// Prod's exchanges matching that started around , never a retry. + static async Task> NearAsync(BitweenApi prod, string filter, DateTime around) + { + static string At(DateTime on) => on.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture); + var answer = await prod.GetAsync($"api/xchanges?filter={Uri.EscapeDataString(filter)}" + + $"&filter={Uri.EscapeDataString($"StartedOn:6:{At(around - Before)}")}" + + $"&filter={Uri.EscapeDataString($"StartedOn:8:{At(around + After)}")}&page=0&size=50"); + return (answer?["result"] as JsonArray ?? []).OfType().Where(r => string.IsNullOrEmpty((string)r["retryFor"])).ToList(); } /// A page of a subscription's exchanges on prod, newest first: since a day, or ever. @@ -160,17 +336,97 @@ static DateTime StartedOn(JsonObject row) => internal static string Outcome(JsonObject row) => (bool?)row["status"] == false ? "failed" : (bool?)row["responseBad"] == true ? "badResponse" : "success"; - async Task SampleAsync(HttpClient files, string subscription, JsonObject row, MirrorFaker faker, CancellationToken cancellation) + async Task SampleAsync(Taking taking, JsonObject row, ISet family, CancellationToken cancellation) { - var id = (string)row["id"] ?? ""; + var id = Id(row); + var subscription = row["subscriptionId"] is JsonValue sid ? taking.Ctx.KeyOf("subscriptions", sid) : null; + var type = (string)row["documentName"]; // Named by staging, never by a path prod could choose: the subscription by a hash of its name, the // exchange by its id when that is only letters and digits. - var folder = $"{SamplesPrefix}/{Folder(subscription)}/{(SafeId.IsMatch(id) ? id : Folder(id))}"; + var folder = $"{SamplesPrefix}/{Folder(subscription ?? type ?? "")}/{(SafeId.IsMatch(id) ? id : Folder(id))}"; var (status, errorTypes) = ErrorOf((string)row["exception"]); + var (files, faker) = (taking.Files, taking.Faker); + var rolled = (string)SubscriptionOf(taking, row)?.File["type"] == "Aggregation" + ? await RolledAsync(taking, (string)row["inputUrl"], folder, cancellation) + : null; return new MirrorSample(id, StartedOn(row), Outcome(row), status, errorTypes, await FileAsync(files, (string)row["inputUrl"], (string)row["inputFileName"], $"{folder}/input", faker, cancellation), await FileAsync(files, (string)row["outputUrl"], (string)row["outputFileName"], $"{folder}/output", faker, cancellation), - await FileAsync(files, (string)row["responseUrl"], (string)row["responseFileName"], $"{folder}/response", faker, cancellation)); + await FileAsync(files, (string)row["responseUrl"], (string)row["responseFileName"], $"{folder}/response", faker, cancellation), + row["partnerId"] is JsonValue partner ? taking.Ctx.KeyOf("partners", partner) : null, + (string)row["handlerId"], (string)row["mapperId"], + subscription, type, + (string)row["sourceXchangeId"] is { } parent && family.Contains(parent) ? parent : null, + rolled); + } + + /// How many of the files an aggregation rolled up a sample keeps. + const int RolledFiles = 5; + + /// + /// A few of the files an aggregation rolled up on prod, each faked as it arrives: its input lists them. + /// Read before the input is faked, which would turn the links into test addresses. + /// + async Task> RolledAsync(Taking taking, string inputUrl, string folder, CancellationToken cancellation) + { + var (files, faker) = (taking.Files, taking.Faker); + if (string.IsNullOrEmpty(inputUrl)) return []; + byte[] list; + try + { + list = await DownloadAsync(files, FromProd(inputUrl), cancellation); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or InvalidOperationException or UriFormatException) + { + return []; + } + List urls; + try + { + urls = list == null ? [] : JsonSerializer.Deserialize>(list) ?? []; + } + catch (JsonException) + { + urls = []; + } + finally + { + if (list != null) Array.Clear(list); + } + var kept = new List(); + for (var i = 0; i < Math.Min(RolledFiles, urls.Count); i++) + { + var name = $"rolled-{i + 1}"; + kept.Add(await ServedAsync(taking.Prod, urls[i]) is { } link + ? await FileAsync(files, link, name, $"{folder}/rolled/{i + 1}", faker, cancellation) + : new MirrorSampleFile(null, faker.FileName(name), null, 0, "It isn't a file prod serves, so staging doesn't fetch it.")); + } + return kept; + } + + /// An exchange file's place in storage: …/{exchange id}/input, output or response. + static readonly Regex StoredExchangeFile = new(@"/(?[0-9a-f]{32})/(?input|output|response)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /// + /// Where staging reads a file an aggregation lists: one of prod's own links, or a storage address of + /// one of prod's exchanges' files - from before files were private - through prod's link to that + /// file. Null for any other address: staging never fetches what prod's data merely names. + /// + async Task ServedAsync(BitweenApi prod, string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Scheme is not ("https" or "http")) return null; + if (uri.AbsolutePath.Contains("/" + FileLinks.RoutePrefix + "/", StringComparison.Ordinal)) return FromProd(url); + if (StoredExchangeFile.Match(uri.AbsolutePath) is not { Success: true } m) return null; + try + { + return await ByIdAsync(prod, m.Groups["id"].Value) is { } row && (string)row[$"{m.Groups["type"].Value.ToLowerInvariant()}Url"] is { } link + ? FromProd(link) + : null; + } + catch (Exception ex) when (ex is BitweenApiException or HttpRequestException or TaskCanceledException) + { + return null; + } } static readonly Regex SafeId = new("^[A-Za-z0-9_-]{1,64}$", RegexOptions.Compiled); @@ -285,7 +541,7 @@ string FromProd(string url) /// Files of samples taken before that the new ones don't name: staging keeps only the latest. async Task RemoveUnusedSamplesAsync(MirrorSamples samples) { - var kept = samples.Subscriptions.SelectMany(s => s.Samples).SelectMany(s => new[] { s.Input, s.Output, s.Response }) + var kept = (samples.Families ?? []).SelectMany(f => f.Members).SelectMany(s => new[] { s.Input, s.Output, s.Response }.Concat(s.Rolled ?? [])) .Where(f => f?.Key != null).Select(f => f.Key).Append(SamplesManifest).ToHashSet(StringComparer.Ordinal); foreach (var file in await cloudFiles.ListAsync(SamplesPrefix + "/")) if (!kept.Contains(file.Key)) diff --git a/SW.Bitween.Api/Services/Mirror/MirrorService.cs b/SW.Bitween.Api/Services/Mirror/MirrorService.cs index 79854949..6e0d6b7d 100644 --- a/SW.Bitween.Api/Services/Mirror/MirrorService.cs +++ b/SW.Bitween.Api/Services/Mirror/MirrorService.cs @@ -11,6 +11,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using SW.Bitween.Cli; using SW.Bitween.Cli.Configuration; using SW.PrimitiveTypes; @@ -77,7 +78,7 @@ public record MirrorCopyResult(int Created, int Updated, int Removed, int Adapte /// older is refused with the reason. /// public sealed partial class MirrorService(BitweenOptions options, FileLinks links, IHttpContextAccessor httpContextAccessor, IConfiguration configuration, - NativeAdapterDiscoveryService nativeAdapters, ICloudFilesService cloudFiles) + NativeAdapterDiscoveryService nativeAdapters, ICloudFilesService cloudFiles, IServiceScopeFactory scopes, MirrorMock mock) { /// The configuration name staging's copy is recorded under, so a later copy removes what prod no longer has. public const string ConfigurationName = "prod-mirror"; @@ -126,8 +127,8 @@ public async Task CopyAsync(bool removeExtras, CancellationTok failed.Select(f => new MirrorFailure(f.Change.Section, f.Change.Noun, f.Change.Key, f.Error)).ToList(), after); } - /// A comparison, and the two connections its planner carries out changes over until it is done with. - sealed record Comparison(MirrorParity Parity, Planner Planner, Plan Plan, BitweenApi Prod, BitweenApi Self) : IDisposable + /// A comparison, prod's setup as staging copies it, and the two connections its planner carries out changes over until it is done with. + sealed record Comparison(MirrorParity Parity, Planner Planner, Plan Plan, MirrorSetupCopy Copy, BitweenApi Prod, BitweenApi Self) : IDisposable { public void Dispose() { @@ -136,13 +137,13 @@ public void Dispose() } } - async Task CompareAsync(bool removeExtras, string copyKey) + async Task CompareAsync(bool removeExtras, string copyKey, string selfToken = null) { var prod = Connect(); BitweenApi self = null; try { - return await CompareAsync(prod, () => self = SelfApi(copyKey), removeExtras); + return await CompareAsync(prod, () => self = SelfApi(copyKey, selfToken), removeExtras); } catch { @@ -202,7 +203,7 @@ async Task CompareAsync(BitweenApi prod, Func openSelf, new MirrorSide(links.BaseUrl, BitweenInfo.Version(options).ToString(3), await ExchangesAsync(self)), Verdict(kinds, notes), notes, kinds, adapters, copy.Addresses, copy.FakedKeys, unknown); - return new Comparison(parity, planner, plan, prod, self); + return new Comparison(parity, planner, plan, copy, prod, self); } // ------------------------------------------------------------------ prod @@ -695,9 +696,9 @@ HashSet KeepList() => /// This Bitween's own API, as the caller, the way the kit installer calls itself: where it listens. /// During a copy each call carries the copy's key, so saves know they recreate prod. /// - BitweenApi SelfApi(string copyKey) + BitweenApi SelfApi(string copyKey, string token = null) { - var profile = new Profile { Url = SelfUrl, AccessToken = CallerToken() }; + var profile = new Profile { Url = SelfUrl, AccessToken = token ?? CallerToken() }; return copyKey == null ? new BitweenApi(null, null, profile) : new BitweenApi(null, null, profile, new WithCopyKey(copyKey, new HttpClientHandler { UseCookies = false })); @@ -705,7 +706,8 @@ BitweenApi SelfApi(string copyKey) string SelfUrl => string.IsNullOrWhiteSpace(links.InstanceUrl) ? links.BaseUrl : links.InstanceUrl; - string CallerToken() + /// The token of whoever asked: staging reads its own setup as them. + internal string CallerToken() { var header = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString(); return header?.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) == true diff --git a/SW.Bitween.Api/Services/Mirror/MirrorSetup.cs b/SW.Bitween.Api/Services/Mirror/MirrorSetup.cs index 10416d2a..6e48376a 100644 --- a/SW.Bitween.Api/Services/Mirror/MirrorSetup.cs +++ b/SW.Bitween.Api/Services/Mirror/MirrorSetup.cs @@ -17,10 +17,15 @@ public record MirrorUse(string Section, string Noun, string Key, string Where); public record MirrorFakedKey(string Id, string Label, int Count, bool Kept); /// Prod's setup as staging gets it: every value handled, and what that turned up. +/// +/// The data sources and bus gateways prod has on and the copy switched off, as section/key: a rehearsal's +/// traffic still goes through them, as prod's does. +/// public record MirrorSetupCopy( Dictionary> Sections, IReadOnlyList Addresses, - IReadOnlyList FakedKeys); + IReadOnlyList FakedKeys, + IReadOnlySet SwitchedOff = null); /// /// Prod's setup, read as a configuration file would say it, with what staging must not hold taken out: @@ -42,8 +47,11 @@ public static MirrorSetupCopy Copy(IReadOnlyDictionary> p foreach (var (section, items) in prod) sections[section] = items.Where(e => e.Key != null).OrderBy(e => e.Key, StringComparer.Ordinal) .Select(e => Handle(section, e.Key, (JsonObject)e.File.DeepClone(), faker, keep)).ToList(); + var switchedOff = prod.Where(p => p.Key is "dataSources" or "busGateways") + .SelectMany(p => p.Value.Where(e => e.Key != null && !Values.Same(e.File["inactive"], true)).Select(e => $"{p.Key}/{e.Key}")) + .ToHashSet(StringComparer.Ordinal); - return new MirrorSetupCopy(sections, AddressesIn(sections), FakedKeys(prod, keep)); + return new MirrorSetupCopy(sections, AddressesIn(sections), FakedKeys(prod, keep), switchedOff); } static JsonObject Handle(string section, string key, JsonObject item, MirrorFaker faker, ISet keep) diff --git a/SW.Bitween.Api/Services/Mirror/MirrorTraffic.cs b/SW.Bitween.Api/Services/Mirror/MirrorTraffic.cs new file mode 100644 index 00000000..c9687750 --- /dev/null +++ b/SW.Bitween.Api/Services/Mirror/MirrorTraffic.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using Microsoft.AspNetCore.Http; +using SW.Bitween.Domain; + +namespace SW.Bitween.Services.Mirror; + +/// +/// A rehearsal's traffic on a mirror, told apart by its correlation id, which every exchange of a family +/// carries on: the document routed, the exchanges it starts, the responses they hand on. +/// +/// Staging's subscriptions stay paused, so nothing runs on its own; rehearsal traffic alone gets +/// through, as prod's traffic gets through prod's subscriptions. Nor is it ever a duplicate of the +/// rehearsal before it, which sent the same documents. Calls through a door - an API gateway, the +/// partner API - carry a key the rehearsal opens for as long as it runs. +/// +/// +public static class MirrorTraffic +{ + const string Prefix = "rehearsal-"; + + public const string Header = "X-Bitween-Mirror-Rehearsal"; + + /// The value a rehearsal's message carries its correlation in, across the bus. + public const string BusValue = "BitweenMirrorRehearsal"; + + static readonly ConcurrentDictionary Open = new(StringComparer.Ordinal); + + public static string NewCorrelation() => Prefix + Guid.NewGuid().ToString("N"); + + /// Rehearsal traffic, on a Bitween marked as a mirror. + public static bool IsRehearsal(BitweenOptions options, string correlationId) => + options.Mirror.Target && correlationId?.StartsWith(Prefix, StringComparison.Ordinal) == true; + + /// A key for one rehearsal's calls through a door; close it when the rehearsal ends. + public static string OpenKey() + { + var key = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); + Open[key] = 0; + return key; + } + + public static void CloseKey(string key) + { + if (key != null) Open.TryRemove(key, out _); + } + + /// + /// The correlation id for a call through a door: a rehearsal's own when the call carries an open + /// key, on a mirror, naming the correlation the rehearsal chose; null for anyone else's. + /// + public static string CorrelationFor(BitweenOptions options, IHeaderDictionary headers) + { + if (!options.Mirror.Target || headers?[Header].ToString() is not { Length: > 0 } value) return null; + var at = value.IndexOf(' '); + if (at < 0 || !Open.ContainsKey(value[..at])) return null; + var correlation = value[(at + 1)..].Trim(); + return correlation.StartsWith(Prefix, StringComparison.Ordinal) && correlation.Length <= 64 ? correlation : null; + } + + // ------------------------------------------------------------------ where it goes + + static volatile IReadOnlySet switchedOff; + + /// + /// The bus gateways a rehearsal's traffic routes through though the copy switched them off: those + /// prod has on. Null while no rehearsal runs. + /// + public static IReadOnlySet SwitchedOffGateways => switchedOff; + + internal static void RouteThrough(IReadOnlySet gateways) => switchedOff = gateways; + + // ------------------------------------------------------------------ which exchange is running + + /// An exchange of a rehearsal while staging runs it. + public sealed record Running(string Id, string Correlation, int? SubscriptionId); + + static readonly AsyncLocal current = new(); + static readonly ConcurrentDictionary running = new(StringComparer.Ordinal); + + /// The rehearsal exchange this code runs for, where Bitween runs its adapter itself. + public static Running Current => current.Value; + + /// Every rehearsal exchange running now: an adapter in its own process says nothing of which it runs for. + public static IReadOnlyList Now => running.Values.ToList(); + + /// Marks a rehearsal exchange as running until disposed; nothing for any other exchange. + public static IDisposable Track(BitweenOptions options, Xchange xchange) => + Track(options, xchange.Id, xchange.CorrelationId, xchange.SubscriptionId); + + internal static IDisposable Track(BitweenOptions options, string id, string correlationId, int? subscriptionId) + { + if (!IsRehearsal(options, correlationId)) return null; + var entry = new Running(id, correlationId, subscriptionId); + current.Value = entry; + running[id] = entry; + return new Untrack(id); + } + + sealed class Untrack(string id) : IDisposable + { + public void Dispose() + { + running.TryRemove(id, out _); + current.Value = null; + } + } +} diff --git a/SW.Bitween.Api/Services/XchangeService.cs b/SW.Bitween.Api/Services/XchangeService.cs index c93b2850..e589568e 100644 --- a/SW.Bitween.Api/Services/XchangeService.cs +++ b/SW.Bitween.Api/Services/XchangeService.cs @@ -62,7 +62,10 @@ public async Task SubmitFilterXchange(int documentId, XchangeFile file, string[] var document = await BitweenCache.DocumentByIdAsync(documentId); Xchange xchange; - var duplicate = await new DuplicateArrivals(dbContext).CheckAsync(document, null, file); + // A mirror's rehearsal sends the documents the one before it sent: never a duplicate of those. + var duplicate = Services.Mirror.MirrorTraffic.IsRehearsal(BitweenSettings, correlationId) + ? null + : await new DuplicateArrivals(dbContext).CheckAsync(document, null, file); if (duplicate?.Drop == true) { await dbContext.SaveChangesAsync(); @@ -74,7 +77,8 @@ public async Task SubmitFilterXchange(int documentId, XchangeFile file, string[] { xchange = new Xchange(documentId, null, file, references, SubscriptionType.Internal, correlationId, source); - var result = await filterService.Filter(xchange.DocumentId, file); + var result = await filterService.Filter(xchange.DocumentId, file, + Services.Mirror.MirrorTraffic.IsRehearsal(BitweenSettings, correlationId)); await CreateXchangesForHits(xchange, result, file); } else @@ -324,7 +328,7 @@ private async Task HandOnResponse(Xchange xchange, XchangeFile response ? new XchangeSource(xchange.Id, SourceDocument.Read(inputFile.Data, SourceDocument.PathsUsedBy(target))) : null; - if (target.PausedOn != null) + if (target.PausedOn != null && !Services.Mirror.MirrorTraffic.IsRehearsal(BitweenSettings, xchange.CorrelationId)) { await CreateOnHoldXchange(target, responseFile, null, partnerId, xchange.CorrelationId, source); return null; @@ -350,8 +354,11 @@ private async Task HandOnResponse(Xchange xchange, XchangeFile response /// private Task PublishResponse(Xchange xchange, XchangeFile responseFile) { - dbContext.Add(new OutboxMessage(xchange.ResponseMessageTypeName, responseFile.Data, - new Dictionary { [StartupValuesFiller.SourceXchangeBusValue] = xchange.Id })); + var values = new Dictionary { [StartupValuesFiller.SourceXchangeBusValue] = xchange.Id }; + // The bus starts a correlation of its own; a mirror's rehearsal follows its traffic across it. + if (Services.Mirror.MirrorTraffic.IsRehearsal(BitweenSettings, xchange.CorrelationId)) + values[Services.Mirror.MirrorTraffic.BusValue] = xchange.CorrelationId; + dbContext.Add(new OutboxMessage(xchange.ResponseMessageTypeName, responseFile.Data, values)); return Task.CompletedTask; } @@ -782,6 +789,7 @@ private async Task Process(XchangeMessage message) } using var activity = BitweenTelemetry.ActivitySource.StartActivity("exchange.process"); + using var rehearsed = Services.Mirror.MirrorTraffic.Track(BitweenSettings, xchange); activity?.SetTag("bitween.exchange.id", xchange.Id); activity?.SetTag("bitween.document.id", xchange.DocumentId); activity?.SetTag("bitween.subscription.id", xchange.SubscriptionId); @@ -812,7 +820,8 @@ private async Task Process(XchangeMessage message) { await DocumentSchema.Enforce(await BitweenCache.DocumentByIdAsync(xchange.DocumentId), inputFile); if (!loaded.HasPromoted || xchange.SubscriptionId == null) - result = await filterService.Filter(xchange.DocumentId, inputFile); + result = await filterService.Filter(xchange.DocumentId, inputFile, + Services.Mirror.MirrorTraffic.IsRehearsal(BitweenSettings, xchange.CorrelationId)); } if (!loaded.HasPromoted) AddPromotedProperties(xchange, result); @@ -1434,10 +1443,12 @@ async Task CreateXchangesForHits(Xchange xchange, FilterResult result, XchangeFi string original = null; var originalRead = false; + // A mirror's subscriptions stay paused, so only its rehearsal's traffic runs through them. + var rehearsing = Services.Mirror.MirrorTraffic.IsRehearsal(BitweenSettings, xchange.CorrelationId); foreach (var subscriptionId in result.Hits) { var subscription = await BitweenCache.SubscriptionByIdAsync(subscriptionId); - if (subscription.PausedOn != null) + if (subscription.PausedOn != null && !rehearsing) { await CreateOnHoldXchange(subscription, inputFile); } @@ -1471,7 +1482,7 @@ async Task CreateXchangesForHits(Xchange xchange, FilterResult result, XchangeFi // A route reads what it uses from the input of the exchange whose delivery got the // response its message carries, if it was one. Auto-matched Internal subscriptions above never do. var source = await SourceFor(subscription); - if (subscription.PausedOn != null) + if (subscription.PausedOn != null && !rehearsing) { await CreateOnHoldXchange(subscription, inputFile, source: source); } diff --git a/SW.Bitween.Configuration/BitweenApi.cs b/SW.Bitween.Configuration/BitweenApi.cs index 5b55a8d2..f98cd66f 100644 --- a/SW.Bitween.Configuration/BitweenApi.cs +++ b/SW.Bitween.Configuration/BitweenApi.cs @@ -164,7 +164,9 @@ public async Task PostFileAsync(string path, string file, string conte async Task SendAsync(Func make) { var token = profile.AccessToken; - if (Expired(token)) await RenewAsync(token); + // A token it can't renew - one handed over with no way to sign in again - is sent as it is: the + // server allows for clocks a little apart, and says itself when it won't take it. + if (Expired(token) && CanRenew) await RenewAsync(token); var response = await SendOnceAsync(make()); if (response.StatusCode == HttpStatusCode.Unauthorized) { @@ -182,6 +184,8 @@ async Task SendAsync(Func make) } } + bool CanRenew => serviceToken != null || member != null || !string.IsNullOrEmpty(profile.RefreshToken); + Task SendOnceAsync(HttpRequestMessage request) { request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", profile.AccessToken); diff --git a/SW.Bitween.UnitTests/MirrorCatchingTests.cs b/SW.Bitween.UnitTests/MirrorCatchingTests.cs index b1b441b3..72d4a3ea 100644 --- a/SW.Bitween.UnitTests/MirrorCatchingTests.cs +++ b/SW.Bitween.UnitTests/MirrorCatchingTests.cs @@ -33,12 +33,13 @@ public class MirrorCatchingTests new HashSet(StringComparer.OrdinalIgnoreCase) { "login.mirror.invalid/v1/session" }); static readonly MirrorSamples Samples = new("done", null, null, 1, 1, null, - [ - new MirrorSubscriptionSamples("Push labels", "12", [ - new MirrorSample("x1", new DateTime(2026, 10, 20), "success", null, [], null, null, - new MirrorSampleFile("mirror/samples/a/x1/response", "label.json", "json", 20, null)), - ], null), - ]); + [new MirrorSubscriptionSamples("Push labels", "12", ["x1"], null)], + [ + new MirrorFamily("x1", "direct", "Push labels", null, [ + new MirrorSample("x1", new DateTime(2026, 10, 20), "success", null, [], null, null, + new MirrorSampleFile("mirror/samples/a/x1/response", "label.json", "json", 20, null), Subscription: "Push labels"), + ]), + ]); static MirrorMock Mock(string passHosts = null) { diff --git a/SW.Bitween.UnitTests/MirrorRehearsalTests.cs b/SW.Bitween.UnitTests/MirrorRehearsalTests.cs new file mode 100644 index 00000000..af5929c2 --- /dev/null +++ b/SW.Bitween.UnitTests/MirrorRehearsalTests.cs @@ -0,0 +1,234 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SW.Bitween.Services.Mirror; + +namespace SW.Bitween.UnitTests; + +/// +/// How a rehearsed exchange is compared with prod's: faked values that are only copied read the same, +/// values worked out from them keep their look (check), and a changed ending, format, field or way of +/// writing a value is different. And how staging answers a rehearsed exchange's calls: with what prod's +/// partner answered in that same exchange. +/// +[TestClass] +public class MirrorRehearsalTests +{ + static byte[] Bytes(string text) => Encoding.UTF8.GetBytes(text); + + static readonly MirrorSampleFile Kept = new("mirror/samples/a/x1/output", "out.json", "json", 10, null); + + static MirrorDiff Compare(string prod, string staging) => MirrorCompare.File("output", "What it sent", Kept, Bytes(prod), Bytes(staging)); + + [TestMethod] + public void The_same_file_however_it_is_laid_out_is_the_same() + { + var diff = Compare("""{"order":{"ref":"XKQ-1","lines":[{"sku":"A1"}]}}""", "{\n \"order\": { \"lines\": [ { \"sku\": \"A1\" } ], \"ref\": \"XKQ-1\" }\n}"); + + Assert.AreEqual(MirrorCompare.Same, diff.Level); + Assert.AreEqual("JSON · 2 fields", diff.Prod); + } + + [TestMethod] + public void A_missing_field_a_value_written_another_way_and_a_type_change_are_different() + { + var diff = Compare("""{"ref":"XKQ-1","on":"2026-11-03","qty":3}""", """{"on":"03/11/2026","qty":"3"}"""); + + Assert.AreEqual(MirrorCompare.Different, diff.Level); + StringAssert.Contains(diff.Note, "1 field missing"); + StringAssert.Contains(diff.Note, "2 values written another way"); + var on = diff.Fields.Single(f => f.Path == "on"); + Assert.AreEqual(("\"2026-11-03\"", "\"03/11/2026\""), (on.Prod, on.Staging)); + Assert.AreEqual("Missing", diff.Fields.Single(f => f.Path == "ref").Staging); + } + + [TestMethod] + public void Only_text_that_keeps_its_look_is_a_check() + { + // A total worked out from faked amounts: other digits, the same look. + var diff = Compare("""{"name":"Kara Doyle","total":"4821.37"}""", """{"name":"Kara Doyle","total":"9310.52"}"""); + + Assert.AreEqual(MirrorCompare.Check, diff.Level); + Assert.AreEqual(MirrorCompare.Check, diff.Fields.Single().Level); + Assert.AreEqual(MirrorCompare.Look("2026-03-14"), MirrorCompare.Look("2031-07-22")); + Assert.AreNotEqual(MirrorCompare.Look("Kara Doyle"), MirrorCompare.Look("KARA DOYLE")); + } + + [TestMethod] + public void A_shorter_list_is_said_once_not_once_per_field_of_its_items() + { + var diff = Compare("""{"lines":[{"sku":"A1","qty":1},{"sku":"B2","qty":2}]}""", """{"lines":[{"sku":"A1","qty":1}]}"""); + + Assert.AreEqual(MirrorCompare.Different, diff.Level); + var count = diff.Fields.Single(); + Assert.AreEqual(("lines", "2 items", "1 item"), (count.Path, count.Prod, count.Staging)); + } + + [TestMethod] + public void Xml_csv_and_text_are_compared_in_their_own_way() + { + var xml = MirrorCompare.File("output", "x", Kept, Bytes("""AB"""), + Bytes("\n A\n")); + Assert.AreEqual(MirrorCompare.Different, xml.Level); + Assert.AreEqual(("Order/Line", "2 items", "1 item"), (xml.Fields[0].Path, xml.Fields[0].Prod, xml.Fields[0].Staging)); + + var csv = MirrorCompare.File("output", "x", Kept, Bytes("id;name\n1;Kara\n2;Lior\n"), Bytes("id;name\n1;Kara\n")); + Assert.AreEqual(MirrorCompare.Different, csv.Level); + Assert.AreEqual(("Rows", "3 rows", "2 rows"), (csv.Fields[0].Path, csv.Fields[0].Prod, csv.Fields[0].Staging)); + + var format = MirrorCompare.File("output", "x", Kept, Bytes("""{"a":1}"""), Bytes("1")); + Assert.AreEqual(MirrorCompare.Different, format.Level); + Assert.AreEqual("Written as XML where prod's was JSON.", format.Note); + } + + [TestMethod] + public void A_file_only_one_side_has_or_prod_didnt_keep_says_so() + { + Assert.AreEqual(MirrorCompare.Different, MirrorCompare.File("output", "x", Kept, Bytes("{}"), null).Level); + Assert.AreEqual(MirrorCompare.Different, MirrorCompare.File("output", "x", null, null, Bytes("{}")).Level); + Assert.IsNull(MirrorCompare.File("output", "x", null, null, null)); + var notKept = MirrorCompare.File("output", "x", new MirrorSampleFile(null, "a.pdf", "pdf", 0, "PDFs can't be faked."), null, Bytes("%PDF-1.4")); + Assert.AreEqual(MirrorCompare.Info, notKept.Level); + StringAssert.Contains(notKept.Note, "PDFs can't be faked."); + } + + static MirrorSample Sample(string outcome, int? status = null, params string[] errors) => + new("x1", DateTime.UtcNow, outcome, status, errors, null, null, null); + + [TestMethod] + public void How_it_ended_counts_and_what_it_used_only_explains() + { + var worse = MirrorCompare.Result(Sample("success"), "failed", null, ["JsonException"]); + Assert.AreEqual(MirrorCompare.Different, worse.Level); + Assert.AreEqual(("Worked", "Failed · JsonException"), (worse.Prod, worse.Staging)); + + // Prod's call never got through, and the rehearsal replays that: the same failure. + Assert.AreEqual(MirrorCompare.Same, MirrorCompare.Result(Sample("failed", null, "HttpRequestException", "SocketException"), "failed", null, ["HttpRequestException"]).Level); + Assert.AreEqual(MirrorCompare.Check, MirrorCompare.Result(Sample("failed", null, "JsonException"), "failed", null, ["NullReferenceException"]).Level); + Assert.AreEqual(MirrorCompare.Same, MirrorCompare.Result(Sample("failed", 500), "failed", 500, []).Level); + + var used = new MirrorDiff("used", "Handler", MirrorCompare.Info, "acme 1.4", "acme 1.5"); + Assert.AreEqual(MirrorCompare.Same, MirrorCompare.Verdict([MirrorCompare.Result(Sample("success"), "success", null, []), used])); + } + + // ------------------------------------------------------------------ answers + + const string Test = "m1a2b3c4d.mirror.invalid"; + const string Other = "m9f8e7d6c.mirror.invalid"; + + static readonly BitweenOptions Mirror = new() { Mirror = { Target = true } }; + + static MirrorMock Mock() + { + var setup = new MirrorSetupHosts( + new HashSet(StringComparer.OrdinalIgnoreCase) { "api.staging.acme.test" }, + new Dictionary>(StringComparer.OrdinalIgnoreCase) { [Test] = ["Push labels"], [Other] = ["Nightly report"] }, + new Dictionary(), new HashSet(), + new Dictionary { [12] = "Push labels", [13] = "Nightly report" }); + return new MirrorMock(null!, new BitweenOptions(), NullLogger.Instance) + { + ReadOverride = () => Task.FromResult((setup, (MirrorSamples)null)), + }; + } + + static MirrorRequest Post(string host) => new("POST", new Uri($"https://{host}/v1/labels"), "application/json", Bytes("{}"), "Bitween"); + + static MirrorAnswer Answer(int status, string body) => new(status, "application/json", Bytes(body), "Prod's partner's answer in the exchange being rehearsed, faked."); + + [TestMethod] + public async Task A_rehearsed_exchange_gets_what_prods_partner_answered_its_twin() + { + var mock = Mock(); + var correlation = MirrorTraffic.NewCorrelation(); + mock.Replay(correlation, [new MirrorReplayAnswer("Push labels", Answer(400, """{"error":"bad sku"}"""))]); + + using (MirrorTraffic.Track(Mirror, "s1", correlation, 12)) + { + var answered = await mock.AnswerAsync(Post(Test)); + Assert.AreEqual((400, """{"error":"bad sku"}"""), (answered.Status, Encoding.UTF8.GetString(answered.Body))); + Assert.AreEqual(400, (await mock.AnswerAsync(Post("api.zohoapis.eu"))).Status, "a host written inside the adapter"); + Assert.AreEqual(200, (await mock.AnswerAsync(Post(Other))).Status, "an address only another subscription uses keeps its usual answer"); + // Sent on for real, and noted: it explains what came back. + mock.Passed("GET", "api.staging.acme.test", "/v1/rates", await mock.PassesAsync("api.staging.acme.test"), "Bitween"); + } + + var calls = mock.EndReplay(); + Assert.AreEqual(4, calls.Count); + Assert.IsTrue(calls.All(c => c.Exchange == "s1"), "each call is tied to the exchange that made it"); + Assert.AreEqual("Sent for real: staging's setup names it.", calls.Single(c => c.Real).Why); + Assert.AreEqual(200, (await mock.AnswerAsync(Post(Test))).Status, "the replay is over"); + Assert.AreEqual(0, mock.EndReplay().Count); + } + + [TestMethod] + public async Task Twins_of_one_subscription_get_their_own_answers_in_order() + { + var mock = Mock(); + var correlation = MirrorTraffic.NewCorrelation(); + mock.Replay(correlation, [new MirrorReplayAnswer("Push labels", Answer(200, "first")), new MirrorReplayAnswer("Push labels", Answer(200, "second"))]); + + async Task Ask(string exchange) + { + using var _ = MirrorTraffic.Track(Mirror, exchange, correlation, 12); + return Encoding.UTF8.GetString((await mock.AnswerAsync(Post(Test))).Body); + } + + Assert.AreEqual("first", await Ask("s1")); + Assert.AreEqual("second", await Ask("s2")); + Assert.AreEqual("first", await Ask("s1"), "an exchange keeps its answer for every call it makes"); + mock.EndReplay(); + } + + [TestMethod] + public async Task A_call_that_never_got_through_on_prod_doesnt_on_staging_either() + { + var mock = Mock(); + var correlation = MirrorTraffic.NewCorrelation(); + mock.Replay(correlation, [new MirrorReplayAnswer("Push labels", new MirrorAnswer(0, null, [], "Prod's call didn't get through.", Fail: true))]); + using var client = new HttpClient(new MirrorCatchingHandler(mock) { InnerHandler = new HttpClientHandler() }); + + using (MirrorTraffic.Track(Mirror, "s1", correlation, 12)) + { + var failed = await Assert.ThrowsExceptionAsync(() => client.PostAsync($"https://{Test}/v1/labels", new StringContent("{}"))); + Assert.AreEqual("Prod's call didn't get through.", failed.Message); + } + mock.EndReplay(); + } + + [TestMethod] + public void Only_a_rehearsal_with_an_open_key_on_a_mirror_runs_as_rehearsal_traffic() + { + var correlation = MirrorTraffic.NewCorrelation(); + var key = MirrorTraffic.OpenKey(); + var headers = new HeaderDictionary { [MirrorTraffic.Header] = $"{key} {correlation}" }; + + Assert.AreEqual(correlation, MirrorTraffic.CorrelationFor(Mirror, headers)); + Assert.IsNull(MirrorTraffic.CorrelationFor(new BitweenOptions(), headers), "not a mirror"); + Assert.IsNull(MirrorTraffic.CorrelationFor(Mirror, new HeaderDictionary { [MirrorTraffic.Header] = $"guessed {correlation}" })); + Assert.IsNull(MirrorTraffic.CorrelationFor(Mirror, new HeaderDictionary { [MirrorTraffic.Header] = $"{key} 12345" }), "only a rehearsal's own correlation"); + MirrorTraffic.CloseKey(key); + Assert.IsNull(MirrorTraffic.CorrelationFor(Mirror, headers), "the rehearsal ended"); + + Assert.IsTrue(MirrorTraffic.IsRehearsal(Mirror, correlation)); + Assert.IsFalse(MirrorTraffic.IsRehearsal(new BitweenOptions(), correlation)); + Assert.IsFalse(MirrorTraffic.IsRehearsal(Mirror, Guid.NewGuid().ToString("N"))); + } + + [TestMethod] + public void A_login_server_of_stagings_own_signs_what_a_jwt_gateway_checks() + { + var mock = Mock(); + var token = mock.SignToken("https://m77.mirror.invalid/realms/acme", "orders", "client_id", "kqwe-1294"); + var jwt = new Microsoft.IdentityModel.JsonWebTokens.JsonWebToken(token); + + Assert.AreEqual("https://m77.mirror.invalid/realms/acme", jwt.Issuer); + Assert.AreEqual("kqwe-1294", jwt.GetClaim("client_id").Value); + Assert.AreEqual("RS256", jwt.Alg); + } +} diff --git a/SW.Bitween.UnitTests/MirrorSetupTests.cs b/SW.Bitween.UnitTests/MirrorSetupTests.cs index 3047553d..8dd716b1 100644 --- a/SW.Bitween.UnitTests/MirrorSetupTests.cs +++ b/SW.Bitween.UnitTests/MirrorSetupTests.cs @@ -23,6 +23,20 @@ static Dictionary> Prod(params (string Section, string Ke static JsonObject Only(MirrorSetupCopy copy, string section) => copy.Sections[section].Single(); + [TestMethod] + public void Data_sources_and_bus_gateways_arrive_switched_off_and_say_which_prod_has_on() + { + var copy = MirrorSetup.Copy(Prod( + ("busGateways", "Carrier events", """{ "name": "Carrier events", "inactive": false, "endpointProperties": { "Url": "amqp://events.carrier.com" } }"""), + ("busGateways", "Old feed", """{ "name": "Old feed", "inactive": true }"""), + ("dataSources", "ERP", """{ "name": "ERP", "properties": { "Host": "erp.acme.com" } }""")), Faker, new HashSet()); + + Assert.IsTrue(copy.Sections["busGateways"].All(g => (bool)g["inactive"]!)); + Assert.IsTrue((bool)Only(copy, "dataSources")["inactive"]!); + // A rehearsal's traffic goes through the ones prod has on, as prod's does; never through one prod has off. + CollectionAssert.AreEquivalent(new[] { "busGateways/Carrier events", "dataSources/ERP" }, copy.SwitchedOff.ToList()); + } + [TestMethod] public void A_subscription_keeps_how_it_behaves_and_arrives_paused() { diff --git a/SW.Bitween.Web/ClientApp/src/api/client.ts b/SW.Bitween.Web/ClientApp/src/api/client.ts index 6efc5536..4afda5df 100644 --- a/SW.Bitween.Web/ClientApp/src/api/client.ts +++ b/SW.Bitween.Web/ClientApp/src/api/client.ts @@ -3,6 +3,7 @@ import type { MirrorCalls, MirrorCopyResult, MirrorParityAnswer, + MirrorRehearsal, MirrorSamples, MirrorStatus, InviteResult, @@ -667,6 +668,8 @@ export interface ApiClient { getMirrorSamples(): Promise; takeMirrorSamples(): Promise; getMirrorCalls(): Promise; + getMirrorRehearsal(): Promise; + rehearseMirror(): Promise; // - settings - listSettings(): Promise; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/mirror.ts b/SW.Bitween.Web/ClientApp/src/api/http/mirror.ts index bb8a5e9d..71c44441 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/mirror.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/mirror.ts @@ -1,5 +1,5 @@ import type { ApiClient } from "../client"; -import type { MirrorCalls, MirrorCopyResult, MirrorParityAnswer, MirrorSamples, MirrorStatus } from "../types"; +import type { MirrorCalls, MirrorCopyResult, MirrorParityAnswer, MirrorRehearsal, MirrorSamples, MirrorStatus } from "../types"; import { get, post } from "./request"; export const mirrorMethods = { @@ -30,4 +30,13 @@ export const mirrorMethods = { takeMirrorSamples(): Promise { return post("/mirror/takesamples", {}); }, + + getMirrorRehearsal(): Promise { + return get("/mirror/rehearsal"); + }, + + /** Starts a rehearsal in the background: every sample run on staging, then compared with prod's; follow it with getMirrorRehearsal. */ + rehearseMirror(): Promise { + return post("/mirror/rehearse", {}); + }, } satisfies Partial; diff --git a/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts b/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts index 3b41d761..fd590450 100644 --- a/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts +++ b/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts @@ -219,6 +219,7 @@ export const keys = { parity: ["mirror", "parity"] as const, samples: ["mirror", "samples"] as const, calls: ["mirror", "calls"] as const, + rehearsal: ["mirror", "rehearsal"] as const, }, settings: { diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index 2c60a3c0..3119ecc9 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -2419,12 +2419,47 @@ export interface MirrorSample { input: MirrorSampleFile | null; output: MirrorSampleFile | null; response: MirrorSampleFile | null; + /** The partner it ran as, by name. */ + partner: string | null; + /** The handler it ran, as prod recorded it: the adapter, and its version where one was pinned. */ + handler: string | null; + mapper: string | null; + /** Its subscription, by name; null for a document being routed. */ + subscription: string | null; + /** The type of what came in, by name. */ + informationType: string | null; + /** The exchange on prod whose answer or message started it; null for the one that came in. */ + parent: string | null; + /** For an aggregation: a few of the files it rolled up, faked. */ + rolled?: MirrorSampleFile[] | null; +} + +/** + * One thing that came in on prod and every exchange it led to: a document routed, a partner's call + * through a gateway, a file a receiver read, an aggregation, then the answers those handed on. + */ +export interface MirrorFamily { + /** Its first exchange's id on prod. */ + id: string; + /** How it came in on prod. */ + entry: "document" | "gateway" | "api" | "receiver" | "aggregation" | "direct"; + /** What it came in through, by name: the information type, the gateway, the receiving subscription. */ + door: string | null; + /** The partner who called, for a gateway or the partner API. */ + partner: string | null; + /** Its exchanges in the order they started on prod; the first is the one that came in. */ + members: MirrorSample[]; + /** How many more exchanges prod's family had than the sample keeps. */ + cut: number; + /** Prod doesn't say which subscriptions a document of it went to: staging's are run, but not counted as more than prod's. */ + unlinked?: boolean; } export interface MirrorSubscriptionSamples { subscription: string; prodId: string; - samples: MirrorSample[]; + /** The families it takes part in, by id. */ + families: string[]; /** Why it has none. */ reason: string | null; } @@ -2446,10 +2481,88 @@ export interface MirrorCall { method: string; host: string; path: string; - /** 0 when the call failed before staging could answer. */ + /** 0 when the call failed before staging could answer, or was sent on for real. */ status: number; why: string; from: string; + /** Sent on for real rather than answered: only noted for a rehearsal. */ + real: boolean; +} + +/** + * One line of a rehearsed exchange's compare. different and check count towards the verdict; info is a + * difference that explains, such as the upgrade itself; faked is the same as prod's with faked values. + */ +export interface MirrorDiff { + /** The part of the exchange it is about; entry and family are a family's own: how it came in, and what it led to. */ + part: "result" | "output" | "answer" | "calls" | "input" | "used" | "entry" | "family"; + name: string; + level: "different" | "check" | "info" | "faked" | "same"; + /** Prod's side, as staging can show it: faked. Null where only staging's side says anything. */ + prod: string | null; + staging: string | null; + note: string | null; + fields: MirrorFieldDiff[] | null; + /** For a setup item: where it opens on staging. */ + section: string | null; + stagingId: string | null; + type: string | null; +} + +export interface MirrorFieldDiff { + path: string; + level: "different" | "check" | "info" | "same"; + prod: string | null; + staging: string | null; +} + +/** One exchange of a rehearsed family: prod's, the twin staging ran, and how the two compare. */ +export interface MirrorRun { + /** Null for one staging ran that prod's family didn't. */ + prodExchangeId: string | null; + /** Its subscription, or the information type of a document being routed. */ + subscription: string; + /** Null when it didn't run. */ + stagingExchangeId: string | null; + ranOn: string; + /** How it ended on staging; null when it didn't run or finish. */ + outcome: "success" | "badResponse" | "failed" | null; + status: number | null; + errorTypes: string[]; + verdict: "same" | "check" | "different" | null; + /** Why it didn't run, or didn't finish. */ + notRun: string | null; + output: MirrorSampleFile | null; + response: MirrorSampleFile | null; + diffs: MirrorDiff[]; +} + +export interface MirrorRehearsal { + state: "none" | "running" | "done" | "failed"; + startedOn: string | null; + finishedOn: string | null; + done: number; + total: number; + error: string | null; + prodVersion: string | null; + stagingVersion: string | null; + /** Why what each exchange used isn't compared with prod's setup, when it isn't. */ + setupError: string | null; + families: MirrorFamilyRun[]; +} + +/** One of prod's families, sent in on staging the way it came in on prod, and every exchange staging ran for it. */ +export interface MirrorFamilyRun { + familyId: string; + /** The correlation id it ran under on staging; null when it didn't run. */ + correlation: string | null; + ranOn: string; + /** The worst of its exchanges and how it came in; null when it didn't run. */ + verdict: "same" | "check" | "different" | null; + notRun: string | null; + /** The family's own lines: how it came in, the release, and anything prod's family had more of. */ + diffs: MirrorDiff[]; + members: MirrorRun[]; } export interface MirrorCalls { @@ -2468,4 +2581,5 @@ export interface MirrorSamples { total: number; error: string | null; subscriptions: MirrorSubscriptionSamples[]; + families: MirrorFamily[]; } diff --git a/SW.Bitween.Web/ClientApp/src/pages/mirror/FileCompare.tsx b/SW.Bitween.Web/ClientApp/src/pages/mirror/FileCompare.tsx new file mode 100644 index 00000000..2eee80c4 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/mirror/FileCompare.tsx @@ -0,0 +1,90 @@ +import { useEffect, useRef } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { EditorState, type Extension } from "@codemirror/state"; +import { EditorView, lineNumbers } from "@codemirror/view"; +import { MergeView } from "@codemirror/merge"; +import { LoadingBlock, ProblemText } from "../../components/ui/basics"; +import { languageFor } from "../../lib/adapterSource"; + +const editorTheme = EditorView.theme({ + "&": { maxHeight: "28rem", backgroundColor: "#ffffff", fontSize: "12px" }, + ".cm-scroller": { fontFamily: "var(--font-mono)", overflow: "auto" }, + "&.cm-focused": { outline: "none" }, +}); + +/** + * Prod's file and staging's side by side: what differs highlighted, long runs that are the same folded + * away. Prod's is staging's faked copy, like everything of prod's here. JSON and XML written on one line + * are laid out first, so the two line up field by field. + */ +export default function FileCompare({ prodUrl, stagingUrl, format }: { prodUrl: string | null; stagingUrl: string | null; format: string | null }) { + const files = useQuery({ + queryKey: ["mirror", "fileCompare", prodUrl, stagingUrl], + queryFn: () => Promise.all([read(prodUrl), read(stagingUrl)]), + staleTime: Infinity, + }); + const host = useRef(null); + + useEffect(() => { + if (!files.data || !host.current) return; + const [a, b] = files.data.map((text) => laidOut(text, format)); + const language = languageFor(`file.${format ?? "txt"}`); + const extensions: Extension[] = [editorTheme, EditorState.readOnly.of(true), EditorView.editable.of(false), lineNumbers(), EditorView.lineWrapping]; + if (language) extensions.push(language); + const view = new MergeView({ + a: { doc: a, extensions }, + b: { doc: b, extensions }, + parent: host.current, + highlightChanges: true, + gutter: true, + collapseUnchanged: { margin: 3, minSize: 8 }, + }); + return () => view.destroy(); + }, [files.data, format]); + + if (files.isPending) return ; + if (files.isError) return {files.error instanceof Error ? files.error.message : "The files couldn't be read."}; + return ( +
+
+ Prod's, faked + Staging's +
+
+
+ ); +} + +async function read(url: string | null): Promise { + if (!url) return ""; + const response = await fetch(url); + if (!response.ok) throw new Error(`A file couldn't be read: the server answered ${response.status}. It may have been removed since.`); + return response.text(); +} + +/** JSON indented, XML one element to a line when it came on one; anything else as it is. */ +export function laidOut(text: string, format: string | null): string { + if (format === "json") { + try { + return JSON.stringify(JSON.parse(text), null, 2); + } catch { + return text; + } + } + if (format === "xml" && !text.trim().includes("\n")) { + let depth = 0; + return text + .trim() + .replace(/>\s*\n<") + .split("\n") + .map((line) => { + if (line.startsWith("]*>$/.test(line) && !line.endsWith("/>")) depth++; + return indented; + }) + .join("\n"); + } + return text; +} diff --git a/SW.Bitween.Web/ClientApp/src/pages/mirror/MirrorPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/mirror/MirrorPage.tsx index df939d9b..af265660 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/mirror/MirrorPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/mirror/MirrorPage.tsx @@ -1,5 +1,5 @@ -import { useMutation, useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query"; -import { CircleCheck, ChevronDown, ChevronRight, ExternalLink, GitCompareArrows, TriangleAlert } from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { CircleCheck, ChevronDown, ChevronRight, GitCompareArrows, TriangleAlert } from "lucide-react"; import { useState, type ReactNode } from "react"; import { Link } from "react-router"; import { @@ -13,12 +13,10 @@ import { type MirrorKind, type MirrorNote, type MirrorParity, - type MirrorSample, - type MirrorSampleFile, + type MirrorRehearsal, type MirrorSamples, type MirrorStatus, type SettingRow, - type SubscriptionType, } from "../../api"; import { keys } from "../../api/queryKeys"; import { useSessionCan } from "../../auth/useSessionCan"; @@ -29,37 +27,13 @@ import { ConfirmDialog, Dialog } from "../../components/ui/overlays"; import { Panel } from "../../components/ui/Panel"; import { SegmentedControl } from "../../components/ui/SegmentedControl"; import { MiniTable } from "../../components/ui/Table"; -import { formatDateTime, timeAgo } from "../../lib/dates"; +import { timeAgo } from "../../lib/dates"; import { whole } from "../../lib/numbers"; -import { subscriptionPath } from "../../lib/subscriptionPaths"; -import { pathOf, type PageId } from "../../pages"; +import { ItemLinks, plural } from "./shared"; +import { RehearsalList, RehearsalPanel } from "./Rehearsal"; const KEEP_SETTING = "Bitween.Mirror.KeepRealValues"; -/** Where an item of each kind opens. Subscription categories have no page of their own. */ -const KIND_PAGE: Record = { - workGroups: "workGroup", - informationTypes: "informationType", - partners: "partner", - globalValues: "globalValueSet", - connections: "connection", - notificationChannels: "notificationChannel", - retryPolicies: "retryPolicy", - dataSources: "dataSource", - dataSourceStatements: "dataSource", - apiGateways: "apiGateway", - busGateways: "busGateway", - notifiers: "notifier", -}; - -/** The page an item opens on, from its id; null for a kind with no page, or an item not there. */ -function itemPath(section: string, id: string | null, type?: string | null): string | null { - if (!id) return null; - if (section === "subscriptions") return subscriptionPath(Number(id), (type ?? null) as SubscriptionType | null); - const page = KIND_PAGE[section]; - return page ? pathOf(page, { id }) : null; -} - const STATE_LABEL: Record = { inSync: { label: "Same", tone: "ok", title: "Staging has it as prod has it" }, differs: { label: "Differs", tone: "warn", title: "Staging has it, but not as prod has it. Copying updates it." }, @@ -163,12 +137,17 @@ type FoldId = "setup" | "samples" | "calls" | "adapters" | "faked" | "addresses" function Comparison({ parity, status, onConnection }: { parity: MirrorParity; status: MirrorStatus; onConnection: () => void }) { const [open, setOpen] = useState>(new Set(["setup"])); - // Followed every two seconds while a taking is under way, so the page shows how far it has got. + // Followed every two seconds while a taking or a rehearsal is under way, so the page shows how far it has got. const samples = useQuery({ queryKey: keys.mirror.samples, queryFn: () => api.getMirrorSamples(), refetchInterval: (q) => (q.state.data?.state === "running" ? 2000 : false), }); + const rehearsal = useQuery({ + queryKey: keys.mirror.rehearsal, + queryFn: () => api.getMirrorRehearsal(), + refetchInterval: (q) => (q.state.data?.state === "running" ? 2000 : false), + }); // Read while the list is open, and again every ten seconds: calls come in as flows run. const calls = useQuery({ queryKey: keys.mirror.calls, @@ -210,7 +189,7 @@ function Comparison({ parity, status, onConnection }: { parity: MirrorParity; st - show("samples")} /> + show("samples")} /> - {samples.data && samples.data.subscriptions.length > 0 && ( - s.samples.length > 0).length)} of ${whole(samples.data.subscriptions.length)} subscriptions have samples`} - description="How each sample ended on prod, and its files as staging keeps them, faked. Open a file to see what staging holds, or the exchange on prod."> - + {samples.data && (samples.data.families ?? []).length > 0 && ( + + )} @@ -272,6 +251,21 @@ function Comparison({ parity, status, onConnection }: { parity: MirrorParity; st ); } +/** The fold's one line: how the samples ran, or how many there are when none has run. */ +function rehearsalSummary(samples: MirrorSamples, rehearsal: MirrorRehearsal | undefined): string { + const ids = new Set((samples.families ?? []).map((f) => f.id)); + const runs = (rehearsal?.families ?? []).filter((r) => ids.has(r.familyId)); + if (runs.length === 0) return `${plural(ids.size, "arrival", "arrivals")}, not rehearsed yet`; + const n = (v: string) => runs.filter((r) => r.verdict === v).length; + const notRun = ids.size - runs.filter((r) => r.verdict).length; + return [ + n("different") > 0 && `${whole(n("different"))} different`, + n("check") > 0 && `${whole(n("check"))} to check`, + n("same") > 0 && `${whole(n("same"))} the same`, + notRun > 0 && `${whole(notRun)} not run`, + ].filter(Boolean).join(", "); +} + /** The page's answer, first: ready or not, and what stands between. */ function Verdict({ parity }: { parity: MirrorParity }) { const v = parity.verdict; @@ -375,22 +369,6 @@ function NoteLinks({ note, context }: { note: MirrorNote; context: NoteContext } ); } -/** "On staging" and "On prod" links to one item, as far as each side has it. */ -function ItemLinks({ section, item, prodUrl }: { section: string; item: MirrorItem; prodUrl: string }) { - const staging = itemPath(section, item.stagingId, item.type); - const prod = itemPath(section, item.prodId, item.type); - return ( - - {staging && On staging} - {prod && ( - - On prod - - )} - - ); -} - function CopyPanel({ parity, target, notes, context }: { parity: MirrorParity; target: boolean; notes: MirrorNote[]; context: NoteContext }) { const canCopy = useSessionCan("mirror.operate"); const queryClient = useQueryClient(); @@ -478,152 +456,6 @@ function CopyPanel({ parity, target, notes, context }: { parity: MirrorParity; t ); } -const plural = (n: number, one: string, many: string) => `${whole(n)} ${n === 1 ? one : many}`; - -/** Takes a few of prod's exchanges for each subscription, faked, and says how far a taking under way has got. */ -function SamplesPanel({ samples, target, onShow }: { samples: UseQueryResult; target: boolean; onShow: () => void }) { - const canTake = useSessionCan("mirror.operate"); - const queryClient = useQueryClient(); - const take = useMutation({ - mutationFn: () => api.takeMirrorSamples(), - onSuccess: (r) => queryClient.setQueryData(keys.mirror.samples, r), - }); - const s = samples.data; - const running = s?.state === "running"; - const subscriptions = s?.subscriptions ?? []; - const taken = subscriptions.filter((x) => x.samples.length > 0); - const count = taken.reduce((n, x) => n + x.samples.length, 0); - const notKept = subscriptions - .flatMap((x) => x.samples) - .flatMap((x) => [x.input, x.output, x.response]) - .filter((f) => f?.notCopied).length; - - return ( - - {samples.isPending ? ( - - ) : samples.isError ? ( - void samples.refetch()} /> - ) : ( -
- {running ? ( -

- Taking samples: {whole(s.done)} of {whole(s.total)} subscriptions read. Prod is read one subscription at a time. -

- ) : !s || s.state === "none" || subscriptions.length === 0 ? ( -

No samples yet.

- ) : ( -

- {plural(count, "sample", "samples")} from {whole(taken.length)} of prod's {plural(subscriptions.length, "subscription", "subscriptions")} - {s?.finishedOn && `, taken ${timeAgo(s.finishedOn)}`}. - {subscriptions.length > taken.length && ` ${plural(subscriptions.length - taken.length, "subscription has", "subscriptions have")} none; the list says why for each.`} - {notKept > 0 && ` ${plural(notKept, "file isn't", "files aren't")} kept; the list says why.`}{" "} - -

- )} - {s?.state === "failed" && s.error && {s.error}} -
- )} -
- -
- {take.error instanceof Error ? take.error.message : ""} -
- ); -} - -const OUTCOME: Record = { - success: { label: "Worked", tone: "ok", title: "It worked on prod" }, - badResponse: { label: "Bad answer", tone: "warn", title: "It was delivered on prod, and the partner answered with an error" }, - failed: { label: "Failed", tone: "danger", title: "It failed on prod" }, -}; - -function SamplesList({ samples, prodUrl, items }: { samples: MirrorSamples; prodUrl: string; items: Map }) { - const [open, setOpen] = useState(null); - const [all, setAll] = useState(false); - const none = samples.subscriptions.filter((s) => s.samples.length === 0); - const shown = all ? samples.subscriptions : samples.subscriptions.filter((s) => s.samples.length > 0); - const exchanges = pathOf("exchanges"); - return ( -
- {none.length > 0 && ( - - )} - {shown.map((s) => { - const item = items.get(`subscriptions\u0000${s.subscription}`); - const isOpen = open === s.subscription; - const ended = (Object.keys(OUTCOME) as MirrorSample["outcome"][]) - .map((o) => [o, s.samples.filter((x) => x.outcome === o).length] as const) - .filter(([, n]) => n > 0); - return ( -
-
- - {s.samples.length > 0 ? ( - {ended.map(([o, n]) => `${n} ${OUTCOME[o].label.toLowerCase()}`).join(", ")} - ) : ( - {s.reason} - )} - {item && } -
- {isOpen && ( -
    - {s.samples.map((x) => ( -
  • - - {OUTCOME[x.outcome].label} - {x.status != null && ` ${x.status}`} - - {formatDateTime(x.startedOn)} - - - - {exchanges && ( - - On prod - - )} - {x.errorTypes.length > 0 && ( - - {x.errorTypes.join(" → ")} - - )} -
  • - ))} -
- )} -
- ); - })} -
- ); -} - /** Each host staging answered calls to, and what it answered last. */ function CallsList({ hosts, prodUrl, items }: { hosts: MirrorHostCalls[]; prodUrl: string; items: Map }) { if (hosts.length === 0) return

No calls yet. They come in as flows run on staging.

; @@ -649,22 +481,6 @@ function CallsList({ hosts, prodUrl, items }: { hosts: MirrorHostCalls[]; prodUr ); } -/** One of a sample's files: a link that opens staging's faked copy, or why it wasn't kept. */ -function SampleFile({ label, what, file }: { label: string; what: string; file: MirrorSampleFile | null }) { - if (!file) return null; - if (!file.url) - return ( - - {label}: not kept{file.format ? ` (${file.format})` : ""} - - ); - return ( - - {label} ({file.format}) - - ); -} - function Fold({ id, open, onToggle, title, summary, description, children }: { id: FoldId; open: boolean; diff --git a/SW.Bitween.Web/ClientApp/src/pages/mirror/Rehearsal.tsx b/SW.Bitween.Web/ClientApp/src/pages/mirror/Rehearsal.tsx new file mode 100644 index 00000000..5415fe7c --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/mirror/Rehearsal.tsx @@ -0,0 +1,591 @@ +import { useMutation, useQueryClient, type UseQueryResult } from "@tanstack/react-query"; +import { ChevronDown, ChevronRight, CircleAlert, CircleCheck, CircleX, ExternalLink, Info } from "lucide-react"; +import { lazy, Suspense, useState, type ReactNode } from "react"; +import { Link } from "react-router"; +import { + api, + type MirrorDiff, + type MirrorFamily, + type MirrorFamilyRun, + type MirrorItem, + type MirrorRehearsal, + type MirrorRun, + type MirrorSample, + type MirrorSampleFile, + type MirrorSamples, +} from "../../api"; +import { keys } from "../../api/queryKeys"; +import { useSessionCan } from "../../auth/useSessionCan"; +import { Badge, Button, FormError, InlineNotice, LoadError, LoadingBlock } from "../../components/ui/basics"; +import { Panel } from "../../components/ui/Panel"; +import { SegmentedControl } from "../../components/ui/SegmentedControl"; +import { formatDateTime, timeAgo } from "../../lib/dates"; +import { whole } from "../../lib/numbers"; +import { pathOf } from "../../pages"; +import { exchangePath, itemPath, ItemLinks, OUTCOME, plural, SampleFile } from "./shared"; + +const FileCompare = lazy(() => import("./FileCompare")); + +type Verdict = "different" | "check" | "same" | "notRun"; + +const VERDICT: Record = { + different: { label: "Different", tone: "danger", title: "It ended another way than on prod, or what it sent or got back changed: its format, its fields, or how a value is written" }, + check: { label: "Check", tone: "warn", title: "Only values' text changed, and each kept its look. Fake data explains that when a mapping works values out; a wrong field taken doesn't" }, + same: { label: "Same", tone: "ok", title: "It ended as on prod, and sent and got back the same" }, + notRun: { label: "Not run", tone: "neutral", title: "It didn't run on staging: it says why" }, +}; + +const ORDER: Verdict[] = ["different", "check", "same", "notRun"]; + +/** How a family came in on prod, in words. */ +const ENTRY: Record = { + document: { label: "Document", title: "A document, routed to the subscriptions that want it" }, + gateway: { label: "Gateway call", title: "A partner's call through an API gateway" }, + api: { label: "Partner API call", title: "A partner's call to the partner API" }, + receiver: { label: "File received", title: "A file a receiver read" }, + aggregation: { label: "Aggregation", title: "Exchanges rolled up by an aggregation" }, + direct: { label: "Started by hand", title: "An exchange someone started" }, +}; + +/** One of prod's families, and its run on staging when the last rehearsal ran it. */ +interface Entry { + family: MirrorFamily; + run: MirrorFamilyRun | undefined; + verdict: Verdict; +} + +function entriesOf(samples: MirrorSamples, rehearsal: MirrorRehearsal | undefined): Entry[] { + const runs = new Map((rehearsal?.families ?? []).map((r) => [r.familyId, r])); + return (samples.families ?? []).map((family) => { + const run = runs.get(family.id); + return { family, run, verdict: (run?.verdict ?? "notRun") as Verdict }; + }); +} + +/** The worst ending of a family's exchanges: a family that failed anywhere failed. */ +function worst(outcomes: (MirrorSample["outcome"] | null)[]): MirrorSample["outcome"] | null { + return outcomes.includes("failed") ? "failed" : outcomes.includes("badResponse") ? "badResponse" : outcomes.includes("success") ? "success" : null; +} + +/** + * Takes samples of prod's traffic and rehearses them: each family sent in on staging the way it came + * in on prod, run as real exchanges, then compared with prod's. Says how far work under way has got. + */ +export function RehearsalPanel({ samples, rehearsal, target, onShow }: { + samples: UseQueryResult; + rehearsal: UseQueryResult; + target: boolean; + onShow: () => void; +}) { + const canOperate = useSessionCan("mirror.operate"); + const queryClient = useQueryClient(); + const take = useMutation({ + mutationFn: () => api.takeMirrorSamples(), + onSuccess: (r) => queryClient.setQueryData(keys.mirror.samples, r), + }); + const rehearse = useMutation({ + mutationFn: () => api.rehearseMirror(), + onSuccess: (r) => queryClient.setQueryData(keys.mirror.rehearsal, r), + }); + + const s = samples.data; + const r = rehearsal.data; + const taking = s?.state === "running"; + const rehearsing = r?.state === "running"; + const subscriptions = s?.subscriptions ?? []; + const families = s?.families ?? []; + const covered = subscriptions.filter((x) => x.families.length > 0).length; + const exchanges = families.reduce((n, f) => n + f.members.length, 0); + const notKept = families.flatMap((f) => f.members).flatMap((x) => [x.input, x.output, x.response]).filter((f) => f?.notCopied).length; + const rehearsed = r && r.state !== "none" && r.families.length > 0; + // Samples taken again after the last rehearsal aren't in it. + const stale = rehearsed && s?.finishedOn && r.startedOn && s.finishedOn > r.startedOn; + const off = !target ? "This deployment isn't marked as a mirror" : !canOperate ? "Your role can't do this on this staging" : undefined; + + return ( + + {samples.isPending || rehearsal.isPending ? ( + + ) : samples.isError ? ( + void samples.refetch()} /> + ) : ( +
+ + {taking ? ( +

Taking samples: {whole(s.done)} of {whole(s.total)} subscriptions read. Prod is read one subscription at a time.

+ ) : !s || s.state === "none" || subscriptions.length === 0 ? ( +

No samples yet. Each is something that came in on prod and every exchange it led to, with their files faked.

+ ) : ( +

+ {plural(families.length, "arrival", "arrivals")} from prod, {plural(exchanges, "exchange", "exchanges")} in all, covering {whole(covered)} of + prod's {plural(subscriptions.length, "subscription", "subscriptions")}{s.finishedOn && `, taken ${timeAgo(s.finishedOn)}`}. + {notKept > 0 && ` ${plural(notKept, "file isn't", "files aren't")} kept; each exchange says why.`}{" "} + +

+ )} + {s?.state === "failed" && s.error && {s.error}} + + {take.error instanceof Error ? take.error.message : ""} +
+ + + {rehearsal.isError ? ( + void rehearsal.refetch()} /> + ) : rehearsing ? ( +

Rehearsing: {whole(r.done)} of {whole(r.total)} arrivals run. Each runs on its own, so prod's answers go to the right exchanges.

+ ) : !rehearsed ? ( +

Not rehearsed yet.

+ ) : ( + + )} + {stale &&

Samples were taken again since. Rehearse again to compare them.

} + {r?.setupError && {r.setupError}} + {r?.state === "failed" && r.error && {r.error}} + + {rehearse.error instanceof Error ? rehearse.error.message : ""} + +
+ )} +
+ ); +} + +function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) { + return ( +
+ {n} +
+

{title}

+ {children} +
+
+ ); +} + +/** The last rehearsal in one line: how many arrivals ran, and how they compare. */ +function Summary({ rehearsal, onShow }: { rehearsal: MirrorRehearsal; onShow: () => void }) { + const runs = rehearsal.families; + const n = (v: Verdict) => runs.filter((x) => (x.verdict ?? "notRun") === v).length; + const ran = runs.length - n("notRun"); + const parts = [ + n("different") > 0 && `${whole(n("different"))} different`, + n("check") > 0 && `${whole(n("check"))} to check`, + n("same") > 0 && `${whole(n("same"))} the same`, + ].filter(Boolean); + const versions = + rehearsal.prodVersion && rehearsal.stagingVersion + ? rehearsal.prodVersion === rehearsal.stagingVersion + ? ` Both run ${rehearsal.stagingVersion}.` + : ` Staging runs ${rehearsal.stagingVersion}, prod ${rehearsal.prodVersion}.` + : ""; + return ( +

+ {rehearsal.finishedOn ? `Rehearsed ${timeAgo(rehearsal.finishedOn)}: ` : "Rehearsed: "} + {plural(ran, "arrival", "arrivals")} ran{parts.length > 0 && ` (${parts.join(", ")})`} + {n("notRun") > 0 && `, ${whole(n("notRun"))} didn't`}.{versions}{" "} + +

+ ); +} + +// ------------------------------------------------------------------ arrival by arrival + +/** Prod's families and how each ran on staging, the ones that differ first. */ +export function RehearsalList({ samples, rehearsal, prodUrl, items }: { + samples: MirrorSamples; + rehearsal: MirrorRehearsal | undefined; + prodUrl: string; + items: Map; +}) { + const entries = entriesOf(samples, rehearsal); + const counts = Object.fromEntries(ORDER.map((v) => [v, entries.filter((e) => e.verdict === v).length])) as Record; + const [filter, setFilter] = useState(() => (counts.different > 0 ? "different" : counts.check > 0 ? "check" : "all")); + const [open, setOpen] = useState(null); + const [without, setWithout] = useState(false); + const none = samples.subscriptions.filter((s) => s.families.length === 0); + const shown = entries + .filter((e) => filter === "all" || e.verdict === filter) + .sort((a, b) => ORDER.indexOf(a.verdict) - ORDER.indexOf(b.verdict) || b.family.members[0].startedOn.localeCompare(a.family.members[0].startedOn)); + + return ( +
+ ({ value: v, label: `${VERDICT[v].label} (${whole(counts[v])})`, title: VERDICT[v].title })), + { value: "all" as const, label: `All (${whole(entries.length)})`, title: "Every sample" }, + ]} + /> + {shown.length === 0 ? ( +

None.

+ ) : ( +
    + {shown.map((e) => ( + setOpen(open === e.family.id ? null : e.family.id)} + prodUrl={prodUrl} + items={items} + rehearsed={!!rehearsal?.families.length} + /> + ))} +
+ )} + {none.length > 0 && ( +
+ + {without && ( +
    + {none.map((s) => ( +
  • + {s.subscription} {s.reason} +
  • + ))} +
+ )} +
+ )} +
+ ); +} + +function FamilyRow({ entry, open, onToggle, prodUrl, items, rehearsed }: { + entry: Entry; + open: boolean; + onToggle: () => void; + prodUrl: string; + items: Map; + rehearsed: boolean; +}) { + const { family, run, verdict } = entry; + const exchanges = pathOf("exchanges"); + const first = family.members[0]; + const prodEnding = worst(family.members.map((m) => m.outcome)); + const stagingEnding = run ? worst(run.members.map((m) => m.outcome)) : null; + const name = family.door ?? first.subscription ?? first.informationType ?? ""; + const notRun = run?.notRun ?? (rehearsed ? "It wasn't in the last rehearsal: rehearse again." : "Not rehearsed yet."); + return ( +
  • +
    + + {family.partner && from {family.partner}} + {formatDateTime(first.startedOn)} + {plural(family.members.length + family.cut, "exchange", "exchanges")}{family.cut > 0 ? "+" : ""} + + {prodEnding && } + → + {stagingEnding ? : {run?.correlation ? "didn't finish" : "not run"}} + + + {run?.correlation && exchanges && ( + + Staging's exchanges + + )} + {exchanges && ( + m.prodExchangeId).join(","))}`} target="_blank" rel="noreferrer" + className="inline-flex items-center gap-0.5 text-ink-500 hover:underline" title="Open prod's exchanges, in a new tab: their real values are there"> + On prod + + )} + +
    + {open && ( +
    + {run && run.members.length > 0 ? ( + + ) : ( +
    +

    {notRun}

    + +
    + )} +
    + )} +
  • + ); +} + +/** A family that didn't run: what prod ran, with its files. */ +function MembersPlain({ family, prodUrl }: { family: MirrorFamily; prodUrl: string }) { + const exchanges = pathOf("exchanges"); + return ( +
      + {family.members.map((m) => ( +
    • + {m.subscription ?? `Document of “${m.informationType}”`} + + {m.errorTypes.length > 0 && ( + + {m.errorTypes.join(" → ")} + + )} + + + + {exchanges && ( + + On prod + + )} +
    • + ))} +
    + ); +} + +/** How the family came in, then each of its exchanges: prod's beside staging's, the ones that differ first. */ +function FamilyCompare({ family, run, prodUrl, items }: { family: MirrorFamily; run: MirrorFamilyRun; prodUrl: string; items: Map }) { + const [open, setOpen] = useState(() => { + const at = run.members.findIndex((m) => m.verdict === "different" || m.verdict === "check"); + return at < 0 ? null : at; + }); + const samples = new Map(family.members.map((m) => [m.prodExchangeId, m])); + const exchanges = pathOf("exchanges"); + const loud = run.diffs.filter((d) => d.level !== "same" || d.part === "entry"); + return ( +
    + {loud.length > 0 && ( +
      + {loud.map((d) => )} +
    + )} +
    +

    Its exchanges

    +
      + {run.members.map((m, i) => { + const sample = m.prodExchangeId ? samples.get(m.prodExchangeId) : undefined; + const v = (m.verdict ?? "notRun") as Verdict; + const item = sample?.subscription ? items.get(`subscriptions\u0000${sample.subscription}`) : undefined; + return ( +
    • +
      + + + {sample ? : not on prod} + → + {m.outcome ? : {m.stagingExchangeId ? "didn't finish" : "not run"}} + + + {m.stagingExchangeId && Staging's exchange} + {m.prodExchangeId && exchanges && ( + + On prod + + )} + +
      + {open === i && ( +
      + {m.diffs.length > 0 ? :

      {m.notRun}

      } + {item && ( +

      + The subscription: +

      + )} +
      + )} +
    • + ); + })} +
    +
    +
    + ); +} + +function Ending({ outcome, status, where }: { outcome: MirrorSample["outcome"]; status: number | null; where: "prod" | "staging" }) { + const o = OUTCOME[outcome]; + return ( + + {o.label} + {status != null && ` ${status}`} + + ); +} + +// ------------------------------------------------------------------ one exchange, prod beside staging + +const LEVEL = { + different: { icon: CircleX, tone: "text-danger-600", label: "Different" }, + check: { icon: CircleAlert, tone: "text-warn-600", label: "Check" }, + info: { icon: Info, tone: "text-ink-500", label: "Differs, which explains rather than counts" }, + faked: { icon: CircleCheck, tone: "text-ok-600", label: "Same, with faked values" }, + same: { icon: CircleCheck, tone: "text-ok-600", label: "Same" }, +} as const; + +/** What changed first; what is the same as prod folded into one line. */ +function Compare({ run, sample }: { run: MirrorRun; sample: MirrorSample | undefined }) { + const [sameOpen, setSameOpen] = useState(false); + const changed = run.diffs.filter((d) => d.level === "different" || d.level === "check" || d.level === "info"); + const same = run.diffs.filter((d) => d.level === "same" || d.level === "faked"); + const headline = + run.diffs.find((d) => d.part === "result" && d.note)?.note ?? + (run.verdict === "same" ? "It went as on prod." : run.verdict === "check" ? "Only values' text changed, and each kept its look." : "Something it sent or got back changed."); + return ( +
    +

    {headline}

    + {changed.length > 0 && ( +
    +

    What changed

    +
      + {changed.map((d) => )} +
    +
    + )} + {same.length > 0 && ( +
    + + {sameOpen && ( +
      + {same.map((d) => )} +
    + )} +
    + )} +
    + ); +} + +/** The files of one part of the exchange, prod's (faked) and staging's. */ +function filesOf(diff: MirrorDiff, run: MirrorRun | undefined, sample: MirrorSample | undefined): { prod: MirrorSampleFile | null; staging: MirrorSampleFile | null } | null { + if (diff.part === "output") return { prod: sample?.output ?? null, staging: run?.output ?? null }; + if (diff.part === "answer") return { prod: sample?.response ?? null, staging: run?.response ?? null }; + if (diff.part === "input") return { prod: sample?.input ?? null, staging: null }; + return null; +} + +function DiffLine({ diff, run, sample }: { diff: MirrorDiff; run?: MirrorRun; sample?: MirrorSample }) { + const [open, setOpen] = useState(false); + const [comparing, setComparing] = useState(false); + const files = filesOf(diff, run, sample); + const fields = diff.fields ?? []; + const canOpen = fields.length > 0 || !!files?.prod?.url || !!files?.staging?.url || !!diff.note; + const level = LEVEL[diff.level]; + const Icon = level.icon; + const staging = diff.section ? itemPath(diff.section, diff.stagingId, diff.type) : null; + return ( +
  • + + {open && ( +
    + {diff.note &&

    {diff.note}

    } + {fields.length > 0 && } + {(files || staging) && ( +

    + {files?.prod?.url && Prod's file} + {files?.staging?.url && Staging's file} + {files?.prod?.url && files.staging?.url && ( + + )} + {staging && On staging} +

    + )} + {comparing && files?.prod?.url && files.staging?.url && ( + }> + + + )} +
    + )} +
  • + ); +} + +/** Field by field: where, prod's value (faked) and staging's. */ +function Fields({ diff }: { diff: MirrorDiff }) { + const fields = diff.fields ?? []; + const calls = diff.part === "calls"; + return ( +
    + + + + + {!calls && } + + + + + {fields.map((f, i) => { + const Icon = LEVEL[f.level].icon; + return ( + + + {!calls && } + + + ); + })} + +
    {calls ? "Call" : "Where"}Prod (faked){calls ? "What happened" : "Staging"}
    + + + {f.path} + + {f.prod}{f.staging}
    +
    + ); +} diff --git a/SW.Bitween.Web/ClientApp/src/pages/mirror/__tests__/MirrorPage.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/mirror/__tests__/MirrorPage.test.tsx index 8a0df6cd..80bd6c1c 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/mirror/__tests__/MirrorPage.test.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/mirror/__tests__/MirrorPage.test.tsx @@ -1,7 +1,7 @@ import { screen, within } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { describe, expect, it } from "vitest"; -import type { MirrorItem, MirrorParity, MirrorSamples } from "../../../api"; +import type { MirrorItem, MirrorParity, MirrorRehearsal, MirrorSample, MirrorSamples } from "../../../api"; import { apiPath, renderApp } from "../../../__tests__/support/renderApp"; const status = (over = {}) => @@ -67,10 +67,33 @@ const parity: MirrorParity = { const parityHandler = (answer: MirrorParity | { error: string } = parity) => http.get(apiPath("/mirror/parity"), () => HttpResponse.json(answer)); -const noSamples: MirrorSamples = { state: "none", startedOn: null, finishedOn: null, done: 0, total: 0, error: null, subscriptions: [] }; +const noSamples: MirrorSamples = { state: "none", startedOn: null, finishedOn: null, done: 0, total: 0, error: null, subscriptions: [], families: [] }; const samplesHandler = (answer: MirrorSamples = noSamples) => http.get(apiPath("/mirror/samples"), () => HttpResponse.json(answer)); +const noRehearsal: MirrorRehearsal = { + state: "none", startedOn: null, finishedOn: null, done: 0, total: 0, error: null, prodVersion: null, stagingVersion: null, setupError: null, families: [], +}; + +const rehearsalHandler = (answer: MirrorRehearsal = noRehearsal) => http.get(apiPath("/mirror/rehearsal"), () => HttpResponse.json(answer)); + +const sample = (over: Partial & Pick): MirrorSample => ({ + startedOn: "2026-10-20T10:00:00Z", + outcome: "success", + status: null, + errorTypes: [], + input: { key: `mirror/samples/x/${over.prodExchangeId}/input`, name: "order.json", format: "json", size: 120, notCopied: null, url: `https://staging.acme.test/api/files/s/${over.prodExchangeId}` }, + output: null, + response: null, + partner: null, + handler: null, + mapper: null, + subscription: "Push labels", + informationType: "Label request", + parent: null, + ...over, +}); + const taken: MirrorSamples = { state: "done", startedOn: new Date().toISOString(), @@ -79,24 +102,27 @@ const taken: MirrorSamples = { total: 2, error: null, subscriptions: [ + { subscription: "Push labels", prodId: "12", reason: null, families: ["a1b2"] }, + { subscription: "Nightly report", prodId: "13", reason: "Prod has no exchanges of it.", families: [] }, + ], + families: [ { - subscription: "Push labels", - prodId: "12", - reason: null, - samples: [ - { + id: "a1b2", + entry: "gateway", + door: "acme-labels", + partner: "Acme Retail", + cut: 0, + members: [ + sample({ prodExchangeId: "a1b2", - startedOn: "2026-10-20T10:00:00Z", outcome: "failed", status: 500, errorTypes: ["HttpRequestException"], input: { key: "mirror/samples/x/a1b2/input", name: "ORD_2025-04-11.xml", format: "xml", size: 120, notCopied: null, url: "https://staging.acme.test/api/files/s/in" }, - output: null, response: { key: null, name: "label.pdf", format: "pdf", size: 0, notCopied: "PDFs can't be faked.", url: null }, - }, + }), ], }, - { subscription: "Nightly report", prodId: "13", reason: "Prod has no exchanges of it.", samples: [] }, ], }; @@ -110,7 +136,7 @@ describe("prod mirror", () => { }); it("answers first, then says what needs you, what the test can't cover and what differs on purpose", async () => { - renderApp("/mirror", { handlers: [status(), parityHandler(), samplesHandler()] }); + renderApp("/mirror", { handlers: [status(), parityHandler(), samplesHandler(), rehearsalHandler()] }); const verdict = await screen.findByRole("status", { name: "Readiness" }); expect(within(verdict).getByText("Not ready to test yet")).toBeVisible(); @@ -129,7 +155,7 @@ describe("prod mirror", () => { }); it("opens the section a note points at", async () => { - const { user } = renderApp("/mirror", { handlers: [status(), parityHandler(), samplesHandler()] }); + const { user } = renderApp("/mirror", { handlers: [status(), parityHandler(), samplesHandler(), rehearsalHandler()] }); // Folded until asked for, so the page leads with what to do. await screen.findByRole("status", { name: "Readiness" }); @@ -139,7 +165,7 @@ describe("prod mirror", () => { }); it("lists each kind's items with a way to each, on staging and on prod", async () => { - const { user } = renderApp("/mirror", { handlers: [status(), parityHandler(), samplesHandler()] }); + const { user } = renderApp("/mirror", { handlers: [status(), parityHandler(), samplesHandler(), rehearsalHandler()] }); await user.click(await screen.findByRole("button", { name: /^subscriptions: 40 same/ })); const row = screen.getByText("Push labels").parentElement!; @@ -161,6 +187,7 @@ describe("prod mirror", () => { status(), parityHandler(), samplesHandler(), + rehearsalHandler(), http.post(apiPath("/mirror/copy"), async ({ request }) => { asked = await request.json(); return HttpResponse.json({ @@ -190,6 +217,7 @@ describe("prod mirror", () => { status(), parityHandler(), samplesHandler(), + rehearsalHandler(), http.post(apiPath("/mirror/copy"), async ({ request }) => { asked = await request.json(); return HttpResponse.json({ created: 0, updated: 0, removed: 1, adapterSteps: 0, failed: [], after: parity }); @@ -206,13 +234,14 @@ describe("prod mirror", () => { expect(asked).toEqual({ removeExtras: true }); }); - it("takes samples, follows the taking and lists each one with its faked files and the exchange on prod", async () => { + it("takes samples, follows the taking and lists each arrival with its faked files and its exchanges on prod", async () => { let samples: MirrorSamples = noSamples; let asked = 0; const { user } = renderApp("/mirror", { handlers: [ status(), parityHandler(), + rehearsalHandler(), http.get(apiPath("/mirror/samples"), () => HttpResponse.json(samples)), http.post(apiPath("/mirror/takesamples"), () => { asked++; @@ -222,29 +251,144 @@ describe("prod mirror", () => { ], }); - expect(await screen.findByText("No samples yet.")).toBeVisible(); + expect(await screen.findByText(/^No samples yet\./)).toBeVisible(); + // Nothing to rehearse until there are samples. + expect(screen.getByRole("button", { name: "Rehearse" })).toBeDisabled(); await user.click(screen.getByRole("button", { name: "Take samples" })); expect(await screen.findByText(/Taking samples: 1 of 2 subscriptions read/)).toBeVisible(); expect(asked).toBe(1); // The taking ends; the page follows it on its own. samples = taken; - expect(await screen.findByText(/1 sample from 1 of prod's 2 subscriptions/, {}, { timeout: 4000 })).toBeVisible(); + expect(await screen.findByText(/1 arrival from prod, 1 exchange in all, covering 1 of prod's 2 subscriptions/, {}, { timeout: 4000 })).toBeVisible(); await user.click(screen.getByRole("button", { name: "See the samples" })); - await user.click(screen.getByRole("button", { name: "Push labels: 1 samples" })); - expect(screen.getByText("Failed 500")).toBeVisible(); - expect(screen.getByText("HttpRequestException")).toBeVisible(); - expect(screen.getByRole("link", { name: "In (xml)" })).toHaveAttribute("href", "https://staging.acme.test/api/files/s/in"); - expect(screen.getByTitle("PDFs can't be faked.")).toHaveTextContent("Answer: not kept (pdf)"); - const onProd = screen.getAllByRole("link", { name: /On prod/ }).find((a) => a.getAttribute("href")?.includes("ids=a1b2")); - expect(onProd).toHaveAttribute("href", "https://bitween.acme.test/exchanges?ids=a1b2"); - - // A subscription without samples says why, when asked to show it. - await user.click(screen.getByRole("checkbox", { name: /without samples too/ })); + const row = screen.getByRole("button", { name: /^acme-labels, .*: Not run$/ }).closest("li")!; + expect(within(row).getByTitle("A partner's call through an API gateway")).toHaveTextContent("Gateway call"); + expect(within(row).getByText("from Acme Retail")).toBeVisible(); + expect(within(row).getByRole("link", { name: /On prod/ })).toHaveAttribute("href", "https://bitween.acme.test/exchanges?ids=a1b2"); + + await user.click(within(row).getByRole("button", { name: /^acme-labels/ })); + expect(within(row).getByText("Not rehearsed yet.")).toBeVisible(); + expect(within(row).getByText("Failed 500")).toBeVisible(); + expect(within(row).getByText("HttpRequestException")).toBeVisible(); + expect(within(row).getByRole("link", { name: "In (xml)" })).toHaveAttribute("href", "https://staging.acme.test/api/files/s/in"); + expect(within(row).getByTitle("PDFs can't be faked.")).toHaveTextContent("Answer: not kept (pdf)"); + + // A subscription without samples says why, when asked. + await user.click(screen.getByRole("button", { name: /1 subscription has no traffic on prod/ })); expect(screen.getByText("Prod has no exchanges of it.")).toBeVisible(); }); + it("rehearses, then lists each arrival and the exchanges it led to, prod beside staging, what changed first", async () => { + const now = new Date().toISOString(); + const withFamilies: MirrorSamples = { + ...taken, + subscriptions: [ + { subscription: "Push labels", prodId: "12", reason: null, families: ["f1", "f2"] }, + { subscription: "Send confirmation", prodId: "15", reason: null, families: ["f1"] }, + { subscription: "Nightly roll-up", prodId: "14", reason: null, families: ["f3"] }, + ], + families: [ + { id: "f1", entry: "gateway", door: "acme-orders", partner: "Acme Retail", cut: 0, + members: [sample({ prodExchangeId: "p1" }), sample({ prodExchangeId: "p1b", subscription: "Send confirmation", parent: "p1", startedOn: "2026-10-20T10:00:05Z" })] }, + { id: "f2", entry: "document", door: "Label request", partner: null, cut: 0, + members: [sample({ prodExchangeId: "p2", subscription: null, startedOn: "2026-10-19T10:00:00Z" }), sample({ prodExchangeId: "p2b", startedOn: "2026-10-19T10:00:01Z" })] }, + { id: "f3", entry: "aggregation", door: "Nightly roll-up", partner: null, cut: 0, members: [sample({ prodExchangeId: "p3", subscription: "Nightly roll-up" })] }, + ], + }; + type Diff = MirrorRehearsal["families"][number]["diffs"][number]; + const diff = (over: Partial & Pick): Diff => ({ + prod: null, staging: null, note: null, fields: null, section: null, stagingId: null, type: null, ...over, + }); + const member = (over: Partial & { prodExchangeId: string | null; subscription: string }) => ({ + stagingExchangeId: null, ranOn: now, outcome: null, status: null, errorTypes: [], verdict: null, notRun: null, output: null, response: null, diffs: [], + ...over, + }) as MirrorRehearsal["families"][number]["members"][number]; + const rehearsed: MirrorRehearsal = { + state: "done", startedOn: now, finishedOn: now, done: 3, total: 3, error: null, prodVersion: "10.0.110", stagingVersion: "10.0.115", setupError: null, + families: [ + { + familyId: "f1", correlation: "rehearsal-abc", ranOn: now, verdict: "different", notRun: null, + diffs: [ + diff({ part: "entry", name: "How it came in", level: "same", prod: "A call to the gateway “acme-orders” from “Acme Retail”", staging: "Taken: staging answered 202" }), + diff({ part: "family", name: "Bitween", level: "info", prod: "10.0.110", staging: "10.0.115", note: "The release being tried." }), + ], + members: [ + member({ prodExchangeId: "p1", subscription: "Push labels", stagingExchangeId: "s1", outcome: "success", verdict: "same", + diffs: [diff({ part: "result", name: "Result", level: "same", prod: "Worked", staging: "Worked" })] }), + member({ prodExchangeId: "p1b", subscription: "Send confirmation", stagingExchangeId: "s1b", outcome: "failed", errorTypes: ["JsonException"], verdict: "different", + diffs: [ + diff({ part: "result", name: "Result", level: "different", prod: "Worked", staging: "Failed · JsonException", note: "It worked on prod and doesn't on staging." }), + diff({ part: "output", name: "What it sent", level: "different", prod: "JSON · 3 fields", staging: "JSON · 2 fields", note: "1 field missing.", + fields: [{ path: "order.ref", level: "different", prod: "\"XKQ-1\"", staging: "Missing" }] }), + diff({ part: "input", name: "What was handed on to it", level: "same", prod: "JSON · 4 fields", staging: "JSON · 4 fields" }), + ] }), + ], + }, + { + familyId: "f2", correlation: "rehearsal-def", ranOn: now, verdict: "same", notRun: null, + diffs: [diff({ part: "entry", name: "How it came in", level: "same", prod: "A document of “Label request”", staging: "The same document, routed by staging" })], + members: [ + member({ prodExchangeId: "p2", subscription: "Label request", stagingExchangeId: "s2", outcome: "success", verdict: "same", diffs: [diff({ part: "result", name: "Result", level: "same", prod: "Worked", staging: "Worked" })] }), + member({ prodExchangeId: "p2b", subscription: "Push labels", stagingExchangeId: "s2b", outcome: "success", verdict: "same", diffs: [diff({ part: "result", name: "Result", level: "same", prod: "Worked", staging: "Worked" })] }), + ], + }, + { familyId: "f3", correlation: null, ranOn: now, verdict: null, notRun: "Staging had nothing to roll up: none of the exchanges it aggregates worked in this rehearsal.", diffs: [], members: [] }, + ], + }; + let rehearsal: MirrorRehearsal = noRehearsal; + let asked = 0; + const { user } = renderApp("/mirror", { + handlers: [ + status(), + parityHandler(), + samplesHandler(withFamilies), + http.get(apiPath("/mirror/rehearsal"), () => HttpResponse.json(rehearsal)), + http.post(apiPath("/mirror/rehearse"), () => { + asked++; + rehearsal = { ...noRehearsal, state: "running", done: 1, total: 3 }; + return HttpResponse.json(rehearsal); + }), + ], + }); + + expect(await screen.findByText("Not rehearsed yet.")).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Rehearse" })); + expect(await screen.findByText(/Rehearsing: 1 of 3 arrivals run/)).toBeVisible(); + expect(asked).toBe(1); + + // It ends; the page follows it on its own. + rehearsal = rehearsed; + expect(await screen.findByText(/2 arrivals ran \(1 different, 1 the same\), 1 didn't\. Staging runs 10\.0\.115, prod 10\.0\.110\./, {}, { timeout: 4000 })).toBeVisible(); + + // The list opens on what differs: each arrival with every exchange it led to, on staging and on prod. + await user.click(screen.getByRole("button", { name: "See them" })); + expect(screen.getByRole("radio", { name: /^Different \(1\)/ })).toBeChecked(); + const row = screen.getByRole("button", { name: /^acme-orders, .*: Different$/ }).closest("li")!; + expect(within(row).getByRole("link", { name: "Staging's exchanges" })).toHaveAttribute("href", "/exchanges?correlationId=rehearsal-abc"); + expect(within(row).getByRole("link", { name: /On prod/ })).toHaveAttribute("href", "https://bitween.acme.test/exchanges?ids=p1%2Cp1b"); + + // Opened: how it came in, then its exchanges, the one that differs opened, what changed first. + await user.click(within(row).getByRole("button", { name: /^acme-orders, .*: Different$/ })); + const entry = within(row).getByRole("list", { name: "How it came in" }); + expect(within(entry).getByRole("button", { name: /How it came in/ })).toHaveTextContent(/A call to the gateway “acme-orders” from “Acme Retail”\s*→\s*Taken: staging answered 202/); + expect(within(entry).getByRole("button", { name: /Bitween/ })).toHaveTextContent(/10\.0\.110\s*→\s*10\.0\.115/); + const pushLabels = within(row).getByRole("button", { name: "Push labels: Same" }).closest("li")!; + expect(within(pushLabels).getByRole("link", { name: "Staging's exchange" })).toHaveAttribute("href", "/exchanges?ids=s1"); + expect(within(row).getByText("It worked on prod and doesn't on staging.")).toBeVisible(); + const changed = within(row).getByRole("region", { name: "What changed" }); + expect(within(changed).getByRole("button", { name: /Result/ })).toHaveTextContent(/Worked\s*→\s*Failed · JsonException/); + await user.click(within(changed).getByRole("button", { name: /What it sent/ })); + expect(within(changed).getByText("order.ref")).toBeVisible(); + expect(within(row).getByRole("region", { name: "Same as prod" })).toHaveTextContent("What was handed on to it"); + + // One that didn't run says why. + await user.click(screen.getByRole("radio", { name: /^Not run \(1\)/ })); + await user.click(screen.getByRole("button", { name: /^Nightly roll-up, .*: Not run$/ })); + expect(screen.getByText(/Staging had nothing to roll up/)).toBeVisible(); + }); + it("lists the calls staging answered, each with what it answered last", async () => { const withCalls: MirrorParity = { ...parity, @@ -255,6 +399,7 @@ describe("prod mirror", () => { status(), parityHandler(withCalls), samplesHandler(), + rehearsalHandler(), http.get(apiPath("/mirror/calls"), () => HttpResponse.json({ catching: true, @@ -276,7 +421,7 @@ describe("prod mirror", () => { it("says why prod can't be compared, with a way to fix the connection", async () => { renderApp("/mirror", { - handlers: [status(), samplesHandler(), parityHandler({ error: "Prod runs r8 or older. The mirror copies prods on r10.0 or later; move this client to r10 first." })], + handlers: [status(), samplesHandler(), rehearsalHandler(), parityHandler({ error: "Prod runs r8 or older. The mirror copies prods on r10.0 or later; move this client to r10 first." })], }); expect(await screen.findByText(/Prod runs r8 or older/)).toBeVisible(); diff --git a/SW.Bitween.Web/ClientApp/src/pages/mirror/shared.tsx b/SW.Bitween.Web/ClientApp/src/pages/mirror/shared.tsx new file mode 100644 index 00000000..560874f8 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/mirror/shared.tsx @@ -0,0 +1,75 @@ +import { ExternalLink } from "lucide-react"; +import { Link } from "react-router"; +import type { MirrorItem, MirrorSample, MirrorSampleFile, SubscriptionType } from "../../api"; +import { whole } from "../../lib/numbers"; +import { subscriptionPath } from "../../lib/subscriptionPaths"; +import { pathOf, type PageId } from "../../pages"; + +/** Where an item of each kind opens. Subscription categories have no page of their own. */ +const KIND_PAGE: Record = { + workGroups: "workGroup", + informationTypes: "informationType", + partners: "partner", + globalValues: "globalValueSet", + connections: "connection", + notificationChannels: "notificationChannel", + retryPolicies: "retryPolicy", + dataSources: "dataSource", + dataSourceStatements: "dataSource", + apiGateways: "apiGateway", + busGateways: "busGateway", + notifiers: "notifier", +}; + +/** The page an item opens on, from its id; null for a kind with no page, or an item not there. */ +export function itemPath(section: string, id: string | null, type?: string | null): string | null { + if (!id) return null; + if (section === "subscriptions") return subscriptionPath(Number(id), (type ?? null) as SubscriptionType | null); + const page = KIND_PAGE[section]; + return page ? pathOf(page, { id }) : null; +} + +export const plural = (n: number, one: string, many: string) => `${whole(n)} ${n === 1 ? one : many}`; + +/** "On staging" and "On prod" links to one item, as far as each side has it. */ +export function ItemLinks({ section, item, prodUrl }: { section: string; item: MirrorItem; prodUrl: string }) { + const staging = itemPath(section, item.stagingId, item.type); + const prod = itemPath(section, item.prodId, item.type); + return ( + + {staging && On staging} + {prod && ( + + On prod + + )} + + ); +} + +export const OUTCOME: Record = { + success: { label: "Worked", tone: "ok", title: "It worked" }, + badResponse: { label: "Bad answer", tone: "warn", title: "It was delivered, and the partner answered with an error" }, + failed: { label: "Failed", tone: "danger", title: "It failed" }, +}; + +/** One of a sample's files: a link that opens staging's faked copy, or why it wasn't kept. */ +export function SampleFile({ label, what, file }: { label: string; what: string; file: MirrorSampleFile | null }) { + if (!file) return null; + if (!file.url) + return ( + + {label}: not kept{file.format ? ` (${file.format})` : ""} + + ); + return ( + + {label} ({file.format}) + + ); +} + +/** The exchanges page, showing one exchange. */ +export function exchangePath(id: string): string { + return `${pathOf("exchanges")}?ids=${encodeURIComponent(id)}`; +} diff --git a/SW.Bitween.Web/Startup.cs b/SW.Bitween.Web/Startup.cs index bcf87d8e..d73abcec 100644 --- a/SW.Bitween.Web/Startup.cs +++ b/SW.Bitween.Web/Startup.cs @@ -162,7 +162,7 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddScoped(); services.AddSingleton(); diff --git a/docs/api-reference.md b/docs/api-reference.md index 061ddc0d..090a2850 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -348,7 +348,9 @@ See [Data sources](data-sources.md) and [Databases](databases.md). | `GET /api/mirror` | `mirror.view`. Whether this Bitween is a mirror (`target`), prod's address and how it signs in, without calling prod. | | `GET /api/mirror/parity` | `mirror.view`. Reads prod and this Bitween now: `verdict` (`ready`, `matching`, `total`, `needsYou`, `toCopy`, `notCovered`), `notes` (each in `needsYou`, `copy`, `notCovered` or `onPurpose`, with the item or page it is about), per kind what is the same, differs, is missing or is only here with each item's id on both sides, `adapters`, `addresses` (the test ones, with what uses each), `fakedKeys` and `unknown` (fields prod sends that this release doesn't know). A prod that can't be reached or is older than r10.0 answers `{ error }`. See [Prod mirror](prod-mirror.md). | | `GET /api/mirror/samples` | `mirror.view`. The samples of prod's exchanges this Bitween holds: `state` (`none`, `running`, `done` or `failed`), `done` of `total` subscriptions read, and per subscription its samples, each with how it ended on prod (`outcome`, `status`, `errorTypes`) and its `input`, `output` and `response` files: `format`, a faked `name`, a `url` that opens the faked file, or `notCopied` and why. A subscription without samples has a `reason`. | -| `POST /api/mirror/takesamples` | `mirror.operate`. Starts taking samples in the background and answers as `GET /api/mirror/samples` does. Refused with `MIRROR_NOT_TARGET` unless the deployment sets `Bitween:Mirror:Target`. | +| `POST /api/mirror/takesamples` | `mirror.operate`. Starts taking samples in the background and answers as `GET /api/mirror/samples` does. Refused with `MIRROR_NOT_TARGET` unless the deployment sets `Bitween:Mirror:Target`, and with `MIRROR_BUSY` while a rehearsal runs. | +| `GET /api/mirror/rehearsal` | `mirror.view`. The last rehearsal: `state` (`none`, `running`, `done` or `failed`), `done` of `total` samples run, `prodVersion`, `stagingVersion`, `setupError` (why what each exchange used wasn't compared, when it wasn't) and per sample a run: `stagingExchangeId`, how it ended on the staging (`outcome`, `status`, `errorTypes`), `verdict` (`same`, `check` or `different`) or `notRun` and why, its `output` and `response` files, and `diffs`, each with its `part`, `level`, prod's and the staging's side, a `note` and `fields`. | +| `POST /api/mirror/rehearse` | `mirror.operate`. Starts a rehearsal in the background and answers as `GET /api/mirror/rehearsal` does. Refused with `MIRROR_NOT_TARGET` unless the deployment sets `Bitween:Mirror:Target`, and with `MIRROR_BUSY` while samples are being taken. | | `GET /api/mirror/calls` | `mirror.view`. The calls this node answered in place of the network since it started: `catching` (whether it is a mirror), `hosts` (each with `test` for a test address, `calls`, `lastAt`, `lastWhy` and the subscriptions that use it) and the 50 most `recent`. A call with `status` 0 failed before the staging could answer it. | | `POST /api/mirror/copy` | `mirror.operate`, and edit rights on what it copies. `{ removeExtras }`. Copies prod's setup with every value faked and answers `{ created, updated, removed, adapterSteps, failed, after }`. Refused with `MIRROR_NOT_TARGET` unless the deployment sets `Bitween:Mirror:Target`. | diff --git a/docs/prod-mirror.md b/docs/prod-mirror.md index 7c6b3337..b590d9de 100644 --- a/docs/prod-mirror.md +++ b/docs/prod-mirror.md @@ -40,7 +40,9 @@ Prod may hold several items of a kind under one name. Each after the oldest is n ## Samples of prod's traffic -**Take samples** takes a few of prod's recent exchanges for each subscription: up to five, mostly ones that worked, with one the partner answered badly and one that failed when prod has them, never a retry or one still running. For each it keeps what came in, what Bitween sent and what the partner answered, and how the exchange ended. The rehearsal runs them through the staging's flows. +**Take samples** takes a few of prod's recent exchanges for each subscription: up to five, mostly ones that worked, with one the partner answered badly and one that failed when prod has them, never a retry or one still running. Each comes with its **arrival**: the thing that came in on prod and every exchange it led to there. That is a partner's call through a gateway or the partner API, a document routed to the subscriptions that want it, a file a receiver read, or an aggregation, followed by the answers it handed on to response subscriptions and the messages bus gateways took. For each exchange it keeps what came in, what Bitween sent and what the partner answered, and how the exchange ended. + +Prod records how its exchanges are linked: a document's subscriptions share its correlation id and its input, and an answer handed on or a message names the exchange it came from. The samples follow those links only. Each search on prod is bound to the minutes around the exchange it starts from, which prod has indexed, so a big prod isn't searched end to end. An arrival keeps up to 25 exchanges. A subscription whose handler reads values from the exchange that started it (`{{source.…}}`) runs only inside that exchange's arrival; when prod doesn't say which exchange that was, the page says so. Each file is faked the moment it arrives, in its own format, before it is saved: @@ -56,8 +58,39 @@ Values are faked the way partner properties are, so the same value is the same f A file that can't be faked safely isn't kept, and the page says why: PDFs, archives, Word files, EDI files, workbooks with pictures, charts or pivot tables, binary files and files over 5 MB. Of prod's error only the partner's HTTP status and the kinds of error it names, such as `HttpRequestException`, are kept: the message can hold prod's data, so the page links to the exchange on prod to read it. +An aggregation also keeps a few of the files it rolled up, faked, read through prod's own links to them. Staging never fetches an address that prod's data merely names. + Prod is read gently: one subscription at a time, one page of its recent exchanges, then each file. Taking samples again replaces the ones taken before. +## Rehearsal + +**Rehearse** sends each arrival in on the staging the way it came in on prod, with prod's input as the staging keeps it, faked. The staging then runs everything after it by itself, as prod did: + +| It came in on prod as | The rehearsal sends it in | +|---|---| +| A partner's call to an API gateway | As that partner, to the staging's gateway: with the key the copy gave the partner, or, for a gateway that checks tokens, a token the staging signs as the partner's login server would | +| A partner's call to the partner API | As that partner, to the staging's partner API | +| A document | To the staging, which routes it to the subscriptions that want it | +| A file a receiver read | Handed straight to the receiving subscription; reading it from the server comes with the test servers | +| An aggregation | **Aggregate now**, rolling up what the staging ran of the subscription it aggregates; when it ran none, the files prod rolled up, faked | +| An exchange started by hand | Started the same way | + +The staging's subscriptions stay paused, and its data sources and bus gateways switched off, so nothing runs on its own. Only the rehearsal's traffic gets through: it carries a correlation id of its own, which every exchange it leads to carries on, across the bus too, and calls through a gateway carry a key the rehearsal opens for as long as it runs. That traffic passes paused subscriptions, goes through the bus gateways prod has on, and is never taken for a duplicate of the rehearsal before it. Anyone else's traffic meets the staging as it is. + +When an exchange calls a partner, the staging answers with what prod's partner answered its twin, faked: the same body, `400` for an answer prod's partner gave with an error, the status prod's error names, or a call that fails to connect when prod's never got through. Arrivals run one at a time, so each exchange gets its own twin's answer, and they appear on the staging's **Exchanges** page under their correlation id. + +Each of the staging's exchanges is paired with its twin on prod (the same subscription, in the order they started) and compared, and gets one of three verdicts: + +- **Different**: it ended another way (worked, answered badly, failed), or what it sent or got back changed its format, lost or gained a field, has a list of another length, or writes a value another way: a date in another format, a number turned text, a value left empty. An exchange one side ran and the other didn't is different too, and so is a call the staging's gateway turned away. +- **Check**: only values' text changed, and each kept its look. Fake data explains that when a mapping works values out, such as a sum or a cut; a wrong field taken doesn't, so it is worth a look. +- **Same**: nothing changed. + +An arrival's verdict is the worst of its exchanges'. Both sides go through the same faker, so a value that is only faked reads the same on both. Opening an arrival shows how it came in, then its exchanges, the ones that differ first. Opening an exchange shows what changed first, prod beside staging, and folds what is the same into one line. A changed file opens field by field, and side by side. **What it used** compares the subscription, its information type, the partner it ran as, the global values it names and its adapters with prod's: prod's setup as it is today, faked as the copy fakes it, since prod's API doesn't give the setup an exchange ran with, only its adapters. These only explain a verdict. So do **Calls it made**: a call to a host sent for real (one the staging's setup names, the staging itself or this machine) gets that host's answer, not prod's partner's, and the page says so. + +An arrival doesn't run when it can't be sent in the way it came in on prod, and the page says why: a subscription, partner, gateway or information type the staging doesn't have yet, or an input that isn't kept. + +Taking samples and rehearsing don't run at the same time. A rehearsal replaces the one before it. + ## Calls the staging answers On a mirror, the staging answers some of the calls its flows make itself, so nothing on the staging reaches prod's partners: @@ -83,16 +116,18 @@ It answers first: **Ready to test the upgrade**, or **Not ready to test yet**, w - **Needs you**: what only a person can do, such as installing an adapter, each with where it is done. - **Copy prod's setup**: what copying will do, and the button. It creates what the staging lacks and updates what differs, carries on past an item that fails, and lists each one with Bitween's reason. Tick **Also remove** to delete what only the staging has. -- **Samples of prod's traffic**: how many samples the staging holds and when they were taken, and the button that takes them again. While it runs, how far it has got. +- **Rehearse prod's traffic**: how many samples the staging holds, and how the last rehearsal compared, with the buttons that take samples and rehearse. While either runs, how far it has got. - **Not covered by the test**: what is on prod but not on the staging, or not all of it: items waiting for an adapter, items the staging couldn't create, and fields this release doesn't know. - **Different on staging, on purpose**: paused subscriptions, switched-off data sources and bus gateways, fake values, test addresses, stand-in secrets and names told apart. None of it is a problem. -Then the details, folded: **Prod's setup, item by item** (per kind, what is the same, differs, is missing or is only on the staging), **Samples, subscription by subscription** (how each ended on prod, a link to each faked file and to the exchange on prod), **Adapters**, **Fake values** (where a value can be kept as it is) and **Test addresses** (each with what uses it). +Then the details, folded: **Prod's setup, item by item** (per kind, what is the same, differs, is missing or is only on the staging), **Arrival by arrival** (each arrival with its verdict, how it came in, prod's ending beside the staging's, the staging's exchanges and prod's, the different ones first), **Calls staging answered**, **Adapters**, **Fake values** (where a value can be kept as it is) and **Test addresses** (each with what uses it). When the staging can't create one of prod's items, the page says so, with what Bitween said. That is all it knows: an upgrade doesn't create items again, so a refusal here says nothing about how prod will behave. ## Not covered yet -- Running prod's flows on the staging and comparing the results with prod's. Until then the samples are only kept. -- SFTP, FTP and mail test servers. Until then those addresses lead nowhere. +- SFTP, FTP and mail test servers. Until then those addresses lead nowhere, and a receiver's rehearsal starts from the file prod's receiver read. +- Databases: a flow that reads or writes one, and data sources. They will answer with what they returned on prod, with a note that a test database tests more. +- Files that can't be faked, such as PDFs: they will get stand-ins of the same kind, with a note asking for real test files. +- Comparing a rehearsal with one run on the staging before the upgrade. The first upgrade with the mirror compares with prod's own results, which is why faked values that a mapping works out read as **Check**. - Runtime settings, roles, members and service accounts, which differ between environments on purpose. From 538d1ebb086aafa278e1743b0f8d5e20eb4400c4 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 25 Oct 2026 11:58:19 +0300 Subject: [PATCH 07/16] feat: bring prod's items onto a mirror from the list pages A picker with prod's traffic, a preview of everything that comes and why, and a bring that copies only what staging lacks, never overwriting or removing. --- SW.Bitween.Api/Resources/Mirror/Mirror.cs | 82 ++++ SW.Bitween.Api/Services/Mirror/MirrorBring.cs | 38 ++ .../Services/Mirror/MirrorService.Bring.cs | 419 ++++++++++++++++++ .../Services/Mirror/MirrorService.cs | 51 ++- SW.Bitween.UnitTests/MirrorBringTests.cs | 97 ++++ .../src/__tests__/support/renderApp.tsx | 2 + SW.Bitween.Web/ClientApp/src/api/client.ts | 7 + .../ClientApp/src/api/http/mirror.ts | 28 +- SW.Bitween.Web/ClientApp/src/api/queryKeys.ts | 2 + SW.Bitween.Web/ClientApp/src/api/types.ts | 76 ++++ .../pages/aggregations/AggregationsPage.tsx | 8 +- .../pages/api-gateways/ApiGatewaysPage.tsx | 8 +- .../pages/bus-gateways/BusGatewaysPage.tsx | 2 + .../src/pages/mirror/BringFromProd.tsx | 382 ++++++++++++++++ .../mirror/__tests__/BringFromProd.test.tsx | 125 ++++++ .../ResponseSubscriptionsPage.tsx | 8 +- .../scheduled-jobs/ScheduledJobsPage.tsx | 8 +- .../pages/subscriptions/SubscriptionsPage.tsx | 2 + docs/api-reference.md | 3 + docs/prod-mirror.md | 19 + 20 files changed, 1342 insertions(+), 25 deletions(-) create mode 100644 SW.Bitween.Api/Services/Mirror/MirrorBring.cs create mode 100644 SW.Bitween.Api/Services/Mirror/MirrorService.Bring.cs create mode 100644 SW.Bitween.UnitTests/MirrorBringTests.cs create mode 100644 SW.Bitween.Web/ClientApp/src/pages/mirror/BringFromProd.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/mirror/__tests__/BringFromProd.test.tsx diff --git a/SW.Bitween.Api/Resources/Mirror/Mirror.cs b/SW.Bitween.Api/Resources/Mirror/Mirror.cs index caad7cb7..def5f639 100644 --- a/SW.Bitween.Api/Resources/Mirror/Mirror.cs +++ b/SW.Bitween.Api/Resources/Mirror/Mirror.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using SW.Bitween.Services.Mirror; @@ -137,6 +138,87 @@ public async Task Handle() } } +/// +/// How many of prod's items there are of each kind a page lists - subscriptions by type, API and bus +/// gateways - so each page offers to bring only what prod has. Read from prod's lists alone. +/// +[HandlerName("prodkinds")] +public class ProdKinds(BitweenDbContext dbContext, RequestContext requestContext, MirrorService mirror) : IQueryHandler +{ + public async Task Handle() + { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Mirror.View); + return await mirror.ProdKindsAsync(); + } +} + +public class MirrorProdRequest +{ + /// subscriptions, apiGateways or busGateways. + public string Section { get; set; } + + /// Of subscriptions, only those of this type: Receiving, Aggregation or Response, say. + public string Type { get; set; } +} + +/// +/// Prod's items of one kind, busiest first, each with its traffic over the last week and whether staging +/// has it: what a page offers to bring. Prod is only read. +/// +[HandlerName("prod")] +public class Prod(BitweenDbContext dbContext, RequestContext requestContext, MirrorService mirror) : IQueryHandler +{ + public async Task Handle(MirrorProdRequest request) + { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Mirror.View); + try + { + return await mirror.ProdListAsync(request?.Section, string.IsNullOrWhiteSpace(request?.Type) ? null : request.Type); + } + catch (MirrorRefusal refusal) + { + return new { Error = refusal.Message }; + } + } +} + +public class MirrorBringRequest +{ + /// subscriptions, apiGateways or busGateways. + public string Section { get; set; } + + /// The picked items, by the name prod has them under (a gateway's address for an API gateway). + public List Keys { get; set; } + + /// Bring them; without it, only say what bringing them would do. + public bool Apply { get; set; } +} + +/// +/// Brings prod's picked items onto this staging with what they need to run as on prod, faked: what staging +/// doesn't have comes as prod has it, what it has stays as it is, and nothing is removed. Without +/// it says what it would do. Only on a Bitween its deployment marks +/// as a mirror. +/// +[HandlerName("bring")] +public class Bring(BitweenDbContext dbContext, RequestContext requestContext, BitweenOptions options, MirrorService mirror) + : ICommandHandler +{ + public async Task Handle(MirrorBringRequest request) + { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Mirror.Operate); + if (!options.Mirror.Target) throw new SWValidationException("MIRROR_NOT_TARGET", MirrorService.NotTarget); + try + { + return await mirror.BringAsync(request?.Section, request?.Keys ?? [], request?.Apply ?? false); + } + catch (MirrorRefusal refusal) + { + throw new SWValidationException("MIRROR", refusal.Message); + } + } +} + public class MirrorCopyRequest { /// Also remove what staging has and prod doesn't. Off unless asked, since it deletes. diff --git a/SW.Bitween.Api/Services/Mirror/MirrorBring.cs b/SW.Bitween.Api/Services/Mirror/MirrorBring.cs new file mode 100644 index 00000000..fad922b9 --- /dev/null +++ b/SW.Bitween.Api/Services/Mirror/MirrorBring.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; + +namespace SW.Bitween.Services.Mirror; + +/// How many of prod's items there are of each kind a page lists: where the pages offer to bring them. +/// Prod's subscriptions by type, Receiving or Aggregation for instance, and all of them under "all". +public record MirrorProdKinds(IReadOnlyDictionary Subscriptions, int ApiGateways, int BusGateways, string Error = null); + +/// An item's traffic on prod over the window: exchanges, how many failed, when the last one was, and each day's count. +public record MirrorProdCounts(long Total, long Failed, DateTimeOffset? LastOn, IReadOnlyList Daily); + +/// One of prod's items as a page offers it: its traffic, and whether staging has it. +/// What it is for, in a few words: a subscription's partner and information type, a gateway's partners. +/// As in : inSync, differs, missing or waiting. +public record MirrorProdItem(string Key, string ProdId, string Type, string Detail, string State, string StagingId, string Reason, MirrorProdCounts Traffic); + +/// +/// Prod's items of one kind, busiest first. is the window the traffic covers; +/// null with when prod can't say, and the items are then by name. +/// +public record MirrorProdList(string Section, string Noun, string Plural, int? Days, string TrafficNote, IReadOnlyList Items); + +/// +/// One item a bring touches, and what happens to it: +/// +/// new: staging doesn't have it, and gets prod's, faked; +/// kept: staging has it, and keeps its own; +/// adds: staging has it, and gets only what leads to what is brought: a gateway's partner, a bus route; +/// waits: it can't come yet, and says why; +/// install: an adapter staging installs from the marketplace. +/// +/// +/// Why it comes: picked, or what needs it. +public record MirrorPiece(string Section, string Noun, string Key, string State, string Why, string Reason = null, string StagingId = null, string Type = null); + +/// What bringing the picked items does, or did: every item it touches, what only a person can do, and what failed. +public record MirrorBring(bool Applied, IReadOnlyList Pieces, IReadOnlyList NeedsYou, IReadOnlyList Failed); diff --git a/SW.Bitween.Api/Services/Mirror/MirrorService.Bring.cs b/SW.Bitween.Api/Services/Mirror/MirrorService.Bring.cs new file mode 100644 index 00000000..a17d92de --- /dev/null +++ b/SW.Bitween.Api/Services/Mirror/MirrorService.Bring.cs @@ -0,0 +1,419 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using SW.Bitween.Cli; +using SW.Bitween.Cli.Configuration; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Services.Mirror; + +public sealed partial class MirrorService +{ + /// How far back the traffic a page shows goes. + const int TrafficDays = 7; + + /// The kinds a page offers to bring from prod. + static readonly string[] Bringable = ["subscriptions", "apiGateways", "busGateways"]; + + /// A global values set, as {{globals.set.key}} and a mapper's __globals__.set name it. + static readonly Regex GlobalsNamed = new(@"(?:\bglobals|__globals__)\??\.([A-Za-z0-9_\-]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + /// A connection, as {{connections.name.accessToken}} names it. + static readonly Regex ConnectionsNamed = new(@"\{\{\s*connections\.([A-Za-z0-9_\-]+)\.", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + // ------------------------------------------------------------------ what prod has + + /// + /// How many of prod's items there are of each kind a page lists, read from prod's lists alone: cheap + /// enough for every page that offers to bring them. Why prod can't be read, when it can't. + /// + public async Task ProdKindsAsync() + { + try + { + using var prod = Connect(); + await VersionAsync(prod); + var types = new Dictionary(StringComparer.Ordinal); + var all = 0; + for (var page = 0; ; page++) + { + var answer = await prod.GetAsync($"api/subscriptions?page={page}&size=500"); + var rows = (answer?["result"] as JsonArray ?? []).OfType().ToList(); + foreach (var type in rows.Select(r => (string)r["type"])) + { + all++; + if (type != null) types[type] = types.GetValueOrDefault(type) + 1; + } + if (rows.Count < 500 || all >= ((int?)answer?["totalCount"] ?? all)) break; + } + types["all"] = all; + return new MirrorProdKinds(types, await TotalAsync(prod, "apigateways"), await TotalAsync(prod, "busgateways")); + } + catch (MirrorRefusal refusal) + { + return new MirrorProdKinds(new Dictionary(), 0, 0, refusal.Message); + } + catch (Exception ex) when (ex is BitweenApiException or HttpRequestException or TaskCanceledException) + { + return new MirrorProdKinds(new Dictionary(), 0, 0, $"Prod couldn't be read: {ex.Message}"); + } + } + + static async Task TotalAsync(BitweenApi prod, string resource) + { + try { return (int?)(await prod.GetAsync($"api/{resource}?page=0&size=1"))?["totalCount"] ?? 0; } + catch (BitweenApiException ex) when (ex.Status is HttpStatusCode.NotFound or HttpStatusCode.Forbidden) { return 0; } + } + + /// + /// Prod's items of one kind - subscriptions, of one type if asked, or API or bus gateways - each with + /// its traffic over the last week and whether staging has it, busiest first. A gateway's traffic is + /// that of the subscriptions it leads to. + /// + public async Task ProdListAsync(string section, string type) + { + var kind = KindToBring(section); + using var comparison = await CompareAsync(removeExtras: false, copyKey: null); + var (days, note, traffic) = await TrafficAsync(comparison.Prod); + var subscriptionIds = comparison.Parity.Kinds.First(k => k.Section == "subscriptions").Items + .Where(i => i.ProdId != null).GroupBy(i => i.Key).ToDictionary(g => g.Key, g => g.First().ProdId, StringComparer.Ordinal); + MirrorProdCounts Of(string subscription) => + subscription != null && subscriptionIds.TryGetValue(subscription, out var id) ? traffic.GetValueOrDefault(id) : null; + + var index = Index(comparison.Copy.Sections); + var items = new List(); + foreach (var row in comparison.Parity.Kinds.First(k => k.Section == kind.Section).Items) + { + if (row.State == "extra" || (type != null && row.Type != type)) continue; + var item = index.GetValueOrDefault((kind.Section, row.Key)); + var counts = kind.Section == "subscriptions" ? Of(row.Key) + : Sum(LinksOf(kind.Section, item).Select(l => l.Subscription).Distinct().Select(Of).Where(c => c != null).ToList()); + items.Add(new MirrorProdItem(row.Key, row.ProdId, row.Type, DetailOf(kind.Section, item), row.State, row.StagingId, row.Reason, counts)); + } + items = (days == null ? items.OrderBy(i => i.Key, StringComparer.OrdinalIgnoreCase) + : items.OrderByDescending(i => i.Traffic?.Total ?? 0).ThenBy(i => i.Key, StringComparer.OrdinalIgnoreCase)).ToList(); + return new MirrorProdList(kind.Section, kind.Noun, Plural(kind.Noun), days, note, items); + } + + static Kind KindToBring(string section) => + Bringable.Contains(section) ? Kinds.Of(section) + : throw new SWValidationException("MIRROR_SECTION", $"Bring subscriptions, API gateways or bus gateways from prod, not “{section}”."); + + /// + /// Each of prod's subscriptions' traffic over the last week, by id, from the counts prod keeps by the + /// hour. Without them prod could only say by reading through its exchanges, a heavy read on a busy + /// prod, so a prod whose release doesn't keep them shows none, and why. + /// + static async Task<(int? Days, string Note, Dictionary Counts)> TrafficAsync(BitweenApi prod) + { + JsonNode answer; + try { answer = await prod.GetAsync($"api/metrics/subscriptions?days={TrafficDays}"); } + catch (BitweenApiException ex) when (ex.Status == HttpStatusCode.NotFound) + { + return (null, "Prod's release doesn't count traffic yet, so there are no numbers until prod runs one that does.", []); + } + catch (BitweenApiException ex) when (ex.Status == HttpStatusCode.Forbidden) + { + return (null, "Staging's login on prod can't read prod's traffic. On prod, give its role permission to view subscriptions.", []); + } + var counts = new Dictionary(StringComparer.Ordinal); + foreach (var row in (answer?["subscriptions"] as JsonArray ?? []).OfType()) + if (row["subscriptionId"]?.ToString() is { } id) + counts[id] = new MirrorProdCounts((long?)row["total"] ?? 0, (long?)row["failed"] ?? 0, (DateTimeOffset?)row["lastExchangeHour"], + (row["daily"] as JsonArray ?? []).Select(d => (long?)d ?? 0).ToList()); + return ((int?)answer?["days"] ?? TrafficDays, null, counts); + } + + static MirrorProdCounts Sum(IReadOnlyList counts) => + counts.Count == 0 ? null + : new MirrorProdCounts(counts.Sum(c => c.Total), counts.Sum(c => c.Failed), counts.Max(c => c.LastOn), + Enumerable.Range(0, counts.Max(c => c.Daily.Count)).Select(i => counts.Sum(c => i < c.Daily.Count ? c.Daily[i] : 0)).ToList()); + + /// What an item is for, in a few words: a subscription's partner and information type, a gateway's partners or routes. + static string DetailOf(string section, JsonObject item) + { + var links = LinksOf(section, item).Count(); + string[] parts = section switch + { + "subscriptions" => [(string)item?["partner"], (string)item?["informationType"], + (string)item?["aggregationFor"] is { } of ? $"rolls up “{of}”" : null], + "apiGateways" => [Count(links, "partner")], + _ => [(string)item?["informationType"], Count(links, "route")], + }; + return string.Join(" · ", parts.Where(p => !string.IsNullOrEmpty(p))); + } + + /// What a gateway leads to: each attached partner with its subscription, or each bus route. + static IEnumerable<(string Subscription, string Partner)> LinksOf(string section, JsonObject item) => + section is not ("apiGateways" or "busGateways") ? [] + : (item?[section == "apiGateways" ? "partners" : "routes"] as JsonArray ?? []).OfType() + .Select(l => ((string)l["subscription"], (string)l["partner"])) + .Where(l => l.Item1 != null); + + // ------------------------------------------------------------------ bringing it + + /// + /// Brings prod's picked items onto staging with what they need to run as on prod (see ). + /// What staging doesn't have comes as prod has it, faked as a copy fakes it; what staging has stays as + /// staging has it, so nobody's edits are overwritten, except that a gateway staging has gets the partner + /// or route that leads to what is brought. Nothing is removed. Without it only + /// says what it would do. + /// + public async Task BringAsync(string section, IReadOnlyCollection keys, bool apply) + { + var kind = KindToBring(section); + if (keys.Count == 0) throw new SWValidationException("MIRROR_NOTHING", $"Pick the {Plural(kind.Noun)} to bring from prod."); + var copyKey = apply ? MirrorCopyGate.OpenKey() : null; + try + { + using var comparison = await CompareAsync(removeExtras: false, copyKey); + var index = Index(comparison.Copy.Sections); + if (keys.FirstOrDefault(k => !index.ContainsKey((kind.Section, k))) is { } gone) + throw new MirrorRefusal($"Prod doesn't have the {kind.Noun} “{gone}” any more."); + + var needs = Needs(comparison.Copy.Sections, keys.Select(k => (kind.Section, k))); + var (pieces, wants) = Pieces(comparison, index, needs); + var uses = AdapterUses(needs.Keys.GroupBy(n => n.Section) + .ToDictionary(g => g.Key, g => g.Select(n => index[n]).ToList())); + var adapters = comparison.Parity.Adapters.Where(a => uses.ContainsKey(a.Id)).Select(a => a with { UsedBy = uses[a.Id] }).ToList(); + foreach (var a in adapters.Where(a => a.State == "missing" && a.Source == "marketplace" && a.ProdVersion != null)) + { + pieces.Add(new MirrorPiece(Kinds.Adapters, "adapter", $"{a.Id} {a.ProdVersion}", "install", + $"{Capital(Count(a.UsedBy, "item"))} brought here use{(a.UsedBy == 1 ? "s" : "")} it.")); + Add(wants, Kinds.Adapters, new JsonObject { ["install"] = a.Id, ["version"] = a.ProdVersion, ["current"] = true }); + } + + var config = comparison.Config; + config.Sections.Clear(); + foreach (var (s, items) in wants) config.Sections[s] = items; + var plan = await comparison.Planner.PlanAsync(prune: false); + var needsYou = AdapterNotes(adapters); + needsYou.AddRange(plan.Problems.Distinct().Select(p => new MirrorNote($"problem:{p}", "needsYou", $"On staging: {p}"))); + if (!apply) return new MirrorBring(false, pieces, needsYou, []); + + var failed = await comparison.Planner.ApplyAllAsync(plan); + foreach (var change in plan.Changes) LastFailures.TryRemove((change.Section, change.Key), out _); + foreach (var (change, error) in failed) LastFailures[(change.Section, change.Key)] = error; + var made = pieces.Select(p => p.State == "new" ? p with { StagingId = IdOn(comparison.Staging, p.Section, p.Key, index) } : p).ToList(); + return new MirrorBring(true, made, needsYou, failed.Select(f => new MirrorFailure(f.Change.Section, f.Change.Noun, f.Change.Key, f.Error)).ToList()); + } + finally + { + MirrorCopyGate.CloseKey(copyKey); + } + } + + /// Where staging has an item now, as its page opens it: a SQL statement's is its data source's. + static string IdOn(Context staging, string section, string key, Dictionary<(string Section, string Key), JsonObject> index) + { + var (s, k) = section == "dataSourceStatements" ? ("dataSources", (string)index[(section, key)]["dataSource"]) : (section, key); + try { return staging.Has(s, k) ? staging.IdOf(s, k) : null; } + catch (ConfigException) { return null; } + } + + /// Prod's items, as the copy has them, by kind and key. + static Dictionary<(string Section, string Key), JsonObject> Index(Dictionary> sections) + { + var index = new Dictionary<(string, string), JsonObject>(); + foreach (var (section, items) in sections.Where(s => s.Key != Kinds.Adapters)) + foreach (var item in items) + if (Kinds.Of(section).KeyOf(item) is { } key) index.TryAdd((section, key), item); + return index; + } + + /// + /// The picked items and what each needs on staging to run as on prod, each with why it comes: what it + /// names (see ), and for a subscription, how prod's traffic reaches it - the API + /// gateway a partner calls it through, the bus gateway that routes to it, the subscription whose + /// responses it runs on - with what those need in turn. A picked gateway brings what it leads to, and a + /// data source its SQL statements. + /// + internal static Dictionary<(string Section, string Key), string> Needs(Dictionary> sections, + IEnumerable<(string Section, string Key)> picked) + { + var index = Index(sections); + var chosen = picked.ToHashSet(); + var needs = new Dictionary<(string, string), string>(); + var queue = new Queue<(string Section, string Key, string Why)>(chosen.Select(p => (p.Section, p.Key, "Picked."))); + while (queue.Count > 0) + { + var (section, key, why) = queue.Dequeue(); + if (needs.ContainsKey((section, key)) || !index.TryGetValue((section, key), out var item)) continue; + needs[(section, key)] = why; + var noun = Kinds.Of(section).Noun; + foreach (var (s, k, how) in Names(sections, section, item)) queue.Enqueue((s, k, $"The {noun} “{key}” {how ?? "uses it"}.")); + switch (section) + { + case "dataSources": + foreach (var statement in (sections.GetValueOrDefault("dataSourceStatements") ?? []).Where(st => (string)st["dataSource"] == key)) + queue.Enqueue(("dataSourceStatements", Kinds.Of("dataSourceStatements").KeyOf(statement), $"One of the data source “{key}”'s SQL statements.")); + break; + case "apiGateways" or "busGateways" when chosen.Contains((section, key)): + foreach (var (subscription, partner) in LinksOf(section, item)) + { + queue.Enqueue(("subscriptions", subscription, $"The {noun} “{key}” leads to it.")); + if (partner != null) queue.Enqueue(("partners", partner, $"It reaches “{subscription}” through the {noun} “{key}”.")); + } + break; + case "subscriptions": + foreach (var gateway in sections.GetValueOrDefault("apiGateways") ?? []) + foreach (var (_, partner) in LinksOf("apiGateways", gateway).Where(l => l.Subscription == key)) + { + queue.Enqueue(("apiGateways", (string)gateway["urlName"], $"Prod's partners reach “{key}” through it.")); + if (partner != null) queue.Enqueue(("partners", partner, $"It calls “{key}” through the API gateway “{gateway["urlName"]}”.")); + } + foreach (var gateway in sections.GetValueOrDefault("busGateways") ?? []) + foreach (var (_, partner) in LinksOf("busGateways", gateway).Where(l => l.Subscription == key)) + { + queue.Enqueue(("busGateways", (string)gateway["name"], $"It routes prod's bus messages to “{key}”.")); + if (partner != null) queue.Enqueue(("partners", partner, $"“{key}” runs as it on what the bus gateway “{gateway["name"]}” routes.")); + } + foreach (var source in (sections.GetValueOrDefault("subscriptions") ?? []).Where(s => (string)s["responseSubscription"] == key)) + queue.Enqueue(("subscriptions", (string)source["name"], $"“{key}” runs on its responses.")); + break; + } + } + return needs; + } + + /// + /// What one of prod's items names, which staging needs before it can have it: what its fields refer to, + /// the notification channels a subscription notifies and its retry alerts go to, and the global values + /// sets and connections its adapters name in {{globals.…}} and {{connections.…}}. A gateway's partners + /// and routes aren't among them: a gateway comes with those that can come (see ). + /// + /// Each with how the item uses it, when there's more to say than that it does. + static IEnumerable<(string Section, string Key, string How)> Names(Dictionary> sections, string section, JsonObject item) + { + foreach (var f in Kinds.Of(section).Fields.Where(f => f.Ref != null && item.ContainsKey(f.File))) + foreach (var name in f.Mode == FieldMode.RefList ? (item[f.File] as JsonArray ?? []).Select(x => x?.ToString()) : [item[f.File]?.ToString()]) + if (!string.IsNullOrEmpty(name)) + yield return (f.Ref, name, f.File switch + { + "responseSubscription" => "hands its responses to it", + "aggregationFor" => "rolls up its exchanges", + _ => null, + }); + + if (section == "retryPolicies") + foreach (var channel in AlertChannels(item["groups"])) yield return ("notificationChannels", channel, "sends its alerts there"); + if (section != "subscriptions") yield break; + + foreach (var row in (item["notifications"] as JsonArray ?? []).OfType()) + if ((string)row["channel"] is { Length: > 0 } channel) yield return ("notificationChannels", channel, "notifies there"); + foreach (var channel in AlertChannels(item["customRetryPolicy"]?["groups"])) yield return ("notificationChannels", channel, "sends its alerts there"); + + var texts = new[] { "receiver", "validator", "mapper", "handler" } + .SelectMany(role => item[role]?["properties"] as JsonObject ?? []) + .Select(p => p.Value is JsonValue v && v.TryGetValue(out var s) ? s : null).Where(t => t != null).ToList(); + foreach (var set in Named(texts, GlobalsNamed, sections, "globalValues", "id")) yield return ("globalValues", set, "reads its values"); + foreach (var connection in Named(texts, ConnectionsNamed, sections, "connections", "name")) yield return ("connections", connection, "signs in with it"); + } + + /// The items of a kind that name the way finds, as prod's setup calls them. + static IEnumerable Named(List texts, Regex pattern, Dictionary> sections, string section, string field) + { + var known = (sections.GetValueOrDefault(section) ?? []).Select(x => (string)x[field]).Where(x => x != null).ToList(); + return texts.SelectMany(t => pattern.Matches(t)).Select(m => m.Groups[1].Value).Distinct(StringComparer.OrdinalIgnoreCase) + .Select(found => known.FirstOrDefault(k => string.Equals(k, found, StringComparison.OrdinalIgnoreCase))).Where(k => k != null); + } + + static IEnumerable AlertChannels(JsonNode groups) => + (groups as JsonArray ?? []).OfType().Select(g => (string)g["alertChannel"]).Where(c => !string.IsNullOrEmpty(c)); + + /// + /// Each item a bring touches, and what the planner is to make of it: prod's, as the copy fakes it, for + /// what staging doesn't have; staging's own with the partners and routes it gets, for a gateway it has. + /// A gateway leads only to what staging will have. A subscription that waits for an adapter waits, and + /// so does what needs something that can't come, saying for what. + /// + static (List Pieces, Dictionary> Wants) Pieces(Comparison comparison, + Dictionary<(string Section, string Key), JsonObject> index, Dictionary<(string Section, string Key), string> needs) + { + var staging = comparison.Staging; + var sections = comparison.Copy.Sections; + var rows = comparison.Parity.Kinds.SelectMany(k => k.Items.Select(i => (k.Section, Item: i))) + .GroupBy(x => (x.Section, x.Item.Key)).ToDictionary(g => g.Key, g => g.First().Item); + + var waits = needs.Keys.Where(n => !staging.Has(n.Section, n.Key) && rows.GetValueOrDefault(n)?.State == "waiting") + .ToDictionary(n => n, n => rows[n].Reason); + bool WillHave((string Section, string Key) n) => staging.Has(n.Section, n.Key) || (needs.ContainsKey(n) && !waits.ContainsKey(n)); + for (var changed = true; changed;) + { + changed = false; + foreach (var n in needs.Keys.Where(n => !staging.Has(n.Section, n.Key) && !waits.ContainsKey(n)).ToList()) + { + var blocked = Names(sections, n.Section, index[n]).Select(m => (m.Section, m.Key)).Where(m => !WillHave(m)).ToList(); + if (blocked.Count == 0) continue; + waits[n] = $"It needs the {Kinds.Of(blocked[0].Section).Noun} “{blocked[0].Key}”, which can't come yet."; + changed = true; + } + } + + var pieces = new List(); + var wants = new Dictionary>(StringComparer.Ordinal); + var order = Kinds.All.Select(k => k.Section).ToList(); + foreach (var ((section, key), why) in needs.OrderBy(n => order.IndexOf(n.Key.Section)).ThenBy(n => n.Key.Key, StringComparer.Ordinal)) + { + var noun = Kinds.Of(section).Noun; + var row = rows.GetValueOrDefault((section, key)); + var item = index[(section, key)]; + var links = LinksOf(section, item).Where(l => WillHave(("subscriptions", l.Subscription)) && (l.Partner == null || WillHave(("partners", l.Partner)))).ToList(); + if (!staging.Has(section, key)) + { + if (waits.TryGetValue((section, key), out var reason)) + { + pieces.Add(new MirrorPiece(section, noun, key, "waits", why, reason, Type: row?.Type)); + continue; + } + var want = (JsonObject)item.DeepClone(); + if (section is "apiGateways" or "busGateways") + want[section == "apiGateways" ? "partners" : "routes"] = new JsonArray(LinksIn(section, item, links).ToArray()); + Add(wants, section, want); + pieces.Add(new MirrorPiece(section, noun, key, "new", why, Type: row?.Type)); + continue; + } + + var have = staging.Current[section].First(e => e.Key == key); + var added = section switch + { + "apiGateways" => links.Where(l => needs.ContainsKey(("subscriptions", l.Subscription)) && + !LinksOf(section, have.File).Any(h => h.Partner == l.Partner)).ToList(), + "busGateways" => links.Where(l => needs.ContainsKey(("subscriptions", l.Subscription)) && !LinksOf(section, have.File).Contains(l)).ToList(), + _ => [], + }; + if (added.Count > 0) + { + var want = (JsonObject)have.File.DeepClone(); + var field = section == "apiGateways" ? "partners" : "routes"; + want[field] = new JsonArray([.. (have.File[field] as JsonArray ?? []).Select(x => x?.DeepClone()), .. LinksIn(section, item, added)]); + Add(wants, section, want); + pieces.Add(new MirrorPiece(section, noun, key, "adds", why, section == "apiGateways" + ? $"Staging has it, and gets {string.Join(", ", added.Select(l => $"the partner “{l.Partner}” for “{l.Subscription}”"))}." + : $"Staging has it, and gets the route{(added.Count == 1 ? "" : "s")} to {string.Join(", ", added.Select(l => $"“{l.Subscription}”"))}.", + row?.StagingId, row?.Type)); + continue; + } + pieces.Add(new MirrorPiece(section, noun, key, "kept", why, + row?.State == "differs" ? "Staging's differs from prod's, and stays as it is." : null, row?.StagingId, row?.Type)); + } + return (pieces, wants); + } + + static void Add(Dictionary> wants, string section, JsonObject want) + { + if (!wants.TryGetValue(section, out var list)) wants[section] = list = []; + list.Add(want); + } + + /// A gateway's partners or routes as prod has them, those of only. + static IEnumerable LinksIn(string section, JsonObject item, IReadOnlyCollection<(string Subscription, string Partner)> links) => + (item[section == "apiGateways" ? "partners" : "routes"] as JsonArray ?? []).OfType() + .Where(l => links.Contains(((string)l["subscription"], (string)l["partner"]))) + .Select(l => l.DeepClone()); +} diff --git a/SW.Bitween.Api/Services/Mirror/MirrorService.cs b/SW.Bitween.Api/Services/Mirror/MirrorService.cs index 6e0d6b7d..c2c4f5d7 100644 --- a/SW.Bitween.Api/Services/Mirror/MirrorService.cs +++ b/SW.Bitween.Api/Services/Mirror/MirrorService.cs @@ -127,8 +127,12 @@ public async Task CopyAsync(bool removeExtras, CancellationTok failed.Select(f => new MirrorFailure(f.Change.Section, f.Change.Noun, f.Change.Key, f.Error)).ToList(), after); } - /// A comparison, prod's setup as staging copies it, and the two connections its planner carries out changes over until it is done with. - sealed record Comparison(MirrorParity Parity, Planner Planner, Plan Plan, MirrorSetupCopy Copy, BitweenApi Prod, BitweenApi Self) : IDisposable + /// + /// A comparison, prod's setup as staging copies it, and the two connections its planner carries out + /// changes over until it is done with; with the file the planner plans from and staging as it read it. + /// + sealed record Comparison(MirrorParity Parity, Planner Planner, Plan Plan, MirrorSetupCopy Copy, BitweenApi Prod, BitweenApi Self, + ConfigFile Config, Context Staging) : IDisposable { public void Dispose() { @@ -203,7 +207,7 @@ async Task CompareAsync(BitweenApi prod, Func openSelf, new MirrorSide(links.BaseUrl, BitweenInfo.Version(options).ToString(3), await ExchangesAsync(self)), Verdict(kinds, notes), notes, kinds, adapters, copy.Addresses, copy.FakedKeys, unknown); - return new Comparison(parity, planner, plan, copy, prod, self); + return new Comparison(parity, planner, plan, copy, prod, self, config, selfCtx); } // ------------------------------------------------------------------ prod @@ -565,25 +569,9 @@ internal static string Plural(string noun) => static List Notes(List kinds, List adapters, Plan plan, int extras, List renamed, List unknown, MirrorSetupCopy copy, bool catching) { - var notes = new List(); + var notes = AdapterNotes(adapters); // ---- needs you - foreach (var a in adapters.Where(a => a.State == "missing" && a.Source != "builtin" && !(a.Source == "marketplace" && a.ProdVersion != null))) - { - var where = a.Source == "marketplace" - ? $"Install the adapter {a.Id} from the Marketplace tab of the Adapters page" - : $"Upload the adapter {a.Id}{(a.ProdVersion == null ? "" : $", version {a.ProdVersion},")} on the Adapters page"; - notes.Add(new MirrorNote($"adapter:{a.Id}", "needsYou", - $"{where}. {Used(a.UsedBy)} it; staging copies {(a.UsedBy == 1 ? "that one" : "them")} once it's there.", - Action: a.Source == "marketplace" ? "adapters-marketplace" : "adapters-installed")); - } - foreach (var a in adapters.Where(a => a.State == "different")) - notes.Add(new MirrorNote($"current:{a.Id}", "needsYou", - $"Make version {a.ProdVersion} of the adapter {a.Id} the current one on staging, as on prod. Staging runs {a.StagingVersion}.", - Action: "adapters-installed")); - foreach (var a in adapters.Where(a => a.State == "missing" && a.Source == "builtin")) - notes.Add(new MirrorNote($"builtin:{a.Id}", "needsYou", - $"This release no longer has the built-in adapter {a.Id}. {Used(a.UsedBy)} it, and would stop working after the upgrade.")); foreach (var k in kinds.Where(k => k.Skipped != null && k.Skipped != NotOnProdsRelease)) notes.Add(new MirrorNote($"skipped:{k.Section}", "needsYou", $"{Capital(k.Plural)}: {k.Skipped} On prod, give its role permission to view {k.Plural}.")); @@ -653,6 +641,29 @@ static List Notes(List kinds, List adapte return notes; } + /// What only a person can do about prod's adapters on staging: upload one, make a version current, or know a built-in one is gone. + static List AdapterNotes(IEnumerable adapters) + { + var notes = new List(); + foreach (var a in adapters.Where(a => a.State == "missing" && a.Source != "builtin" && !(a.Source == "marketplace" && a.ProdVersion != null))) + { + var where = a.Source == "marketplace" + ? $"Install the adapter {a.Id} from the Marketplace tab of the Adapters page" + : $"Upload the adapter {a.Id}{(a.ProdVersion == null ? "" : $", version {a.ProdVersion},")} on the Adapters page"; + notes.Add(new MirrorNote($"adapter:{a.Id}", "needsYou", + $"{where}. {Used(a.UsedBy)} it, and {(a.UsedBy == 1 ? "it comes" : "they come")} to staging once it's there.", + Action: a.Source == "marketplace" ? "adapters-marketplace" : "adapters-installed")); + } + foreach (var a in adapters.Where(a => a.State == "different")) + notes.Add(new MirrorNote($"current:{a.Id}", "needsYou", + $"Make version {a.ProdVersion} of the adapter {a.Id} the current one on staging, as on prod. Staging runs {a.StagingVersion}.", + Action: "adapters-installed")); + foreach (var a in adapters.Where(a => a.State == "missing" && a.Source == "builtin")) + notes.Add(new MirrorNote($"builtin:{a.Id}", "needsYou", + $"This release no longer has the built-in adapter {a.Id}. {Used(a.UsedBy)} it, and would stop working after the upgrade.")); + return notes; + } + static string Capital(string text) => text.Length == 0 ? text : char.ToUpperInvariant(text[0]) + text[1..]; static MirrorVerdict Verdict(List kinds, List notes) diff --git a/SW.Bitween.UnitTests/MirrorBringTests.cs b/SW.Bitween.UnitTests/MirrorBringTests.cs new file mode 100644 index 00000000..ff1252e2 --- /dev/null +++ b/SW.Bitween.UnitTests/MirrorBringTests.cs @@ -0,0 +1,97 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Nodes; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SW.Bitween.Services.Mirror; + +namespace SW.Bitween.UnitTests; + +/// +/// What bringing prod's picked items onto staging takes with them: what each names, and how prod's +/// traffic reaches a subscription, so it runs on staging as it does on prod. +/// +[TestClass] +public class MirrorBringTests +{ + static Dictionary> Setup(params (string Section, string Json)[] items) => + items.GroupBy(i => i.Section).ToDictionary(g => g.Key, g => g.Select(i => (JsonObject)JsonNode.Parse(i.Json)!).ToList()); + + static readonly Dictionary> Prod = Setup( + ("informationTypes", """{ "name": "Orders" }"""), + ("partners", """{ "name": "Acme" }"""), + ("partners", """{ "name": "Globex" }"""), + ("globalValues", """{ "id": "carrier", "values": { "token": "x" } }"""), + ("connections", """{ "name": "dhl" }"""), + ("notificationChannels", """{ "name": "Ops mail" }"""), + ("notificationChannels", """{ "name": "Retry alerts" }"""), + ("retryPolicies", """{ "name": "Carrier", "alertChannel": "Retry alerts" }"""), + ("dataSources", """{ "name": "Warehouse" }"""), + ("dataSources", """{ "name": "Unused" }"""), + ("dataSourceStatements", """{ "dataSource": "Warehouse", "name": "outbox" }"""), + ("subscriptions", """ + { + "name": "Order intake", "type": "GatewayApiCall", "informationType": "Orders", "retryPolicy": "Carrier", + "responseSubscription": "Archive", "notifications": [{ "channel": "Ops mail", "onFailure": true }], + "handler": { "adapter": "NativeHttpHandler", "properties": { + "Token": "{{globals.carrier.token}}", "Auth": "Bearer {{connections.dhl.accessToken}}" } } + } + """), + ("subscriptions", """{ "name": "Archive", "type": "Response" }"""), + ("subscriptions", """{ "name": "Returns", "type": "GatewayApiCall" }"""), + ("subscriptions", """{ "name": "Drain", "type": "Receiving", "dataSource": "Warehouse" }"""), + ("apiGateways", """ + { + "name": "Intake", "urlName": "intake", + "partners": [{ "partner": "Acme", "subscription": "Order intake" }, { "partner": "Globex", "subscription": "Returns" }] + } + """), + ("busGateways", """{ "name": "Orders out", "informationType": "Orders", "routes": [{ "subscription": "Archive" }] }""")); + + static List Keys(Dictionary<(string Section, string Key), string> needs) => + needs.Keys.Select(n => $"{n.Section}/{n.Key}").Order().ToList(); + + [TestMethod] + public void A_subscription_brings_what_it_names_and_how_prods_traffic_reaches_it() + { + var needs = MirrorService.Needs(Prod, [("subscriptions", "Order intake")]); + + CollectionAssert.AreEquivalent(new[] + { + "subscriptions/Order intake", "informationTypes/Orders", "retryPolicies/Carrier", "notificationChannels/Retry alerts", + "notificationChannels/Ops mail", "globalValues/carrier", "connections/dhl", + // It hands its responses to Archive, which a bus gateway also routes to. + "subscriptions/Archive", "busGateways/Orders out", + // Acme calls it through the gateway; Globex calls another subscription there, which stays behind. + "apiGateways/intake", "partners/Acme", + }, Keys(needs)); + Assert.AreEqual("Picked.", needs[("subscriptions", "Order intake")]); + Assert.AreEqual("It calls “Order intake” through the API gateway “intake”.", needs[("partners", "Acme")]); + Assert.AreEqual("The subscription “Order intake” hands its responses to it.", needs[("subscriptions", "Archive")]); + Assert.AreEqual("The subscription “Order intake” reads its values.", needs[("globalValues", "carrier")]); + } + + [TestMethod] + public void A_picked_gateway_brings_everything_it_leads_to() + { + var needs = MirrorService.Needs(Prod, [("apiGateways", "intake")]); + + CollectionAssert.IsSubsetOf(new[] { "subscriptions/Order intake", "subscriptions/Returns", "partners/Acme", "partners/Globex" }, Keys(needs)); + Assert.AreEqual("The API gateway “intake” leads to it.", needs[("subscriptions", "Returns")]); + } + + [TestMethod] + public void A_subscription_on_a_data_source_brings_its_sql_statements_and_nothing_else_of_prods() + { + var needs = MirrorService.Needs(Prod, [("subscriptions", "Drain")]); + + CollectionAssert.AreEquivalent(new[] { "subscriptions/Drain", "dataSources/Warehouse", "dataSourceStatements/Warehouse/outbox" }, Keys(needs)); + } + + [TestMethod] + public void Something_prod_no_longer_has_is_left_out() + { + var needs = MirrorService.Needs(Prod, [("subscriptions", "Gone")]); + + Assert.AreEqual(0, needs.Count); + } +} diff --git a/SW.Bitween.Web/ClientApp/src/__tests__/support/renderApp.tsx b/SW.Bitween.Web/ClientApp/src/__tests__/support/renderApp.tsx index 1767a19a..f4cdd586 100644 --- a/SW.Bitween.Web/ClientApp/src/__tests__/support/renderApp.tsx +++ b/SW.Bitween.Web/ClientApp/src/__tests__/support/renderApp.tsx @@ -87,6 +87,8 @@ export function renderApp(path: string, { as = {}, config = {}, handlers = [] }: ), // Metrics, which many pages show beside what they are about: none yet, unless a test says. ...defaultMetrics(), + // Whether this Bitween mirrors prod, which list pages ask before offering to bring from it: it doesn't, unless a test says. + http.get(apiPath("/mirror"), () => HttpResponse.json({ target: false, sourceUrl: null, signIn: null, configured: false })), ); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); diff --git a/SW.Bitween.Web/ClientApp/src/api/client.ts b/SW.Bitween.Web/ClientApp/src/api/client.ts index 4afda5df..aec77a58 100644 --- a/SW.Bitween.Web/ClientApp/src/api/client.ts +++ b/SW.Bitween.Web/ClientApp/src/api/client.ts @@ -1,7 +1,11 @@ import type { AddBusRouteInput, AttachPartnerInput } from "./http/gateways"; import type { + MirrorBring, + MirrorBringSection, MirrorCalls, MirrorCopyResult, + MirrorProdKinds, + MirrorProdListAnswer, MirrorParityAnswer, MirrorRehearsal, MirrorSamples, @@ -670,6 +674,9 @@ export interface ApiClient { getMirrorCalls(): Promise; getMirrorRehearsal(): Promise; rehearseMirror(): Promise; + getMirrorProdKinds(): Promise; + getMirrorProd(section: MirrorBringSection, type?: string): Promise; + bringFromProd(section: MirrorBringSection, keys: string[], apply: boolean): Promise; // - settings - listSettings(): Promise; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/mirror.ts b/SW.Bitween.Web/ClientApp/src/api/http/mirror.ts index 71c44441..42aa5de6 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/mirror.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/mirror.ts @@ -1,5 +1,16 @@ import type { ApiClient } from "../client"; -import type { MirrorCalls, MirrorCopyResult, MirrorParityAnswer, MirrorRehearsal, MirrorSamples, MirrorStatus } from "../types"; +import type { + MirrorBring, + MirrorBringSection, + MirrorCalls, + MirrorCopyResult, + MirrorParityAnswer, + MirrorProdKinds, + MirrorProdListAnswer, + MirrorRehearsal, + MirrorSamples, + MirrorStatus, +} from "../types"; import { get, post } from "./request"; export const mirrorMethods = { @@ -39,4 +50,19 @@ export const mirrorMethods = { rehearseMirror(): Promise { return post("/mirror/rehearse", {}); }, + + /** How many of prod's items there are of each kind a page lists; reads only prod's lists. */ + getMirrorProdKinds(): Promise { + return get("/mirror/prodkinds"); + }, + + /** Prod's items of one kind, busiest first, with whether staging has each; reads prod and staging, a few seconds. */ + getMirrorProd(section: MirrorBringSection, type?: string): Promise { + return get(`/mirror/prod?section=${section}${type ? `&type=${encodeURIComponent(type)}` : ""}`); + }, + + /** What bringing the picked items from prod does; with `apply`, brings them. */ + bringFromProd(section: MirrorBringSection, keys: string[], apply: boolean): Promise { + return post("/mirror/bring", { section, keys, apply }); + }, } satisfies Partial; diff --git a/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts b/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts index fd590450..26966f6b 100644 --- a/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts +++ b/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts @@ -220,6 +220,8 @@ export const keys = { samples: ["mirror", "samples"] as const, calls: ["mirror", "calls"] as const, rehearsal: ["mirror", "rehearsal"] as const, + prodKinds: ["mirror", "prodKinds"] as const, + prod: (section: string, type?: string) => ["mirror", "prod", section, type ?? "all"] as const, }, settings: { diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index 3119ecc9..306b2851 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -2583,3 +2583,79 @@ export interface MirrorSamples { subscriptions: MirrorSubscriptionSamples[]; families: MirrorFamily[]; } + +/** The kinds a page brings from prod. */ +export type MirrorBringSection = "subscriptions" | "apiGateways" | "busGateways"; + +/** How many of prod's items there are of each kind a page lists, read from prod's lists alone. */ +export interface MirrorProdKinds { + /** By subscription type, and every one of them under "all". */ + subscriptions: Record; + apiGateways: number; + busGateways: number; + /** Why prod couldn't be read; pages still offer to bring, and the list says why. */ + error: string | null; +} + +/** An item's traffic on prod over the window. */ +export interface MirrorProdCounts { + total: number; + failed: number; + lastOn: string | null; + /** One count a day, oldest first. */ + daily: number[]; +} + +/** One of prod's items as a page offers it: its traffic, and whether staging has it. */ +export interface MirrorProdItem { + key: string; + prodId: string | null; + type: string | null; + /** What it is for, in a few words: a subscription's partner and information type, a gateway's partners. */ + detail: string; + state: MirrorItemState; + stagingId: string | null; + reason: string | null; + traffic: MirrorProdCounts | null; +} + +/** Prod's items of one kind, busiest first. */ +export interface MirrorProdList { + section: MirrorBringSection; + noun: string; + plural: string; + /** The window the traffic covers; null when prod can't say, and trafficNote says why. */ + days: number | null; + trafficNote: string | null; + items: MirrorProdItem[]; +} + +export type MirrorProdListAnswer = MirrorProdList | { error: string }; + +/** + * What happens to one item a bring touches: new (staging gets prod's, faked), kept (staging has it + * and keeps its own), adds (staging has it and gets the partner or route that leads to what comes), + * waits (it can't come yet: reason says why), install (an adapter from the marketplace). + */ +export type MirrorPieceState = "new" | "kept" | "adds" | "waits" | "install"; + +export interface MirrorPiece { + section: string; + noun: string; + key: string; + state: MirrorPieceState; + /** Why it comes: picked, or what needs it. */ + why: string; + reason: string | null; + stagingId: string | null; + type: string | null; +} + +/** What bringing the picked items does, or did. */ +export interface MirrorBring { + applied: boolean; + pieces: MirrorPiece[]; + /** What only a person can do first: upload an adapter, say. */ + needsYou: MirrorNote[]; + failed: { section: string; noun: string; key: string; error: string }[]; +} diff --git a/SW.Bitween.Web/ClientApp/src/pages/aggregations/AggregationsPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/aggregations/AggregationsPage.tsx index 0ad2396b..c62728a9 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/aggregations/AggregationsPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/aggregations/AggregationsPage.tsx @@ -6,6 +6,7 @@ import { api, type SubscriptionRow, type ScheduleHealth } from "../../api"; import { Can } from "../../auth/guards"; import { useSessionCan } from "../../auth/useSessionCan"; import { PageHeader } from "../../components/layout/PageHeader"; +import { BringFromProd } from "../mirror/BringFromProd"; import { Badge, Button, EmptyState, LoadError, LoadingBlock } from "../../components/ui/basics"; import { ConfirmDialog } from "../../components/ui/overlays"; import { ExceptionLine } from "../../components/ui/Exception"; @@ -138,7 +139,12 @@ export function AggregationsPage() { + + {createAction} + + } help={{ title: "How aggregations work", body: ( diff --git a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewaysPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewaysPage.tsx index aecd9586..d525bd5a 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewaysPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewaysPage.tsx @@ -4,6 +4,7 @@ import { Plus, Webhook } from "lucide-react"; import { api, type SubscriptionRow } from "../../api"; import { Can } from "../../auth/guards"; import { PageHeader } from "../../components/layout/PageHeader"; +import { BringFromProd } from "../mirror/BringFromProd"; import { Badge, Button, EmptyState, LoadError, LoadingBlock } from "../../components/ui/basics"; import { Pagination } from "../../components/ui/Pagination"; import { Table } from "../../components/ui/Table"; @@ -60,7 +61,12 @@ export function ApiGatewaysPage() { + + {createAction} + + } help={{ title: "How API gateways work", body: ( diff --git a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewaysPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewaysPage.tsx index 7807054d..5cccf833 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewaysPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewaysPage.tsx @@ -6,6 +6,7 @@ import { api, type BusGatewayRow, type SubscriptionRow } from "../../api"; import { Can } from "../../auth/guards"; import { useSessionCan } from "../../auth/useSessionCan"; import { PageHeader } from "../../components/layout/PageHeader"; +import { BringFromProd } from "../mirror/BringFromProd"; import { Badge, Button, EmptyState, LoadError, LoadingBlock } from "../../components/ui/basics"; import { Pagination } from "../../components/ui/Pagination"; import { FilterSelect, SearchSelect } from "../../components/ui/SearchSelect"; @@ -106,6 +107,7 @@ export function BusGatewaysPage() { + {createAction} } diff --git a/SW.Bitween.Web/ClientApp/src/pages/mirror/BringFromProd.tsx b/SW.Bitween.Web/ClientApp/src/pages/mirror/BringFromProd.tsx new file mode 100644 index 00000000..49d2a937 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/mirror/BringFromProd.tsx @@ -0,0 +1,382 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { CircleAlert, CircleCheck, Download } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Link } from "react-router"; +import { + api, + type MirrorBring, + type MirrorBringSection, + type MirrorItemState, + type MirrorPiece, + type MirrorPieceState, + type MirrorProdCounts, + type MirrorProdItem, +} from "../../api"; +import { keys } from "../../api/queryKeys"; +import { useSessionCan } from "../../auth/useSessionCan"; +import { Sparkline } from "../../components/charts/Sparkline"; +import { Badge, Button, FormError, LoadError, LoadingBlock } from "../../components/ui/basics"; +import { Dialog } from "../../components/ui/overlays"; +import { SearchBox } from "../../components/ui/SearchBox"; +import { timeAgo } from "../../lib/dates"; +import { whole } from "../../lib/numbers"; +import { itemPath, plural } from "./shared"; + +/** Whether staging has one of prod's items, as the picker says it. */ +const ON_STAGING: Record, { label: string; tone: "ok" | "neutral" | "crimson" | "warn"; title: string }> = { + inSync: { label: "On staging", tone: "ok", title: "Staging has it as prod has it." }, + differs: { label: "Differs on staging", tone: "neutral", title: "Staging has it, and it differs from prod's. Bringing it keeps staging's." }, + missing: { label: "Not on staging", tone: "crimson", title: "Bringing it copies prod's, faked." }, + waiting: { label: "Waits for an adapter", tone: "warn", title: "It can't come until staging has the adapter it runs." }, +}; + +/** What bringing does to each item, in the order the preview lists them. */ +const PIECES: { state: MirrorPieceState; title: string; description: string }[] = [ + { state: "new", title: "Comes from prod", description: "Staging doesn't have these: they come as prod has them, faked." }, + { state: "adds", title: "Gets what leads to it", description: "Staging has these, and gets only the partner or route that reaches what comes." }, + { state: "install", title: "Installed from the marketplace", description: "Adapters staging doesn't have yet." }, + { state: "waits", title: "Can't come yet", description: "These stay behind until what they need is on staging." }, +]; + +/** + * "Bring from prod" on a list page: on a staging that mirrors prod, for whoever may operate the + * mirror, and only while prod has something this page lists - or prod can't be read, which the + * picker then says. Opens the picker. + */ +export function BringFromProd({ section, type, label }: { section: MirrorBringSection; type?: string; label: string }) { + const canOperate = useSessionCan("mirror.operate"); + const status = useQuery({ queryKey: keys.mirror.status, queryFn: () => api.getMirror(), enabled: canOperate, staleTime: 5 * 60_000 }); + const ready = canOperate && !!status.data?.target && !!status.data.configured; + const kinds = useQuery({ queryKey: keys.mirror.prodKinds, queryFn: () => api.getMirrorProdKinds(), enabled: ready, staleTime: 60_000 }); + const [open, setOpen] = useState(false); + + if (!ready || !kinds.data) return null; + const count = section === "subscriptions" ? (kinds.data.subscriptions[type ?? "all"] ?? 0) : kinds.data[section]; + if (!kinds.data.error && count === 0) return null; + return ( + <> + + {open && setOpen(false)} />} + + ); +} + +function BringDialog({ section, type, label, onClose }: { section: MirrorBringSection; type?: string; label: string; onClose: () => void }) { + const queryClient = useQueryClient(); + const list = useQuery({ queryKey: keys.mirror.prod(section, type), queryFn: () => api.getMirrorProd(section, type) }); + const [picked, setPicked] = useState>(new Set()); + const [shown, setShown] = useState(null); + const preview = useMutation({ mutationFn: () => api.bringFromProd(section, [...picked], false), onSuccess: setShown }); + const bring = useMutation({ + mutationFn: () => api.bringFromProd(section, [...picked], true), + onSuccess: (result) => { + setShown(result); + // A bring touches several kinds at once: every list may show something new. + void queryClient.invalidateQueries(); + }, + }); + + const title = `Bring ${label} from prod`; + if (shown?.applied) return ; + if (shown) + return ( + + setShown(null)} + onBring={() => bring.mutate()} + /> + + ); + return ( + + {list.isPending ? ( + + ) : list.isError ? ( + void list.refetch()} /> + ) : "error" in list.data ? ( +

    {list.data.error}

    + ) : ( + preview.mutate()} + /> + )} +
    + ); +} + +function Picker({ + items, + days, + trafficNote, + label, + picked, + onPick, + busy, + error, + onCancel, + onNext, +}: { + items: MirrorProdItem[]; + days: number | null; + trafficNote: string | null; + label: string; + picked: Set; + onPick: (next: Set) => void; + busy: boolean; + error?: string; + onCancel: () => void; + onNext: () => void; +}) { + const [q, setQ] = useState(""); + const visible = useMemo(() => { + const words = q.trim().toLowerCase(); + return words ? items.filter((i) => `${i.key} ${i.detail}`.toLowerCase().includes(words)) : items; + }, [items, q]); + const allShown = visible.length > 0 && visible.every((i) => picked.has(i.key)); + const toggle = (key: string) => { + const next = new Set(picked); + if (!next.delete(key)) next.add(key); + onPick(next); + }; + const toggleShown = () => { + const next = new Set(picked); + for (const i of visible) { + if (allShown) next.delete(i.key); + else next.add(i.key); + } + onPick(next); + }; + + return ( +
    +

    + {days != null ? `Prod's ${label}, busiest first, with their exchanges over the last ${days} days.` : trafficNote} +

    +
    + + +
    +
      + {visible.map((item) => ( +
    • + +
    • + ))} + {visible.length === 0 &&
    • Nothing on prod matches “{q}”.
    • } +
    + {error} +
    + {picked.size === 0 ? "Nothing picked yet" : `${whole(picked.size)} picked`} +
    + + +
    +
    +
    + ); +} + +/** One item's traffic on prod: how many, how many failed, a day-by-day line, and when the last was. */ +function Traffic({ counts, days, name }: { counts: MirrorProdCounts | null; days: number; name: string }) { + if (!counts) return No traffic; + return ( + + + + + {whole(counts.total)} + + + {counts.failed > 0 ? ( + + {whole(counts.failed)} failed + + ) : counts.lastOn ? ( + {timeAgo(counts.lastOn)} + ) : null} + + + + ); +} + +function Preview({ + result, + busy, + error, + onBack, + onBring, +}: { + result: MirrorBring; + busy: boolean; + error?: string; + onBack: () => void; + onBring: () => void; +}) { + const doing = result.pieces.filter((p) => p.state === "new" || p.state === "adds" || p.state === "install").length; + const kept = result.pieces.filter((p) => p.state === "kept"); + return ( +
    + {doing === 0 && !result.pieces.some((p) => p.state === "waits") && ( +

    Staging already has everything these need. Nothing comes.

    + )} + {result.needsYou.length > 0 && ( +
    +

    + Needs you first +

    +
      + {result.needsYou.map((n) => ( +
    • + {n.text}{" "} + {n.action === "adapters-marketplace" ? ( + Open the Marketplace + ) : n.action === "adapters-installed" ? ( + Open Adapters + ) : null} +
    • + ))} +
    +
    + )} + {PIECES.map(({ state, title, description }) => { + const pieces = result.pieces.filter((p) => p.state === state); + if (pieces.length === 0) return null; + return ( +
    +

    + {title} ({whole(pieces.length)}) +

    +

    {description}

    + +
    + ); + })} + {kept.length > 0 && ( +
    + + Already on staging, kept as it is ({whole(kept.length)}) + + +
    + )} +

    + As on every staging copy, values come faked, subscriptions arrive paused, and bus gateways and data sources switched + off. Nothing on staging is overwritten or removed. +

    + {error} +
    + + +
    +
    + ); +} + +function PieceList({ pieces, linked = false }: { pieces: MirrorPiece[]; linked?: boolean }) { + return ( +
      + {pieces.map((p) => { + const path = linked || p.state !== "new" ? itemPath(p.section, p.stagingId, p.type) : null; + return ( +
    • + {p.noun} + {path ? ( + + {p.key} + + ) : ( + {p.key} + )} + {p.reason ?? p.why} +
    • + ); + })} +
    + ); +} + +function Brought({ result, onClose }: { result: MirrorBring; onClose: () => void }) { + const failed = new Set(result.failed.map((f) => `${f.section}/${f.key}`)); + const done = result.pieces.filter((p) => (p.state === "new" || p.state === "adds" || p.state === "install") && !failed.has(`${p.section}/${p.key}`)); + return ( +
    + {done.length > 0 && ( +
    +

    + Brought {plural(done.length, "item", "items")} +

    +

    Subscriptions are paused: they run only when a test runs them.

    + +
    + )} + {result.failed.length > 0 && ( +
    +

    + Staging couldn't make {plural(result.failed.length, "item", "items")} +

    +
      + {result.failed.map((f) => ( +
    • + The {f.noun} {f.key}: {f.error} +
    • + ))} +
    +
    + )} +
    + +
    +
    + ); +} diff --git a/SW.Bitween.Web/ClientApp/src/pages/mirror/__tests__/BringFromProd.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/mirror/__tests__/BringFromProd.test.tsx new file mode 100644 index 00000000..98d9b08b --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/mirror/__tests__/BringFromProd.test.tsx @@ -0,0 +1,125 @@ +import { screen, within } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; +import type { MirrorBring, MirrorProdKinds, MirrorProdList } from "../../../api"; +import { apiPath, renderApp } from "../../../__tests__/support/renderApp"; +import { empty, none, page } from "../../../__tests__/support/listPage"; +import { raw } from "../../subscriptions/__tests__/subscriptionBackend"; + +const mirror = http.get(apiPath("/mirror"), () => + HttpResponse.json({ target: true, sourceUrl: "https://bitween.acme.test", signIn: "token", configured: true }), +); + +const kinds = (subscriptions: Record) => + http.get(apiPath("/mirror/prodkinds"), () => + HttpResponse.json({ subscriptions, apiGateways: 0, busGateways: 0, error: null }), + ); + +const prodList: MirrorProdList = { + section: "subscriptions", + noun: "subscription", + plural: "subscriptions", + days: 7, + trafficNote: null, + items: [ + { + key: "Orders rollup", + prodId: "31", + type: "Aggregation", + detail: "Acme · rolls up “Orders in”", + state: "missing", + stagingId: null, + reason: null, + traffic: { total: 1204, failed: 3, lastOn: new Date().toISOString(), daily: [100, 200, 150, 180, 190, 200, 184] }, + }, + { + key: "Returns rollup", + prodId: "32", + type: "Aggregation", + detail: "Acme", + state: "inSync", + stagingId: "8", + reason: null, + traffic: { total: 12, failed: 0, lastOn: null, daily: [2, 2, 2, 2, 2, 1, 1] }, + }, + ], +}; + +const brought = (applied: boolean): MirrorBring => ({ + applied, + pieces: [ + { section: "subscriptions", noun: "subscription", key: "Orders rollup", state: "new", why: "Picked.", reason: null, stagingId: applied ? "41" : null, type: "Aggregation" }, + { section: "subscriptions", noun: "subscription", key: "Orders in", state: "new", why: "The subscription “Orders rollup” rolls up its exchanges.", reason: null, stagingId: applied ? "42" : null, type: "Internal" }, + { section: "partners", noun: "partner", key: "Acme", state: "kept", why: "The subscription “Orders in” uses it.", reason: null, stagingId: "3", type: null }, + ], + needsYou: [ + { id: "adapter:acme.mapper", group: "needsYou", text: "Upload the adapter acme.mapper on the Adapters page.", section: null, key: null, action: "adapters-installed" }, + ], + failed: [], +}); + +/** The aggregations page's own lookups, answered with one aggregation of staging's. */ +const aggregationsPage = [ + http.get(apiPath("/subscriptions"), () => HttpResponse.json(page([raw({ id: 8, name: "Returns rollup", type: "Aggregation" })]))), + ...empty("/documents", "/partners", "/workgroups", "/retrypolicies"), + ...none("/subscriptions/lastruns", "/subscriptions/schedulehealth"), +]; + +describe("bringing prod's items from a list page", () => { + it("lists prod's busiest first, shows everything that comes, and brings it", async () => { + const asked: unknown[] = []; + const { user } = renderApp("/aggregations", { + handlers: [ + mirror, + kinds({ all: 5, Aggregation: 2 }), + http.get(apiPath("/mirror/prod"), ({ request }) => { + expect(new URL(request.url).search).toBe("?section=subscriptions&type=Aggregation"); + return HttpResponse.json(prodList); + }), + http.post(apiPath("/mirror/bring"), async ({ request }) => { + const body = (await request.json()) as { apply: boolean }; + asked.push(body); + return HttpResponse.json(brought(body.apply)); + }), + ...aggregationsPage, + ], + }); + + await user.click(await screen.findByRole("button", { name: "Bring from prod" })); + const dialog = await screen.findByRole("dialog", { name: "Bring aggregations from prod" }); + expect(await within(dialog).findByText("1,204")).toBeVisible(); + expect(within(dialog).getByText("3 failed")).toBeVisible(); + expect(within(dialog).getByText("Not on staging")).toBeVisible(); + + await user.click(within(dialog).getByRole("checkbox", { name: "Pick “Orders rollup”" })); + await user.click(within(dialog).getByRole("button", { name: "See what comes" })); + expect(await within(dialog).findByText("Comes from prod")).toBeVisible(); + expect(within(dialog).getByText("The subscription “Orders rollup” rolls up its exchanges.")).toBeVisible(); + expect(within(dialog).getByText(/Upload the adapter acme\.mapper/)).toBeVisible(); + expect(asked).toEqual([{ section: "subscriptions", keys: ["Orders rollup"], apply: false }]); + + await user.click(within(dialog).getByRole("button", { name: "Bring 2 items" })); + expect(await within(dialog).findByText("Brought 2 items")).toBeVisible(); + expect(within(dialog).getByRole("link", { name: "Orders rollup" })).toHaveAttribute("href", "/aggregations/41"); + expect(asked).toHaveLength(2); + expect(asked[1]).toEqual({ section: "subscriptions", keys: ["Orders rollup"], apply: true }); + }); + + it("isn't offered where prod has none of what the page lists", async () => { + let read = false; + renderApp("/aggregations", { + handlers: [ + mirror, + http.get(apiPath("/mirror/prodkinds"), () => { + read = true; + return HttpResponse.json({ subscriptions: { all: 3, Receiving: 3 }, apiGateways: 0, busGateways: 0, error: null }); + }), + ...aggregationsPage, + ], + }); + + expect(await screen.findByRole("row", { name: /Returns rollup/ })).toBeVisible(); + await expect.poll(() => read).toBe(true); + expect(screen.queryByRole("button", { name: "Bring from prod" })).not.toBeInTheDocument(); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/pages/response-subscriptions/ResponseSubscriptionsPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/response-subscriptions/ResponseSubscriptionsPage.tsx index 4cc32e01..6e53d57b 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/response-subscriptions/ResponseSubscriptionsPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/response-subscriptions/ResponseSubscriptionsPage.tsx @@ -6,6 +6,7 @@ import { api } from "../../api"; import { Can } from "../../auth/guards"; import { useSessionCan } from "../../auth/useSessionCan"; import { PageHeader } from "../../components/layout/PageHeader"; +import { BringFromProd } from "../mirror/BringFromProd"; import { Button, EmptyState, LoadError, LoadingBlock, ProblemText } from "../../components/ui/basics"; import { ExceptionLine } from "../../components/ui/Exception"; import { Pagination } from "../../components/ui/Pagination"; @@ -72,7 +73,12 @@ export function ResponseSubscriptionsPage() { + + {createAction} + + } help={{ title: "How response subscriptions work", body: ( diff --git a/SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/ScheduledJobsPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/ScheduledJobsPage.tsx index 6541ff56..45cc96a0 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/ScheduledJobsPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/ScheduledJobsPage.tsx @@ -6,6 +6,7 @@ import { api, type SubscriptionRow, type ScheduleHealth } from "../../api"; import { Can } from "../../auth/guards"; import { useSessionCan } from "../../auth/useSessionCan"; import { PageHeader } from "../../components/layout/PageHeader"; +import { BringFromProd } from "../mirror/BringFromProd"; import { Badge, Button, EmptyState, LoadError, LoadingBlock } from "../../components/ui/basics"; import { ConfirmDialog } from "../../components/ui/overlays"; import { Pagination } from "../../components/ui/Pagination"; @@ -138,7 +139,12 @@ export function ScheduledJobsPage() { + + {createAction} + + } help={{ title: "How scheduled jobs work", body: ( diff --git a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionsPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionsPage.tsx index 3ecfdcc0..0d65353f 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionsPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionsPage.tsx @@ -6,6 +6,7 @@ import { api, type SubscriptionRow, type SubscriptionType } from "../../api"; import { Can } from "../../auth/guards"; import { useSessionCan } from "../../auth/useSessionCan"; import { PageHeader } from "../../components/layout/PageHeader"; +import { BringFromProd } from "../mirror/BringFromProd"; import { Button, EmptyState, LoadError, LoadingBlock } from "../../components/ui/basics"; import { Pagination } from "../../components/ui/Pagination"; import { FilterSelect, SearchSelect } from "../../components/ui/SearchSelect"; @@ -124,6 +125,7 @@ export function SubscriptionsPage() { description="Every pipeline that moves a document - what comes in, how it's transformed, where it goes." actions={
    +