diff --git a/src/GovUK.Dfe.FlexForms.Api.Client/GovUK.Dfe.FlexForms.Api.Client.csproj b/src/GovUK.Dfe.FlexForms.Api.Client/GovUK.Dfe.FlexForms.Api.Client.csproj
index 6de4d6b..bc581cf 100644
--- a/src/GovUK.Dfe.FlexForms.Api.Client/GovUK.Dfe.FlexForms.Api.Client.csproj
+++ b/src/GovUK.Dfe.FlexForms.Api.Client/GovUK.Dfe.FlexForms.Api.Client.csproj
@@ -24,7 +24,7 @@
-
+
diff --git a/src/GovUK.Dfe.FlexForms.Application/GovUK.Dfe.FlexForms.Application.csproj b/src/GovUK.Dfe.FlexForms.Application/GovUK.Dfe.FlexForms.Application.csproj
index 62317db..eddccb5 100644
--- a/src/GovUK.Dfe.FlexForms.Application/GovUK.Dfe.FlexForms.Application.csproj
+++ b/src/GovUK.Dfe.FlexForms.Application/GovUK.Dfe.FlexForms.Application.csproj
@@ -21,7 +21,7 @@
-
+
diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/GovUK.Dfe.FlexForms.Infrastructure.csproj b/src/GovUK.Dfe.FlexForms.Infrastructure/GovUK.Dfe.FlexForms.Infrastructure.csproj
index 0106c44..bd6d90d 100644
--- a/src/GovUK.Dfe.FlexForms.Infrastructure/GovUK.Dfe.FlexForms.Infrastructure.csproj
+++ b/src/GovUK.Dfe.FlexForms.Infrastructure/GovUK.Dfe.FlexForms.Infrastructure.csproj
@@ -11,7 +11,7 @@
-
+
diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Messaging/Outbox/TenantOutboxDeliveryService.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Messaging/Outbox/TenantOutboxDeliveryService.cs
index fb15ae3..88bc371 100644
--- a/src/GovUK.Dfe.FlexForms.Infrastructure/Messaging/Outbox/TenantOutboxDeliveryService.cs
+++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Messaging/Outbox/TenantOutboxDeliveryService.cs
@@ -15,7 +15,8 @@ namespace GovUK.Dfe.FlexForms.Infrastructure.Messaging.Outbox;
/// Runs one MassTransit per distinct tenant EA
/// database. The built-in delivery service is disabled because it has no tenant context and would
/// only ever drain the first tenant's database. Tenants sharing a database share a loop.
-/// The set of loops is reconciled whenever tenant configuration is refreshed.
+/// The set of loops is reconciled whenever tenant configuration changes, and again after
+/// if any loop failed to start.
///
public sealed class TenantOutboxDeliveryService(
IServiceProvider provider,
@@ -26,6 +27,8 @@ public sealed class TenantOutboxDeliveryService(
{
private const string ConnectionStringName = "DefaultConnection";
+ internal static readonly TimeSpan StartRetryDelay = TimeSpan.FromMinutes(1);
+
private readonly Dictionary _running = new(StringComparer.OrdinalIgnoreCase);
private readonly Channel _reconcileSignal = Channel.CreateBounded(
@@ -77,6 +80,8 @@ private async Task ReconcileAsync(CancellationToken cancellationToken)
foreach (var key in _running.Keys.Where(k => !desired.ContainsKey(k)).ToList())
await StopDeliveryAsync(key);
+ var anyStartFailed = false;
+
foreach (var (key, tenant) in desired)
{
if (_running.ContainsKey(key))
@@ -88,9 +93,26 @@ private async Task ReconcileAsync(CancellationToken cancellationToken)
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
- logger.LogError(ex, "Failed to start outbox delivery for tenant {TenantName}", tenant.Name);
+ anyStartFailed = true;
+ logger.LogError(ex, "Failed to start outbox delivery for tenant {TenantName}. Retrying in {RetryDelay}",
+ tenant.Name, StartRetryDelay);
}
}
+
+ if (anyStartFailed)
+ _ = SignalReconcileAfterDelayAsync(cancellationToken);
+ }
+
+ private async Task SignalReconcileAfterDelayAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ await Task.Delay(StartRetryDelay, cancellationToken);
+ _reconcileSignal.Writer.TryWrite(true);
+ }
+ catch (OperationCanceledException)
+ {
+ }
}
private async Task StartDeliveryAsync(string key, TenantConfiguration tenant, CancellationToken cancellationToken)
diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/DatabaseTenantConfigurationProvider.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/DatabaseTenantConfigurationProvider.cs
index dd1e0e7..c145983 100644
--- a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/DatabaseTenantConfigurationProvider.cs
+++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/DatabaseTenantConfigurationProvider.cs
@@ -1,3 +1,5 @@
+using System.Security.Cryptography;
+using System.Text;
using System.Text.Json;
using GovUK.Dfe.FlexForms.Domain.Tenancy;
using GovUK.Dfe.FlexForms.Infrastructure.Database;
@@ -13,6 +15,12 @@ namespace GovUK.Dfe.FlexForms.Infrastructure.Services;
/// Loads tenant configuration from the tenant config database and caches it in memory.
/// Implements IHostedService to load at startup and refresh periodically.
/// Uses volatile dictionary swaps for lock-free reads.
+///
+/// The snapshot is only swapped, and only fired,
+/// when the loaded data differs from the current snapshot. Subscribers rebuild expensive state
+/// (OIDC metadata caches, auth registries, options) on every notification, so an unconditional
+/// notify on each periodic refresh would discard those caches every interval.
+///
///
public class DatabaseTenantConfigurationProvider(
IServiceScopeFactory scopeFactory,
@@ -30,6 +38,10 @@ public class DatabaseTenantConfigurationProvider(
private DateTimeOffset? _lastRefreshedUtc;
+ private string? _snapshotFingerprint;
+
+ private readonly SemaphoreSlim _refreshGate = new(1, 1);
+
private Timer? _refreshTimer;
private bool _disposed;
@@ -56,6 +68,9 @@ public IReadOnlyCollection GetAllTenants()
///
public async Task RefreshAsync(CancellationToken cancellationToken = default)
{
+ // Serialised so the fingerprint comparison and snapshot swap cannot interleave between
+ // the timer and admin-triggered refreshes.
+ await _refreshGate.WaitAsync(cancellationToken);
try
{
using var scope = scopeFactory.CreateScope();
@@ -72,8 +87,11 @@ public async Task RefreshAsync(CancellationToken cancellationToken = default)
var newTenantsById = new Dictionary();
var newTenantsByOrigin = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ using var fingerprint = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
- foreach (var entity in tenantEntities)
+ // Deterministic order: the query has no ORDER BY, and both the fingerprint and the
+ // last-wins origin index must not depend on whatever order the database returns.
+ foreach (var entity in tenantEntities.OrderBy(t => t.Id))
{
var configPairs = new List>();
@@ -122,11 +140,24 @@ public async Task RefreshAsync(CancellationToken cancellationToken = default)
{
newTenantsByOrigin[origin] = tenantConfig;
}
+
+ AppendTenantToFingerprint(fingerprint, entity.Id, entity.Name, configPairs, origins);
+ }
+
+ var newFingerprint = Convert.ToHexString(fingerprint.GetHashAndReset());
+ _lastRefreshedUtc = DateTimeOffset.UtcNow;
+
+ if (string.Equals(newFingerprint, _snapshotFingerprint, StringComparison.Ordinal))
+ {
+ logger.LogDebug(
+ "Tenant configuration unchanged ({TenantCount} active tenants for target '{Target}'); keeping current snapshot",
+ newTenantsById.Count, targetApplication);
+ return;
}
_tenantsById = newTenantsById;
_tenantsByOrigin = newTenantsByOrigin;
- _lastRefreshedUtc = DateTimeOffset.UtcNow;
+ _snapshotFingerprint = newFingerprint;
logger.LogInformation(
"Tenant configuration refreshed. Loaded {TenantCount} active tenants for target '{Target}'",
@@ -142,6 +173,56 @@ public async Task RefreshAsync(CancellationToken cancellationToken = default)
logger.LogError(ex,
"Failed to refresh tenant configuration from database. Retaining previous configuration");
}
+ finally
+ {
+ _refreshGate.Release();
+ }
+ }
+
+ ///
+ /// Feeds everything that ends up in a into the snapshot
+ /// fingerprint. Values are length-prefixed so adjacent fields cannot run into each other,
+ /// and null is distinguished from empty string.
+ ///
+ private static void AppendTenantToFingerprint(
+ IncrementalHash hash,
+ Guid tenantId,
+ string tenantName,
+ List> configPairs,
+ string[] origins)
+ {
+ AppendFingerprintValue(hash, tenantId.ToString("N"));
+ AppendFingerprintValue(hash, tenantName);
+
+ // Stable sort: duplicate keys keep their relative order, which decides last-wins.
+ AppendFingerprintValue(hash, configPairs.Count.ToString());
+ foreach (var pair in configPairs.OrderBy(p => p.Key, StringComparer.Ordinal))
+ {
+ AppendFingerprintValue(hash, pair.Key);
+ AppendFingerprintValue(hash, pair.Value);
+ }
+
+ AppendFingerprintValue(hash, origins.Length.ToString());
+ foreach (var origin in origins.Order(StringComparer.OrdinalIgnoreCase))
+ {
+ AppendFingerprintValue(hash, origin);
+ }
+ }
+
+ private static void AppendFingerprintValue(IncrementalHash hash, string? value)
+ {
+ Span length = stackalloc byte[sizeof(int)];
+ if (value is null)
+ {
+ BitConverter.TryWriteBytes(length, -1);
+ hash.AppendData(length);
+ return;
+ }
+
+ var bytes = Encoding.UTF8.GetBytes(value);
+ BitConverter.TryWriteBytes(length, bytes.Length);
+ hash.AppendData(length);
+ hash.AppendData(bytes);
}
///
diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Services/DatabaseTenantConfigurationProviderTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Services/DatabaseTenantConfigurationProviderTests.cs
new file mode 100644
index 0000000..8802f50
--- /dev/null
+++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Services/DatabaseTenantConfigurationProviderTests.cs
@@ -0,0 +1,210 @@
+using GovUK.Dfe.FlexForms.Domain.Tenancy;
+using GovUK.Dfe.FlexForms.Domain.Tenancy.Entities;
+using GovUK.Dfe.FlexForms.Infrastructure.Database;
+using GovUK.Dfe.FlexForms.Infrastructure.Services;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+
+namespace GovUK.Dfe.FlexForms.Application.Tests.Services;
+
+public class DatabaseTenantConfigurationProviderTests
+{
+ private static readonly Guid TenantA = Guid.Parse("11111111-1111-4111-8111-111111111111");
+ private static readonly Guid TenantB = Guid.Parse("22222222-2222-4222-8222-222222222222");
+
+ private readonly string _databaseName = Guid.NewGuid().ToString();
+ private readonly ServiceProvider _services;
+ private readonly ITenantConfigurationChangedNotifier _notifier = Substitute.For();
+
+ public DatabaseTenantConfigurationProviderTests()
+ {
+ _services = new ServiceCollection()
+ .AddDbContext(o => o.UseInMemoryDatabase(_databaseName))
+ .BuildServiceProvider();
+ }
+
+ private DatabaseTenantConfigurationProvider CreateProvider()
+ => new(
+ _services.GetRequiredService(),
+ Substitute.For>(),
+ changeNotifier: _notifier);
+
+ private async Task WithDbAsync(Func action)
+ {
+ using var scope = _services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ await action(db);
+ await db.SaveChangesAsync();
+ }
+
+ private static TenantEntity Tenant(Guid id, string name, string dfeSignInJson, params string[] origins)
+ => new()
+ {
+ Id = id,
+ Name = name,
+ Settings =
+ {
+ new TenantSettingEntity
+ {
+ Id = Guid.NewGuid(),
+ TenantId = id,
+ Category = "DfESignIn",
+ Target = "Api",
+ Settings = dfeSignInJson
+ }
+ },
+ FrontendOrigins = origins
+ .Select(o => new TenantFrontendOriginEntity { Id = Guid.NewGuid(), TenantId = id, Origin = o })
+ .ToList()
+ };
+
+ [Fact]
+ public async Task RefreshAsync_FirstLoad_SwapsSnapshotAndNotifies()
+ {
+ await WithDbAsync(db =>
+ {
+ db.Tenants.Add(Tenant(TenantA, "A", """{"ClientId":"client-a"}""", "https://a.example.com"));
+ return Task.CompletedTask;
+ });
+
+ var provider = CreateProvider();
+
+ await provider.RefreshAsync();
+
+ _notifier.Received(1).Notify();
+ Assert.Equal("client-a", provider.GetTenant(TenantA)!.Settings["DfESignIn:ClientId"]);
+ Assert.Same(provider.GetTenant(TenantA), provider.GetTenantByOrigin("https://a.example.com"));
+ }
+
+ [Fact]
+ public async Task RefreshAsync_Unchanged_DoesNotNotifyOrReplaceSnapshot()
+ {
+ await WithDbAsync(db =>
+ {
+ db.Tenants.Add(Tenant(TenantA, "A", """{"ClientId":"client-a"}""", "https://a.example.com"));
+ db.Tenants.Add(Tenant(TenantB, "B", """{"ClientId":"client-b"}"""));
+ return Task.CompletedTask;
+ });
+
+ var provider = CreateProvider();
+ await provider.RefreshAsync();
+ var firstSnapshot = provider.GetTenant(TenantA);
+ var firstRefreshedUtc = provider.LastRefreshedUtc;
+ _notifier.ClearReceivedCalls();
+
+ await provider.RefreshAsync();
+ await provider.RefreshAsync();
+
+ _notifier.DidNotReceive().Notify();
+ Assert.Same(firstSnapshot, provider.GetTenant(TenantA));
+ Assert.True(provider.LastRefreshedUtc >= firstRefreshedUtc);
+ }
+
+ [Fact]
+ public async Task RefreshAsync_SettingValueChanged_NotifiesWithNewValue()
+ {
+ await WithDbAsync(db =>
+ {
+ db.Tenants.Add(Tenant(TenantA, "A", """{"ClientId":"client-a"}"""));
+ return Task.CompletedTask;
+ });
+
+ var provider = CreateProvider();
+ await provider.RefreshAsync();
+ _notifier.ClearReceivedCalls();
+
+ await WithDbAsync(async db =>
+ {
+ var setting = await db.TenantSettings.SingleAsync(s => s.TenantId == TenantA);
+ setting.Settings = """{"ClientId":"client-a-rotated"}""";
+ });
+
+ await provider.RefreshAsync();
+
+ _notifier.Received(1).Notify();
+ Assert.Equal("client-a-rotated", provider.GetTenant(TenantA)!.Settings["DfESignIn:ClientId"]);
+ }
+
+ [Fact]
+ public async Task RefreshAsync_TenantDeactivated_Notifies()
+ {
+ await WithDbAsync(db =>
+ {
+ db.Tenants.Add(Tenant(TenantA, "A", """{"ClientId":"client-a"}"""));
+ db.Tenants.Add(Tenant(TenantB, "B", """{"ClientId":"client-b"}"""));
+ return Task.CompletedTask;
+ });
+
+ var provider = CreateProvider();
+ await provider.RefreshAsync();
+ _notifier.ClearReceivedCalls();
+
+ await WithDbAsync(async db =>
+ {
+ var tenant = await db.Tenants.SingleAsync(t => t.Id == TenantB);
+ tenant.IsActive = false;
+ });
+
+ await provider.RefreshAsync();
+
+ _notifier.Received(1).Notify();
+ Assert.Null(provider.GetTenant(TenantB));
+ Assert.Equal(1, provider.ActiveTenantCount);
+ }
+
+ [Fact]
+ public async Task RefreshAsync_FrontendOriginAdded_Notifies()
+ {
+ await WithDbAsync(db =>
+ {
+ db.Tenants.Add(Tenant(TenantA, "A", """{"ClientId":"client-a"}""", "https://a.example.com"));
+ return Task.CompletedTask;
+ });
+
+ var provider = CreateProvider();
+ await provider.RefreshAsync();
+ _notifier.ClearReceivedCalls();
+
+ await WithDbAsync(db =>
+ {
+ db.TenantFrontendOrigins.Add(new TenantFrontendOriginEntity
+ {
+ Id = Guid.NewGuid(),
+ TenantId = TenantA,
+ Origin = "https://a2.example.com"
+ });
+ return Task.CompletedTask;
+ });
+
+ await provider.RefreshAsync();
+
+ _notifier.Received(1).Notify();
+ Assert.NotNull(provider.GetTenantByOrigin("https://a2.example.com"));
+ }
+
+ [Fact]
+ public async Task RefreshAsync_ValueMovedBetweenAdjacentFields_IsDetectedAsChange()
+ {
+ await WithDbAsync(db =>
+ {
+ db.Tenants.Add(Tenant(TenantA, "A", """{"Ab":"c"}"""));
+ return Task.CompletedTask;
+ });
+
+ var provider = CreateProvider();
+ await provider.RefreshAsync();
+ _notifier.ClearReceivedCalls();
+
+ await WithDbAsync(async db =>
+ {
+ var setting = await db.TenantSettings.SingleAsync(s => s.TenantId == TenantA);
+ setting.Settings = """{"A":"bc"}""";
+ });
+
+ await provider.RefreshAsync();
+
+ _notifier.Received(1).Notify();
+ }
+}
diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl
deleted file mode 100644
index 3e5db97..0000000
--- a/terraform/.terraform.lock.hcl
+++ /dev/null
@@ -1,107 +0,0 @@
-# This file is maintained automatically by "terraform init".
-# Manual edits may be lost in future updates.
-
-provider "registry.terraform.io/azure/azapi" {
- version = "1.15.0"
- constraints = "~> 1.13"
- hashes = [
- "h1:pO/phGY+TxMEKQ+ffYj+vUIvG5A1tno/sZYDb/yyA/w=",
- "zh:0627a8bc77254debc25dc0c7b62e055138217c97b03221e593c3c56dc7550671",
- "zh:2fe045f07070ef75d0bec4b0595a74c14394daa838ddb964e2fd23cc98c40c34",
- "zh:343009f39c957883b2c06145a5954e524c70f93585f943f1ea3d28ef6995d0d0",
- "zh:53fe9ab54485aaebc9b91e27a10bce2729a1c95b1399079e631dc6bb9e3f27dc",
- "zh:63c407e7dc04d178d4798c17ad489d9cc92f7d1941d7f4a3f560b95908b6107b",
- "zh:7d6fc2b432b264f036bb80ab2b2ba67f80a5d98da8a8c322aa097833dad598c9",
- "zh:7ec49c0a8799d469eb6e2a1f856693f9862f1b73f5ed70adc1b346e5a4c6458d",
- "zh:889704f10319d301d677539d788fc82a7c73608ab78cb93e1280ac2be39e6e00",
- "zh:90b4b07405b7cde9ebae3b034cb5bb5dd18484d1b95bd250f905451f1e86ac3f",
- "zh:92aa9c241a8cb2a6d81ad47bc007c119f8b818464a960ebaf39008766c361e6b",
- "zh:f28fbd0a2c59e239b53067bc1adc691be444876bcb2d4f78d310f549724da6e0",
- "zh:ffb15e0ddfa505d0e9b75341570199076ae574887124f398162b1ead9376b25f",
- ]
-}
-
-provider "registry.terraform.io/hashicorp/archive" {
- version = "2.8.0"
- constraints = "~> 2.6"
- hashes = [
- "h1:WB6H5ksIZiyq1lQlD/PWeh+tn4FLsbSjVnRW3+4xe2Y=",
- "zh:0d14713fdc259fb377d0b899ad3c650a34194bd52194c863303ef22a65a580e2",
- "zh:369b56040c7a8085d04e7e8ffac1e2b321a3170e502f788819bc34b868ec016f",
- "zh:4d1a3b983ed6af5a52bfe12794674ae55cbadfa6021b37106ade68b433ad216a",
- "zh:5c547549e26e083573c78a966ca68ce6d7df6bb8f3948f66a575f07da46b74ea",
- "zh:6de093e62a975eb19a5e3017ce38e6e3cb639c17b79648d2000e0a8348f0e997",
- "zh:7267936c2cdbc448efeb594d73e6b56a53d6a7ae14fe88cdd2a4133adc3302f0",
- "zh:7482f023050ed426b4b45116e1761643bc33b1fd4ce4a6fab207ae2571f35940",
- "zh:76bbd93b234e5a2927d98b511d86565700f549b570871a194c35f944b96cefb7",
- "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
- "zh:c6afc4bc1f002bac9c173007dd4da05fde788cd14c2916089f958c33fedb0dfa",
- "zh:d3ba40bd806a3a08e9237dece679193c99afb2085de6b45d7f5d1f673cfcd368",
- "zh:e1ad7ded53ecd6f0e5b473a3b44eae2b2e885653a56050ab583d387332be02e4",
- "zh:e93e78575ce82be6084cc153c24ba8f385dc8d6880888ee66e918460c870953d",
- ]
-}
-
-provider "registry.terraform.io/hashicorp/azurerm" {
- version = "4.81.0"
- constraints = "~> 4.0, ~> 4.37"
- hashes = [
- "h1:XhToZua4gtih1Kv8RdStcfND83G4Tmb6GZFT4jEUhDU=",
- "zh:0732e7b74264ddfa2b90ba69d01c283d3cbae9f72ed3e506c6ac92529fed7fd3",
- "zh:12afb524e232fe4e3d6161927724af5dfa4831d71edd9c174917ca9b7377bfae",
- "zh:169d619ae202c4145e02fb706fb7c3679445ab3e3ff722edbf89597517a8c92e",
- "zh:6beb95a3ef2f2d9c76abaa48e5450e90686a3fb6a47f1cb0ff7c5e94b6960151",
- "zh:705e075fb5ffc4bf66fd7cbabf1a65007a41621e80030a2c158a4c83b6046216",
- "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
- "zh:79a8d17fefe647040fcb9ee8821a4f09f395427c4fd49493489b9a93a9a1038e",
- "zh:8cc3f900b3774c0ae37ae42365c4579a199cf9e5edf88e476fdf5ab1048f84ea",
- "zh:dec373b9390fa95e257291acd018ed65a7d512b428645d35e22cdbe8b245a08b",
- "zh:e60f1e9fb45df6defade2855ed6e68547409ea75d30655c556adb0c08579749b",
- "zh:f901d12ec82f3f8b5880a27b5cbcd7bd0d97e60c9367a2d7ed82fdd1157b39ff",
- "zh:facf68ea5bf0f2b8ba720e7fba5f86492e1d4c591100460bb91c3f79f391f4b6",
- ]
-}
-
-provider "registry.terraform.io/hashicorp/null" {
- version = "3.3.1"
- constraints = "~> 3.2"
- hashes = [
- "h1:m5FqidbIgh+E9OigiZh8/xbkvpUQFSj3hZo/jqNLCLQ=",
- "zh:08c59776542ea16e5a8545752787b17ff412922182b4cfabe16139197be8ac44",
- "zh:123109cc7e5ed6d515787fbc212f2a3fd5e75647bb24ab7c801ccd4d4ed42451",
- "zh:14b3fa4372754b54844b41d5dbd4671a292d8d6828b90169061feb4d7b15dd05",
- "zh:56a4daaa3212f57b764bf3d1f333141c6610c5f21abb240e0111221f7c7fa4d4",
- "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
- "zh:7e888a026dbacd2474a42264227ae35f639780f0f0c613529d10a95cd61988b3",
- "zh:85a53646267e87d600df7124e4767ffde9bba3b6356d45d961618bdd68131cc7",
- "zh:8ffa0e9c7c39b2ab0905b472465d6e35ef0b776b3f6273bb34c150340b61bff1",
- "zh:9846510a1841530d4403f4818e233f91e3b3bade7441047599fbf800742f65be",
- "zh:afa98d44860875f037c6def0a7e6ff208e042712ba771f620482b143cd336891",
- "zh:bdca130d9ef27488ae0b13bc8fd8019e8bbdd4f2ceff29da066bd333165d68c5",
- "zh:cb3b94cbca88210dd0d1f11e2b8a89333f48c3857faf8f70f589072ce7c28610",
- "zh:f0c0ba87925fe32f84b80f7513b1efb1b0866f51f899ba825e95ad59ff09b018",
- ]
-}
-
-provider "registry.terraform.io/statuscakedev/statuscake" {
- version = "2.2.2"
- constraints = "~> 2.1"
- hashes = [
- "h1:nVaJkDBk4sv0yWFzg3p+yeJGzE8mB4KJv3Q6/UgU164=",
- "zh:0916313344c579d6e05d70f88129a10fe48f7dabe0e61cad17874d6c496f288d",
- "zh:0d491ff72c2eda6482855033ca2146c5ace1663d07cb3da7253b59ed2e2ec6f4",
- "zh:11fffbce18eb3d3c283e877242f477e0c561342c19090240b60af7d948bd84ac",
- "zh:1c3e89cf19118fc07d7b04257251fc9897e722c16e0a0df7b07fcd261f8c12e7",
- "zh:1c6116092c59bc0010e147dc7832ae981d528f235cef563e5ae05a93ef8bac5c",
- "zh:1f13a543b1d32cc1f1e3d2ed5ca83445f088787c335690fe20dee1203488a8bc",
- "zh:23f55fd0714696c3863f892646a79a780cea1923c0c4d2b1064df735ba4156f5",
- "zh:288fbb4431ac12014aa5aab10ee7166dfe71fd1158464e06e8a527aa4919e64e",
- "zh:2a0746d3c8cdc7d0875df0f4605d81130e6da0ce6196d0f5f8661fe9191bc2ab",
- "zh:31d660756abc53d252906fe1239fad58ecb6c1d0dbb087408a8af266be1f9ae4",
- "zh:6765281d1b7efb41085c5375660b6c6b271babc5d09b2c030bec7176f7a7bfb6",
- "zh:6d7204eadb667c1f2cab762a7a97234cf47452f0ecad680f5106c8fd02524c87",
- "zh:b70a97b0eba471d683e23ce8744a5c67ef9952086e2d6f5825b72f32b6caff89",
- "zh:df89ee4aaba88faea33d33384a36ea52588b5514644d335f724378a682d8d9da",
- "zh:ffe2e1e7224ea5f4dd65b9eef45464e3124cfc824473b04e7af429f9177f2375",
- ]
-}