From 2fd5360db274a0e466f1572724bae65691bf8c7b Mon Sep 17 00:00:00 2001 From: gbaudrit Date: Fri, 11 Sep 2026 00:55:41 +0200 Subject: [PATCH 1/2] feat(console): manage source registries and discovery --- docs/concepts/sources.md | 2 + docs/reference/current-capabilities.md | 2 + docs/reference/source-registries.md | 10 + .../MainLayout.razor.cs | 4 +- .../Resources/MainLayoutStrings.fr-FR.resx | 2 + .../Resources/MainLayoutStrings.resx | 2 + .../Components/Pages/SourceRegistries.razor | 302 ++++++++++++++++++ .../Pages/SourceRegistries.razor.css | 25 ++ .../Pages/SourceRegistriesStrings.cs | 3 + .../Pages/SourceRegistryDiscovery.razor | 206 ++++++++++++ .../Pages/SourceRegistryDiscovery.razor.css | 22 ++ .../Components/Pages/Sources.razor | 1 + .../WebConsoleServiceCollectionExtensions.cs | 1 + .../Console/SourceRegistriesApiClient.cs | 124 +++++++ .../Console/SourceRegistryEditorModel.cs | 75 +++++ .../Pages/SourceRegistriesStrings.fr-FR.resx | 26 ++ .../Pages/SourceRegistriesStrings.resx | 26 ++ .../Pages/SourcesStrings.fr-FR.resx | 2 +- .../Components/Pages/SourcesStrings.resx | 2 +- .../SourceRegistriesLocalizationTests.cs | 41 +++ .../SourceRegistryConsoleTests.cs | 250 +++++++++++++++ 21 files changed, 1125 insertions(+), 3 deletions(-) create mode 100644 src/Agentstration.Web/Components/Pages/SourceRegistries.razor create mode 100644 src/Agentstration.Web/Components/Pages/SourceRegistries.razor.css create mode 100644 src/Agentstration.Web/Components/Pages/SourceRegistriesStrings.cs create mode 100644 src/Agentstration.Web/Components/Pages/SourceRegistryDiscovery.razor create mode 100644 src/Agentstration.Web/Components/Pages/SourceRegistryDiscovery.razor.css create mode 100644 src/Agentstration.Web/Console/SourceRegistriesApiClient.cs create mode 100644 src/Agentstration.Web/Console/SourceRegistryEditorModel.cs create mode 100644 src/Agentstration.Web/Resources/Components/Pages/SourceRegistriesStrings.fr-FR.resx create mode 100644 src/Agentstration.Web/Resources/Components/Pages/SourceRegistriesStrings.resx create mode 100644 tests/Agentstration.Web.Tests/SourceRegistriesLocalizationTests.cs create mode 100644 tests/Agentstration.Web.Tests/SourceRegistryConsoleTests.cs diff --git a/docs/concepts/sources.md b/docs/concepts/sources.md index 3c61752a..c517c74b 100644 --- a/docs/concepts/sources.md +++ b/docs/concepts/sources.md @@ -101,6 +101,8 @@ Configured state, observed state, immutable refresh history, and cached bytes re Startup only ensures the local official registration and observed-state records; it performs no network request. Deleting an external registration removes desired configuration but preserves immutable refresh history and retained cache bytes for later provenance and retention processing. Registry availability therefore has no effect on direct Source imports or on already imported Sources, snapshots, Bootstrap Profiles, and Packs. See the [registration reference](../reference/source-registries.md) for the complete API and enterprise example. +The Platform-administrator Console mirrors this boundary under **System > Source registries**: registration management and Registry refresh stay separate from imported Source and Channel administration. Discovery shows all retained observations and their independent trust evidence before an exact selection is confirmed; the resulting Source then continues through the existing Source, Channel, Bootstrap, and Pack workflows. + ## Optional verification index Agentstration can consult a static verification index without making it a startup or offline dependency: diff --git a/docs/reference/current-capabilities.md b/docs/reference/current-capabilities.md index 7959d026..209460ec 100644 --- a/docs/reference/current-capabilities.md +++ b/docs/reference/current-capabilities.md @@ -40,6 +40,8 @@ The management plane is the source of truth for agent definitions and desired st The official static Source registry is available as an optional instance-owned discovery input, and Platform administrators can add independent community or private enterprise registrations. Registrations have ETag-protected CRUD, explicit trust/network/refresh/cache policies, and optional instance Secret references resolved only for exact same-origin requests. Manual and opt-in scheduled refreshes retrieve only compatible Registry v1 shards, validate canonical digests, retain a bounded last-known-good cache, and persist conditional HTTP, backoff, staleness, failure, and recovery state across restart. Registry trust evaluation preserves every observation, gates remote publisher assertions through local policy, rejects accepted digest conflicts and revocation, and feeds the existing exact SourceVersion verification boundary. Merged discovery remains offline; an explicit retained-observation selection retrieves and imports only one exact manifest with complete Registry provenance and no Channel materialization. It never equates a trusted origin or verified SourceVersion with a verified mutable Channel Snapshot or makes startup depend on the public network. See [Source registry registrations](source-registries.md). +The Console exposes these operations under **System > Source registries**. It supports registration CRUD, enablement, manual refresh, refresh history, Secret-reference selection, merged discovery filters, all observation evidence, and exact import confirmation. The official registration, local origin policy, publisher evidence, SourceVersion verification, and later Snapshot verification are presented separately on responsive desktop and mobile layouts. + ## Schedule Triggers Agentstration can submit autonomous Work from a declarative Workspace-scoped `Trigger`. Trigger owns **when**, Work owns **what**, Flow owns **how**, and Runtime owns execution. Automation is not a second runtime. diff --git a/docs/reference/source-registries.md b/docs/reference/source-registries.md index 25209d40..d0c5706f 100644 --- a/docs/reference/source-registries.md +++ b/docs/reference/source-registries.md @@ -14,6 +14,16 @@ The API supports: The official `agentstration-official` registration is created only when absent. Local changes survive restart and product upgrades. It can be disabled or reconfigured but not deleted. +## Console workflow + +Platform administrators can use **System > Source registries** (`/settings/source-registries`) instead of calling these APIs directly. The registration list separates the well-known official registration from endpoints trusted by local policy and shows enabled state, endpoint type, freshness, last outcome, next scheduled refresh, and actionable errors. Each detail page provides ETag-protected editing, manual refresh, retained refresh history, and the current origin-trust explanation. A concurrency conflict must be resolved by reloading the latest server version before saving again. + +Private registrations select an existing instance-scoped Secret by reference. The Console uses the authorized Secret inventory and never requests, renders, or logs its value. Secret creation and value management remain in the canonical **Secrets** experience. + +Use the **Discover Sources** tab (`/settings/source-registries/discovery`) to apply the bounded server-side filters and inspect grouped portable identities. Every exact version retains all Registry observations and displays four independent dimensions: Registry origin trust, publisher evidence, SourceVersion verification, and Snapshot verification. Snapshot verification is explicitly not evaluated until a Channel is materialized. Shard-local `latest` markers never become a global latest version. + +Selecting an observation opens a confirmation that repeats the Registry registration, observation ID, shard, opaque version, and expected manifest digest. The server revalidates that retained tuple on confirmation. Conflicted, revoked, untrusted, or otherwise policy-denied choices remain unavailable; stale choices carry an explicit warning. A successful import opens the ordinary Source detail page and does not create or refresh Channels. + ## Private enterprise example Create an instance-scoped Vault and Secret through the normal Secret APIs, store the Bearer token through the write-only Secret value endpoint, and retain only this reference in the registration: diff --git a/src/Agentstration.Web.Components/MainLayout.razor.cs b/src/Agentstration.Web.Components/MainLayout.razor.cs index a34c4930..bef0fa57 100644 --- a/src/Agentstration.Web.Components/MainLayout.razor.cs +++ b/src/Agentstration.Web.Components/MainLayout.razor.cs @@ -23,7 +23,7 @@ private sealed record CommandItem(string Label, string Url, string Icon, string new("Group.Operate", [new("Nav.Triggers", "/triggers", "clock", "work"), new("Nav.Deployments", "/deployments", "server", "runtime"), new("Nav.Tasks", "/tasks", "tasks", "work")]), new("Group.Runs", [new("Nav.AgentRuns", "/agent-runs", "play-circle", "execution"), new("Nav.FlowRuns", "/flow-runs", "flow-run", "flow"), new("Nav.RunEvents", "/run-events", "activity")]), new("Group.Configure", [new("Nav.WorkplaceSetup", "/workspaces", "layout-grid", "work"), new("Nav.Packs", "/packs", "package"), new("Nav.Tools", "/tools", "wrench", "tool"), new("Nav.ModelProviders", "/modelproviders", "cpu", "model"), new("Nav.SourceProviders", "/sourceproviders", "database", "source"), new("Nav.RuntimeProfiles", "/runtimeprofiles", "cube", "runtime"), new("Nav.Secrets", "/secrets", "key")]), - new("Group.System", [new("Nav.ResourceScopes", "/settings/resource-scopes", "layers", RequiredPermissions: ["resources/read"]), new("Nav.Sources", "/settings/sources", "books"), new("Nav.Extensions", "/extensions", "puzzle"), new("Nav.Cleanup", "/cleanup", "trash", RequiredPermissions: ["resources/delete", "runs/delete"]), new("Nav.Organization", "/settings/organization", "building"), new("Nav.Bootstrap", "/settings/bootstrap", "upload-cloud"), new("Nav.Profile", "/settings/profile", "user-circle"), new("Nav.Settings", "/settings", "settings")]) + new("Group.System", [new("Nav.ResourceScopes", "/settings/resource-scopes", "layers", RequiredPermissions: ["resources/read"]), new("Nav.Sources", "/settings/sources", "books"), new("Nav.SourceRegistries", "/settings/source-registries", "database"), new("Nav.Extensions", "/extensions", "puzzle"), new("Nav.Cleanup", "/cleanup", "trash", RequiredPermissions: ["resources/delete", "runs/delete"]), new("Nav.Organization", "/settings/organization", "building"), new("Nav.Bootstrap", "/settings/bootstrap", "upload-cloud"), new("Nav.Profile", "/settings/profile", "user-circle"), new("Nav.Settings", "/settings", "settings")]) ]; private static readonly CommandDefinition[] CommandDefinitions = @@ -60,6 +60,8 @@ private sealed record CommandItem(string Label, string Url, string Icon, string new("Nav.Settings", "/settings", "⚙", "Group.System", "configuration paramètres"), new("Nav.ResourceScopes", "/settings/resource-scopes", "◇", "Group.System", "resource ownership hierarchy scopes ressources propriété hiérarchie", ["resources/read"]), new("Nav.Sources", "/settings/sources", "▤", "Group.System", "sources catalogs reusable content catalogues contenu réutilisable"), + new("Nav.SourceRegistries", "/settings/source-registries", "◫", "Group.System", "registry registries discovery trust registre registres découverte confiance"), + new("Command.DiscoverSources", "/settings/source-registries/discovery", "⌕", "Command", "registry catalog source discovery registre catalogue découverte"), new("Command.ProfileSettings", "/settings/profile", "○", "Group.System", "appearance theme personal preferences apparence thème préférences"), new("Nav.Extensions", "/extensions", "⬢", "Group.System", "AEP option contracts compatibility extensions"), new("Nav.Cleanup", "/cleanup", "⌫", "Group.System", "cleanup clean delete purge nettoyage suppression", ["resources/delete", "runs/delete"]), diff --git a/src/Agentstration.Web.Components/Resources/MainLayoutStrings.fr-FR.resx b/src/Agentstration.Web.Components/Resources/MainLayoutStrings.fr-FR.resx index cd04ef4a..5f5020dc 100644 --- a/src/Agentstration.Web.Components/Resources/MainLayoutStrings.fr-FR.resx +++ b/src/Agentstration.Web.Components/Resources/MainLayoutStrings.fr-FR.resx @@ -11,4 +11,6 @@ Créer un agentCréer un profil de modèleCréer un FlowCréer une entréeCréer un fournisseur d’outilsCréer un fournisseur de modèlesCréer un profil d’exécutionCréer un secretCoffresCréer un coffreParamètres du profilEspaces de travail de l’organisationMembres de l’organisation Périmètres Sources + Registres de Sources + Découvrir des Sources diff --git a/src/Agentstration.Web.Components/Resources/MainLayoutStrings.resx b/src/Agentstration.Web.Components/Resources/MainLayoutStrings.resx index 6d2b0940..5f95489c 100644 --- a/src/Agentstration.Web.Components/Resources/MainLayoutStrings.resx +++ b/src/Agentstration.Web.Components/Resources/MainLayoutStrings.resx @@ -11,4 +11,6 @@ Create agentCreate model profileCreate flowCreate entryCreate tool providerCreate model providerCreate runtime profileCreate secretVaultsCreate vaultProfile settingsOrganization workspacesOrganization members Resource scopes Sources + Source registries + Discover Sources diff --git a/src/Agentstration.Web/Components/Pages/SourceRegistries.razor b/src/Agentstration.Web/Components/Pages/SourceRegistries.razor new file mode 100644 index 00000000..7be3cb6a --- /dev/null +++ b/src/Agentstration.Web/Components/Pages/SourceRegistries.razor @@ -0,0 +1,302 @@ +@page "/settings/source-registries" +@page "/settings/source-registries/new" +@page "/settings/source-registries/{Name}" +@using Agentstration.Management.Core +@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = Agentstration.Web.Security.AgentstrationPolicies.PlatformAdmin)] +@implements IDisposable +@inject ISourceRegistriesClient Client +@inject ISecretsClient Secrets +@inject NavigationManager Navigation +@inject NotificationState Notifications +@inject TimeProvider Clock +@inject Microsoft.Extensions.Localization.IStringLocalizer Localizer + +@T("PageTitle") + + + @if (IsList) + { + + @T("AddRegistry") + } + else + { + @T("BackToRegistries") + } + + + + + @T("Registrations") + @T("DiscoverSources") + @T("ImportedSources") + + +@if (error is not null) +{ + +} + +@if (IsList) +{ + @if (busy && registries is null) + { + + } + else if (registries?.Count == 0) + { + @T("AddRegistry") + } + else + { +
+ + + +
+
+

