Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/MauiSherpa.Core/Interfaces.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2384,6 +2384,11 @@ Task<OperationResult> RunAsync(
/// Whether an operation is currently running
/// </summary>
bool IsRunning { get; }

/// <summary>
/// Requests cancellation of the running operation
/// </summary>
void RequestCancellation();

/// <summary>
/// Event fired when the modal is shown
Expand Down Expand Up @@ -4001,6 +4006,15 @@ List<string> DestinationKeys
public interface IPublishProfileService
{
Task<IReadOnlyList<PublishProfile>> GetProfilesAsync();
/// <summary>
/// Reads the profiles, reporting the merged set each time a provider finishes so the
/// UI can render the fast providers' profiles without waiting on the slow ones.
/// </summary>
Task<IReadOnlyList<PublishProfile>> GetProfilesAsync(
IProgress<IReadOnlyList<PublishProfile>>? progress);
/// <summary>Drops the cached profiles and re-reads them from every configured provider.</summary>
Task<IReadOnlyList<PublishProfile>> RefreshProfilesAsync(
IProgress<IReadOnlyList<PublishProfile>>? progress = null);
Task<PublishProfile?> GetProfileAsync(string id);
Task SaveProfileAsync(PublishProfile profile);
Task DeleteProfileAsync(string id);
Expand Down
212 changes: 148 additions & 64 deletions src/MauiSherpa.Core/Services/PublishProfileService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ namespace MauiSherpa.Core.Services;
public class PublishProfileService : IPublishProfileService
{
const string CloudKeyPrefix = "sherpa-publish-profiles/";
const int MaxParallelSecretReads = 8;

readonly ICloudSecretsService _cloudService;
readonly ICertificateSyncService _certSync;
Expand Down Expand Up @@ -66,7 +67,11 @@ public PublishProfileService(
_syncCoordinator.ItemStateChanged += OnSyncItemStateChanged;
}

public async Task<IReadOnlyList<PublishProfile>> GetProfilesAsync()
public Task<IReadOnlyList<PublishProfile>> GetProfilesAsync() =>
GetProfilesAsync(progress: null);

public async Task<IReadOnlyList<PublishProfile>> GetProfilesAsync(
IProgress<IReadOnlyList<PublishProfile>>? progress)
{
lock (_cacheLock)
{
Expand All @@ -84,72 +89,15 @@ public async Task<IReadOnlyList<PublishProfile>> GetProfilesAsync()
}

var generation = Interlocked.Read(ref _cacheGeneration);
var profiles = new Dictionary<string, PublishProfile>(StringComparer.Ordinal);
if (_providerRegistry is not null)
{
var providerConfigs = await _providerRegistry.GetProvidersAsync();
foreach (var config in providerConfigs
.OrderBy(provider => provider.ProviderType == CloudSecretsProviderType.Local ? 0 : 1)
.ThenBy(provider => provider.Name, StringComparer.OrdinalIgnoreCase))
{
try
{
var provider = await _providerRegistry.GetProviderAsync(config.Id);
if (provider is null)
continue;

var keys = await provider.ListSecretsAsync(CloudKeyPrefix);
foreach (var key in keys)
{
var bytes = await provider.GetSecretAsync(key);
if (bytes is null)
continue;

var profile = JsonSerializer.Deserialize<PublishProfile>(
Encoding.UTF8.GetString(bytes),
JsonOptions);
if (profile is not null)
profiles.TryAdd(profile.Id, profile);
}
}
catch (Exception ex)
{
_logger.LogWarning($"Failed to load publish profiles from '{config.Name}': {ex.Message}");
}
}
}
else if (_cloudService.ActiveProvider is not null)
{
var keys = await _cloudService.ListSecretsAsync(CloudKeyPrefix);
foreach (var key in keys)
{
try
{
var bytes = await _cloudService.GetSecretAsync(key);
if (bytes is null)
continue;

var profile = JsonSerializer.Deserialize<PublishProfile>(
Encoding.UTF8.GetString(bytes),
JsonOptions);
if (profile is not null)
profiles.TryAdd(profile.Id, profile);
}
catch (Exception ex)
{
_logger.LogWarning($"Failed to load publish profile '{key}': {ex.Message}");
}
}
}

var loaded = profiles.Values.OrderBy(p => p.Name).ToList();
var loaded = await LoadProfilesFromProvidersAsync(progress);
lock (_cacheLock)
{
// If the cache was invalidated while we were reading, leave it empty so the
// next call refetches — but still hand back what we just read. Returning
// nothing here made the page render an empty grid after a sync completed.
if (generation == Interlocked.Read(ref _cacheGeneration))
_cache = loaded;
return _cache is not null
? _cache
: Array.Empty<PublishProfile>();
return loaded;
}
}
finally
Expand All @@ -158,6 +106,137 @@ public async Task<IReadOnlyList<PublishProfile>> GetProfilesAsync()
}
}

public Task<IReadOnlyList<PublishProfile>> RefreshProfilesAsync(
IProgress<IReadOnlyList<PublishProfile>>? progress = null)
{
InvalidateCache();
return GetProfilesAsync(progress);
}

async Task<List<PublishProfile>> LoadProfilesFromProvidersAsync(
IProgress<IReadOnlyList<PublishProfile>>? progress)
{
var profiles = new Dictionary<string, PublishProfile>(StringComparer.Ordinal);

if (_providerRegistry is null)
{
if (_cloudService.ActiveProvider is null)
return [];

var activeKeys = await _cloudService.ListSecretsAsync(CloudKeyPrefix);
foreach (var profile in await ReadProfilesAsync(activeKeys, key => _cloudService.GetSecretAsync(key)))
profiles.TryAdd(profile.Id, profile);
return Snapshot(profiles);
}

var orderedConfigs = (await _providerRegistry.GetProvidersAsync())
.OrderBy(provider => provider.ProviderType == CloudSecretsProviderType.Local ? 0 : 1)
.ThenBy(provider => provider.Name, StringComparer.OrdinalIgnoreCase)
.ToList();

// Every provider starts listing at once, but contents are read in priority order so
// a slow remote vault never holds up the profiles a fast local one already has.
var listings = orderedConfigs.Select(ListProfileKeysAsync).ToList();
var claimedIds = new HashSet<string>(StringComparer.Ordinal);

foreach (var listingTask in listings)
{
var listing = await listingTask;
if (listing.Provider is null)
continue;

// Only read the copies this provider owns outright. A key already claimed by a
// higher-priority provider would be deserialized and then dropped on merge, so
// fetching it is pure latency — and remote vaults are where that latency lives.
var keysToRead = listing.Keys
.Where(key => !TryGetComparableProfileId(key, out var id) || claimedIds.Add(id))
.ToList();
Comment on lines +151 to +153

var loaded = await ReadProfilesAsync(keysToRead, key => listing.Provider.GetSecretAsync(key));
foreach (var profile in loaded)
profiles.TryAdd(profile.Id, profile);

_logger.LogInformation(
$"Provider '{listing.Config.Name}' listed {listing.Keys.Count} publish profile key(s), " +
$"read {keysToRead.Count}, {loaded.Count} readable");

progress?.Report(Snapshot(profiles));
}

return Snapshot(profiles);
}

static List<PublishProfile> Snapshot(Dictionary<string, PublishProfile> profiles) =>
profiles.Values.OrderBy(profile => profile.Name).ToList();

static bool TryGetComparableProfileId(string key, out string id)
{
if (SecretItemAdapterHelper.TryGetRelativeKey(key, CloudKeyPrefix, out var relativeKey))
{
id = SecretItemAdapterHelper.GetComparableStorageKey(relativeKey);
return true;
}

id = string.Empty;
return false;
}

async Task<ProviderListing> ListProfileKeysAsync(CloudSecretsProviderConfig config)
{
try
{
var provider = await _providerRegistry!.GetProviderAsync(config.Id);
if (provider is null)
return new ProviderListing(config, null, Array.Empty<string>());

var keys = await provider.ListSecretsAsync(CloudKeyPrefix);
return new ProviderListing(config, provider, keys);
}
catch (Exception ex)
{
_logger.LogWarning($"Failed to load publish profiles from '{config.Name}': {ex.Message}");
return new ProviderListing(config, null, Array.Empty<string>());
}
}

async Task<IReadOnlyList<PublishProfile>> ReadProfilesAsync(
IReadOnlyList<string> keys,
Func<string, Task<byte[]?>> readSecretAsync)
{
var loaded = new PublishProfile?[keys.Count];
using var throttle = new SemaphoreSlim(MaxParallelSecretReads);

await Task.WhenAll(keys.Select(async (key, index) =>
{
await throttle.WaitAsync();
try
{
var bytes = await readSecretAsync(key);
if (bytes is null)
return;

loaded[index] = JsonSerializer.Deserialize<PublishProfile>(
Encoding.UTF8.GetString(bytes),
JsonOptions);
}
catch (Exception ex)
{
_logger.LogWarning($"Failed to load publish profile '{key}': {ex.Message}");
}
finally
{
throttle.Release();
}
}));

return loaded.Where(profile => profile is not null).Select(profile => profile!).ToList();
}

readonly record struct ProviderListing(
CloudSecretsProviderConfig Config,
ICloudSecretsProvider? Provider,
IReadOnlyList<string> Keys);

public async Task<PublishProfile?> GetProfileAsync(string id)
{
var profiles = await GetProfilesAsync();
Expand Down Expand Up @@ -211,10 +290,15 @@ void OnSyncItemStateChanged(SecretItemRef item)
if (item.Kind != SecretItemKind.PublishProfile)
return;

InvalidateCache();
OnProfilesChanged?.Invoke();
}

void InvalidateCache()
{
Interlocked.Increment(ref _cacheGeneration);
lock (_cacheLock)
_cache = null;
OnProfilesChanged?.Invoke();
}

public async Task<Dictionary<string, string>> ResolveSecretsAsync(
Expand Down
7 changes: 7 additions & 0 deletions src/MauiSherpa.Core/Services/SecretItemAdapterHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,13 @@ public static async Task<bool> DeleteArtifactsAsync(
public static bool KeyMatchesPrefix(string key, string prefix) =>
NormalizeStorageKey(key).StartsWith(NormalizeStorageKey(prefix), StringComparison.Ordinal);

/// <summary>
/// Returns a form of the key that ignores the punctuation differences providers
/// introduce (Azure Key Vault cannot store '/', for example), so keys from two
/// different providers can be compared for identity.
/// </summary>
public static string GetComparableStorageKey(string key) => NormalizeStorageKey(key);

public static bool StorageKeysEqual(string left, string right) =>
string.Equals(
NormalizeStorageKey(left),
Expand Down
19 changes: 16 additions & 3 deletions src/MauiSherpa.Core/Services/SecretsPublisherService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -224,13 +224,26 @@ public async Task DeletePublisherAsync(string id)

public async Task<bool> TestConnectionAsync(string publisherId, CancellationToken cancellationToken = default)
{
var publisher = GetPublisherInstance(publisherId);
var publisher = await ResolvePublisherInstanceAsync(publisherId);
if (publisher == null)
return false;

return await publisher.TestConnectionAsync(cancellationToken);
}

/// <summary>
/// Resolves a publisher, loading the configured publishers first when nothing has
/// populated the cache yet. <see cref="GetPublisherInstance"/> is synchronous and can
/// only see an already-loaded cache, so callers that reach it cold get nothing back.
/// </summary>
async Task<ISecretsPublisher?> ResolvePublisherInstanceAsync(string publisherId)
{
if (_cachedPublishers is null)
await GetPublishersAsync();

return GetPublisherInstance(publisherId);
}

public ISecretsPublisher? GetPublisherInstance(string publisherId)
{
if (_publisherInstances.TryGetValue(publisherId, out var cached))
Expand All @@ -255,7 +268,7 @@ public async Task<bool> TestConnectionAsync(string publisherId, CancellationToke

public async Task<IReadOnlyList<PublisherRepository>> ListRepositoriesAsync(string publisherId, string? filter = null, CancellationToken cancellationToken = default)
{
var publisher = GetPublisherInstance(publisherId);
var publisher = await ResolvePublisherInstanceAsync(publisherId);
if (publisher == null)
return new List<PublisherRepository>();

Expand All @@ -264,7 +277,7 @@ public async Task<IReadOnlyList<PublisherRepository>> ListRepositoriesAsync(stri

public async Task PublishSecretsAsync(string publisherId, string repositoryId, IReadOnlyDictionary<string, string> secrets, IProgress<string>? progress = null, CancellationToken cancellationToken = default)
{
var publisher = GetPublisherInstance(publisherId);
var publisher = await ResolvePublisherInstanceAsync(publisherId);
if (publisher == null)
throw new InvalidOperationException($"Publisher not found: {publisherId}");

Expand Down
2 changes: 2 additions & 0 deletions src/MauiSherpa.MacOS/BlazorContentPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1070,6 +1070,8 @@ void FullRebuildToolbar()
("create", "New Secret", "plus"),
("export", "Export", "square.and.arrow.up"),
("export-selected", "Export Selected", "square.and.arrow.up"),
("select", "Select", "checkmark.circle"),
("publish-selected", "Publish Selected", "square.and.arrow.up"),
("cancel-selection", "Cancel", "xmark"),
("create-folder", "New Folder", "folder.badge.plus"),
("rename-folder", "Rename Folder", "pencil"),
Expand Down
7 changes: 7 additions & 0 deletions src/MauiSherpa/Components/SecretProviderPicker.razor
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
{
<section class="provider-popover"
id="@_popoverId"
data-popover-panel
role="dialog"
aria-modal="false"
aria-labelledby="@_headingId"
Expand Down Expand Up @@ -221,6 +222,12 @@
await _interopModule.InvokeVoidAsync("bindPopover", _rootId, _dotNetReference);
_interopBound = true;
}
else if (_isOpen && _interopModule is not null)
{
// Provider rows change height as their sync state resolves, so re-pin the
// panel to the trigger after every render while it is open.
await _interopModule.InvokeVoidAsync("positionPopover", _rootId);
}
else if (!_isOpen && _restoreFocus && _interopModule is not null)
{
_restoreFocus = false;
Expand Down
Loading
Loading