Skip to content

Commit 4eb9949

Browse files
OCI registry support for Bicep extension publishing (#18956)
# OCI Registry Support for Bicep Extension Publishing ## Description Adds support for publishing and restoring Bicep modules and extensions to/from non-Azure OCI-compliant container registries (GHCR, Docker Hub, etc.). Previously, `publish`, `restore`, and `publish-extension` only worked with Azure Container Registry (ACR) because the transport was hard-coded to the Azure SDK's `ContainerRegistryContentClient`. This change introduces an alternative transport built on [ORAS (OCI Registry As Storage)](https://oras.land/), so Bicep artifacts can be stored in any compliant registry. Related to [#4884](#4884). --- ## User Experience ### Feature flag The feature is gated behind an experimental flag called `ociEnabled`. Either of the following enables it: 1. CLI flag — pass `--oci-enabled` before the subcommand: ```bash bicep --oci-enabled publish myModule.bicep \ --target 'br:ghcr.io/myorg/bicep/modules/my-module:v1.0' ``` 2. `bicepconfig.json`: ```json { "experimentalFeaturesEnabled": { "ociEnabled": true } } ``` ### Authentication When the flag is enabled and the target host is not an Azure-managed registry, Bicep authenticates using Docker credentials: 1. Docker credential helpers — reads `~/.docker/config.json` and invokes the configured `credsStore` or per-registry `credHelpers` (e.g. `docker-credential-desktop`, `docker-credential-ecr-login`). 2. Static `auth` entries — falls back to base64-encoded `username:password` entries in the `auths` section of `~/.docker/config.json`. ACR (`*.azurecr.io`, `*.azurecr.cn`, `*.azurecr.us`, `*.azurecr.de`, `*.azurecr.gov`) and `mcr.microsoft.com` continue to use the existing Azure SDK auth path regardless of the flag. ### Example: publish a module to GHCR ```bash # Log in to GHCR (one-time, stores creds in Docker config) echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin # Publish bicep --oci-enabled publish ./main.bicep \ --target 'br:ghcr.io/myorg/bicep/modules/network:v1.0' # Reference in another file: # module vnet 'br:ghcr.io/myorg/bicep/modules/network:v1.0' = { ... } # Restore bicep --oci-enabled restore ./consumer.bicep ``` ### Example: publish an extension ```bash bicep --oci-enabled publish-extension \ --target 'br:myregistry.example.com/bicep/extensions/my-ext:v1.0' \ --index-file ./out/index.json ``` --- ## Design ### Session API All push/pull/resolve operations go through a single `IRegistrySession` interface: - `PushAsync` — pushes config, layers, and manifest - `PullAsync` — fetches manifest and layers, returns an `OciArtifactResult` - `ResolveAsync` — resolves a reference to `(digest, OciManifest)` Two implementations: | Session | Wraps | Used for | |---|---|---| | `AcrRegistrySession` | `AzureContainerRegistryManager` (Azure SDK) | ACR / `mcr.microsoft.com` always; non-ACR hosts when `ociEnabled` is off (preserves pre-flag behavior) | | `OrasRegistrySession` | [oras-dotnet](https://github.com/oras-project/oras-dotnet) | Non-ACR hosts when `ociEnabled` is on | Sessions are created by `OciRegistryTransportFactory.CreateSession(reference, cloud)`, which inspects the registry hostname (and the `ociEnabled` feature flag for non-ACR hosts) to pick the implementation. There's no provider/strategy plumbing — the routing is a single inline check. ### Transport vs. session `IOciRegistryTransport` (singleton) is reserved for catalog enumeration (`GetRepositoryNamesAsync`, `GetRepositoryTagsAsync`) used by `PrivateAcrModuleMetadataProvider`. It always resolves to `AzureContainerRegistryManager` because catalog APIs are ACR-specific today. `IRegistrySession` (per-call, scoped to a `(registry, repository, credentials)` tuple) handles per-artifact push/pull/resolve. This split keeps a clear separation between "list what's in the registry" (singleton, ACR-only) and "act on a specific artifact" (per-reference, transport-agnostic). ### Credentials For non-ACR hosts, `DockerCredentialProvider` is injected directly into `OrasRegistrySession`. It implements oras-dotnet's `ICredentialProvider` and the [Docker credential helper protocol](https://docs.docker.com/engine/reference/commandline/login/#credential-helpers) — sends the registry hostname to `docker-credential-<helper> get` via stdin and parses the JSON response. Supports username/password and identity-token auth. ACR credentials continue to flow through the Azure SDK's existing token chain inside `AzureContainerRegistryManager`. ### Registry routing Hosts matching `*.azurecr.{io,cn,us,de,gov}` or `mcr.microsoft.com` are classified as Azure-SDK hosts (`OciRegistryTransportFactory.IsAzureSdkHost`) and always route through `AcrRegistrySession`. All other hosts route through `OrasRegistrySession` when `ociEnabled` is on, and fall back to `AcrRegistrySession` otherwise (preserving the pre-PR anonymous-then-authenticated Azure SDK flow for non-ACR hosts). ACR registries behind custom domains will fall through to the generic ORAS path, which works via Docker credential helpers (e.g. `docker-credential-acr-env`). A future improvement could use OCI auth challenge detection to identify the backing provider regardless of hostname. ### New dependency Project reference to [oras-dotnet](https://github.com/oras-project/oras-dotnet) for the generic OCI transport. --- ## What's unchanged - ACR workflows — still use the Azure SDK path, no behavioral changes. - Module reference syntax — same `br:registry/path:tag` format for any registry. - Local artifact cache — non-Azure artifacts are cached the same way as ACR artifacts. --- ## Checklist - [x] I have read and adhere to the [contribution guide](https://github.com/Azure/bicep/blob/main/CONTRIBUTING.md). ###### Microsoft Reviewers: [Open in CodeFlow](https://microsoft.github.io/open-pr/?codeflow=https://github.com/Azure/bicep/pull/18956) --------- Signed-off-by: willdavsmith <willdavsmith@gmail.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4f6a7a1 commit 4eb9949

47 files changed

Lines changed: 1838 additions & 170 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/experimental-features.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ Enables Bicep to run deployments locally, so that you can run Bicep extensions w
3030
Moves defining extension configurations to the module level rather than from within a template. The feature also
3131
includes enhancements for Deployment stacks extensibility integration. This feature is not ready for use.
3232

33+
### `ociEnabled`
34+
35+
Enables publishing and restoring Bicep modules and extensions from non-Azure OCI-compliant registries (e.g. GHCR, Docker Hub) using the ORAS transport.
36+
37+
For security, Bicep only connects (and sends credentials) to registries on a trusted allowlist. Azure Container Registry (`*.azurecr.io`/`.cn`/`.us`), `mcr.microsoft.com`, `mcr.azure.cn`, and `ghcr.io` are trusted by default. To allow additional registries, set the `BICEP_TRUSTED_REGISTRIES` environment variable to a comma-separated list of hostnames or `*.suffix` wildcards (e.g. `harbor.contoso.com,*.contoso.io`). References to untrusted registries are rejected before any connection is made.
38+
39+
When using ambient `DOCKER_USERNAME`/`DOCKER_PASSWORD` credentials, also set `DOCKER_REGISTRY` to the hostname those credentials belong to; they are only sent to a matching registry, never to arbitrary hosts. Per-registry credentials in your Docker `config.json` (`auths`/`credHelpers`/`credsStore`) continue to be matched by host.
40+
3341
### `resourceInfoCodegen`
3442

3543
Enables the 'resourceInfo' function for simplified code generation.

src/Bicep.Cli.IntegrationTests/PublishExtensionCommandTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ public async Task Publish_extension_should_fail_for_malformed_index()
146146
var indexPath = FileHelper.SaveResultFile(TestContext, "index.json", "malformed", outputDirectory);
147147

148148
var result = await Bicep(InvocationSettings.Default, "publish-extension", indexPath, "--target", $"br:example.com/test/extension:0.0.1");
149-
result.Should().Fail().And.HaveStderrMatch("*Extension package creation failed: 'm' is an invalid start of a value.*");
149+
result.Should().Fail().And.HaveStderrMatch("*Extension package creation failed:*'m' is an invalid start of a value.*");
150150
}
151151