@T("Distribution")

@T("ConfiguredRegistries")

@T("ConfiguredRegistriesDescription")

+
+ +
@T("Registry")@T("EndpointType")@T("Freshness")@T("LastResult")@T("NextRefresh")@T("Enabled")
+ + @item.Registration.Definition.DisplayName
@item.Registration.Name@if (IsOfficial(item)) { @T("WellKnownOfficial") } + @TrustPolicy(item.Registration.Definition.TrustPolicy) + @FormatDate(item.Observed.Definition.LastSuccessfulRefreshAt) + @RefreshOutcome(item.Observed.Definition.LastOutcome)@if (!string.IsNullOrWhiteSpace(item.Observed.Definition.LastErrorMessage)) { @item.Observed.Definition.LastErrorMessage } + @NextRefresh(item) + +
@T("Manage") →
+
+
+
+
+ } +} +else if (model is null) +{ + +} +else +{ + var official = !IsNew && string.Equals(Name, SourceRegistryWellKnown.OfficialName, StringComparison.Ordinal); + + +
+

@T("Identity")

@T("Registration")

@T(official ? "OfficialRegistrationHelp" : "RegistrationHelp")

@if (view is not null) { }
+
+ + + + + +
+
+
+

@T("Connection")

@T("EndpointAndCredential")

@T("CredentialHelp")

+
+ + @if (model.AuthenticationMode == SourceRegistryAuthenticationMode.StaticBearer) + { + + } + + +
+
+
+

@T("Automation")

@T("RefreshPolicy")

@T("RefreshPolicyHelp")

+
+ + + + + + + + +
+
+
@T("Cancel")
+
+ + @if (!IsNew && view is not null) + { +
+
+

@T("Trust")

@T("OriginTrust")

@T("IndependentTrustHelp")

+ @if (originTrust is null) { } + else {
@T("OriginClassification")
@T("LocalPolicy")
@TrustPolicy(originTrust.Policy)
@T("Reason")
@originTrust.ReasonCode
@T("Evaluated")
@FormatDate(originTrust.EvaluatedAt)
} +
+
+

@T("ObservedState")

@T("LastKnownGood")

+
@T("Freshness")
@ObservedStatus(view.Observed.Definition.Status)
@T("LastSuccessfulRefresh")
@FormatDate(view.Observed.Definition.LastSuccessfulRefreshAt)
@T("IndexDigest")
@Short(view.Observed.Definition.Current?.IndexDigest)
@T("CachedShards")
@(view.Observed.Definition.Current?.Catalogs.Count ?? 0)
+
+
+
+

@T("History")

@T("RefreshHistory")

@T("RefreshHistoryHelp")

+ @if (refreshes.Count == 0) { } + else {
@foreach (var refresh in refreshes) {
@RefreshOutcome(refresh.Definition.Outcome)@FormatDate(refresh.Definition.CompletedAt) · @RefreshTrigger(refresh.Definition.Trigger) · @F("RetryCount", refresh.Definition.RetryCount)@Short(refresh.Definition.IndexDigest)@if (!string.IsNullOrWhiteSpace(refresh.Definition.ErrorMessage)) { @refresh.Definition.ErrorMessage }
}
} +
+
+ @if (!official) { } +
+ } +} + + + +@code { + [Parameter] public string? Name { get; set; } + private readonly CancellationTokenSource cancellation = new(); + private IReadOnlyList? registries; + private IReadOnlyList refreshes = []; + private IReadOnlyList instanceSecrets = []; + private ResourceSnapshot? snapshot; + private SourceRegistryRegistrationView? view; + private SourceRegistryOriginTrustView? originTrust; + private SourceRegistryEditorModel? model; + private AgentstrationApiException? error; + private bool busy; + private bool confirmDelete; + private bool IsNew => Navigation.ToBaseRelativePath(Navigation.Uri).EndsWith("/new", StringComparison.OrdinalIgnoreCase); + private bool IsList => string.IsNullOrWhiteSpace(Name) && !IsNew; + private bool CanSave => model is not null + && (model.AuthenticationMode != SourceRegistryAuthenticationMode.StaticBearer + || !string.IsNullOrWhiteSpace(model.CredentialName)); + private string T(string key) => Localizer[key].Value; + private string F(string key, params object[] arguments) => Localizer[key, arguments].Value; + + protected override Task OnParametersSetAsync() => LoadAsync(); + + private async Task LoadAsync() + { + busy = true; error = null; + try + { + if (IsList) { registries = await Client.GetRegistriesAsync(cancellation.Token); return; } + instanceSecrets = (await Secrets.GetSecretsAsync(cancellation.Token)).Where(value => value.Resource.ScopeRef == ResourceScopeRef.Instance).OrderBy(value => value.Resource.Definition.DisplayName, StringComparer.CurrentCultureIgnoreCase).ToArray(); + if (IsNew) { model = new(); return; } + snapshot = await Client.GetRegistryAsync(Name!, cancellation.Token); + view = snapshot.Value; + model = SourceRegistryEditorModel.From(view.Registration); + (refreshes, originTrust) = await LoadDetailsAsync(Name!); + } + catch (AgentstrationApiException exception) { error = exception; } + finally { busy = false; } + } + + private async Task<(IReadOnlyList, SourceRegistryOriginTrustView)> LoadDetailsAsync(string name) + { + var historyTask = Client.GetRefreshesAsync(name, cancellation.Token); + var trustTask = Client.GetOriginTrustAsync(name, cancellation.Token); + await Task.WhenAll(historyTask, trustTask); + return (await historyTask, await trustTask); + } + + private async Task SaveAsync() + { + if (model is null || busy) return; + busy = true; error = null; + try + { + if (IsNew) + { + var created = await Client.CreateRegistryAsync(new(model.Name.Trim(), model.ToProperties()), cancellation.Token); + Notify("RegistryCreated", created.Value.Registration.Definition.DisplayName); + Navigation.NavigateTo(RegistryUrl(created.Value.Registration.Name)); + } + else + { + snapshot = await Client.UpdateRegistryAsync(Name!, new(model.ToProperties()), snapshot!.ETag, cancellation.Token); + view = snapshot.Value; + model = SourceRegistryEditorModel.From(view.Registration); + Notify("RegistryUpdated", view.Registration.Definition.DisplayName); + (refreshes, originTrust) = await LoadDetailsAsync(Name!); + } + } + catch (AgentstrationApiException exception) { error = exception; } + catch (Exception exception) when (exception is ArgumentException or UriFormatException or OverflowException) { error = new(exception.Message, Guid.NewGuid().ToString("N")); } + finally { busy = false; } + } + + private async Task ToggleAsync(SourceRegistryRegistrationView item) + { + await RunListActionAsync(async () => + { + var current = await Client.GetRegistryAsync(item.Registration.Name, cancellation.Token); + await Client.UpdateRegistryAsync(item.Registration.Name, new(current.Value.Registration.Definition with { Enabled = !item.Registration.Definition.Enabled }), current.ETag, cancellation.Token); + }); + } + + private Task RefreshAsync(SourceRegistryRegistrationView item) => RunListActionAsync(async () => { await Client.RefreshRegistryAsync(item.Registration.Name, cancellation.Token); }); + + private async Task RunListActionAsync(Func action) + { + busy = true; error = null; + try { await action(); registries = await Client.GetRegistriesAsync(cancellation.Token); } + catch (AgentstrationApiException exception) + { + error = exception; + try { registries = await Client.GetRegistriesAsync(cancellation.Token); } + catch (AgentstrationApiException) { } + } + finally { busy = false; } + } + + private async Task RefreshCurrentAsync() + { + if (Name is null) return; + busy = true; error = null; + try { snapshot = await Client.RefreshRegistryAsync(Name, cancellation.Token); view = snapshot.Value; model = SourceRegistryEditorModel.From(view.Registration); (refreshes, originTrust) = await LoadDetailsAsync(Name); Notify("RegistryRefreshed", view.Registration.Definition.DisplayName); } + catch (AgentstrationApiException exception) + { + error = exception; + try + { + snapshot = await Client.GetRegistryAsync(Name, cancellation.Token); + view = snapshot.Value; + model = SourceRegistryEditorModel.From(view.Registration); + (refreshes, originTrust) = await LoadDetailsAsync(Name); + } + catch (AgentstrationApiException) { } + } + finally { busy = false; } + } + + private async Task DeleteAsync() + { + if (Name is null || snapshot is null) return; + confirmDelete = false; busy = true; error = null; + try { await Client.DeleteRegistryAsync(Name, snapshot.ETag, cancellation.Token); Navigation.NavigateTo("/settings/source-registries"); } + catch (AgentstrationApiException exception) { error = exception; } + finally { busy = false; } + } + + private Task ReloadAfterConflictAsync() => LoadAsync(); + private void Notify(string titleKey, string message) => Notifications.Add(new(Guid.NewGuid(), T(titleKey), message, Clock.GetLocalNow(), UiStatus.Success)); + private static bool IsOfficial(SourceRegistryRegistrationView value) => string.Equals(value.Registration.Name, SourceRegistryWellKnown.OfficialName, StringComparison.Ordinal); + private static bool IsHealthy(SourceRegistryObservedStatus status) => status is SourceRegistryObservedStatus.Fresh or SourceRegistryObservedStatus.Recovered; + private static bool NeedsAttention(SourceRegistryObservedStatus status) => status is SourceRegistryObservedStatus.Stale or SourceRegistryObservedStatus.RefreshFailed or SourceRegistryObservedStatus.Invalid or SourceRegistryObservedStatus.NoCompatibleCatalog or SourceRegistryObservedStatus.PolicyDenied; + private string EndpointType(SourceRegistryRegistrationView value) => IsOfficial(value) ? T("Endpoint.Official") : value.Registration.Definition.AuthenticationMode != SourceRegistryAuthenticationMode.None || value.Registration.Definition.EndpointPolicy.AllowPrivateNetwork ? T("Endpoint.Private") : T("Endpoint.External"); + private string NextRefresh(SourceRegistryRegistrationView item) + { + if (!item.Registration.Definition.Enabled || !item.Registration.Definition.RefreshPolicy.PeriodicEnabled) return T("NotScheduled"); + var observed = item.Observed.Definition; + if (observed.LastAttemptedAt is not { } lastAttempt) return T("DueNow"); + var next = SourceRefreshScheduler.GetNextDue(lastAttempt, observed.ConsecutiveFailures, + item.Registration.Definition.RefreshPolicy, $"registry|{item.Registration.Uid:N}"); + return next <= Clock.GetUtcNow() ? T("DueNow") : FormatDate(next); + } + private string ObservedStatus(SourceRegistryObservedStatus value) => T($"Observed.{value}"); + private string RefreshOutcome(SourceRegistryRefreshOutcome? value) => value is null ? T("Never") : T($"Outcome.{value}"); + private string RefreshTrigger(SourceRegistryRefreshTrigger value) => T($"Trigger.{value}"); + private string TrustPolicy(SourceRegistryTrustPolicy value) => T($"TrustPolicy.{value}"); + private string OriginClassification(SourceRegistryOriginClassification value) => T($"Origin.{value}"); + private string BooleanLabel(bool value) => T(value ? "Yes" : "No"); + private static UiStatus ObservedTone(SourceRegistryObservedStatus value) => value switch { SourceRegistryObservedStatus.Fresh or SourceRegistryObservedStatus.Recovered => UiStatus.Success, SourceRegistryObservedStatus.NeverFetched or SourceRegistryObservedStatus.Disabled => UiStatus.Neutral, SourceRegistryObservedStatus.Stale or SourceRegistryObservedStatus.NoCompatibleCatalog => UiStatus.Warning, _ => UiStatus.Danger }; + private string FormatDate(DateTimeOffset? value) => value is null ? "—" : value.Value.ToLocalTime().ToString("g"); + private static string Short(string? value) => string.IsNullOrWhiteSpace(value) ? "—" : value.Length <= 20 ? value : $"{value[..12]}…{value[^7..]}"; + private static string RegistryUrl(string name) => $"/settings/source-registries/{Uri.EscapeDataString(name)}"; + public void Dispose() { cancellation.Cancel(); cancellation.Dispose(); } +} diff --git a/src/Agentstration.Web/Components/Pages/SourceRegistries.razor.css b/src/Agentstration.Web/Components/Pages/SourceRegistries.razor.css new file mode 100644 index 00000000..89f73034 --- /dev/null +++ b/src/Agentstration.Web/Components/Pages/SourceRegistries.razor.css @@ -0,0 +1,25 @@ +.registry-summary { grid-template-columns: repeat(3, minmax(0, 1fr)); margin-bottom: 1rem; } +.registry-list-panel { overflow: hidden; } +.registry-table { min-width: 0; overflow-x: auto; } +.registry-table ::deep .data-grid { min-width: 72rem; white-space: normal; } +.registry-table ::deep td { vertical-align: top; } +.registry-table small, .registry-kind-note { display: block; margin-top: .25rem; color: var(--muted); } +.registry-table .registry-kind-note { color: var(--primary); font-weight: 700; } +.registry-table .registry-error, .registry-history .registry-error { color: var(--danger); max-width: 18rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.registry-official { background: color-mix(in srgb, var(--primary) 5%, transparent); } +.registry-row-actions { display: flex; align-items: flex-start; gap: .7rem; flex-wrap: wrap; } +.switch-button { display: inline-flex; align-items: center; gap: .45rem; border: 0; background: transparent; color: inherit; cursor: pointer; } +.switch-button > span { width: 2rem; height: 1.1rem; padding: .14rem; border-radius: 999px; background: var(--border-strong, var(--border)); } +.switch-button > span::after { content: ""; display: block; width: .82rem; height: .82rem; border-radius: 50%; background: white; transition: transform .15s ease; } +.switch-button[aria-checked="true"] > span { background: var(--primary); } +.switch-button[aria-checked="true"] > span::after { transform: translateX(.9rem); } +.registry-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; margin-top: 1rem; } +.registry-history { display: grid; } +.registry-history article { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 1rem; padding: .8rem 0; border-top: 1px solid var(--border); } +.registry-history article:first-child { border-top: 0; } +.registry-history small { display: block; margin-top: .2rem; color: var(--muted); } +.registry-destructive-actions { justify-content: flex-end; } +@media (max-width: 800px) { + .registry-summary, .registry-detail-grid { grid-template-columns: 1fr; } + .registry-history article { grid-template-columns: 1fr; } +} diff --git a/src/Agentstration.Web/Components/Pages/SourceRegistriesStrings.cs b/src/Agentstration.Web/Components/Pages/SourceRegistriesStrings.cs new file mode 100644 index 00000000..019251b6 --- /dev/null +++ b/src/Agentstration.Web/Components/Pages/SourceRegistriesStrings.cs @@ -0,0 +1,3 @@ +namespace Agentstration.Web.Components.Pages; + +public sealed class SourceRegistriesStrings; diff --git a/src/Agentstration.Web/Components/Pages/SourceRegistryDiscovery.razor b/src/Agentstration.Web/Components/Pages/SourceRegistryDiscovery.razor new file mode 100644 index 00000000..57a3e015 --- /dev/null +++ b/src/Agentstration.Web/Components/Pages/SourceRegistryDiscovery.razor @@ -0,0 +1,206 @@ +@page "/settings/source-registries/discovery" +@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = Agentstration.Web.Security.AgentstrationPolicies.PlatformAdmin)] +@implements IDisposable +@inject ISourceRegistriesClient Client +@inject NavigationManager Navigation +@inject NotificationState Notifications +@inject TimeProvider Clock +@inject Microsoft.Extensions.Localization.IStringLocalizer Localizer + +@T("DiscoveryPageTitle") + + + + + + @T("Registrations") + @T("DiscoverSources") + @T("ImportedSources") + + +
+

