Skip to content

Commit 2f0e78d

Browse files
authored
Onboard microsoft graph extension with registry provided types (#15038)
Onboard Microsoft Graph extension to allow Graph Bicep types to be fetched from registry ###### Microsoft Reviewers: [Open in CodeFlow](https://microsoft.github.io/open-pr/?codeflow=https://github.com/Azure/bicep/pull/15038)
1 parent 981bec1 commit 2f0e78d

File tree

8 files changed

+402
-6
lines changed

8 files changed

+402
-6
lines changed
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System.IO.Abstractions.TestingHelpers;
5+
using Azure;
6+
using Bicep.Core.Configuration;
7+
using Bicep.Core.Diagnostics;
8+
using Bicep.Core.Registry;
9+
using Bicep.Core.Semantics.Namespaces;
10+
using Bicep.Core.TypeSystem.Providers;
11+
using Bicep.Core.UnitTests;
12+
using Bicep.Core.UnitTests.Assertions;
13+
using Bicep.Core.UnitTests.Mock;
14+
using Bicep.Core.UnitTests.Registry;
15+
using Bicep.Core.UnitTests.Utils;
16+
using FluentAssertions;
17+
using Microsoft.VisualStudio.TestTools.UnitTesting;
18+
using Moq;
19+
using RegistryUtils = Bicep.Core.UnitTests.Utils.ContainerRegistryClientFactoryExtensions;
20+
21+
namespace Bicep.Core.IntegrationTests
22+
{
23+
24+
[TestClass]
25+
public class MsGraphTypesViaRegistryTests : TestBase
26+
{
27+
private const string versionV10 = "1.2.3";
28+
private const string versionBeta = "1.2.3-beta";
29+
private static readonly string EmptyIndexJsonBeta = $$"""
30+
{
31+
"resources": {},
32+
"resourceFunctions": {},
33+
"settings": {
34+
"name": "MicrosoftGraphBeta",
35+
"version": "{{versionBeta}}",
36+
"isSingleton": false
37+
}
38+
}
39+
""";
40+
private static readonly string EmptyIndexJsonV10 = $$"""
41+
{
42+
"resources": {},
43+
"resourceFunctions": {},
44+
"settings": {
45+
"name": "MicrosoftGraphV1.0",
46+
"version": "{{versionV10}}",
47+
"isSingleton": false
48+
}
49+
}
50+
""";
51+
52+
53+
private async Task<ServiceBuilder> GetServices()
54+
{
55+
var indexJsonBeta = FileHelper.SaveResultFile(TestContext, "types/index-beta.json", EmptyIndexJsonBeta);
56+
var indexJsonV10 = FileHelper.SaveResultFile(TestContext, "types/index-v1.0.json", EmptyIndexJsonV10);
57+
58+
var cacheRoot = FileHelper.GetUniqueTestOutputPath(TestContext);
59+
Directory.CreateDirectory(cacheRoot);
60+
61+
var services = new ServiceBuilder()
62+
.WithFeatureOverrides(new(ExtensibilityEnabled: true, CacheRootDirectory: cacheRoot))
63+
.WithContainerRegistryClientFactory(RegistryHelper.CreateOciClientForMsGraphExtension());
64+
65+
await RegistryHelper.PublishMsGraphExtension(services.Build(), indexJsonBeta, "beta", versionBeta);
66+
await RegistryHelper.PublishMsGraphExtension(services.Build(), indexJsonV10, "v1", versionV10);
67+
68+
return services;
69+
}
70+
71+
private async Task<ServiceBuilder> ServicesWithTestExtensionArtifact(ArtifactRegistryAddress artifactRegistryAddress, BinaryData artifactPayload)
72+
{
73+
(var clientFactory, var blobClients) = RegistryUtils.CreateMockRegistryClients(artifactRegistryAddress.ClientDescriptor());
74+
75+
(_, var client) = blobClients.First();
76+
var configResult = await client.UploadBlobAsync(BinaryData.FromString("{}"));
77+
var blobResult = await client.UploadBlobAsync(artifactPayload);
78+
var manifest = BicepTestConstants.GetBicepExtensionManifest(blobResult.Value, configResult.Value);
79+
await client.SetManifestAsync(manifest, artifactRegistryAddress.ExtensionVersion);
80+
81+
var cacheRoot = FileHelper.GetUniqueTestOutputPath(TestContext);
82+
Directory.CreateDirectory(cacheRoot);
83+
84+
return new ServiceBuilder()
85+
.WithFeatureOverrides(new(ExtensibilityEnabled: true, CacheRootDirectory: cacheRoot))
86+
.WithContainerRegistryClientFactory(clientFactory);
87+
}
88+
89+
[TestMethod]
90+
[DynamicData(nameof(ArtifactRegistryCorruptedPackageNegativeTestScenarios), DynamicDataSourceType.Method)]
91+
public async Task Bicep_compiler_handles_corrupted_extension_package_gracefully(
92+
BinaryData payload,
93+
string innerErrorMessage)
94+
{
95+
// ARRANGE
96+
var testArtifactAddress = new ArtifactRegistryAddress("biceptestdf.azurecr.io", "bicep/extensions/microsoftgraph/beta", "0.0.0-corruptpng");
97+
98+
var services = await ServicesWithTestExtensionArtifact(testArtifactAddress, payload);
99+
100+
// ACT
101+
var result = await CompilationHelper.RestoreAndCompile(services, @$"
102+
extension '{testArtifactAddress.ToSpecificationString(':')}'
103+
");
104+
105+
// ASSERT
106+
result.Should().NotGenerateATemplate();
107+
result.Should().HaveDiagnostics([
108+
("BCP396", DiagnosticLevel.Error, """The referenced extension types artifact has been published with malformed content.""")
109+
]);
110+
}
111+
112+
public record ArtifactRegistryAddress(string RegistryAddress, string RepositoryPath, string ExtensionVersion)
113+
{
114+
public string ToSpecificationString(char delim) => $"br:{RegistryAddress}/{RepositoryPath}{delim}{ExtensionVersion}";
115+
116+
public (string, string) ClientDescriptor() => (RegistryAddress, RepositoryPath);
117+
}
118+
119+
[TestMethod]
120+
[DynamicData(nameof(ArtifactRegistryAddressNegativeTestScenarios), DynamicDataSourceType.Method)]
121+
public async Task Repository_not_found_in_registry(
122+
ArtifactRegistryAddress artifactRegistryAddress,
123+
Exception exceptionToThrow,
124+
IEnumerable<(string, DiagnosticLevel, string)> expectedDiagnostics)
125+
{
126+
// ARRANGE
127+
// mock the blob client to throw the expected exception
128+
var mockBlobClient = StrictMock.Of<MockRegistryBlobClient>();
129+
mockBlobClient.Setup(m => m.GetManifestAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ThrowsAsync(exceptionToThrow);
130+
131+
// mock the registry client to return the mock blob client
132+
var containerRegistryFactoryBuilder = new TestContainerRegistryClientFactoryBuilder();
133+
containerRegistryFactoryBuilder.RegisterMockRepositoryBlobClient(
134+
artifactRegistryAddress.RegistryAddress,
135+
artifactRegistryAddress.RepositoryPath,
136+
mockBlobClient.Object);
137+
138+
var services = new ServiceBuilder()
139+
.WithFeatureOverrides(new(ExtensibilityEnabled: true))
140+
.WithContainerRegistryClientFactory(containerRegistryFactoryBuilder.Build().clientFactory);
141+
142+
// ACT
143+
var result = await CompilationHelper.RestoreAndCompile(services, @$"
144+
extension '{artifactRegistryAddress.ToSpecificationString(':')}'
145+
");
146+
147+
// ASSERT
148+
result.Should().NotGenerateATemplate();
149+
result.Should().HaveDiagnostics(expectedDiagnostics);
150+
}
151+
152+
public static IEnumerable<object[]> ArtifactRegistryAddressNegativeTestScenarios()
153+
{
154+
// constants
155+
const string placeholderExtensionVersion = "0.0.0-placeholder";
156+
157+
// unresolvable host registry. For example if DNS is down or unresponsive
158+
const string unreachableRegistryAddress = "unknown.registry.azurecr.io";
159+
const string NoSuchHostMessage = $" (No such host is known. ({unreachableRegistryAddress}:443))";
160+
var AggregateExceptionMessage = $"Retry failed after 4 tries. Retry settings can be adjusted in ClientOptions.Retry or by configuring a custom retry policy in ClientOptions.RetryPolicy.{string.Concat(Enumerable.Repeat(NoSuchHostMessage, 4))}";
161+
var unreachable = new ArtifactRegistryAddress(unreachableRegistryAddress, "bicep/extensions/microsoftgraph/beta", placeholderExtensionVersion);
162+
yield return new object[] {
163+
unreachable,
164+
new AggregateException(AggregateExceptionMessage),
165+
new (string, DiagnosticLevel, string)[]{
166+
("BCP192", DiagnosticLevel.Error, @$"Unable to restore the artifact with reference ""{unreachable.ToSpecificationString(':')}"": {AggregateExceptionMessage}")
167+
},
168+
};
169+
170+
// manifest not found is thrown when the repository address is not registered and/or the version doesn't exist in the registry
171+
const string NotFoundMessage = "The artifact does not exist in the registry.";
172+
var withoutRepo = new ArtifactRegistryAddress(LanguageConstants.BicepPublicMcrRegistry, "unknown/path/microsoftgraph/beta", placeholderExtensionVersion);
173+
yield return new object[] {
174+
withoutRepo,
175+
new RequestFailedException(404, NotFoundMessage),
176+
new (string, DiagnosticLevel, string)[]{
177+
("BCP192", DiagnosticLevel.Error, $@"Unable to restore the artifact with reference ""{withoutRepo.ToSpecificationString(':')}"": {NotFoundMessage}")
178+
},
179+
};
180+
}
181+
182+
public static IEnumerable<object[]> ArtifactRegistryCorruptedPackageNegativeTestScenarios()
183+
{
184+
// Scenario: When OciTypeLoader.FromDisk() throws, the exception is exposed as a diagnostic
185+
// Some cases covered by this test are:
186+
// - Artifact layer payload is not a GZip compressed
187+
// - Artifact layer payload is a GZip compressedbut is not composed of Tar entries
188+
yield return new object[]
189+
{
190+
BinaryData.FromString("This is a NOT GZip compressed data"),
191+
"The archive entry was compressed using an unsupported compression method.",
192+
};
193+
194+
// Scenario: Artifact layer payload is missing an "index.json"
195+
yield return new object[]
196+
{
197+
ThirdPartyTypeHelper.GetTypesTgzBytesFromFiles(
198+
("unknown.json", "{}")),
199+
"The path: index.json was not found in artifact contents"
200+
};
201+
202+
// Scenario: "index.json" is not valid JSON
203+
yield return new object[]
204+
{
205+
ThirdPartyTypeHelper.GetTypesTgzBytesFromFiles(
206+
("index.json", """{"INVALID_JSON": 777""")),
207+
"'7' is an invalid end of a number. Expected a delimiter. Path: $.INVALID_JSON | LineNumber: 0 | BytePositionInLine: 20."
208+
};
209+
210+
// Scenario: "index.json" with malformed or missing required data
211+
yield return new object[]
212+
{
213+
ThirdPartyTypeHelper.GetTypesTgzBytesFromFiles(
214+
("index.json", """{ "UnexpectedMember": false}""")),
215+
"Value cannot be null. (Parameter 'source')"
216+
};
217+
}
218+
219+
[TestMethod]
220+
public async Task External_MsGraph_namespace_can_be_loaded_from_configuration()
221+
{
222+
var services = await GetServices();
223+
224+
services = services.WithConfigurationPatch(c => c.WithExtensions($$"""
225+
{
226+
"az": "builtin:",
227+
"msGraphBeta": "br:{{LanguageConstants.BicepPublicMcrRegistry}}/bicep/extensions/microsoftgraph/beta:{{versionBeta}}",
228+
"msGraphV1": "br:{{LanguageConstants.BicepPublicMcrRegistry}}/bicep/extensions/microsoftgraph/v1:{{versionV10}}"
229+
}
230+
"""));
231+
232+
var result = await CompilationHelper.RestoreAndCompile(services, ("main.bicep", @$"
233+
extension msGraphBeta
234+
extension msGraphV1
235+
"));
236+
237+
result.Should().GenerateATemplate();
238+
}
239+
240+
[TestMethod]
241+
public async Task BuiltIn_MsGraph_namespace_can_be_loaded_from_configuration()
242+
{
243+
var services = await GetServices();
244+
var result = await CompilationHelper.RestoreAndCompile(services, ("main.bicep", @$"
245+
extension microsoftGraph
246+
"));
247+
248+
result.Should().GenerateATemplate();
249+
}
250+
251+
[TestMethod]
252+
public async Task MsGraph_namespace_can_be_loaded_dynamically_using_extension_configuration()
253+
{
254+
//ARRANGE
255+
var artifactRegistryAddress = new ArtifactRegistryAddress(
256+
"fake.azurecr.io",
257+
"fake/path/microsoftgraph/beta",
258+
"1.0.0-fake");
259+
var services = await ServicesWithTestExtensionArtifact(
260+
artifactRegistryAddress,
261+
ThirdPartyTypeHelper.GetTypesTgzBytesFromFiles(("index.json", EmptyIndexJsonBeta)));
262+
services = services.WithConfigurationPatch(c => c.WithExtensions($$"""
263+
{
264+
"az": "builtin:",
265+
"msGraphBeta": "{{artifactRegistryAddress.ToSpecificationString(':')}}"
266+
}
267+
"""));
268+
269+
//ACT
270+
var result = await CompilationHelper.RestoreAndCompile(services, ("main.bicep", @$"
271+
extension msGraphBeta
272+
"));
273+
274+
//ASSERT
275+
result.Should().GenerateATemplate();
276+
result.Template.Should().NotBeNull();
277+
result.Template.Should().HaveValueAtPath("$.imports.MicrosoftGraphBeta.version", versionBeta);
278+
}
279+
}
280+
}

src/Bicep.Core.UnitTests/Utils/RegistryHelper.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,18 @@ public static async Task PublishAzExtension(IDependencyHelper services, string p
164164
await PublishExtensionToRegistryAsync(services, pathToIndexJson, $"br:{LanguageConstants.BicepPublicMcrRegistry}/{repository}:{version}");
165165
}
166166

167+
public static async Task PublishMsGraphExtension(IDependencyHelper services, string pathToIndexJson, string repoVersion, string extensionVersion)
168+
{
169+
var repository = "bicep/extensions/microsoftgraph/" + repoVersion;
170+
await PublishExtensionToRegistryAsync(services, pathToIndexJson, $"br:{LanguageConstants.BicepPublicMcrRegistry}/{repository}:{extensionVersion}");
171+
}
172+
167173
public static IContainerRegistryClientFactory CreateOciClientForAzExtension()
168174
=> CreateMockRegistryClients((LanguageConstants.BicepPublicMcrRegistry, $"bicep/extensions/az")).factoryMock;
175+
176+
public static IContainerRegistryClientFactory CreateOciClientForMsGraphExtension()
177+
=> CreateMockRegistryClients(
178+
(LanguageConstants.BicepPublicMcrRegistry, $"bicep/extensions/microsoftgraph/beta"),
179+
(LanguageConstants.BicepPublicMcrRegistry, $"bicep/extensions/microsoftgraph/v1")
180+
).factoryMock;
169181
}

src/Bicep.Core/Semantics/Namespaces/MicrosoftGraphNamespaceType.cs

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,37 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33
using System.Collections.Immutable;
4+
using System.Diagnostics;
5+
using System.Reflection;
6+
using Azure.Deployments.Core.Definitions.Identifiers;
7+
using Bicep.Core.Diagnostics;
8+
using Bicep.Core.Intermediate;
9+
using Bicep.Core.Registry;
10+
using Bicep.Core.TypeSystem;
411
using Bicep.Core.TypeSystem.Providers;
512
using Bicep.Core.TypeSystem.Providers.MicrosoftGraph;
613
using Bicep.Core.TypeSystem.Types;
14+
using Bicep.Core.Workspaces;
15+
using Microsoft.Graph.Bicep.Types;
16+
using static Bicep.Core.TypeSystem.Providers.ThirdParty.ThirdPartyResourceTypeLoader;
717

818
namespace Bicep.Core.Semantics.Namespaces
919
{
1020
public static class MicrosoftGraphNamespaceType
1121
{
1222
public const string BuiltInName = "microsoftGraph";
23+
public const string TemplateExtensionName = "MicrosoftGraph";
24+
public const string BicepExtensionBetaName = "MicrosoftGraphBeta";
25+
public const string BicepExtensionV10Name = "MicrosoftGraphV1.0";
1326

1427
private static readonly Lazy<IResourceTypeProvider> TypeProviderLazy
1528
= new(() => new MicrosoftGraphResourceTypeProvider(new MicrosoftGraphResourceTypeLoader()));
1629

1730
public static NamespaceSettings Settings { get; } = new(
18-
IsSingleton: true,
31+
IsSingleton: false,
1932
BicepExtensionName: BuiltInName,
2033
ConfigurationType: null,
21-
TemplateExtensionName: "MicrosoftGraph",
34+
TemplateExtensionName: TemplateExtensionName,
2235
TemplateExtensionVersion: "1.0.0");
2336

2437
public static NamespaceType Create(string aliasName)
@@ -32,5 +45,34 @@ public static NamespaceType Create(string aliasName)
3245
ImmutableArray<Decorator>.Empty,
3346
TypeProviderLazy.Value);
3447
}
48+
49+
public static NamespaceType Create(string? aliasName, IResourceTypeProvider resourceTypeProvider, ArtifactReference? artifact)
50+
{
51+
if (resourceTypeProvider is MicrosoftGraphResourceTypeProvider microsoftGraphProvider &&
52+
microsoftGraphProvider.GetNamespaceConfiguration() is NamespaceConfiguration namespaceConfig)
53+
{
54+
return new NamespaceType(
55+
aliasName ?? namespaceConfig.Name,
56+
new NamespaceSettings(
57+
IsSingleton: namespaceConfig.IsSingleton,
58+
BicepExtensionName: namespaceConfig.Name,
59+
ConfigurationType: namespaceConfig.ConfigurationObject,
60+
TemplateExtensionName: TemplateExtensionName,
61+
TemplateExtensionVersion: namespaceConfig.Version),
62+
ImmutableArray<TypeProperty>.Empty,
63+
ImmutableArray<FunctionOverload>.Empty,
64+
ImmutableArray<BannedFunction>.Empty,
65+
ImmutableArray<Decorator>.Empty,
66+
resourceTypeProvider,
67+
artifact);
68+
}
69+
70+
throw new ArgumentException("Invalid resource type provider or namespace config for Microsoft Graph resource.");
71+
}
72+
73+
public static bool ShouldUseLoader(string? typeSettingName)
74+
{
75+
return typeSettingName == TemplateExtensionName || typeSettingName == BicepExtensionBetaName || typeSettingName == BicepExtensionV10Name;
76+
}
3577
}
3678
}

src/Bicep.Core/Semantics/Namespaces/NamespaceProvider.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
using Bicep.Core.TypeSystem;
1616
using Bicep.Core.TypeSystem.Providers;
1717
using Bicep.Core.TypeSystem.Providers.Az;
18+
using Bicep.Core.TypeSystem.Providers.MicrosoftGraph;
1819
using Bicep.Core.TypeSystem.Providers.ThirdParty;
1920
using Bicep.Core.TypeSystem.Types;
2021
using Bicep.Core.Workspaces;
@@ -201,6 +202,11 @@ private ResultWithDiagnosticBuilder<NamespaceType> GetNamespaceTypeForArtifact(A
201202
return new(AzNamespaceType.Create(aliasName, targetScope, typeProvider, sourceFile.FileKind));
202203
}
203204

205+
if (typeProvider is MicrosoftGraphResourceTypeProvider)
206+
{
207+
return new(MicrosoftGraphNamespaceType.Create(aliasName, typeProvider, artifact.Reference));
208+
}
209+
204210
return new(ThirdPartyNamespaceType.Create(aliasName, typeProvider, artifact.Reference));
205211
}
206212
}

0 commit comments

Comments
 (0)