152152
[TestMethod]

src/Bicep.Cli/Commands/PublishExtensionCommand.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ public async Task<int> RunAsync(PublishExtensionArguments args, CancellationToke
6363
var reference = ValidateReference(args.TargetExtensionReference);
6464
var overwriteIfExists = args.Force;
6565

66+
Trace.WriteLine($"Preparing to publish extension \"{reference.FullyQualifiedReference}\" (force={overwriteIfExists}, indexFile={args.IndexFile ?? "<none>"}, binariesSpecified={args.Binaries.Count}).");
67+
6668
var binaries = SupportedArchitectures.All.Select(TryGetBinary).WhereNotNull().ToImmutableArray();
6769
var tarPayload = await GetTypesTarPayload(args, binaries, cancellationToken);
6870

@@ -82,6 +84,7 @@ private async Task<BinaryData> GetTypesTarPayload(PublishExtensionArguments args
8284
var indexUri = inputOutputArgumentsResolver.PathToUri(args.IndexFile);
8385
var indexFile = fileExplorer.GetFile(indexUri);
8486

87+
Trace.WriteLine($"Packaging extension types from index file \"{indexUri.Path}\".");
8588
return await CreateTypesTar(indexFile);
8689
}
8790

@@ -101,6 +104,7 @@ private async Task<BinaryData> GetTypesTarPayload(PublishExtensionArguments args
101104
throw new BicepException($"Failed to load type information: Unable to find a binary for the current architecture ({architecture.Name}).");
102105
}
103106

107+
Trace.WriteLine($"Extracting extension types from binary \"{binaryUri.Path}\" for architecture \"{architecture.Name}\".");
104108
var indexHandle = await GetTypesFromExtension(binaryUri, cancellationToken);
105109
return await CreateTypesTar(indexHandle);
106110
}
@@ -114,7 +118,9 @@ private async Task PublishExtensionAsync(ArtifactReference target, ExtensionPack
114118
{
115119
throw new BicepException($"The extension \"{target.FullyQualifiedReference}\" already exists. Use --force to overwrite the existing extension.");
116120
}
121+
Trace.WriteLine($"Publishing extension package to \"{target.FullyQualifiedReference}\".");
117122
await moduleDispatcher.PublishExtension(target, package);
123+
Trace.WriteLine($"Successfully published extension package to \"{target.FullyQualifiedReference}\".");
118124
}
119125
catch (ExternalArtifactException exception)
120126
{
@@ -169,12 +175,14 @@ private static void ValidateExtension(BinaryData extension)
169175
}
170176
}
171177