@T("CatalogSearch")

@T("Filters")

@T("FiltersHelp")

+
+ + + + + + +
+
+ + + +
+
+ +@if (error is not null) +{ + +} +else if (loading && page is null) +{ + +} +else if (page?.Count == 0) +{ + +} +else if (page is not null) +{ +

@F("DiscoveryCount", page.Count, page.Total)

+
+ @foreach (var source in page.Value) + { +
+

@source.Publisher / @source.Name

@(source.DisplayName ?? source.Name)

@(source.Description ?? T("NoDescription"))

+ @foreach (var version in source.Versions) + { +
+
@version.Version@version.VerificationReasonCode
@if (version.Conflicted) { }
+ @if (version.Conflicted) {

@T("ConflictImportDenied")

} +
+ @foreach (var observation in version.Observations) + { +
+
@observation.RegistrationDisplayName@observation.RegistrationName / @observation.Selection.CatalogName
@if (observation.IsCatalogLatest) { }
+
+ @T("RegistryOriginTrust")@TrustPolicy(observation.TrustPolicy)@OriginClassification(observation.OriginClassification) + @T("PublisherEvidence")@PublisherStatus(ParsePublisherStatus(observation.Publisher.Status))@observation.Publisher.Name + @T("SourceVersionVerification")@VerificationStatus(version.VerificationStatus)@Short(observation.ManifestDigest) + @T("SnapshotVerification")@T("NotEvaluated")@T("AfterChannelRefresh") +
+
@T("Freshness")
@ObservedStatus(observation.Freshness) · @FormatDate(observation.FetchedAt)
@T("ManifestDigest")
@observation.ManifestDigest
@T("IndexDigest")
@Short(observation.IndexDigest)
@T("ShardDigest")
@Short(observation.CatalogDigest)
+ @if (SelectionWarning(version, observation) is { } warning) {

@warning

} + +
+ } +
+
+ } +
+ } +
+ @if (page.Total > page.Skip + page.Count) + { +
+ } +} + +@if (selected is not null) +{ +
+ +
+} + +@code { + private readonly CancellationTokenSource cancellation = new(); + private SourceRegistryDiscoveryPage? page; + private IReadOnlyList publishers = []; + private IReadOnlyList registries = []; + private AgentstrationApiException? error; + private AgentstrationApiException? importError; + private SourceRegistryDiscoveryObservation? selected; + private ResourceScopeRef? importScope; + private string search = string.Empty; + private string publisher = string.Empty; + private string registry = string.Empty; + private SourceRegistryTrustPolicy? trustPolicy; + private SourceRegistryPublisherStatus? publisherStatus; + private SourceVerificationStatus? verificationStatus; + private bool compatibleOnly = true; + private bool freshOnly; + private bool conflictsOnly; + private bool loading; + private bool importing; + private const int PageSize = 25; + private string T(string key) => Localizer[key].Value; + private string F(string key, params object[] arguments) => Localizer[key, arguments].Value; + + protected override async Task OnInitializedAsync() + { + loading = true; + try + { + var publishersTask = Client.GetPublishersAsync(cancellation.Token); + var registriesTask = Client.GetRegistriesAsync(cancellation.Token); + var searchTask = Client.SearchAsync(Query(0, PageSize), cancellation.Token); + await Task.WhenAll(publishersTask, registriesTask, searchTask); + publishers = await publishersTask; registries = await registriesTask; page = await searchTask; + } + catch (AgentstrationApiException exception) { error = exception; } + finally { loading = false; } + } + + private async Task SearchAsync() + { + loading = true; error = null; + try { page = await Client.SearchAsync(Query(0, PageSize), cancellation.Token); } + catch (AgentstrationApiException exception) { error = exception; } + finally { loading = false; } + } + + private async Task LoadMoreAsync() + { + if (page is null) return; + loading = true; error = null; + try + { + var next = await Client.SearchAsync(Query(0, Math.Min(100, page.Take + PageSize)), cancellation.Token); + page = next; + } + catch (AgentstrationApiException exception) { error = exception; } + finally { loading = false; } + } + + private SourceRegistryDiscoveryQuery Query(int skip, int take) => new() { Search = NullIfEmpty(search), Publisher = NullIfEmpty(publisher), Registry = NullIfEmpty(registry), CompatibleOnly = compatibleOnly, FreshOnly = freshOnly, ConflictsOnly = conflictsOnly, TrustPolicy = trustPolicy, PublisherStatus = publisherStatus, VerificationStatus = verificationStatus, Skip = skip, Take = take }; + private async Task SearchOnEnterAsync(KeyboardEventArgs args) { if (args.Key == "Enter") await SearchAsync(); } + private void SetTrustPolicy(ChangeEventArgs args) => trustPolicy = ParseNullable(args.Value); + private void SetPublisherStatus(ChangeEventArgs args) => publisherStatus = ParseNullable(args.Value); + private void SetVerificationStatus(ChangeEventArgs args) => verificationStatus = ParseNullable(args.Value); + private static TEnum? ParseNullable(object? value) where TEnum : struct, Enum => Enum.TryParse(value?.ToString(), out var parsed) ? parsed : null; + private static string? NullIfEmpty(string value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private void Select(SourceRegistryDiscoveryVersion version, SourceRegistryDiscoveryObservation observation) + { + if (!CanSelect(version, observation)) return; + selected = observation; importScope = null; importError = null; + } + + private async Task ImportAsync() + { + if (selected is null || importScope is null) return; + importing = true; importError = null; + try + { + var result = await Client.ImportAsync(selected.Selection, importScope, cancellation.Token); + Notifications.Add(new(Guid.NewGuid(), T(result.Outcome == SourceImportOutcome.Unchanged ? "ImportUnchanged" : "ImportComplete"), F("ImportCompleteMessage", result.Source.Configuration.Definition.DisplayName, result.Version.Definition.Version), Clock.GetLocalNow(), UiStatus.Success)); + Navigation.NavigateTo($"/settings/sources/{Uri.EscapeDataString(result.Source.Source.Definition.Publisher)}/{Uri.EscapeDataString(result.Source.Source.Name)}?scopeRef={Uri.EscapeDataString(result.Source.Source.ScopeRef!.Value.Value)}"); + } + catch (AgentstrationApiException exception) { importError = exception; } + finally { importing = false; } + } + + private void CloseImport() { if (!importing) { selected = null; importScope = null; importError = null; } } + private static bool CanSelect(SourceRegistryDiscoveryVersion version, SourceRegistryDiscoveryObservation observation) => !version.Conflicted && observation.TrustPolicy != SourceRegistryTrustPolicy.Untrusted && ParsePublisherStatus(observation.Publisher.Status) is SourceRegistryPublisherStatus.Verified or SourceRegistryPublisherStatus.Official && version.VerificationStatus is not SourceVerificationStatus.Conflict and not SourceVerificationStatus.Revoked; + private string? SelectionWarning(SourceRegistryDiscoveryVersion version, SourceRegistryDiscoveryObservation observation) => version.Conflicted ? T("Warning.Conflict") : ParsePublisherStatus(observation.Publisher.Status) == SourceRegistryPublisherStatus.Revoked || version.VerificationStatus == SourceVerificationStatus.Revoked ? T("Warning.Revoked") : observation.TrustPolicy == SourceRegistryTrustPolicy.Untrusted ? T("Warning.Untrusted") : ParsePublisherStatus(observation.Publisher.Status) == SourceRegistryPublisherStatus.Declared ? T("Warning.UnverifiedPublisher") : observation.Freshness is not SourceRegistryObservedStatus.Fresh and not SourceRegistryObservedStatus.Recovered ? T("Warning.Stale") : version.VerificationStatus is SourceVerificationStatus.Unverified or SourceVerificationStatus.Unavailable ? T("Warning.UnverifiedVersion") : null; + private static SourceRegistryPublisherStatus ParsePublisherStatus(string value) => Enum.TryParse(value, true, out var parsed) ? parsed : SourceRegistryPublisherStatus.Declared; + private string TrustPolicy(SourceRegistryTrustPolicy value) => T($"TrustPolicy.{value}"); + private string PublisherStatus(SourceRegistryPublisherStatus value) => T($"PublisherStatus.{value}"); + private string VerificationStatus(SourceVerificationStatus value) => T($"Verification.{value}"); + private string OriginClassification(SourceRegistryOriginClassification value) => T($"Origin.{value}"); + private string ObservedStatus(SourceRegistryObservedStatus value) => T($"Observed.{value}"); + private static UiStatus VerificationTone(SourceVerificationStatus value) => value switch { SourceVerificationStatus.Verified => UiStatus.Success, SourceVerificationStatus.Unverified or SourceVerificationStatus.Unavailable => UiStatus.Warning, _ => UiStatus.Danger }; + private static string Short(string value) => value.Length <= 24 ? value : $"{value[..14]}…{value[^8..]}"; + private static string FormatDate(DateTimeOffset value) => value.ToLocalTime().ToString("g"); + public void Dispose() { cancellation.Cancel(); cancellation.Dispose(); } +} diff --git a/src/Agentstration.Web/Components/Pages/SourceRegistryDiscovery.razor.css b/src/Agentstration.Web/Components/Pages/SourceRegistryDiscovery.razor.css new file mode 100644 index 00000000..41944e5d --- /dev/null +++ b/src/Agentstration.Web/Components/Pages/SourceRegistryDiscovery.razor.css @@ -0,0 +1,22 @@ +.discovery-filters { margin-bottom: 1rem; } +.discovery-toggle-row { display: flex; flex-wrap: wrap; gap: 1.25rem; margin-top: 1rem; } +.discovery-results-heading { color: var(--muted); } +.discovery-results { display: grid; gap: 1rem; } +.discovered-source > header, .discovered-version > header, .observation-card > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; } +.discovered-source > header h2 { margin: 0; } +.discovered-version { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border); } +.discovered-version > header code, .observation-card header small { display: block; margin-top: .25rem; } +.version-badges { display: flex; flex-wrap: wrap; gap: .5rem; } +.observation-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .8rem; margin-top: .8rem; } +.observation-card { min-width: 0; padding: 1rem; border: 1px solid var(--border); border-radius: .75rem; background: var(--surface-2); } +.observation-card code { overflow-wrap: anywhere; } +.evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .55rem; margin: .85rem 0; } +.evidence-grid > span { display: grid; gap: .12rem; padding: .65rem; border-left: .2rem solid var(--border-strong, var(--border)); background: var(--surface); } +.evidence-grid small { color: var(--muted); } +.resource-details.compact { margin: .75rem 0; } +.discovery-import-dialog { width: min(42rem, 100%); max-height: calc(100vh - 2rem); overflow: auto; } +.exact-selection { margin: 1rem 0; padding: .9rem; border: 1px solid var(--border); border-radius: .7rem; background: var(--surface-2); } +@media (max-width: 800px) { + .observation-grid, .evidence-grid { grid-template-columns: 1fr; } + .discovered-source > header, .discovered-version > header { flex-direction: column; } +} diff --git a/src/Agentstration.Web/Components/Pages/Sources.razor b/src/Agentstration.Web/Components/Pages/Sources.razor index 4bc98e6f..0a58c7dd 100644 --- a/src/Agentstration.Web/Components/Pages/Sources.razor +++ b/src/Agentstration.Web/Components/Pages/Sources.razor @@ -22,6 +22,7 @@ } else { + @T("DiscoverFromRegistries") } diff --git a/src/Agentstration.Web/Configuration/WebConsoleServiceCollectionExtensions.cs b/src/Agentstration.Web/Configuration/WebConsoleServiceCollectionExtensions.cs index c30d656f..d3969443 100644 --- a/src/Agentstration.Web/Configuration/WebConsoleServiceCollectionExtensions.cs +++ b/src/Agentstration.Web/Configuration/WebConsoleServiceCollectionExtensions.cs @@ -66,6 +66,7 @@ public static IServiceCollection AddAgentstrationWebConsole(this IServiceCollect AddClient(services, configured.ManagementApi); AddClient(services, configured.ManagementApi); AddClient(services, configured.ManagementApi); + AddClient(services, configured.ManagementApi); AddClient(services, configured.ManagementApi); AddClient(services, configured.ManagementApi); AddClient(services, configured.ManagementApi); diff --git a/src/Agentstration.Web/Console/SourceRegistriesApiClient.cs b/src/Agentstration.Web/Console/SourceRegistriesApiClient.cs new file mode 100644 index 00000000..4d1748c4 --- /dev/null +++ b/src/Agentstration.Web/Console/SourceRegistriesApiClient.cs @@ -0,0 +1,124 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Agentstration.Management.Abstractions; +using Agentstration.Management.Contracts; +using Agentstration.Resources; + +namespace Agentstration.Web.Console; + +public interface ISourceRegistriesClient +{ + Task> GetRegistriesAsync(CancellationToken cancellationToken); + Task> GetRegistryAsync(string name, CancellationToken cancellationToken); + Task> CreateRegistryAsync(CreateSourceRegistryRequest request, CancellationToken cancellationToken); + Task> UpdateRegistryAsync(string name, PutSourceRegistryRequest request, string etag, CancellationToken cancellationToken); + Task DeleteRegistryAsync(string name, string etag, CancellationToken cancellationToken); + Task> RefreshRegistryAsync(string name, CancellationToken cancellationToken); + Task> GetRefreshesAsync(string name, CancellationToken cancellationToken); + Task GetOriginTrustAsync(string name, CancellationToken cancellationToken); + Task SearchAsync(SourceRegistryDiscoveryQuery query, CancellationToken cancellationToken); + Task> GetPublishersAsync(CancellationToken cancellationToken); + Task GetSourceAsync(string publisher, string sourceName, CancellationToken cancellationToken); + Task ImportAsync(SourceRegistryObservationSelection selection, ResourceScopeRef? scopeRef, CancellationToken cancellationToken); +} + +public sealed class SourceRegistriesApiClient(HttpClient httpClient) : ISourceRegistriesClient +{ + public async Task> GetRegistriesAsync(CancellationToken cancellationToken) => + (await ApiResponse.ReadAsync>(httpClient, "api/sourceregistries", cancellationToken)).Value; + + public Task> GetRegistryAsync(string name, CancellationToken cancellationToken) => + ReadSnapshotAsync(HttpMethod.Get, RegistryPath(name), null, null, cancellationToken); + + public Task> CreateRegistryAsync(CreateSourceRegistryRequest request, CancellationToken cancellationToken) => + ReadSnapshotAsync(HttpMethod.Post, "api/sourceregistries", JsonContent.Create(request), null, cancellationToken); + + public Task> UpdateRegistryAsync(string name, PutSourceRegistryRequest request, string etag, CancellationToken cancellationToken) => + ReadSnapshotAsync(HttpMethod.Put, RegistryPath(name), JsonContent.Create(request), etag, cancellationToken); + + public async Task DeleteRegistryAsync(string name, string etag, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Delete, RegistryPath(name)); + request.Headers.IfMatch.Add(EntityTagHeaderValue.Parse(etag)); + using var response = await httpClient.SendAsync(request, cancellationToken); + await ApiResponse.EnsureSuccessAsync(response, cancellationToken); + } + + public Task> RefreshRegistryAsync(string name, CancellationToken cancellationToken) => + ReadSnapshotAsync(HttpMethod.Post, $"{RegistryPath(name)}/refresh", null, null, cancellationToken); + + public async Task> GetRefreshesAsync(string name, CancellationToken cancellationToken) => + (await ApiResponse.ReadAsync(httpClient, $"{RegistryPath(name)}/refreshes?take=50", cancellationToken)).Value; + + public Task GetOriginTrustAsync(string name, CancellationToken cancellationToken) => + ApiResponse.ReadAsync(httpClient, $"{RegistryPath(name)}/trust", cancellationToken); + + public Task SearchAsync(SourceRegistryDiscoveryQuery query, CancellationToken cancellationToken) => + ApiResponse.ReadAsync(httpClient, DiscoveryPath(query), cancellationToken); + + public async Task> GetPublishersAsync(CancellationToken cancellationToken) => + (await ApiResponse.ReadAsync>(httpClient, "api/sourceregistries/discovery/publishers", cancellationToken)).Value; + + public async Task GetSourceAsync(string publisher, string sourceName, CancellationToken cancellationToken) + { + using var response = await httpClient.GetAsync( + $"api/sourceregistries/discovery/sources/{Uri.EscapeDataString(publisher)}/{Uri.EscapeDataString(sourceName)}", + cancellationToken); + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return null; + await ApiResponse.EnsureSuccessAsync(response, cancellationToken); + return await response.Content.ReadFromJsonAsync(cancellationToken) + ?? throw new AgentstrationApiException("Agentstration API returned an empty discovered Source.", Guid.NewGuid().ToString("N")); + } + + public async Task ImportAsync(SourceRegistryObservationSelection selection, ResourceScopeRef? scopeRef, CancellationToken cancellationToken) + { + using var response = await httpClient.PostAsJsonAsync("api/sourceregistries/discovery/imports", + new ImportSourceRegistryObservationRequest(selection, scopeRef), cancellationToken); + await ApiResponse.EnsureSuccessAsync(response, cancellationToken); + return await response.Content.ReadFromJsonAsync(cancellationToken) + ?? throw new AgentstrationApiException("Agentstration API returned an empty Source import result.", Guid.NewGuid().ToString("N")); + } + + private async Task> ReadSnapshotAsync( + HttpMethod method, + string path, + HttpContent? content, + string? etag, + CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(method, path) { Content = content }; + if (!string.IsNullOrWhiteSpace(etag)) request.Headers.IfMatch.Add(EntityTagHeaderValue.Parse(etag)); + using var response = await httpClient.SendAsync(request, cancellationToken); + await ApiResponse.EnsureSuccessAsync(response, cancellationToken); + var value = await response.Content.ReadFromJsonAsync(cancellationToken) + ?? throw new AgentstrationApiException("Agentstration API returned an empty Source registry.", Guid.NewGuid().ToString("N")); + var responseEtag = response.Headers.ETag?.ToString(); + if (string.IsNullOrWhiteSpace(responseEtag)) + throw new AgentstrationApiException("Agentstration API did not return the Source registry ETag.", Guid.NewGuid().ToString("N")); + return new(value, responseEtag); + } + + private static string RegistryPath(string name) => $"api/sourceregistries/{Uri.EscapeDataString(name)}"; + + private static string DiscoveryPath(SourceRegistryDiscoveryQuery query) + { + var values = new List(); + Add(values, "search", query.Search); + Add(values, "publisher", query.Publisher); + Add(values, "registry", query.Registry); + values.Add($"compatibleOnly={query.CompatibleOnly.ToString().ToLowerInvariant()}"); + values.Add($"freshOnly={query.FreshOnly.ToString().ToLowerInvariant()}"); + values.Add($"conflictsOnly={query.ConflictsOnly.ToString().ToLowerInvariant()}"); + Add(values, "trustPolicy", query.TrustPolicy?.ToString()); + Add(values, "publisherStatus", query.PublisherStatus?.ToString()); + Add(values, "verificationStatus", query.VerificationStatus?.ToString()); + values.Add($"skip={query.Skip}"); + values.Add($"take={query.Take}"); + return $"api/sourceregistries/discovery?{string.Join('&', values)}"; + } + + private static void Add(ICollection values, string name, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) values.Add($"{name}={Uri.EscapeDataString(value)}"); + } +} diff --git a/src/Agentstration.Web/Console/SourceRegistryEditorModel.cs b/src/Agentstration.Web/Console/SourceRegistryEditorModel.cs new file mode 100644 index 00000000..e7f7b224 --- /dev/null +++ b/src/Agentstration.Web/Console/SourceRegistryEditorModel.cs @@ -0,0 +1,75 @@ +using System.ComponentModel.DataAnnotations; +using Agentstration.Management.Abstractions; +using Agentstration.Resources; + +namespace Agentstration.Web.Console; + +public sealed class SourceRegistryEditorModel +{ + [Required, RegularExpression("^[a-z0-9]+(?:-[a-z0-9]+)*$")] + public string Name { get; set; } = string.Empty; + [Required, StringLength(256)] public string DisplayName { get; set; } = string.Empty; + [Required, Url] public string IndexUrl { get; set; } = string.Empty; + public bool Enabled { get; set; } = true; + public SourceRegistryTrustPolicy TrustPolicy { get; set; } = SourceRegistryTrustPolicy.Untrusted; + public SourceRegistryAuthenticationMode AuthenticationMode { get; set; } + public string CredentialName { get; set; } = string.Empty; + public bool AllowHttp { get; set; } + public bool AllowPrivateNetwork { get; set; } + public bool PeriodicEnabled { get; set; } + [Range(1, 43200)] public int IntervalMinutes { get; set; } = 1440; + [Range(1, 300)] public int TimeoutSeconds { get; set; } = 30; + [Range(1, 10)] public int MaximumAttempts { get; set; } = 3; + [Range(1, 86400)] public int InitialBackoffSeconds { get; set; } = 30; + [Range(1, 86400)] public int MaximumBackoffSeconds { get; set; } = 900; + [Range(0, 3600)] public int JitterSeconds { get; set; } = 15; + [Range(1, 129600)] public int StaleAfterMinutes { get; set; } = 2880; + [Range(1, 100)] public int RetainedObservations { get; set; } = 3; + + public SourceRegistryRegistrationProperties ToProperties() => new() + { + DisplayName = DisplayName.Trim(), + IndexUrl = new Uri(IndexUrl.Trim(), UriKind.Absolute), + Enabled = Enabled, + TrustPolicy = TrustPolicy, + AuthenticationMode = AuthenticationMode, + Credential = AuthenticationMode == SourceRegistryAuthenticationMode.StaticBearer && !string.IsNullOrWhiteSpace(CredentialName) + ? new ResourceReference(CredentialName, ResourceScopeRef.Instance, ResourceNamespace.Default) + : null, + EndpointPolicy = new() { AllowHttp = AllowHttp, AllowPrivateNetwork = AllowPrivateNetwork }, + RefreshPolicy = new() + { + PeriodicEnabled = PeriodicEnabled, + Interval = TimeSpan.FromMinutes(IntervalMinutes), + Timeout = TimeSpan.FromSeconds(TimeoutSeconds), + MaximumAttempts = MaximumAttempts, + InitialBackoff = TimeSpan.FromSeconds(InitialBackoffSeconds), + MaximumBackoff = TimeSpan.FromSeconds(MaximumBackoffSeconds), + Jitter = TimeSpan.FromSeconds(JitterSeconds), + StaleAfter = TimeSpan.FromMinutes(StaleAfterMinutes) + }, + CachePolicy = new() { RetainedObservations = RetainedObservations } + }; + + public static SourceRegistryEditorModel From(SourceRegistryRegistrationResource resource) => new() + { + Name = resource.Name, + DisplayName = resource.Definition.DisplayName, + IndexUrl = resource.Definition.IndexUrl.AbsoluteUri, + Enabled = resource.Definition.Enabled, + TrustPolicy = resource.Definition.TrustPolicy, + AuthenticationMode = resource.Definition.AuthenticationMode, + CredentialName = resource.Definition.Credential?.Name ?? string.Empty, + AllowHttp = resource.Definition.EndpointPolicy.AllowHttp, + AllowPrivateNetwork = resource.Definition.EndpointPolicy.AllowPrivateNetwork, + PeriodicEnabled = resource.Definition.RefreshPolicy.PeriodicEnabled, + IntervalMinutes = checked((int)resource.Definition.RefreshPolicy.Interval.TotalMinutes), + TimeoutSeconds = checked((int)resource.Definition.RefreshPolicy.Timeout.TotalSeconds), + MaximumAttempts = resource.Definition.RefreshPolicy.MaximumAttempts, + InitialBackoffSeconds = checked((int)resource.Definition.RefreshPolicy.InitialBackoff.TotalSeconds), + MaximumBackoffSeconds = checked((int)resource.Definition.RefreshPolicy.MaximumBackoff.TotalSeconds), + JitterSeconds = checked((int)resource.Definition.RefreshPolicy.Jitter.TotalSeconds), + StaleAfterMinutes = checked((int)resource.Definition.RefreshPolicy.StaleAfter.TotalMinutes), + RetainedObservations = resource.Definition.CachePolicy.RetainedObservations + }; +} diff --git a/src/Agentstration.Web/Resources/Components/Pages/SourceRegistriesStrings.fr-FR.resx b/src/Agentstration.Web/Resources/Components/Pages/SourceRegistriesStrings.fr-FR.resx new file mode 100644 index 00000000..ba1d1aa9 --- /dev/null +++ b/src/Agentstration.Web/Resources/Components/Pages/SourceRegistriesStrings.fr-FR.resx @@ -0,0 +1,26 @@ + + + text/microsoft-resx2.0System.Resources.ResXResourceReader, System.Windows.FormsSystem.Resources.ResXResourceWriter, System.Windows.Forms + Registres de Sources · AgentstrationRegistres de SourcesConfigurez les catalogues de confiance, surveillez leur actualisation et conservez les dernières observations valides.Gérez le point de terminaison, la confiance, la référence de credential, l’actualisation et les observations conservées. + Distribution des SourcesRegistresDécouvrir des SourcesSources importéesActualiserAjouter un registreTous les registres + Résumé des registresPoints de terminaison configurésÀ jourCache de dernière observation valideAttention requiseObsolète, en échec ou refuséDistributionRegistres configurésL’enregistrement officiel reste distinct des origines approuvées par la politique locale. + RegistreType de point de terminaisonFraîcheurDernier résultatProchaine actualisationActivéGérerActualiser maintenantEnregistrement officiel well-knownOfficielPrivéExterneNon planifiéeÀ actualiser maintenantJamaisOuiNonModifier l’activation de {0} + Chargement des registres de Sources…Chargement du registre de Sources…Aucun registre de SourcesAjoutez un catalogue pour découvrir des Sources portables.L’opération sur le registre a échoué.Recharger la dernière version + Ajouter un registre de SourcesIdentitéEnregistrementLe nom portable ne peut plus être modifié après la création.Cet enregistrement intégré identifie le Registry officiel well-known ; la politique de confiance locale reste explicite.NomNom d’affichageURL de l’index du registrePolitique de confiance locale + ConnexionPoint de terminaison et credentialSélectionnez uniquement une référence de Secret d’instance autorisée. Sa valeur n’est jamais lue ni affichée ici.AuthentificationAucuneJeton Bearer statiqueSecret du credentialSélectionner un Secret d’instanceLa valeur du Secret reste en écriture seule.Autoriser HTTPAutoriser les adresses du réseau privé + AutomatisationPolitique d’actualisationL’exécution périodique est déléguée au planificateur partagé d’actualisation des Sources.Actualisation périodiqueIntervalle (minutes)Timeout (secondes)Nombre maximal de tentativesBackoff initial (secondes)Backoff maximal (secondes)Jitter (secondes)Obsolète après (minutes)Observations conservéesAnnulerEnregistrement…Créer le registreEnregistrer + ConfianceOrigine du registreLa confiance dans l’origine est indépendante des preuves éditeur et de la vérification du contenu.Évaluation de la confiance dans l’origine…Classification de l’originePolitique localeRaisonÉvaluéeÉtat observéDernière actualisation réussieDigest de l’indexShards en cache + HistoriqueHistorique des actualisationsChaque tentative indique son déclencheur, son résultat, ses retries et l’observation conservée.Aucune tentative d’actualisationActualisez le registre pour créer sa première observation.{0} retriesSupprimer le registreSupprimer ce registre de Sources ?Supprimer « {0} » ? Les observations conservées de cet enregistrement ne seront plus sélectionnables. + Registre crééRegistre mis à jourRegistre actualisé + Découvrir des Sources · AgentstrationDécouvrir des SourcesComparez toutes les observations conservées et importez une version exacte de Source.Catalogues des registresFiltres de découverteLes filtres sont bornés et évalués par l’API canonique de découverte.RechercherSource ou éditeurRechercher des identités portables et des descriptionsÉditeurTous les éditeursTous les registresToute politique de confiancePreuve éditeurTout état éditeurVérification de SourceVersionTout état de vérificationCompatible avec cette version d’AgentstrationObservations à jour uniquementConflits de digest uniquement + La découverte des Sources a échouéRéessayerChargement des catalogues Registry…Aucune Source correspondanteActualisez un registre ou élargissez les filtres.{0} Source(s) portable(s) affichée(s) sur {1}.Aucune description publiée.{0} version(s)Conflit de digestLes registres ne déclarent pas le même digest pour cette version. L’import exact est refusé.Dernière dans ce shard + Preuves de confiance et de vérification indépendantesConfiance dans l’origine RegistryPreuve éditeurVérification du SnapshotNon évaluéeÉvaluée après l’actualisation du ChannelDigest du manifesteDigest du shardSélectionner cette version exacteAfficher plus + Les digests contradictoires interdisent l’import.Cette observation n’est pas à jour. Vérifiez son horodatage avant l’import.La politique Registry locale interdit l’import.La preuve éditeur ou SourceVersion est révoquée.Le registre ne fait que déclarer cet éditeur ; une preuve acceptée est requise.La SourceVersion n’est pas vérifiée indépendamment ; le digest Registry exact restera imposé. + Import Registry exactConfirmer l’import exact de la SourceLe serveur revalidera cette observation conservée avant de récupérer uniquement le manifeste sélectionné.FermerObservationShard de catalogueCette sélection épingle le Registry, l’observation, le shard, la version opaque et le digest attendu. Elle ne crée ni n’actualise aucun Channel.L’import exact a été refusé.Import en cours…Importer la sélection exacteSource importéeSource déjà à jour{0}, version {1} + Non approuvéApprouvéAutoritatif + DéclaréVérifiéÉditeur officielRévoqué + VérifiéeNon vérifiéePreuve indisponibleConflitRévoquée + Enregistrement officielOrigine détenue par AgentstrationOrigine interneOrigine externe + DésactivéJamais récupéréÀ jourObsolèteActualisation en échecInvalideAucun shard compatibleRefusé par la politiqueRétabli + RéussieNon modifiéIndisponibleInvalideAucun shard compatibleDésactivéRefusé par la politiqueManuellePlanifiée + diff --git a/src/Agentstration.Web/Resources/Components/Pages/SourceRegistriesStrings.resx b/src/Agentstration.Web/Resources/Components/Pages/SourceRegistriesStrings.resx new file mode 100644 index 00000000..d229df4c --- /dev/null +++ b/src/Agentstration.Web/Resources/Components/Pages/SourceRegistriesStrings.resx @@ -0,0 +1,26 @@ + + + text/microsoft-resx2.0System.Resources.ResXResourceReader, System.Windows.FormsSystem.Resources.ResXResourceWriter, System.Windows.Forms + Source registries · AgentstrationSource registriesConfigure trusted catalog endpoints, monitor refreshes, and retain last-known-good observations.Manage the endpoint, trust policy, credential reference, refresh policy, and retained observations. + Source distributionRegistriesDiscover SourcesImported SourcesRefreshAdd registryAll registries + Registry summaryConfigured endpointsFreshLast-known-good cacheNeeds attentionStale, failed, or deniedDistributionConfigured registriesThe official registration is distinct from endpoints trusted by local policy. + RegistryEndpoint typeFreshnessLast resultNext refreshEnabledManageRefresh nowWell-known official registrationOfficialPrivateExternalNot scheduledDue nowNeverYesNoChange enabled state for {0} + Loading Source registries…Loading Source registry…No Source registriesAdd a registry endpoint to discover portable Sources.The registry operation failed.Reload latest version + Add a Source registryIdentityRegistrationThe portable name is immutable after creation.This built-in registration identifies the well-known official Registry; local trust policy remains explicit.NameDisplay nameRegistry index URLLocal trust policy + ConnectionEndpoint and credentialSelect only an authorized instance Secret reference. Secret values are never read or displayed here.AuthenticationNoneStatic Bearer tokenCredential SecretSelect an instance SecretThe Secret value remains write-only.Allow HTTPAllow private-network addresses + AutomationRefresh policyPeriodic execution is delegated to the shared Source refresh scheduler.Periodic refreshInterval (minutes)Timeout (seconds)Maximum attemptsInitial backoff (seconds)Maximum backoff (seconds)Jitter (seconds)Stale after (minutes)Retained observationsCancelSaving…Create registrySave changes + TrustRegistry originOrigin trust is independent from publisher evidence and content verification.Evaluating origin trust…Origin classificationLocal policyReasonEvaluatedObserved stateLast successful refreshIndex digestCached shards + HistoryRefresh historyEach attempt records its trigger, outcome, retries, and retained observation.No refresh attemptsRefresh the registry to create its first observation.{0} retriesDelete registryDelete this Source registry?Delete “{0}”? Retained observations for this registration will no longer be selectable. + Registry createdRegistry updatedRegistry refreshed + Discover Sources · AgentstrationDiscover SourcesCompare every retained registry observation and import one exact Source version.Registry catalogsDiscovery filtersFilters are bounded and evaluated by the canonical discovery API.SearchSource or publisherSearch portable identities and descriptionsPublisherAll publishersAll registriesAny trust policyPublisher evidenceAny publisher statusSourceVersion verificationAny verification statusCompatible with this Agentstration versionFresh observations onlyDigest conflicts only + Source discovery failedRetryLoading Registry catalogs…No matching SourcesRefresh a Registry or broaden the current filters.Showing {0} of {1} matching portable Sources.No published description.{0} versionsDigest conflictRegistries disagree on the manifest digest for this version. Exact import is denied.Latest in this shard + Independent trust and verification evidenceRegistry origin trustPublisher evidenceSnapshot verificationNot evaluatedEvaluated after Channel refreshManifest digestShard digestSelect exact versionLoad more + Conflicting manifest digests deny import.This observation is not fresh. Review its timestamp before import.The local Registry policy denies import.Publisher or SourceVersion evidence is revoked.The Registry only declares this publisher; accepted evidence is required.The SourceVersion is not independently verified; the exact Registry digest will still be enforced. + Exact Registry importConfirm exact Source importThe server will revalidate this retained observation before fetching only the selected manifest.CloseObservationCatalog shardThis selection pins the Registry, observation, shard, opaque version, and expected manifest digest. It does not create or refresh Channels.Exact import was denied.Importing…Import exact selectionSource importedSource already current{0} version {1} + UntrustedTrustedAuthoritative + DeclaredVerifiedOfficial publisherRevoked + VerifiedUnverifiedEvidence unavailableConflictRevoked + Official registrationAgentstration-owned originInternal originExternal origin + DisabledNever fetchedFreshStaleRefresh failedInvalidNo compatible shardPolicy deniedRecovered + SucceededNot modifiedUnavailableInvalidNo compatible shardDisabledPolicy deniedManualScheduled + diff --git a/src/Agentstration.Web/Resources/Components/Pages/SourcesStrings.fr-FR.resx b/src/Agentstration.Web/Resources/Components/Pages/SourcesStrings.fr-FR.resx index 9fb62a6a..9dd70804 100644 --- a/src/Agentstration.Web/Resources/Components/Pages/SourcesStrings.fr-FR.resx +++ b/src/Agentstration.Web/Resources/Components/Pages/SourcesStrings.fr-FR.resx @@ -2,7 +2,7 @@ text/microsoft-resx2.0 Sources · AgentstrationSourcesImportez, configurez, vérifiez et parcourez les catalogues de contenu réutilisable. - Toutes les SourcesActualiser la SourceImporter une SourceAdministrateur de la plateforme requisSeuls les administrateurs de la plateforme peuvent gérer les Sources de contenu réutilisable.RéessayerChargement des Sources… + Toutes les SourcesActualiser la SourceImporter une SourceDécouvrir depuis les registresAdministrateur de la plateforme requisSeuls les administrateurs de la plateforme peuvent gérer les Sources de contenu réutilisable.RéessayerChargement des Sources… Nouvelle SourceImportez une version publiée immuable depuis un manifeste HTTP(S) ou du YAML collé.Méthode d’importDepuis une URLColler du YAMLURL du manifesteYAML de la version publiéeImporterImport en cours…Annuler Résumé des SourcesSourcesIdentités de catalogues réutilisablesVersionsHistorique immuableSainesDernier import réussi DistributionSources disponiblesL’identité et l’affichage locaux restent séparés du contenu publié.ActualiserRechercherNom, éditeur ou identitéAucune SourceImportez une version publiée pour créer le premier catalogue réutilisable.SourcePérimètre de propriétéOrigineDernier importSanté{0} version(s)OuvrirManifeste collé diff --git a/src/Agentstration.Web/Resources/Components/Pages/SourcesStrings.resx b/src/Agentstration.Web/Resources/Components/Pages/SourcesStrings.resx index 6f81a6e0..bbb3dcc7 100644 --- a/src/Agentstration.Web/Resources/Components/Pages/SourcesStrings.resx +++ b/src/Agentstration.Web/Resources/Components/Pages/SourcesStrings.resx @@ -2,7 +2,7 @@ text/microsoft-resx2.0 Sources · AgentstrationSourcesImport, configure, verify, and browse reusable content catalogs. - All SourcesRefresh SourceImport SourcePlatform administrator requiredOnly Platform administrators can manage reusable content Sources.RetryLoading Sources… + All SourcesRefresh SourceImport SourceDiscover from registriesPlatform administrator requiredOnly Platform administrators can manage reusable content Sources.RetryLoading Sources… New SourceImport an immutable published version from an HTTP(S) manifest or pasted YAML.Import methodFrom URLPaste YAMLManifest URLPublished Source Version YAMLImportImporting…Cancel Source summarySourcesReusable catalog identitiesVersionsImmutable historyHealthyLast import succeeded DistributionAvailable SourcesLocal identity and display settings remain separate from published content.RefreshSearchName, publisher, or identityNo SourcesImport a published Source Version to create the first reusable catalog.SourceOwnership scopeOriginLatest importHealth{0} version(s)OpenPasted manifest diff --git a/tests/Agentstration.Web.Tests/SourceRegistriesLocalizationTests.cs b/tests/Agentstration.Web.Tests/SourceRegistriesLocalizationTests.cs new file mode 100644 index 00000000..c851bce1 --- /dev/null +++ b/tests/Agentstration.Web.Tests/SourceRegistriesLocalizationTests.cs @@ -0,0 +1,41 @@ +using System.Globalization; +using System.Xml.Linq; +using Agentstration.Web.Components.Pages; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Agentstration.Web.Tests; + +[TestClass] +[DoNotParallelize] +public sealed class SourceRegistriesLocalizationTests +{ + [TestMethod] + public void FrenchCatalogExplainsIndependentRegistryEvidence() + { + using var culture = new TestCultureScope("fr-FR"); + var services = new ServiceCollection().AddLogging().AddLocalization(options => options.ResourcesPath = "Resources").BuildServiceProvider(); + var strings = services.GetRequiredService>(); + + Assert.AreEqual("Registres de Sources", strings["Title"].Value); + Assert.AreEqual("Confiance dans l’origine Registry", strings["RegistryOriginTrust"].Value); + Assert.AreEqual("Preuve éditeur", strings["PublisherEvidence"].Value); + Assert.AreEqual("Vérification de SourceVersion", strings["SourceVersionVerification"].Value); + Assert.AreEqual("Vérification du Snapshot", strings["SnapshotVerification"].Value); + Assert.AreEqual("La valeur du Secret reste en écriture seule.", strings["CredentialValueHidden"].Value); + } + + [TestMethod] + public void NeutralAndFrenchCatalogsHaveSymmetricKeys() + { + var root = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "src", "Agentstration.Web", "Resources", "Components", "Pages"); + var neutral = Keys(Path.Combine(root, "SourceRegistriesStrings.resx")); + var french = Keys(Path.Combine(root, "SourceRegistriesStrings.fr-FR.resx")); + CollectionAssert.AreEquivalent(neutral, french); + } + + private static string[] Keys(string path) + { + return XDocument.Load(path).Root!.Elements("data").Select(value => value.Attribute("name")!.Value).Order(StringComparer.Ordinal).ToArray(); + } +} diff --git a/tests/Agentstration.Web.Tests/SourceRegistryConsoleTests.cs b/tests/Agentstration.Web.Tests/SourceRegistryConsoleTests.cs new file mode 100644 index 00000000..a629e2a1 --- /dev/null +++ b/tests/Agentstration.Web.Tests/SourceRegistryConsoleTests.cs @@ -0,0 +1,250 @@ +using System.Net; +using System.Net.Http.Json; +using Agentstration.Management.Abstractions; +using Agentstration.Management.Contracts; +using Agentstration.Resources; +using Agentstration.Web.Components.Pages; +using Agentstration.Web.Components.State; +using Agentstration.Web.Console; +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; + +namespace Agentstration.Web.Tests; + +[TestClass] +[DoNotParallelize] +public sealed class SourceRegistryConsoleTests +{ + [TestMethod] + public void RegistryListDistinguishesOfficialRegistrationFromLocalTrust() + { + using var culture = new TestCultureScope("en-US"); + using var context = Context(new StubSourceRegistriesClient(registries: + [ + Registry(SourceRegistryWellKnown.OfficialName, "Agentstration Registry", SourceRegistryTrustPolicy.Authoritative, SourceRegistryObservedStatus.Fresh), + Registry("private-team", "Private team", SourceRegistryTrustPolicy.Trusted, SourceRegistryObservedStatus.RefreshFailed, privateEndpoint: true) + ])); + + var rendered = context.Render(); + + rendered.WaitForAssertion(() => + { + StringAssert.Contains(rendered.Markup, "Well-known official registration"); + StringAssert.Contains(rendered.Markup, "Authoritative"); + StringAssert.Contains(rendered.Markup, "Private"); + StringAssert.Contains(rendered.Markup, "Refresh failed"); + Assert.HasCount(2, rendered.FindAll("button[role='switch']")); + }); + } + + [TestMethod] + public void DiscoveryExplainsIndependentEvidenceAndPinsExactSelection() + { + using var culture = new TestCultureScope("en-US"); + var observation = Observation(); + var discovered = new SourceRegistryDiscoverySource("contoso", "assistants", "Contoso assistants", "Reusable assistants", + [ + new("2026.09", false, SourceVerificationStatus.Verified, "source_version_verified", [observation]), + new("2026.08", true, SourceVerificationStatus.Conflict, "source_registry_manifest_digest_conflict", + [observation with { Selection = observation.Selection with { Version = "2026.08" }, ManifestDigest = "sha256:other" }]) + ]); + using var context = Context(new StubSourceRegistriesClient( + registries: [Registry("contoso", "Contoso Registry", SourceRegistryTrustPolicy.Trusted, SourceRegistryObservedStatus.Fresh)], + page: new([discovered], 1, 1, 0, 25))); + context.Services.AddSingleton(new StubScopeInventoryClient()); + + var rendered = context.Render(); + + rendered.WaitForAssertion(() => + { + StringAssert.Contains(rendered.Markup, "Registry origin trust"); + StringAssert.Contains(rendered.Markup, "Publisher evidence"); + StringAssert.Contains(rendered.Markup, "SourceVersion verification"); + StringAssert.Contains(rendered.Markup, "Snapshot verification"); + StringAssert.Contains(rendered.Markup, "Registries disagree on the manifest digest"); + Assert.HasCount(1, rendered.FindAll("button").Where(button => button.TextContent == "Select exact version" && button.HasAttribute("disabled"))); + }); + rendered.FindAll("button").Single(button => button.TextContent == "Select exact version" && !button.HasAttribute("disabled")).Click(); + rendered.WaitForAssertion(() => + { + StringAssert.Contains(rendered.Markup, "Confirm exact Source import"); + StringAssert.Contains(rendered.Markup, observation.Selection.ObservationId.ToString()); + StringAssert.Contains(rendered.Markup, observation.ManifestDigest); + StringAssert.Contains(rendered.Markup, observation.Selection.CatalogName); + }); + } + + [TestMethod] + public async Task ApiClientSendsIfMatchForRegistryUpdates() + { + var view = Registry("contoso", "Contoso Registry", SourceRegistryTrustPolicy.Trusted, SourceRegistryObservedStatus.Fresh); + var handler = new RecordingHandler(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(view), + Headers = { ETag = new System.Net.Http.Headers.EntityTagHeaderValue("\"next\"") } + }); + var client = new SourceRegistriesApiClient(new HttpClient(handler) { BaseAddress = new("https://console.test/") }); + + _ = await client.UpdateRegistryAsync("contoso", new(view.Registration.Definition), "\"current\"", CancellationToken.None); + + Assert.AreEqual("\"current\"", handler.IfMatch); + Assert.AreEqual(HttpMethod.Put, handler.Method); + } + + [TestMethod] + public void RegistryPagesRequirePlatformAdministratorPolicy() + { + foreach (var component in new[] { typeof(SourceRegistries), typeof(SourceRegistryDiscovery) }) + { + var authorization = component.GetCustomAttributes(typeof(AuthorizeAttribute), true).Cast().Single(); + Assert.AreEqual("agentstration:platform-admin", authorization.Policy); + } + } + + [TestMethod] + public void NewRegistryRouteRendersTheEditorInsteadOfTreatingNewAsARegistryName() + { + using var culture = new TestCultureScope("en-US"); + using var context = Context(new StubSourceRegistriesClient(), new StubSecretsClient([])); + context.Services.GetRequiredService().NavigateTo("/settings/source-registries/new"); + + var cut = context.Render(parameters => parameters.Add(component => component.Name, "new")); + + cut.WaitForAssertion(() => Assert.AreEqual("Add a Source registry", cut.Find("h1").TextContent)); + } + + [TestMethod] + public void PrivateRegistryEditorOffersOnlyInstanceSecretReferences() + { + using var culture = new TestCultureScope("en-US"); + using var context = Context(new StubSourceRegistriesClient(), new StubSecretsClient( + [ + Secret("instance-token", "Instance token", ResourceScopeRef.Instance), + Secret("workspace-token", "Workspace token", ResourceScopeRef.Workspace(Guid.Parse("33333333-3333-3333-3333-333333333333"))) + ])); + context.Services.GetRequiredService().NavigateTo("/settings/source-registries/new"); + + var rendered = context.Render(); + rendered.WaitForAssertion(() => Assert.AreEqual("Add a Source registry", rendered.Find("h1").TextContent)); + rendered.FindAll("select")[1].Change(SourceRegistryAuthenticationMode.StaticBearer.ToString()); + + rendered.WaitForAssertion(() => + { + StringAssert.Contains(rendered.Markup, "Instance token"); + Assert.IsFalse(rendered.Markup.Contains("Workspace token", StringComparison.Ordinal)); + StringAssert.Contains(rendered.Markup, "The Secret value remains write-only."); + }); + } + + private static BunitContext Context(ISourceRegistriesClient client, ISecretsClient? secrets = null) + { + var context = new BunitContext(); + context.Services.AddLocalization(options => options.ResourcesPath = "Resources"); + context.Services.AddSingleton(client); + context.Services.AddSingleton(secrets ?? new StubSecretsClient()); + context.Services.AddSingleton(new NotificationState()); + context.Services.AddSingleton(TimeProvider.System); + return context; + } + + private static SourceRegistryRegistrationView Registry(string name, string displayName, SourceRegistryTrustPolicy trust, SourceRegistryObservedStatus status, bool privateEndpoint = false) + { + var uid = Guid.NewGuid(); + var registration = new SourceRegistryRegistrationResource + { + Uid = uid, ApiVersion = ManagementApiVersions.CoreV1, Kind = ResourceKinds.SourceRegistryRegistration, + Metadata = new() { Name = name }, ScopeRef = ResourceScopeRef.Instance, ETag = "\"etag\"", + Definition = new() + { + DisplayName = displayName, + IndexUrl = new(privateEndpoint ? "https://registry.internal/v1/index.json" : "https://registry.agentstration.io/v1/index.json"), + Enabled = true, + TrustPolicy = trust, + AuthenticationMode = privateEndpoint ? SourceRegistryAuthenticationMode.StaticBearer : SourceRegistryAuthenticationMode.None, + Credential = privateEndpoint ? new("registry-token", ResourceScopeRef.Instance, ResourceNamespace.Default) : null, + EndpointPolicy = new() { AllowPrivateNetwork = privateEndpoint }, + RefreshPolicy = new() { PeriodicEnabled = true } + } + }; + var observed = new SourceRegistryObservedStateResource + { + ApiVersion = ManagementApiVersions.CoreV1, Kind = ResourceKinds.SourceRegistryObservedState, + Metadata = new() { Name = name }, ScopeRef = ResourceScopeRef.Instance, + Definition = new() { RegistrationUid = uid, Status = status, LastAttemptedAt = ObservedAt, LastSuccessfulRefreshAt = status == SourceRegistryObservedStatus.Fresh ? ObservedAt : null, LastOutcome = status == SourceRegistryObservedStatus.Fresh ? SourceRegistryRefreshOutcome.Succeeded : SourceRegistryRefreshOutcome.Unavailable, LastErrorMessage = status == SourceRegistryObservedStatus.RefreshFailed ? "Connection failed" : null } + }; + return new(registration, observed); + } + + private static SourceRegistryDiscoveryObservation Observation() => new() + { + Selection = new(Guid.Parse("11111111-1111-1111-1111-111111111111"), Guid.Parse("22222222-2222-2222-2222-222222222222"), "agentstration-0.2", "contoso", "assistants", "2026.09"), + RegistrationName = "contoso", RegistrationDisplayName = "Contoso Registry", + TrustPolicy = SourceRegistryTrustPolicy.Trusted, Freshness = SourceRegistryObservedStatus.Fresh, + OriginClassification = SourceRegistryOriginClassification.External, + IndexDigest = "sha256:index", CatalogDigest = "sha256:catalog", Compatibility = new(), + FetchedAt = ObservedAt, + Publisher = new() { Name = "contoso", DisplayName = "Contoso", Status = SourceRegistryPublisherStatuses.Verified }, + ManifestUrl = "sources/contoso-assistants-2026.09.yaml", ManifestDigest = "sha256:manifest", IsCatalogLatest = true + }; + + private static readonly DateTimeOffset ObservedAt = new(2026, 9, 10, 10, 0, 0, TimeSpan.Zero); + + private static SecretResponse Secret(string name, string displayName, ResourceScopeRef scopeRef) => new(new SecretResource + { + ApiVersion = ManagementApiVersions.CoreV1, Kind = ResourceKinds.Secret, Metadata = new() { Name = name }, ScopeRef = scopeRef, + Definition = new() { DisplayName = displayName, Vault = new("local", scopeRef), Key = name } + }, "Configured", true); + + private sealed class StubSourceRegistriesClient(IReadOnlyList? registries = null, SourceRegistryDiscoveryPage? page = null) : ISourceRegistriesClient + { + public Task> GetRegistriesAsync(CancellationToken cancellationToken) => Task.FromResult(registries ?? []); + public Task SearchAsync(SourceRegistryDiscoveryQuery query, CancellationToken cancellationToken) => Task.FromResult(page ?? new([], 0, 0, query.Skip, query.Take)); + public Task> GetPublishersAsync(CancellationToken cancellationToken) => Task.FromResult>([]); + public Task> GetRegistryAsync(string name, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> CreateRegistryAsync(CreateSourceRegistryRequest request, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> UpdateRegistryAsync(string name, PutSourceRegistryRequest request, string etag, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task DeleteRegistryAsync(string name, string etag, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> RefreshRegistryAsync(string name, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> GetRefreshesAsync(string name, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task GetOriginTrustAsync(string name, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task GetSourceAsync(string publisher, string sourceName, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task ImportAsync(SourceRegistryObservationSelection selection, ResourceScopeRef? scopeRef, CancellationToken cancellationToken) => throw new NotSupportedException(); + } + + private sealed class StubScopeInventoryClient : IResourceScopeInventoryClient + { + public Task GetAsync(CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> GetTargetsAsync(string kind, CancellationToken cancellationToken) => Task.FromResult>([new(ResourceScopeRef.Instance, ResourceScopeKind.Instance, "Instance", true)]); + } + + private sealed class StubSecretsClient(IReadOnlyList? secrets = null) : ISecretsClient + { + public Task> GetSecretsAsync(CancellationToken cancellationToken) => Task.FromResult(secrets ?? []); + public Task> GetVaultsAsync(CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> GetVaultAsync(string name, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> CreateVaultAsync(CreateVaultRequest request, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> UpdateVaultAsync(string name, PutVaultRequest request, string etag, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task DeleteVaultAsync(string name, string etag, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task InitializeVaultAsync(string name, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> GetSecretAsync(string name, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> CreateSecretAsync(CreateSecretRequest request, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> UpdateSecretAsync(string name, PutSecretRequest request, string etag, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task SetSecretValueAsync(string name, string value, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task DeleteSecretValueAsync(string name, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task DeleteSecretAsync(string name, string etag, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task GetSecretUsagesAsync(string name, CancellationToken cancellationToken) => throw new NotSupportedException(); + } + + private sealed class RecordingHandler(HttpResponseMessage response) : HttpMessageHandler + { + public string? IfMatch { get; private set; } + public HttpMethod? Method { get; private set; } + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + IfMatch = request.Headers.IfMatch.SingleOrDefault()?.ToString(); Method = request.Method; + return Task.FromResult(response); + } + } +} From fbcd54a4ef5d34d8944276c2b3fe9300b24e59b7 Mon Sep 17 00:00:00 2001 From: gbaudrit Date: Fri, 11 Sep 2026 01:43:55 +0200 Subject: [PATCH 2/2] chore(tests): normalize registry console fixtures --- .../SourceRegistryConsoleTests.cs | 45 ++++++++++++++----- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/tests/Agentstration.Web.Tests/SourceRegistryConsoleTests.cs b/tests/Agentstration.Web.Tests/SourceRegistryConsoleTests.cs index a629e2a1..6fa21c76 100644 --- a/tests/Agentstration.Web.Tests/SourceRegistryConsoleTests.cs +++ b/tests/Agentstration.Web.Tests/SourceRegistryConsoleTests.cs @@ -7,8 +7,8 @@ using Agentstration.Web.Components.State; using Agentstration.Web.Console; using Bunit; -using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; namespace Agentstration.Web.Tests; @@ -154,8 +154,12 @@ private static SourceRegistryRegistrationView Registry(string name, string displ var uid = Guid.NewGuid(); var registration = new SourceRegistryRegistrationResource { - Uid = uid, ApiVersion = ManagementApiVersions.CoreV1, Kind = ResourceKinds.SourceRegistryRegistration, - Metadata = new() { Name = name }, ScopeRef = ResourceScopeRef.Instance, ETag = "\"etag\"", + Uid = uid, + ApiVersion = ManagementApiVersions.CoreV1, + Kind = ResourceKinds.SourceRegistryRegistration, + Metadata = new() { Name = name }, + ScopeRef = ResourceScopeRef.Instance, + ETag = "\"etag\"", Definition = new() { DisplayName = displayName, @@ -170,9 +174,19 @@ private static SourceRegistryRegistrationView Registry(string name, string displ }; var observed = new SourceRegistryObservedStateResource { - ApiVersion = ManagementApiVersions.CoreV1, Kind = ResourceKinds.SourceRegistryObservedState, - Metadata = new() { Name = name }, ScopeRef = ResourceScopeRef.Instance, - Definition = new() { RegistrationUid = uid, Status = status, LastAttemptedAt = ObservedAt, LastSuccessfulRefreshAt = status == SourceRegistryObservedStatus.Fresh ? ObservedAt : null, LastOutcome = status == SourceRegistryObservedStatus.Fresh ? SourceRegistryRefreshOutcome.Succeeded : SourceRegistryRefreshOutcome.Unavailable, LastErrorMessage = status == SourceRegistryObservedStatus.RefreshFailed ? "Connection failed" : null } + ApiVersion = ManagementApiVersions.CoreV1, + Kind = ResourceKinds.SourceRegistryObservedState, + Metadata = new() { Name = name }, + ScopeRef = ResourceScopeRef.Instance, + Definition = new() + { + RegistrationUid = uid, + Status = status, + LastAttemptedAt = ObservedAt, + LastSuccessfulRefreshAt = status == SourceRegistryObservedStatus.Fresh ? ObservedAt : null, + LastOutcome = status == SourceRegistryObservedStatus.Fresh ? SourceRegistryRefreshOutcome.Succeeded : SourceRegistryRefreshOutcome.Unavailable, + LastErrorMessage = status == SourceRegistryObservedStatus.RefreshFailed ? "Connection failed" : null + } }; return new(registration, observed); } @@ -180,20 +194,29 @@ private static SourceRegistryRegistrationView Registry(string name, string displ private static SourceRegistryDiscoveryObservation Observation() => new() { Selection = new(Guid.Parse("11111111-1111-1111-1111-111111111111"), Guid.Parse("22222222-2222-2222-2222-222222222222"), "agentstration-0.2", "contoso", "assistants", "2026.09"), - RegistrationName = "contoso", RegistrationDisplayName = "Contoso Registry", - TrustPolicy = SourceRegistryTrustPolicy.Trusted, Freshness = SourceRegistryObservedStatus.Fresh, + RegistrationName = "contoso", + RegistrationDisplayName = "Contoso Registry", + TrustPolicy = SourceRegistryTrustPolicy.Trusted, + Freshness = SourceRegistryObservedStatus.Fresh, OriginClassification = SourceRegistryOriginClassification.External, - IndexDigest = "sha256:index", CatalogDigest = "sha256:catalog", Compatibility = new(), + IndexDigest = "sha256:index", + CatalogDigest = "sha256:catalog", + Compatibility = new(), FetchedAt = ObservedAt, Publisher = new() { Name = "contoso", DisplayName = "Contoso", Status = SourceRegistryPublisherStatuses.Verified }, - ManifestUrl = "sources/contoso-assistants-2026.09.yaml", ManifestDigest = "sha256:manifest", IsCatalogLatest = true + ManifestUrl = "sources/contoso-assistants-2026.09.yaml", + ManifestDigest = "sha256:manifest", + IsCatalogLatest = true }; private static readonly DateTimeOffset ObservedAt = new(2026, 9, 10, 10, 0, 0, TimeSpan.Zero); private static SecretResponse Secret(string name, string displayName, ResourceScopeRef scopeRef) => new(new SecretResource { - ApiVersion = ManagementApiVersions.CoreV1, Kind = ResourceKinds.Secret, Metadata = new() { Name = name }, ScopeRef = scopeRef, + ApiVersion = ManagementApiVersions.CoreV1, + Kind = ResourceKinds.Secret, + Metadata = new() { Name = name }, + ScopeRef = scopeRef, Definition = new() { DisplayName = displayName, Vault = new("local", scopeRef), Key = name } }, "Configured", true);