Repository navigation
feat(plugincatalog): pull plugin bundles from the fleet's OCI registry by digest - #3726
Conversation
…y by digest A shared OciRegistryClient (bearer handshake factored out of OciTagLister, GetManifest/GetBlob with digest verification, ListTags), PluginBundleClient fetching a bundle's .zip layer by digest when the index names an `artifact` and taking the HTTP route otherwise, the `artifact` field on both registry indexes rendered from the IPublicationArtifacts seam (default: none), tests against fake registries, and the PluginRegistry doc section. Refs #3720, #3353. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
There are repo-rule violations in the new product code (await/async usage under src/) and forbidden static mutable state in a new test, both of which need to be addressed before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR adds the “consumer half” of pulling plugin bundles from the fleet OCI registry by digest, including manifest/blob verification, while preserving the existing HTTP bundle route as a fallback when no artifact reference is advertised. It also introduces a registry-side seam (IPublicationArtifacts) so the bundle index and catalog can advertise an artifact reference when a publication was pushed.
Changes:
- Introduces
MeshWeaver.PluginCatalog.OciRegistryClientand refactorsOciTagListerto reuse it. - Extends
PluginBundleClientto fetch bundle bytes from OCI artifacts (by digest, verified) whenartifactis present; otherwise uses the existing HTTP endpoint. - Stamps
artifactinto/api/pluginsand/api/plugins/bundles/index.jsonusingIPublicationArtifacts, with tests + docs/What’s New updates.
File summaries
| File | Description |
|---|---|
src/MeshWeaver.PluginCatalog/OciRegistryClient.cs |
New OCI Distribution pull client (handshake, manifest/blob fetch, tags) with digest verification. |
memex/Memex.Portal.Shared/SelfUpdate/OciTagLister.cs |
Refactors tag listing to delegate to OciRegistryClient. |
src/MeshWeaver.PluginCatalog/PluginBundleClient.cs |
Adds BundleRef.Artifact and OCI artifact download path (no HTTP fallback on digest mismatch). |
src/MeshWeaver.PluginCatalog/BundleAdoption.cs |
Adds BundleAdoptionKind.ArtifactRefused for digest mismatches. |
memex/Memex.Portal.Shared/Api/PluginBundleEndpoints.cs |
Stamps artifact into bundle index entries from IPublicationArtifacts. |
memex/Memex.Portal.Shared/Api/PluginRegistryEndpoints.cs |
Stamps artifact into /api/plugins package entries from IPublicationArtifacts. |
src/MeshWeaver.PluginCatalog/PublicationArtifacts.cs |
Adds IPublicationArtifacts seam, default implementation, and lookup helper. |
src/MeshWeaver.PluginCatalog/PluginCatalogConfigurationExtensions.cs |
Registers the default NoPublicationArtifacts via TryAddSingleton. |
src/MeshWeaver.PluginCatalog/Package.cs |
Adds nullable PackageManifest.Artifact. |
test/Memex.Portal.Shared.Test/FakeOciRegistry.cs |
Test fake implementing the OCI pull protocol surface. |
test/Memex.Portal.Shared.Test/OciRegistryClientTest.cs |
Tests handshake, digest verification, paging, and parsing. |
test/Memex.Portal.Shared.Test/PluginBundleArtifactFetchTest.cs |
Tests artifact-based bundle adoption vs HTTP fallback and refusal behavior. |
test/Memex.Portal.Shared.Test/PluginBundleIndexArtifactTest.cs |
Tests index artifact stamping and lookup matching rules. |
src/MeshWeaver.Documentation/Data/Architecture/PluginRegistry.md |
Documents the dual-route bundle fetch behavior and artifact semantics. |
src/MeshWeaver.Documentation/Data/Architecture/PluginBundlesInTheRegistry.md |
Adds the design/contract page for OCI-backed plugin bundles. |
src/MeshWeaver.Documentation/Data/Architecture/ContainerRegistryInMemex.md |
Links to the new “Plugin Bundles in the Registry” page. |
src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-08-plugin-bundles-can-be-pulled-from-the-fleet-registry.md |
Adds a What’s New entry for the feature. |
Review details
Suppressed comments (17)
src/MeshWeaver.PluginCatalog/OciRegistryClient.cs:206
await/async Task usage insrc/is a known deadlock/identity-loss hazard in this codebase (hub/grain/circuit schedulers can be parked; AccessContext can be lost across continuations). Please rework this I/O leaf to avoidawait(e.g., move the work into anIIoPool.InvokeBlockingleaf using synchronousHttpClient.Send/ synchronous stream reads) while keeping the public surface reactive.
next = await ReadTagPageAsync(response, repository, tags, ct).ConfigureAwait(false);
src/MeshWeaver.PluginCatalog/OciRegistryClient.cs:219
await/async Task usage insrc/is a known deadlock/identity-loss hazard in this codebase (hub/grain/circuit schedulers can be parked; AccessContext can be lost across continuations). Please rework this I/O leaf to avoidawait(e.g., move the work into anIIoPool.InvokeBlockingleaf using synchronousHttpClient.Send/ synchronous stream reads) while keeping the public surface reactive.
using var response = await SendAuthenticatedAsync(uri, repository, key, request =>
src/MeshWeaver.PluginCatalog/OciRegistryClient.cs:235
await/async Task usage insrc/is a known deadlock/identity-loss hazard in this codebase. Consider replacing this async read with a blocking I/O leaf insideIIoPool.InvokeBlocking(or another established non-async pattern used in the repo) to keep async continuations off hub/circuit schedulers.
var bytes = await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
src/MeshWeaver.PluginCatalog/OciRegistryClient.cs:259
await/async Task usage insrc/is a known deadlock/identity-loss hazard in this codebase. Please rework this call path to avoidawait(preferIIoPool.InvokeBlocking+ sync HTTP APIs) so hub/grain schedulers are never parked and AccessContext is not lost.
using var response = await SendAuthenticatedAsync(uri, repository, key, null, ct).ConfigureAwait(false);
src/MeshWeaver.PluginCatalog/OciRegistryClient.cs:270
await/async Task usage insrc/is a known deadlock/identity-loss hazard. Thisawait usingintroduces async disposal in product code; prefer a non-async streaming implementation under anIIoPool.InvokeBlockingleaf (sync reads/writes + sync dispose) if possible.
await using var source = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
src/MeshWeaver.PluginCatalog/OciRegistryClient.cs:278
await/async Task usage insrc/is a known deadlock/identity-loss hazard. This async read loop should be expressed withoutawaitin product code (e.g., sync stream read insideIIoPool.InvokeBlocking) to avoid scheduler parking and context loss.
while ((read = await source.ReadAsync(buffer, ct).ConfigureAwait(false)) > 0)
src/MeshWeaver.PluginCatalog/OciRegistryClient.cs:281
await/async Task usage insrc/is a known deadlock/identity-loss hazard. Consider replacing async writes with synchronous writes inside anIIoPool.InvokeBlockingleaf to keep continuations out of hub/circuit schedulers.
await destination.WriteAsync(buffer.AsMemory(0, read), ct).ConfigureAwait(false);
src/MeshWeaver.PluginCatalog/OciRegistryClient.cs:284
await/async Task usage insrc/is a known deadlock/identity-loss hazard. Prefer a non-async flush/disposal path insideIIoPool.InvokeBlocking(sync IO) rather thanawaiting here.
await destination.FlushAsync(ct).ConfigureAwait(false);
src/MeshWeaver.PluginCatalog/OciRegistryClient.cs:288
await/async Task usage insrc/is a known deadlock/identity-loss hazard. Async disposal here should be avoided in product code per repo guidance; consider switching the transfer leaf to a blocking implementation managed byIIoPool.InvokeBlocking.
await destination.DisposeAsync().ConfigureAwait(false);
src/MeshWeaver.PluginCatalog/OciRegistryClient.cs:315
await/async Task usage insrc/is a known deadlock/identity-loss hazard in this codebase. Please rework this request path to avoid awaiting in product code (e.g., sync HTTP in anIIoPool.InvokeBlockingleaf) so hub/grain/circuit schedulers are never parked and AccessContext isn't lost across continuations.
var response = await SendAsync(uri, bearer, configure, ct).ConfigureAwait(false);
src/MeshWeaver.PluginCatalog/OciRegistryClient.cs:326
await/async Task usage insrc/is a known deadlock/identity-loss hazard. The bearer exchange call path should avoidawaitin product code; prefer a blocking leaf onIIoPool.InvokeBlocking(sync HTTP) or an established non-async pattern already used in this repo.
minted = await ExchangeAsync(response, repository, key, ct).ConfigureAwait(false);
src/MeshWeaver.PluginCatalog/OciRegistryClient.cs:329
await/async Task usage insrc/is a known deadlock/identity-loss hazard. This retry send should be implemented withoutawaitin product code (move into a blocking pool leaf) to avoid scheduler parking/context loss.
var retried = await SendAsync(uri, minted, configure, ct).ConfigureAwait(false);
src/MeshWeaver.PluginCatalog/OciRegistryClient.cs:347
await/async Task usage insrc/is a known deadlock/identity-loss hazard. Consider replacing this asyncHttpClient.SendAsynccall with synchronousHttpClient.Sendinside anIIoPool.InvokeBlockingleaf to avoid async continuations in product code.
return await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
src/MeshWeaver.PluginCatalog/OciRegistryClient.cs:389
await/async Task usage insrc/is a known deadlock/identity-loss hazard. The token exchange HTTP call should avoidawaitin product code; prefer sync HTTP in anIIoPool.InvokeBlockingleaf (token responses are small).
using var response = await http.SendAsync(request, ct).ConfigureAwait(false);
src/MeshWeaver.PluginCatalog/OciRegistryClient.cs:400
await/async Task usage insrc/is a known deadlock/identity-loss hazard. Please avoid awaiting the token response body in product code; instead read synchronously within anIIoPool.InvokeBlockingleaf or another established non-async pattern.
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false));
src/MeshWeaver.PluginCatalog/OciRegistryClient.cs:424
await/async Task usage insrc/is a known deadlock/identity-loss hazard. This async stream read should be expressed withoutawaitin product code (sync read inside anIIoPool.InvokeBlockingleaf), keeping the surface reactive.
await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
src/MeshWeaver.PluginCatalog/OciRegistryClient.cs:425
await/async Task usage insrc/is a known deadlock/identity-loss hazard. Please avoidawaiting JSON parsing in product code; parse synchronously within a blocking pool leaf to keep continuations off hub/circuit schedulers.
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct).ConfigureAwait(false);
- Files reviewed: 17/17 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| throw new InvalidOperationException( | ||
| $"{registry} named {next.AbsoluteUri} as the next tags page twice — the listing loops."); | ||
|
|
||
| using var response = await SendAuthenticatedAsync(next, repository, key, null, ct).ConfigureAwait(false); |
| // One packed bundle, shared by both fakes: the OCI registry serves it as the artifact's layer, | ||
| // the plugin registry serves it on the HTTP route. Immutable bytes, never written after | ||
| // construction — the same shape as a media-type table. | ||
| private static readonly byte[] BundleBytes = ModuleBundle(); | ||
|
|
||
| private readonly FakeOciRegistry oci = new(OciHost, Token, $"plugins/Plugins/{Plugin}", BundleBytes); | ||
| private readonly FakePluginRegistry registry = new(BundleBytes); |
Pull request was converted to draft
|
ℹ️ Merge-queue steward: no action — removed from the queue with reason |
…age: keep the artifact/OciRegistryClient row and main's DockerConfigJson row
Implements the consumer half of Plugin Bundles in the Registry (#3720, refs #3353): a plugin bundle can now be pulled from the fleet's OCI registry by digest, verified, with the instance credential the installation already holds.
What changed
A shared OCI client —
MeshWeaver.PluginCatalog.OciRegistryClient/v2/…→401challenge → realm withBasic instance:<token>→ bearer, cached per pull scope for the client's life) factored OUT ofOciTagLister; the lister now resolves its credential and delegates toOciRegistryClient.ListTags. Byte-identical wire behaviour, its four tests unchanged and green.GetManifest(repo, reference)→OciManifest(bytes, media type, verified digest,Config/Layers/Manifestsdescriptors). A manifest asked for by digest must hash to it; one asked for by tag must hash to theDocker-Content-Digestthe registry states.GetBlob(repo, digest, openDestination)→OciBlobReceipt, streamed through theBlobI/O pool with an incremental SHA-256; a mismatch faults withOciDigestMismatchExceptionand the caller discards the destination.IObservable<T>surfaces, one async IO leaf per operation insideIIoPool.Invoke(Http pool for handshake/manifests/tags, Blob pool for blobs). Supporting types:OciDescriptor,OciReference(<registry>/<repo>@sha256:…/:tagparser),OciDigest.PluginBundleClientlearns the artifact pathBundleRef.Artifact(init property, additive). With one,Downloadfetches the manifest by digest and theapplication/vnd.meshweaver.bundle.v1.ziplayer by digest from the OCI registry, presenting the plugin-registry token it already holds; without one it takesGET /api/plugins/bundles/{plugin}/{version}exactly as before. The landed bytes enter the SAME landing path (LandFromBundle→ModuleLandingService, the link probe, the seeder's MVID rule) — the landing contract is untouched.BundleAdoptionKind.ArtifactRefused(new enum member). A transport failure is the ordinaryFetchFailedmiss.Registry side — the
artifactfield, from a seamIPublicationArtifacts(MeshWeaver.PluginCatalog) withRead()carrying a DEFAULT implementation that answers none;NoPublicationArtifactsregistered byAddPluginCatalogviaTryAddSingleton;PublicationArtifact(Source, Package, Version, Reference)andPublicationArtifactLookup.ReferenceFor(the one match rule)./api/plugins/bundles/index.json: each bundle carriesartifact—cr.meshweaver.cloud/plugins/<source>/<package>@sha256:<manifest digest>ornull./api/plugins:PackageManifest.Artifact(additive, nullable) stamped besidesource;{ packages: [...] }andstorageTypeunchanged, so the setup wizard'sSetupRegistryClientkeeps working (itsSetupIdentityPhaseTestfake is the contract).Docs:
PluginRegistry.mdgains "Bundle bytes from the registry"; the design page's consumer table namesOciRegistryClient; What's NewCategory: Feature.Tests (
test/Memex.Portal.Shared.Test, where the bundle-client tests already live — there is noMeshWeaver.PluginCatalog.Testproject)OciRegistryClientTest— handshake withBasic instance:keyand ONE exchange serving manifest + blob; manifest by tag verified against the stated digest; tampered blob REFUSED; tampered manifest REFUSED; refused key faults naming the host; every tag page followed; reference parsing.PluginBundleArtifactFetchTest— an advertised artifact is fetched from the OCI fake and lands (HTTP bundle route never asked); a tampered artifact lands nothing, no file, no sidecar entry, no HTTP fallback, ledgerArtifactRefused;artifact: nulltakes the HTTP route as today.PluginBundleIndexArtifactTest— the index rendersartifactfor a pushed bundle andnullfor the rest (fakeIPublicationArtifactson request services); with the platform default everyartifactisnull; the lookup rule.OciTagListerTestafter the refactor: 5/5 green. Fake registries substitute the NETWORK only; hub, pools, landing service, registration and install path are real.Verification
dotnet build -c Release -warnaserror, one project per invocation,0 Error(s):MeshWeaver.PluginCatalog,Memex.Portal.Shared,Memex.Portal.Shared.Test,MeshWeaver.Documentation.Test,MeshWeaver.Compiler.Pipeline.Test,MeshWeaver.ComboAssembler,MeshWeaver.ComboVerifier,MeshWeaver.PluginTester.dotnet test(Release, fresh.trx): 25/25 across the four classes above;DocumentationLinkIntegrityTest1/1.Pairs-with: none — additions only (a new class, a new interface with a defaulted member, an init property on a public record, a nullable property on a public record, a new enum member; no public surface removed).
Implementers: IPublicationArtifacts.Read — a NEW interface whose only member carries a default implementation;
NoPublicationArtifactsis the platform's implementer, and a satellite that implements it later overridesRead(). No existing implementer anywhere (the interface did not exist).Mirror-sync: none — no i18n catalog change.
Refs #3720, #3353.
🤖 Generated with Claude Code