172-
private static async Task<BinaryData> CreateTypesTar(IFileHandle indexHandle)
178+
private async Task<BinaryData> CreateTypesTar(IFileHandle indexHandle)
173179
{
174180
try
175181
{
182+
Trace.WriteLine($"Bundling extension types from index \"{indexHandle.Uri.Path}\".");
176183
var tarPayload = await TypesV1Archive.PackIntoBinaryData(indexHandle);
177184
ValidateExtension(tarPayload);
185+
Trace.WriteLine($"Successfully bundled extension types from index \"{indexHandle.Uri.Path}\".");
178186

179187
return tarPayload;
180188
}
@@ -188,7 +196,9 @@ private async Task<IFileHandle> GetTypesFromExtension(IOUri binaryUri, Cancellat
188196
{
189197
await using var extension = await localExtensionFactory.Start(binaryUri);
190198

199+
Trace.WriteLine($"Requesting extension type definitions from binary \"{binaryUri.Path}\" via gRPC.");
191200
var typeFiles = await extension.GetTypeFiles(cancellationToken);
201+
Trace.WriteLine($"Received extension type index and {typeFiles.TypeFileContents.Count} additional type file(s) from binary \"{binaryUri.Path}\".");
192202

193203
var fileExplorer = new InMemoryFileExplorer();
194204

src/Bicep.Core.UnitTests/BicepTestConstants.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
using Bicep.Core.Registry;
1515
using Bicep.Core.Registry.Catalog;
1616
using Bicep.Core.Registry.Oci;
17+
using Bicep.Core.Registry.Oci.Oras;
18+
using Bicep.Core.Registry.Sessions;
1719
using Bicep.Core.Semantics.Namespaces;
1820
using Bicep.Core.SourceGraph;
1921
using Bicep.Core.Syntax;
@@ -83,8 +85,15 @@ public static class BicepTestConstants
8385

8486
public static readonly IServiceProvider EmptyServiceProvider = new Mock<IServiceProvider>(MockBehavior.Loose).Object;
8587

86-
public static IArtifactRegistryProvider CreateRegistryProvider(IServiceProvider services) =>
87-
new DefaultArtifactRegistryProvider(TestRegistryConfiguration, services.GetRequiredService<IPublicModuleMetadataProvider>(), ClientFactory, TemplateSpecRepositoryFactory, FileExplorer);
88+
public static IArtifactRegistryProvider CreateRegistryProvider(IServiceProvider services)
89+
{
90+
var transport = new AzureContainerRegistryManager(ClientFactory);
91+
var dockerCredentials = new DockerCredentialProvider(TestEnvironment.Default, new System.IO.Abstractions.TestingHelpers.MockFileSystem());
92+
var transportFactory = new OciRegistryTransportFactory(transport, dockerCredentials);
93+
var publicMetadataProvider = (services.GetService(typeof(IPublicModuleMetadataProvider)) as IPublicModuleMetadataProvider)
94+
?? StrictMock.Of<IPublicModuleMetadataProvider>().Object;
95+
return new DefaultArtifactRegistryProvider(TestRegistryConfiguration, transportFactory, publicMetadataProvider, TemplateSpecRepositoryFactory, FileExplorer);
96+
}
8897

8998
public static readonly RegistryConfiguration TestRegistryConfiguration = new(PermitUntrustedRegistries: true);
9099

src/Bicep.Core.UnitTests/Configuration/ConfigurationManagerTests.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ public void GetBuiltInConfiguration_NoParameter_ReturnsBuiltInConfigurationWithA
102102
},
103103
"experimentalFeaturesWarning": true,
104104
"experimentalFeaturesEnabled": {
105+
"ociEnabled": false,
105106
"symbolicNameCodegen": false,
106107
"moduleExtensionConfigs": false,
107108
"resourceTypedParamsAndOutputs": false,
@@ -186,6 +187,7 @@ public void GetBuiltInConfiguration_DisableAllAnalyzers_ReturnsBuiltInConfigurat
186187
"analyzers": {},
187188
"experimentalFeaturesWarning": true,
188189
"experimentalFeaturesEnabled": {
190+
"ociEnabled": false,
189191
"symbolicNameCodegen": false,
190192
"resourceTypedParamsAndOutputs": false,
191193
"sourceMapping": false,
@@ -292,6 +294,7 @@ public void GetBuiltInConfiguration_DisableAnalyzers_ReturnsBuiltInConfiguration
292294
},
293295
"experimentalFeaturesWarning": true,
294296
"experimentalFeaturesEnabled": {
297+
"ociEnabled": false,
295298
"symbolicNameCodegen": false,
296299
"resourceTypedParamsAndOutputs": false,
297300
"sourceMapping": false,
@@ -376,6 +379,7 @@ public void GetBuiltInConfiguration_EnableExperimentalFeature_ReturnsBuiltInConf
376379
var configuration = IConfigurationManager.GetBuiltInConfiguration();
377380

378381
ExperimentalFeaturesEnabled experimentalFeaturesEnabled = new(
382+
OciEnabled: false,
379383
SymbolicNameCodegen: false,
380384
ResourceTypedParamsAndOutputs: false,
381385
SourceMapping: false,
@@ -461,6 +465,7 @@ public void GetBuiltInConfiguration_EnableExperimentalFeature_ReturnsBuiltInConf
461465
},
462466
"experimentalFeaturesWarning": true,
463467
"experimentalFeaturesEnabled": {
468+
"ociEnabled": false,
464469
"symbolicNameCodegen": false,
465470
"resourceTypedParamsAndOutputs": false,
466471
"sourceMapping": false,
@@ -709,7 +714,9 @@ public void GetConfiguration_ValidCustomConfiguration_OverridesBuiltInConfigurat
709714
},
710715
"cacheRootDirectory": "/home/username/.bicep/cache",
711716
"experimentalFeaturesWarning": false,
712-
"experimentalFeaturesEnabled": {},
717+
"experimentalFeaturesEnabled": {
718+
"ociEnabled": false
719+
},
713720
"formatting": {
714721
"indentKind": "Space",
715722
"newlineKind": "LF",
@@ -813,6 +820,7 @@ public void GetConfiguration_ValidCustomConfiguration_OverridesBuiltInConfigurat
813820
"cacheRootDirectory": "/home/username/.bicep/cache",
814821
"experimentalFeaturesWarning": false,
815822
"experimentalFeaturesEnabled": {
823+
"ociEnabled": false,
816824
"symbolicNameCodegen": false,
817825
"resourceTypedParamsAndOutputs": false,
818826
"sourceMapping": false,

src/Bicep.Core.UnitTests/Features/FeatureProviderOverrides.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ namespace Bicep.Core.UnitTests.Features;
1010
public record FeatureProviderOverrides(
1111
IDirectoryHandle? CacheRootDirectory = null,
1212
bool? RegistryEnabled = default,
13+
bool? OciEnabled = default,
1314
bool? SymbolicNameCodegenEnabled = default,
1415
bool? AdvancedListComprehensionEnabled = default,
1516
bool? ResourceTypedParamsAndOutputsEnabled = default,
@@ -28,6 +29,7 @@ public record FeatureProviderOverrides(
2829
public FeatureProviderOverrides(
2930
TestContext testContext,
3031
bool? RegistryEnabled = default,
32+
bool? OciEnabled = default,
3133
bool? SymbolicNameCodegenEnabled = default,
3234
bool? AdvancedListComprehensionEnabled = default,
3335
bool? ResourceTypedParamsAndOutputsEnabled = default,
@@ -44,6 +46,7 @@ public FeatureProviderOverrides(
4446
bool? DeployCommandsEnabled = default) : this(
4547
FileHelper.GetCacheRootDirectory(testContext),
4648
RegistryEnabled,
49+
OciEnabled,
4750
SymbolicNameCodegenEnabled,
4851
AdvancedListComprehensionEnabled,
4952
ResourceTypedParamsAndOutputsEnabled,

src/Bicep.Core.UnitTests/Features/FeatureProviderTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4+
using System;
45
using System.Diagnostics.CodeAnalysis;
56
using System.IO.Abstractions.TestingHelpers;
67
using Bicep.Core.Configuration;

src/Bicep.Core.UnitTests/Features/OverriddenFeatureProvider.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ public OverriddenFeatureProvider(IFeatureProvider features, FeatureProviderOverr
2121

2222
public IDirectoryHandle CacheRootDirectory => overrides.CacheRootDirectory ?? features.CacheRootDirectory;
2323

24+
public bool OciEnabled => overrides.OciEnabled ?? features.OciEnabled;
25+
2426
public bool SymbolicNameCodegenEnabled => overrides.SymbolicNameCodegenEnabled ?? features.SymbolicNameCodegenEnabled;
2527

2628
public bool ResourceTypedParamsAndOutputsEnabled => overrides.ResourceTypedParamsAndOutputsEnabled ?? features.ResourceTypedParamsAndOutputsEnabled;

src/Bicep.Core.UnitTests/Mock/Registry/RegistryCatalogMocks.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
using Bicep.Core.Registry.Catalog;
1515
using Bicep.Core.Registry.Catalog.Implementation;
1616
using Bicep.Core.Registry.Catalog.Implementation.PrivateRegistries;
17+
using Bicep.Core.Registry.Oci;
1718
using FluentAssertions;
1819
using Microsoft.WindowsAzure.ResourceStack.Common.Extensions;
1920
using Moq;
@@ -120,21 +121,21 @@ params Mock<IRegistryModuleMetadataProvider>[] privateProviders
120121
var privateFactory = StrictMock.Of<IPrivateAcrModuleMetadataProviderFactory>();
121122

122123
// Default - when an unrecognized registry is requested, return a provider that fails to load (similar to real behavior)
123-
privateFactory.Setup(x => x.Create(It.IsAny<CloudConfiguration>(), It.IsAny<string>(), It.IsAny<IContainerRegistryClientFactory>()))
124-
.Returns((CloudConfiguration _, string registry, IContainerRegistryClientFactory _) =>
124+
privateFactory.Setup(x => x.Create(It.IsAny<CloudConfiguration>(), It.IsAny<string>(), It.IsAny<IOciRegistryTransportFactory>()))
125+
.Returns((CloudConfiguration _, string registry, IOciRegistryTransportFactory _) =>
125126
MockFailingPrivateMetadataProvider(registry, new Exception($"Registry {registry} not found in mock")).Object);
126127

127128
foreach (var privateProvider in privateProviders)
128129
{
129130
privateProvider.Object.Registry.Should().NotBe(PublicRegistry);
130-
privateFactory.Setup(x => x.Create(It.IsAny<CloudConfiguration>(), privateProvider.Object.Registry, It.IsAny<IContainerRegistryClientFactory>()))
131+
privateFactory.Setup(x => x.Create(It.IsAny<CloudConfiguration>(), privateProvider.Object.Registry, It.IsAny<IOciRegistryTransportFactory>()))
131132
.Returns(privateProvider.Object);
132133
}
133134

134135
var indexer = new RegistryModuleCatalog(
135136
publicProvider.Object,
136137
privateFactory.Object,
137-
StrictMock.Of<IContainerRegistryClientFactory>().Object,
138+
StrictMock.Of<IOciRegistryTransportFactory>().Object,
138139
BicepTestConstants.BuiltInOnlyConfigurationManager);
139140

140141
return indexer;

0 commit comments

Comments
 (0)