diff --git a/Directory.Packages.props b/Directory.Packages.props index 770e4268567..5a56c51ff1b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -25,7 +25,7 @@ - + @@ -56,7 +56,7 @@ - + diff --git a/playground/AzureContainerApps/AzureContainerApps.AppHost/Program.cs b/playground/AzureContainerApps/AzureContainerApps.AppHost/Program.cs index b8d590e6505..23de037dc33 100644 --- a/playground/AzureContainerApps/AzureContainerApps.AppHost/Program.cs +++ b/playground/AzureContainerApps/AzureContainerApps.AppHost/Program.cs @@ -19,7 +19,7 @@ // Testing secret outputs var cosmosDb = builder.AddAzureCosmosDB("account") .RunAsEmulator(c => c.WithLifetime(ContainerLifetime.Persistent)) - .AddDatabase("db"); + .WithDatabase("db"); // Testing a connection string var blobs = builder.AddAzureStorage("storage") diff --git a/playground/AzureContainerApps/AzureContainerApps.AppHost/account.module.bicep b/playground/AzureContainerApps/AzureContainerApps.AppHost/account.module.bicep index bb9ff75e6bf..792c34dffdd 100644 --- a/playground/AzureContainerApps/AzureContainerApps.AppHost/account.module.bicep +++ b/playground/AzureContainerApps/AzureContainerApps.AppHost/account.module.bicep @@ -1,11 +1,9 @@ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location -param keyVaultName string +param principalType string -resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = { - name: keyVaultName -} +param principalId string resource account 'Microsoft.DocumentDB/databaseAccounts@2024-08-15' = { name: take('account-${uniqueString(resourceGroup().id)}', 44) @@ -21,6 +19,7 @@ resource account 'Microsoft.DocumentDB/databaseAccounts@2024-08-15' = { defaultConsistencyLevel: 'Session' } databaseAccountOfferType: 'Standard' + disableLocalAuth: true } kind: 'GlobalDocumentDB' tags: { @@ -39,10 +38,4 @@ resource db 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2024-08-15' = { parent: account } -resource connectionString 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { - name: 'connectionString' - properties: { - value: 'AccountEndpoint=${account.properties.documentEndpoint};AccountKey=${account.listKeys().primaryMasterKey}' - } - parent: keyVault -} \ No newline at end of file +output connectionString string = account.properties.documentEndpoint \ No newline at end of file diff --git a/playground/AzureContainerApps/AzureContainerApps.AppHost/api.module.bicep b/playground/AzureContainerApps/AzureContainerApps.AppHost/api.module.bicep index b634633c7c2..fddffaab0d6 100644 --- a/playground/AzureContainerApps/AzureContainerApps.AppHost/api.module.bicep +++ b/playground/AzureContainerApps/AzureContainerApps.AppHost/api.module.bicep @@ -5,13 +5,13 @@ param api_containerport string param storage_outputs_blobendpoint string -param account_secretoutputs string - -param outputs_azure_container_registry_managed_identity_id string +param account_outputs_connectionstring string @secure() param secretparam_value string +param outputs_azure_container_registry_managed_identity_id string + param outputs_managed_identity_client_id string param outputs_azure_container_apps_environment_id string @@ -24,26 +24,12 @@ param certificateName string param customDomain string -resource account_secretoutputs_kv 'Microsoft.KeyVault/vaults@2023-07-01' existing = { - name: account_secretoutputs -} - -resource account_secretoutputs_kv_connectionString 'Microsoft.KeyVault/vaults/secrets@2023-07-01' existing = { - name: 'connectionString' - parent: account_secretoutputs_kv -} - resource api 'Microsoft.App/containerApps@2024-03-01' = { name: 'api' location: location properties: { configuration: { secrets: [ - { - name: 'connectionstrings--account' - identity: outputs_azure_container_registry_managed_identity_id - keyVaultUrl: account_secretoutputs_kv_connectionString.properties.secretUri - } { name: 'value' value: secretparam_value @@ -106,7 +92,7 @@ resource api 'Microsoft.App/containerApps@2024-03-01' = { } { name: 'ConnectionStrings__account' - secretRef: 'connectionstrings--account' + value: account_outputs_connectionstring } { name: 'VALUE' diff --git a/playground/AzureContainerApps/AzureContainerApps.AppHost/aspire-manifest.json b/playground/AzureContainerApps/AzureContainerApps.AppHost/aspire-manifest.json index 7687074415d..3a818b8f4b7 100644 --- a/playground/AzureContainerApps/AzureContainerApps.AppHost/aspire-manifest.json +++ b/playground/AzureContainerApps/AzureContainerApps.AppHost/aspire-manifest.json @@ -66,10 +66,11 @@ }, "account": { "type": "azure.bicep.v0", - "connectionString": "{account.secretOutputs.connectionString}", + "connectionString": "{account.outputs.connectionString}", "path": "account.module.bicep", "params": { - "keyVaultName": "" + "principalType": "", + "principalId": "" } }, "storage": { @@ -111,9 +112,9 @@ "params": { "api_containerport": "{api.containerPort}", "storage_outputs_blobendpoint": "{storage.outputs.blobEndpoint}", - "account_secretoutputs": "{account.secretOutputs}", - "outputs_azure_container_registry_managed_identity_id": "{.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", + "account_outputs_connectionstring": "{account.outputs.connectionString}", "secretparam_value": "{secretparam.value}", + "outputs_azure_container_registry_managed_identity_id": "{.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", "outputs_managed_identity_client_id": "{.outputs.MANAGED_IDENTITY_CLIENT_ID}", "outputs_azure_container_apps_environment_id": "{.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID}", "outputs_azure_container_registry_endpoint": "{.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT}", diff --git a/playground/CosmosEndToEnd/CosmosEndToEnd.ApiService/Program.cs b/playground/CosmosEndToEnd/CosmosEndToEnd.ApiService/Program.cs index a6b1bcaf748..3abca853a9b 100644 --- a/playground/CosmosEndToEnd/CosmosEndToEnd.ApiService/Program.cs +++ b/playground/CosmosEndToEnd/CosmosEndToEnd.ApiService/Program.cs @@ -19,8 +19,8 @@ app.MapDefaultEndpoints(); app.MapGet("/", async (CosmosClient cosmosClient) => { - var db = (await cosmosClient.CreateDatabaseIfNotExistsAsync("db")).Database; - var container = (await db.CreateContainerIfNotExistsAsync("entries", "/id")).Container; + var db = cosmosClient.GetDatabase("db"); + var container = db.GetContainer("entries"); // Add an entry to the database on each request. var newEntry = new Entry() { Id = Guid.NewGuid().ToString() }; diff --git a/playground/CosmosEndToEnd/CosmosEndToEnd.AppHost/Program.cs b/playground/CosmosEndToEnd/CosmosEndToEnd.AppHost/Program.cs index 5e8cc180076..c277e3d5071 100644 --- a/playground/CosmosEndToEnd/CosmosEndToEnd.AppHost/Program.cs +++ b/playground/CosmosEndToEnd/CosmosEndToEnd.AppHost/Program.cs @@ -5,7 +5,7 @@ #pragma warning disable ASPIRECOSMOS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. var db = builder.AddAzureCosmosDB("cosmos") - .AddDatabase("db") + .WithDatabase("db", database => database.Containers.Add(new("entries", "/Id"))) .RunAsPreviewEmulator(e => e.WithDataExplorer()); #pragma warning restore ASPIRECOSMOS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. diff --git a/playground/CosmosEndToEnd/CosmosEndToEnd.AppHost/aspire-manifest.json b/playground/CosmosEndToEnd/CosmosEndToEnd.AppHost/aspire-manifest.json index 3de94f0fc92..06d1c611200 100644 --- a/playground/CosmosEndToEnd/CosmosEndToEnd.AppHost/aspire-manifest.json +++ b/playground/CosmosEndToEnd/CosmosEndToEnd.AppHost/aspire-manifest.json @@ -3,10 +3,11 @@ "resources": { "cosmos": { "type": "azure.bicep.v0", - "connectionString": "{cosmos.secretOutputs.connectionString}", + "connectionString": "{cosmos.outputs.connectionString}", "path": "cosmos.module.bicep", "params": { - "keyVaultName": "" + "principalType": "", + "principalId": "" } }, "api": { diff --git a/playground/CosmosEndToEnd/CosmosEndToEnd.AppHost/cosmos.module.bicep b/playground/CosmosEndToEnd/CosmosEndToEnd.AppHost/cosmos.module.bicep index fac0f210b44..89c37b331aa 100644 --- a/playground/CosmosEndToEnd/CosmosEndToEnd.AppHost/cosmos.module.bicep +++ b/playground/CosmosEndToEnd/CosmosEndToEnd.AppHost/cosmos.module.bicep @@ -1,11 +1,9 @@ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location -param keyVaultName string +param principalType string -resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = { - name: keyVaultName -} +param principalId string resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2024-08-15' = { name: take('cosmos-${uniqueString(resourceGroup().id)}', 44) @@ -21,6 +19,7 @@ resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2024-08-15' = { defaultConsistencyLevel: 'Session' } databaseAccountOfferType: 'Standard' + disableLocalAuth: true } kind: 'GlobalDocumentDB' tags: { @@ -39,10 +38,20 @@ resource db 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2024-08-15' = { parent: cosmos } -resource connectionString 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { - name: 'connectionString' +resource entries 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2024-08-15' = { + name: 'entries' + location: location properties: { - value: 'AccountEndpoint=${cosmos.properties.documentEndpoint};AccountKey=${cosmos.listKeys().primaryMasterKey}' + resource: { + id: 'entries' + partitionKey: { + paths: [ + '/Id' + ] + } + } } - parent: keyVault -} \ No newline at end of file + parent: db +} + +output connectionString string = cosmos.properties.documentEndpoint \ No newline at end of file diff --git a/playground/bicep/BicepSample.AppHost/Program.cs b/playground/bicep/BicepSample.AppHost/Program.cs index 43e2d3d2e27..b366b6cad6a 100644 --- a/playground/bicep/BicepSample.AppHost/Program.cs +++ b/playground/bicep/BicepSample.AppHost/Program.cs @@ -42,7 +42,7 @@ .AddDatabase("db2"); var cosmosDb = builder.AddAzureCosmosDB("cosmos") - .AddDatabase("db3"); + .WithDatabase("db3"); var logAnalytics = builder.AddAzureLogAnalyticsWorkspace("lawkspc"); var appInsights = builder.AddAzureApplicationInsights("ai", logAnalytics); diff --git a/playground/bicep/BicepSample.AppHost/aspire-manifest.json b/playground/bicep/BicepSample.AppHost/aspire-manifest.json index 1343ee3843f..89658dfba17 100644 --- a/playground/bicep/BicepSample.AppHost/aspire-manifest.json +++ b/playground/bicep/BicepSample.AppHost/aspire-manifest.json @@ -113,10 +113,11 @@ }, "cosmos": { "type": "azure.bicep.v0", - "connectionString": "{cosmos.secretOutputs.connectionString}", + "connectionString": "{cosmos.outputs.connectionString}", "path": "cosmos.module.bicep", "params": { - "keyVaultName": "" + "principalType": "", + "principalId": "" } }, "lawkspc": { diff --git a/playground/bicep/BicepSample.AppHost/cosmos.module.bicep b/playground/bicep/BicepSample.AppHost/cosmos.module.bicep index 2b0d2b63d68..f3c25bf0a81 100644 --- a/playground/bicep/BicepSample.AppHost/cosmos.module.bicep +++ b/playground/bicep/BicepSample.AppHost/cosmos.module.bicep @@ -1,11 +1,9 @@ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location -param keyVaultName string +param principalType string -resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = { - name: keyVaultName -} +param principalId string resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2024-08-15' = { name: take('cosmos-${uniqueString(resourceGroup().id)}', 44) @@ -21,6 +19,7 @@ resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2024-08-15' = { defaultConsistencyLevel: 'Session' } databaseAccountOfferType: 'Standard' + disableLocalAuth: true } kind: 'GlobalDocumentDB' tags: { @@ -39,10 +38,4 @@ resource db3 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2024-08-15' = { parent: cosmos } -resource connectionString 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { - name: 'connectionString' - properties: { - value: 'AccountEndpoint=${cosmos.properties.documentEndpoint};AccountKey=${cosmos.listKeys().primaryMasterKey}' - } - parent: keyVault -} \ No newline at end of file +output connectionString string = cosmos.properties.documentEndpoint \ No newline at end of file diff --git a/playground/cdk/CdkSample.AppHost/Program.cs b/playground/cdk/CdkSample.AppHost/Program.cs index 062f101322a..a04718791d3 100644 --- a/playground/cdk/CdkSample.AppHost/Program.cs +++ b/playground/cdk/CdkSample.AppHost/Program.cs @@ -9,7 +9,7 @@ var builder = DistributedApplication.CreateBuilder(args); -var cosmosdb = builder.AddAzureCosmosDB("cosmos").AddDatabase("cosmosdb"); +var cosmosdb = builder.AddAzureCosmosDB("cosmos").WithDatabase("cosmosdb"); var sku = builder.AddParameter("storagesku"); var locationOverride = builder.AddParameter("locationOverride"); diff --git a/playground/cdk/CdkSample.AppHost/aspire-manifest.json b/playground/cdk/CdkSample.AppHost/aspire-manifest.json index 8429876c9de..156d8eb6549 100644 --- a/playground/cdk/CdkSample.AppHost/aspire-manifest.json +++ b/playground/cdk/CdkSample.AppHost/aspire-manifest.json @@ -3,10 +3,11 @@ "resources": { "cosmos": { "type": "azure.bicep.v0", - "connectionString": "{cosmos.secretOutputs.connectionString}", + "connectionString": "{cosmos.outputs.connectionString}", "path": "cosmos.module.bicep", "params": { - "keyVaultName": "" + "principalType": "", + "principalId": "" } }, "storagesku": { diff --git a/playground/cdk/CdkSample.AppHost/cosmos.module.bicep b/playground/cdk/CdkSample.AppHost/cosmos.module.bicep index e491d282571..734d6e041b0 100644 --- a/playground/cdk/CdkSample.AppHost/cosmos.module.bicep +++ b/playground/cdk/CdkSample.AppHost/cosmos.module.bicep @@ -1,11 +1,9 @@ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location -param keyVaultName string +param principalType string -resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = { - name: keyVaultName -} +param principalId string resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2024-08-15' = { name: take('cosmos-${uniqueString(resourceGroup().id)}', 44) @@ -21,6 +19,7 @@ resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2024-08-15' = { defaultConsistencyLevel: 'Session' } databaseAccountOfferType: 'Standard' + disableLocalAuth: true } kind: 'GlobalDocumentDB' tags: { @@ -39,10 +38,4 @@ resource cosmosdb 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2024-08-15 parent: cosmos } -resource connectionString 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { - name: 'connectionString' - properties: { - value: 'AccountEndpoint=${cosmos.properties.documentEndpoint};AccountKey=${cosmos.listKeys().primaryMasterKey}' - } - parent: keyVault -} \ No newline at end of file +output connectionString string = cosmos.properties.documentEndpoint \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure.CosmosDB/Aspire.Hosting.Azure.CosmosDB.csproj b/src/Aspire.Hosting.Azure.CosmosDB/Aspire.Hosting.Azure.CosmosDB.csproj index 68eab3d8ea5..35d4b232c21 100644 --- a/src/Aspire.Hosting.Azure.CosmosDB/Aspire.Hosting.Azure.CosmosDB.csproj +++ b/src/Aspire.Hosting.Azure.CosmosDB/Aspire.Hosting.Azure.CosmosDB.csproj @@ -20,7 +20,8 @@ - + + diff --git a/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBEmulatorConnectionString.cs b/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBEmulatorConnectionString.cs index f8cd05afe85..b8e02e2ab15 100644 --- a/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBEmulatorConnectionString.cs +++ b/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBEmulatorConnectionString.cs @@ -2,7 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.Azure.Cosmos; +using Aspire.Hosting.Azure.CosmosDB; namespace Aspire.Hosting.Azure; diff --git a/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBEmulatorHealthCheck.cs b/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBEmulatorHealthCheck.cs new file mode 100644 index 00000000000..19665026f89 --- /dev/null +++ b/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBEmulatorHealthCheck.cs @@ -0,0 +1,61 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Azure.Cosmos; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Aspire.Hosting.Azure.CosmosDB; + +/// +/// This health check also creates default databases and containers for the Azure CosmosDB Emulator. +/// +internal sealed class AzureCosmosDBEmulatorHealthCheck : IHealthCheck +{ + private readonly Func _clientFactory; + private readonly Func _databasesFactory; + private bool _resourcesCreated; + + public AzureCosmosDBEmulatorHealthCheck(Func clientFactory, Func databasesFactory) + { + ArgumentNullException.ThrowIfNull(clientFactory); + ArgumentNullException.ThrowIfNull(databasesFactory); + + _clientFactory = clientFactory; + _databasesFactory = databasesFactory; + } + + /// + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + try + { + var cosmosClient = _clientFactory(); + + await cosmosClient.ReadAccountAsync().WaitAsync(cancellationToken).ConfigureAwait(false); + + // Create the databases and containers if they do not exist. This is only performed once. + if (!_resourcesCreated) + { + var databases = _databasesFactory(); + + foreach (var database in databases) + { + var db = (await cosmosClient.CreateDatabaseIfNotExistsAsync(database.Name, cancellationToken: cancellationToken).ConfigureAwait(false)).Database; + + foreach (var container in database.Containers) + { + await db.CreateContainerIfNotExistsAsync(container.Name, container.PartitionKeyPath, cancellationToken: cancellationToken).ConfigureAwait(false); + } + } + + _resourcesCreated = true; + } + + return HealthCheckResult.Healthy(); + } + catch (Exception ex) + { + return new HealthCheckResult(context.Registration.FailureStatus, exception: ex); + } + } +} diff --git a/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBExtensions.cs b/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBExtensions.cs index 613651079b9..899f42b73b9 100644 --- a/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBExtensions.cs +++ b/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBExtensions.cs @@ -5,7 +5,7 @@ using System.Globalization; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure; -using Aspire.Hosting.Azure.Cosmos; +using Aspire.Hosting.Azure.CosmosDB; using Aspire.Hosting.Utils; using Azure.Identity; using Azure.Provisioning; @@ -14,6 +14,7 @@ using Azure.Provisioning.KeyVault; using Microsoft.Azure.Cosmos; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; namespace Aspire.Hosting; @@ -32,66 +33,7 @@ public static IResourceBuilder AddAzureCosmosDB(this IDis { builder.AddAzureProvisioning(); - var configureInfrastructure = (AzureResourceInfrastructure infrastructure) => - { - var kvNameParam = new ProvisioningParameter(AzureBicepResource.KnownParameters.KeyVaultName, typeof(string)); - infrastructure.Add(kvNameParam); - - var keyVault = KeyVaultService.FromExisting("keyVault"); - keyVault.Name = kvNameParam; - infrastructure.Add(keyVault); - - var cosmosAccount = new CosmosDBAccount(infrastructure.AspireResource.GetBicepIdentifier()) - { - Kind = CosmosDBAccountKind.GlobalDocumentDB, - ConsistencyPolicy = new ConsistencyPolicy() - { - DefaultConsistencyLevel = DefaultConsistencyLevel.Session - }, - DatabaseAccountOfferType = CosmosDBAccountOfferType.Standard, - Locations = - { - new CosmosDBAccountLocation - { - LocationName = new IdentifierExpression("location"), - FailoverPriority = 0 - } - }, - Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } - }; - infrastructure.Add(cosmosAccount); - - var azureResource = (AzureCosmosDBResource)infrastructure.AspireResource; - var azureResourceBuilder = builder.CreateResourceBuilder(azureResource); - List cosmosSqlDatabases = new List(); - foreach (var databaseName in azureResource.Databases) - { - var cosmosSqlDatabase = new CosmosDBSqlDatabase(Infrastructure.NormalizeBicepIdentifier(databaseName)) - { - Parent = cosmosAccount, - Name = databaseName, - Resource = new CosmosDBSqlDatabaseResourceInfo() - { - DatabaseName = databaseName - } - }; - infrastructure.Add(cosmosSqlDatabase); - cosmosSqlDatabases.Add(cosmosSqlDatabase); - } - - var secret = new KeyVaultSecret("connectionString") - { - Parent = keyVault, - Name = "connectionString", - Properties = new SecretProperties - { - Value = BicepFunction.Interpolate($"AccountEndpoint={cosmosAccount.DocumentEndpoint};AccountKey={cosmosAccount.GetKeys().PrimaryMasterKey}") - } - }; - infrastructure.Add(secret); - }; - - var resource = new AzureCosmosDBResource(name, configureInfrastructure); + var resource = new AzureCosmosDBResource(name, ConfigureCosmosDBInfrastructure); return builder.AddResource(resource) .WithManifestPublishingCallback(resource.WriteToManifest); } @@ -155,11 +97,18 @@ private static IResourceBuilder RunAsEmulator(this IResou cosmosClient = CreateCosmosClient(connectionString); }); + // Use custom health check that also seeds the databases and containers var healthCheckKey = $"{builder.Resource.Name}_check"; - builder.ApplicationBuilder.Services.AddHealthChecks().AddAzureCosmosDB(sp => - { - return cosmosClient ?? throw new InvalidOperationException("CosmosClient is not initialized."); - }, name: healthCheckKey); + builder.ApplicationBuilder.Services.AddHealthChecks().Add( + new HealthCheckRegistration( + name: healthCheckKey, + new AzureCosmosDBEmulatorHealthCheck( + () => cosmosClient ?? throw new InvalidOperationException("CosmosClient is not initialized."), + builder.Resource.Databases.ToArray + ), + failureStatus: null, + tags: null) + ); builder.WithHealthCheck(healthCheckKey); @@ -228,7 +177,7 @@ public static IResourceBuilder WithGatewayPort(th /// Builder for the Cosmos emulator container /// Desired partition count. /// Cosmos emulator resource builder. - /// Not calling this method will result in the default of 25 partitions. The actual started partitions is always one more than specified. + /// Not calling this method will result in the default of 10 partitions. The actual started partitions is always one more than specified. /// See this documentation about setting the partition count. /// public static IResourceBuilder WithPartitionCount(this IResourceBuilder builder, int count) @@ -252,9 +201,30 @@ public static IResourceBuilder WithPartitionCount /// AzureCosmosDB resource builder. /// Name of database. /// A reference to the . + [Obsolete($"This method is obsolete and will be removed in a future version. Use {nameof(WithDatabase)} instead to add a Cosmos DB database.")] public static IResourceBuilder AddDatabase(this IResourceBuilder builder, string databaseName) { - builder.Resource.Databases.Add(databaseName); + return builder.WithDatabase(databaseName); + } + + /// + /// Adds a database to the associated Cosmos DB account resource. + /// + /// AzureCosmosDB resource builder. + /// Name of database. + /// An optional method that can be used for customizing the . + /// A reference to the . + public static IResourceBuilder WithDatabase(this IResourceBuilder builder, string name, Action? configure = null) + { + var database = builder.Resource.Databases.FirstOrDefault(x => x.Name == name); + + if (database == null) + { + database = new CosmosDBDatabase(name); + builder.Resource.Databases.Add(database); + } + + configure?.Invoke(database); return builder; } @@ -282,4 +252,128 @@ public static IResourceBuilder WithDataExplorer(t endpoint.Port = port; }); } + + /// + /// Configures the resource to use access key authentication with Azure Cosmos DB. + /// + /// The Azure Cosmos DB resource builder. + /// A reference to the builder. + /// + /// The following example creates an Azure Cosmos DB resource that uses access key authentication. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var cosmosdb = builder.AddAzureCosmosDB("cache") + /// .WithAccessKeyAuthentication(); + /// + /// builder.AddProject<Projects.ProductService>() + /// .WithReference(cosmosdb); + /// + /// builder.Build().Run(); + /// + /// + public static IResourceBuilder WithAccessKeyAuthentication( + this IResourceBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + var azureResource = builder.Resource; + azureResource.ConnectionStringSecretOutput = new BicepSecretOutputReference("connectionString", azureResource); + + return builder; + } + + private static void ConfigureCosmosDBInfrastructure(AzureResourceInfrastructure infrastructure) + { + var azureResource = (AzureCosmosDBResource)infrastructure.AspireResource; + + var cosmosAccount = new CosmosDBAccount(infrastructure.AspireResource.GetBicepIdentifier()) + { + Kind = CosmosDBAccountKind.GlobalDocumentDB, + ConsistencyPolicy = new ConsistencyPolicy() + { + DefaultConsistencyLevel = DefaultConsistencyLevel.Session + }, + DatabaseAccountOfferType = CosmosDBAccountOfferType.Standard, + Locations = + { + new CosmosDBAccountLocation + { + LocationName = new IdentifierExpression("location"), + FailoverPriority = 0 + } + }, + Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } + }; + infrastructure.Add(cosmosAccount); + + foreach (var database in azureResource.Databases) + { + var cosmosSqlDatabase = new CosmosDBSqlDatabase(Infrastructure.NormalizeBicepIdentifier(database.Name)) + { + Parent = cosmosAccount, + Name = database.Name, + Resource = new CosmosDBSqlDatabaseResourceInfo() + { + DatabaseName = database.Name + } + }; + infrastructure.Add(cosmosSqlDatabase); + + foreach (var container in database.Containers) + { + var cosmosContainer = new CosmosDBSqlContainer(Infrastructure.NormalizeBicepIdentifier(container.Name)) + { + Parent = cosmosSqlDatabase, + Name = container.Name, + Resource = new CosmosDBSqlContainerResourceInfo() + { + ContainerName = container.Name, + PartitionKey = new CosmosDBContainerPartitionKey { Paths = [container.PartitionKeyPath] } + } + }; + infrastructure.Add(cosmosContainer); + } + } + + if (azureResource.UseAccessKeyAuthentication) + { + cosmosAccount.DisableLocalAuth = false; + + var kvNameParam = new ProvisioningParameter(AzureBicepResource.KnownParameters.KeyVaultName, typeof(string)); + infrastructure.Add(kvNameParam); + + var keyVault = KeyVaultService.FromExisting("keyVault"); + keyVault.Name = kvNameParam; + infrastructure.Add(keyVault); + + var secret = new KeyVaultSecret("connectionString") + { + Parent = keyVault, + Name = "connectionString", + Properties = new SecretProperties + { + Value = BicepFunction.Interpolate($"AccountEndpoint={cosmosAccount.DocumentEndpoint};AccountKey={cosmosAccount.GetKeys().PrimaryMasterKey}") + } + }; + infrastructure.Add(secret); + } + else + { + cosmosAccount.DisableLocalAuth = true; + + var principalTypeParameter = new ProvisioningParameter(AzureBicepResource.KnownParameters.PrincipalType, typeof(string)); + infrastructure.Add(principalTypeParameter); + + var principalIdParameter = new ProvisioningParameter(AzureBicepResource.KnownParameters.PrincipalId, typeof(string)); + infrastructure.Add(principalIdParameter); + + cosmosAccount.CreateRoleAssignment(CosmosDBBuiltInRole.DocumentDBAccountContributor, principalTypeParameter, principalIdParameter); + + infrastructure.Add(new ProvisioningOutput("connectionString", typeof(string)) + { + Value = cosmosAccount.DocumentEndpoint + }); + } + } } diff --git a/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBResource.cs b/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBResource.cs index 94b1691a0e3..b1ec26a7a3c 100644 --- a/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBResource.cs +++ b/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBResource.cs @@ -1,9 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure; -using Aspire.Hosting.Azure.Cosmos; +using Aspire.Hosting.Azure.CosmosDB; namespace Aspire.Hosting; @@ -15,7 +16,7 @@ public class AzureCosmosDBResource(string name, Action Databases { get; } = []; + internal List Databases { get; } = []; internal EndpointReference EmulatorEndpoint => new(this, "emulator"); @@ -24,6 +25,23 @@ public class AzureCosmosDBResource(string name, Action public BicepSecretOutputReference ConnectionString => new("connectionString", this); + /// + /// Gets the "connectionString" output reference from the bicep template for the Azure Cosmos DB resource. + /// + /// This is used when Entra ID authentication is used. The connection string is an output of the bicep template. + /// + public BicepOutputReference ConnectionStringOutput => new("connectionString", this); + + /// + /// Gets the "connectionString" secret output reference from the bicep template for the Azure Redis resource. + /// + /// This is set when access key authentication is used. The connection string is stored in a secret in the Azure Key Vault. + /// + internal BicepSecretOutputReference? ConnectionStringSecretOutput { get; set; } + + [MemberNotNullWhen(true, nameof(ConnectionStringSecretOutput))] + internal bool UseAccessKeyAuthentication => ConnectionStringSecretOutput is not null; + /// /// Gets a value indicating whether the Azure Cosmos DB resource is running in the local emulator. /// @@ -39,6 +57,8 @@ public class AzureCosmosDBResource(string name, Action IsEmulator ? AzureCosmosDBEmulatorConnectionString.Create(EmulatorEndpoint, IsPreviewEmulator) - : ReferenceExpression.Create($"{ConnectionString}"); -} + : UseAccessKeyAuthentication ? + ReferenceExpression.Create($"{ConnectionStringSecretOutput}") : + ReferenceExpression.Create($"{ConnectionStringOutput}"); +} diff --git a/src/Aspire.Hosting.Azure.CosmosDB/CosmosDBContainer.cs b/src/Aspire.Hosting.Azure.CosmosDB/CosmosDBContainer.cs new file mode 100644 index 00000000000..0bb49bd1b62 --- /dev/null +++ b/src/Aspire.Hosting.Azure.CosmosDB/CosmosDBContainer.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Azure.CosmosDB; + +/// +/// Represents an Azure Cosmos DB Database Container. +/// +/// +/// Use to configure specific properties. +/// +public class CosmosDBContainer +{ + /// + /// Initializes a new instance of the class. + /// + public CosmosDBContainer(string name, string partitionKeyPath) + { + Name = name; + PartitionKeyPath = partitionKeyPath; + } + + /// + /// The container name. + /// + public string Name { get; set; } + + /// + /// The partition key path. + /// + public string PartitionKeyPath { get; set; } +} diff --git a/src/Aspire.Hosting.Azure.CosmosDB/CosmosDBDatabase.cs b/src/Aspire.Hosting.Azure.CosmosDB/CosmosDBDatabase.cs new file mode 100644 index 00000000000..ce0aefe7b9a --- /dev/null +++ b/src/Aspire.Hosting.Azure.CosmosDB/CosmosDBDatabase.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Azure.CosmosDB; + +/// +/// Represents an Azure Cosmos DB Database. +/// +/// +/// Use to configure specific properties. +/// +public class CosmosDBDatabase +{ + /// + /// Initializes a new instance of the class. + /// + public CosmosDBDatabase(string name) + { + Name = name; + } + + /// + /// The database name. + /// + public string Name { get; set; } + + /// + /// The containers for this database. + /// + public List Containers { get; } = []; +} diff --git a/src/Aspire.Hosting.Azure.CosmosDB/CosmosDBEmulatorContainerImageTags.cs b/src/Aspire.Hosting.Azure.CosmosDB/CosmosDBEmulatorContainerImageTags.cs index 7ca91640b66..5644cbf278b 100644 --- a/src/Aspire.Hosting.Azure.CosmosDB/CosmosDBEmulatorContainerImageTags.cs +++ b/src/Aspire.Hosting.Azure.CosmosDB/CosmosDBEmulatorContainerImageTags.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -namespace Aspire.Hosting.Azure.Cosmos; +namespace Aspire.Hosting.Azure.CosmosDB; internal static class CosmosDBEmulatorContainerImageTags { diff --git a/src/Aspire.Hosting.Azure.CosmosDB/PublicAPI.Unshipped.txt b/src/Aspire.Hosting.Azure.CosmosDB/PublicAPI.Unshipped.txt index a830d73815d..503185ecaf3 100644 --- a/src/Aspire.Hosting.Azure.CosmosDB/PublicAPI.Unshipped.txt +++ b/src/Aspire.Hosting.Azure.CosmosDB/PublicAPI.Unshipped.txt @@ -1,8 +1,22 @@ #nullable enable *REMOVED*static Aspire.Hosting.AzureCosmosExtensions.AddAzureCosmosDB(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name, System.Action!, Aspire.Hosting.ResourceModuleConstruct!, Azure.Provisioning.CosmosDB.CosmosDBAccount!, System.Collections.Generic.IEnumerable!>? configureResource) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! *REMOVED*Aspire.Hosting.AzureCosmosDBResource.AzureCosmosDBResource(string! name, System.Action! configureConstruct) -> void +Aspire.Hosting.Azure.CosmosDB.CosmosDBContainer +Aspire.Hosting.Azure.CosmosDB.CosmosDBContainer.CosmosDBContainer(string! name, string! partitionKeyPath) -> void +Aspire.Hosting.Azure.CosmosDB.CosmosDBContainer.Name.get -> string! +Aspire.Hosting.Azure.CosmosDB.CosmosDBContainer.Name.set -> void +Aspire.Hosting.Azure.CosmosDB.CosmosDBContainer.PartitionKeyPath.get -> string! +Aspire.Hosting.Azure.CosmosDB.CosmosDBContainer.PartitionKeyPath.set -> void +Aspire.Hosting.Azure.CosmosDB.CosmosDBDatabase +Aspire.Hosting.Azure.CosmosDB.CosmosDBDatabase.Containers.get -> System.Collections.Generic.List! +Aspire.Hosting.Azure.CosmosDB.CosmosDBDatabase.CosmosDBDatabase(string! name) -> void +Aspire.Hosting.Azure.CosmosDB.CosmosDBDatabase.Name.get -> string! +Aspire.Hosting.Azure.CosmosDB.CosmosDBDatabase.Name.set -> void Aspire.Hosting.AzureCosmosDBResource.AzureCosmosDBResource(string! name, System.Action! configureInfrastructure) -> void +Aspire.Hosting.AzureCosmosDBResource.ConnectionStringOutput.get -> Aspire.Hosting.Azure.BicepOutputReference! static Aspire.Hosting.AzureCosmosExtensions.RunAsPreviewEmulator(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, System.Action!>? configureContainer = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.AzureCosmosExtensions.WithDataExplorer(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, int? port = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.AzureCosmosExtensions.WithAccessKeyAuthentication(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.AzureCosmosExtensions.WithDatabase(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string! name, System.Action? configure = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.AzureCosmosExtensions.WithDataVolume(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string? name = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.AzureCosmosExtensions.WithPartitionCount(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, int count) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! diff --git a/src/Components/Aspire.Microsoft.Azure.Cosmos/Aspire.Microsoft.Azure.Cosmos.csproj b/src/Components/Aspire.Microsoft.Azure.Cosmos/Aspire.Microsoft.Azure.Cosmos.csproj index 74fcb5bf550..d06aca9e711 100644 --- a/src/Components/Aspire.Microsoft.Azure.Cosmos/Aspire.Microsoft.Azure.Cosmos.csproj +++ b/src/Components/Aspire.Microsoft.Azure.Cosmos/Aspire.Microsoft.Azure.Cosmos.csproj @@ -22,6 +22,7 @@ + diff --git a/src/Components/Aspire.Microsoft.Azure.Cosmos/AspireMicrosoftAzureCosmosExtensions.cs b/src/Components/Aspire.Microsoft.Azure.Cosmos/AspireMicrosoftAzureCosmosExtensions.cs index 6339b8bf382..d34ca6bf4e0 100644 --- a/src/Components/Aspire.Microsoft.Azure.Cosmos/AspireMicrosoftAzureCosmosExtensions.cs +++ b/src/Components/Aspire.Microsoft.Azure.Cosmos/AspireMicrosoftAzureCosmosExtensions.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Aspire.Hosting.Azure.Cosmos; +using Aspire.Hosting.Azure.CosmosDB; using Aspire.Microsoft.Azure.Cosmos; using Azure.Identity; using Microsoft.Azure.Cosmos; diff --git a/src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/Aspire.Microsoft.EntityFrameworkCore.Cosmos.csproj b/src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/Aspire.Microsoft.EntityFrameworkCore.Cosmos.csproj index bc103e16bc8..acbc4dc1e26 100644 --- a/src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/Aspire.Microsoft.EntityFrameworkCore.Cosmos.csproj +++ b/src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/Aspire.Microsoft.EntityFrameworkCore.Cosmos.csproj @@ -23,6 +23,7 @@ + diff --git a/src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/AspireAzureEFCoreCosmosExtensions.cs b/src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/AspireAzureEFCoreCosmosExtensions.cs index a46b6620c86..9f170f8748f 100644 --- a/src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/AspireAzureEFCoreCosmosExtensions.cs +++ b/src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/AspireAzureEFCoreCosmosExtensions.cs @@ -3,7 +3,7 @@ using System.Diagnostics.CodeAnalysis; using Aspire; -using Aspire.Hosting.Azure.Cosmos; +using Aspire.Hosting.Azure.CosmosDB; using Aspire.Microsoft.EntityFrameworkCore.Cosmos; using Azure.Identity; using Microsoft.Azure.Cosmos; diff --git a/src/Shared/Cosmos/CosmosConstants.cs b/src/Shared/Cosmos/CosmosConstants.cs index b5f85949874..b9c00c14ed9 100644 --- a/src/Shared/Cosmos/CosmosConstants.cs +++ b/src/Shared/Cosmos/CosmosConstants.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -namespace Aspire.Hosting.Azure.Cosmos; +namespace Aspire.Hosting.Azure.CosmosDB; internal static class CosmosConstants { diff --git a/src/Shared/Cosmos/CosmosUtils.cs b/src/Shared/Cosmos/CosmosUtils.cs index 0e603fe88f0..5c571bdd756 100644 --- a/src/Shared/Cosmos/CosmosUtils.cs +++ b/src/Shared/Cosmos/CosmosUtils.cs @@ -3,7 +3,7 @@ using System.Data.Common; -namespace Aspire.Hosting.Azure.Cosmos; +namespace Aspire.Hosting.Azure.CosmosDB; internal static class CosmosUtils { diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs index 96dcf1d0397..d0639c0746d 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs @@ -238,7 +238,7 @@ public async Task AddAzureCosmosDBEmulator() } [Fact] - public async Task AddAzureCosmosDBViaRunMode() + public async Task AddAzureCosmosDBViaRunMode_WithAccessKeyAuthentication() { using var builder = TestDistributedApplicationBuilder.Create(); @@ -247,8 +247,8 @@ public async Task AddAzureCosmosDBViaRunMode() .ConfigureInfrastructure(infrastructure => { callbackDatabases = infrastructure.GetProvisionableResources().OfType(); - }); - cosmos.AddDatabase("mydatabase"); + }).WithAccessKeyAuthentication(); + cosmos.WithDatabase("mydatabase", db => db.Containers.Add(new("mycontainer", "mypartitionkeypath"))); cosmos.Resource.SecretOutputs["connectionString"] = "mycosmosconnectionstring"; @@ -272,10 +272,6 @@ public async Task AddAzureCosmosDBViaRunMode() param keyVaultName string - resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = { - name: keyVaultName - } - resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2024-08-15' = { name: take('cosmos-${uniqueString(resourceGroup().id)}', 44) location: location @@ -290,6 +286,7 @@ param keyVaultName string defaultConsistencyLevel: 'Session' } databaseAccountOfferType: 'Standard' + disableLocalAuth: false } kind: 'GlobalDocumentDB' tags: { @@ -308,6 +305,26 @@ param keyVaultName string parent: cosmos } + resource mycontainer 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2024-08-15' = { + name: 'mycontainer' + location: location + properties: { + resource: { + id: 'mycontainer' + partitionKey: { + paths: [ + 'mypartitionkeypath' + ] + } + } + } + parent: mydatabase + } + + resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = { + name: keyVaultName + } + resource connectionString 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { name: 'connectionString' properties: { @@ -332,9 +349,9 @@ param keyVaultName string } [Fact] - public async Task AddAzureCosmosDBViaPublishMode() + public async Task AddAzureCosmosDBViaRunMode_NoAccessKeyAuthentication() { - using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + using var builder = TestDistributedApplicationBuilder.Create(); IEnumerable? callbackDatabases = null; var cosmos = builder.AddAzureCosmosDB("cosmos") @@ -342,7 +359,113 @@ public async Task AddAzureCosmosDBViaPublishMode() { callbackDatabases = infrastructure.GetProvisionableResources().OfType(); }); - cosmos.AddDatabase("mydatabase"); + cosmos.WithDatabase("mydatabase", db => db.Containers.Add(new("mycontainer", "mypartitionkeypath"))); + + cosmos.Resource.Outputs["connectionString"] = "mycosmosconnectionstring"; + + var manifest = await ManifestUtils.GetManifestWithBicep(cosmos.Resource); + + var expectedManifest = """ + { + "type": "azure.bicep.v0", + "connectionString": "{cosmos.outputs.connectionString}", + "path": "cosmos.module.bicep", + "params": { + "principalType": "", + "principalId": "" + } + } + """; + + output.WriteLine(manifest.ManifestNode.ToString()); + Assert.Equal(expectedManifest, manifest.ManifestNode.ToString()); + + var expectedBicep = """ + @description('The location for the resource(s) to be deployed.') + param location string = resourceGroup().location + + param principalType string + + param principalId string + + resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2024-08-15' = { + name: take('cosmos-${uniqueString(resourceGroup().id)}', 44) + location: location + properties: { + locations: [ + { + locationName: location + failoverPriority: 0 + } + ] + consistencyPolicy: { + defaultConsistencyLevel: 'Session' + } + databaseAccountOfferType: 'Standard' + disableLocalAuth: true + } + kind: 'GlobalDocumentDB' + tags: { + 'aspire-resource-name': 'cosmos' + } + } + + resource mydatabase 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2024-08-15' = { + name: 'mydatabase' + location: location + properties: { + resource: { + id: 'mydatabase' + } + } + parent: cosmos + } + + resource mycontainer 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2024-08-15' = { + name: 'mycontainer' + location: location + properties: { + resource: { + id: 'mycontainer' + partitionKey: { + paths: [ + 'mypartitionkeypath' + ] + } + } + } + parent: mydatabase + } + + output connectionString string = cosmos.properties.documentEndpoint + """; + output.WriteLine(manifest.BicepText); + Assert.Equal(expectedBicep, manifest.BicepText); + + Assert.NotNull(callbackDatabases); + Assert.Collection( + callbackDatabases, + (database) => Assert.Equal("mydatabase", database.Name.Value) + ); + + var connectionStringResource = (IResourceWithConnectionString)cosmos.Resource; + + Assert.Equal("cosmos", cosmos.Resource.Name); + Assert.Equal("mycosmosconnectionstring", await connectionStringResource.GetConnectionStringAsync()); + } + + [Fact] + public async Task AddAzureCosmosDBViaPublishMode_WithAccessKeyAuthentication() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + IEnumerable? callbackDatabases = null; + var cosmos = builder.AddAzureCosmosDB("cosmos") + .ConfigureInfrastructure(infrastructure => + { + callbackDatabases = infrastructure.GetProvisionableResources().OfType(); + }).WithAccessKeyAuthentication(); + cosmos.WithDatabase("mydatabase", db => db.Containers.Add(new("mycontainer", "mypartitionkeypath"))); cosmos.Resource.SecretOutputs["connectionString"] = "mycosmosconnectionstring"; @@ -366,10 +489,6 @@ public async Task AddAzureCosmosDBViaPublishMode() param keyVaultName string - resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = { - name: keyVaultName - } - resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2024-08-15' = { name: take('cosmos-${uniqueString(resourceGroup().id)}', 44) location: location @@ -384,6 +503,7 @@ param keyVaultName string defaultConsistencyLevel: 'Session' } databaseAccountOfferType: 'Standard' + disableLocalAuth: false } kind: 'GlobalDocumentDB' tags: { @@ -402,6 +522,26 @@ param keyVaultName string parent: cosmos } + resource mycontainer 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2024-08-15' = { + name: 'mycontainer' + location: location + properties: { + resource: { + id: 'mycontainer' + partitionKey: { + paths: [ + 'mypartitionkeypath' + ] + } + } + } + parent: mydatabase + } + + resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = { + name: keyVaultName + } + resource connectionString 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { name: 'connectionString' properties: { @@ -425,6 +565,110 @@ param keyVaultName string Assert.Equal("mycosmosconnectionstring", await connectionStringResource.GetConnectionStringAsync()); } + [Fact] + public async Task AddAzureCosmosDBViaPublishMode_NoAccessKeyAuthentication() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + IEnumerable? callbackDatabases = null; + var cosmos = builder.AddAzureCosmosDB("cosmos") + .ConfigureInfrastructure(infrastructure => + { + callbackDatabases = infrastructure.GetProvisionableResources().OfType(); + }); + cosmos.WithDatabase("mydatabase", db => db.Containers.Add(new("mycontainer", "mypartitionkeypath"))); + + cosmos.Resource.Outputs["connectionString"] = "mycosmosconnectionstring"; + + var manifest = await ManifestUtils.GetManifestWithBicep(cosmos.Resource); + + var expectedManifest = """ + { + "type": "azure.bicep.v0", + "connectionString": "{cosmos.outputs.connectionString}", + "path": "cosmos.module.bicep", + "params": { + "principalType": "", + "principalId": "" + } + } + """; + Assert.Equal(expectedManifest, manifest.ManifestNode.ToString()); + + var expectedBicep = """ + @description('The location for the resource(s) to be deployed.') + param location string = resourceGroup().location + + param principalType string + + param principalId string + + resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2024-08-15' = { + name: take('cosmos-${uniqueString(resourceGroup().id)}', 44) + location: location + properties: { + locations: [ + { + locationName: location + failoverPriority: 0 + } + ] + consistencyPolicy: { + defaultConsistencyLevel: 'Session' + } + databaseAccountOfferType: 'Standard' + disableLocalAuth: true + } + kind: 'GlobalDocumentDB' + tags: { + 'aspire-resource-name': 'cosmos' + } + } + + resource mydatabase 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2024-08-15' = { + name: 'mydatabase' + location: location + properties: { + resource: { + id: 'mydatabase' + } + } + parent: cosmos + } + + resource mycontainer 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2024-08-15' = { + name: 'mycontainer' + location: location + properties: { + resource: { + id: 'mycontainer' + partitionKey: { + paths: [ + 'mypartitionkeypath' + ] + } + } + } + parent: mydatabase + } + + output connectionString string = cosmos.properties.documentEndpoint + """; + output.WriteLine(manifest.BicepText); + Assert.Equal(expectedBicep, manifest.BicepText); + + Assert.NotNull(callbackDatabases); + Assert.Collection( + callbackDatabases, + (database) => Assert.Equal("mydatabase", database.Name.Value) + ); + + var connectionStringResource = (IResourceWithConnectionString)cosmos.Resource; + + Assert.Equal("cosmos", cosmos.Resource.Name); + Assert.Equal("mycosmosconnectionstring", await connectionStringResource.GetConnectionStringAsync()); + } + [Fact] public async Task AddAzureAppConfiguration() { diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs index 62d651204b0..86b877fa746 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs @@ -471,7 +471,7 @@ public async Task ProjectWithManyReferenceTypes() builder.AddAzureContainerAppsInfrastructure(); // CosmosDB uses secret outputs - var db = builder.AddAzureCosmosDB("mydb").AddDatabase("db"); + var db = builder.AddAzureCosmosDB("mydb").WithDatabase("db"); // Postgres uses secret outputs + a literal connection string var pgdb = builder.AddAzurePostgresFlexibleServer("pg").WithPasswordAuthentication().AddDatabase("db"); @@ -536,10 +536,10 @@ public async Task ProjectWithManyReferenceTypes() "path": "api.module.bicep", "params": { "api_containerport": "{api.containerPort}", - "mydb_secretoutputs": "{mydb.secretOutputs}", - "outputs_azure_container_registry_managed_identity_id": "{.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", + "mydb_outputs_connectionstring": "{mydb.outputs.connectionString}", "storage_outputs_blobendpoint": "{storage.outputs.blobEndpoint}", "pg_secretoutputs": "{pg.secretOutputs}", + "outputs_azure_container_registry_managed_identity_id": "{.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", "value0_value": "{value0.value}", "value1_value": "{value1.value}", "outputs_azure_container_apps_environment_default_domain": "{.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN}", @@ -560,14 +560,14 @@ public async Task ProjectWithManyReferenceTypes() param api_containerport string - param mydb_secretoutputs string - - param outputs_azure_container_registry_managed_identity_id string + param mydb_outputs_connectionstring string param storage_outputs_blobendpoint string param pg_secretoutputs string + param outputs_azure_container_registry_managed_identity_id string + @secure() param value0_value string @@ -583,19 +583,10 @@ param outputs_azure_container_registry_endpoint string param api_containerimage string - resource mydb_secretoutputs_kv 'Microsoft.KeyVault/vaults@2023-07-01' existing = { - name: mydb_secretoutputs - } - resource pg_secretoutputs_kv 'Microsoft.KeyVault/vaults@2023-07-01' existing = { name: pg_secretoutputs } - resource mydb_secretoutputs_kv_connectionString 'Microsoft.KeyVault/vaults/secrets@2023-07-01' existing = { - name: 'connectionString' - parent: mydb_secretoutputs_kv - } - resource pg_secretoutputs_kv_db_connectionString 'Microsoft.KeyVault/vaults/secrets@2023-07-01' existing = { name: 'db-connectionString' parent: pg_secretoutputs_kv @@ -607,11 +598,6 @@ param api_containerimage string properties: { configuration: { secrets: [ - { - name: 'connectionstrings--mydb' - identity: outputs_azure_container_registry_managed_identity_id - keyVaultUrl: mydb_secretoutputs_kv_connectionString.properties.secretUri - } { name: 'connectionstrings--db' identity: outputs_azure_container_registry_managed_identity_id @@ -678,7 +664,7 @@ param api_containerimage string } { name: 'ConnectionStrings__mydb' - secretRef: 'connectionstrings--mydb' + value: mydb_outputs_connectionstring } { name: 'ConnectionStrings__blobs' @@ -1105,7 +1091,7 @@ public async Task SecretOutputHandling() builder.AddAzureContainerAppsInfrastructure(); - var db = builder.AddAzureCosmosDB("mydb").AddDatabase("db"); + var db = builder.AddAzureCosmosDB("mydb").WithAccessKeyAuthentication().WithDatabase("db"); builder.AddContainer("api", "image") .WithReference(db) diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureCosmosDBEmulatorFunctionalTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureCosmosDBEmulatorFunctionalTests.cs index d1156b9b1bc..bd9476f10a4 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureCosmosDBEmulatorFunctionalTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureCosmosDBEmulatorFunctionalTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Net; using Aspire.Components.Common.Tests; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Utils; @@ -25,7 +26,7 @@ public class AzureCosmosDBEmulatorFunctionalTests(ITestOutputHelper testOutputHe public async Task VerifyWaitForOnCosmosDBEmulatorBlocksDependentResources(bool usePreview) { // Cosmos can be pretty slow to spin up, lets give it plenty of time. - var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10)); using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); var healthCheckTcs = new TaskCompletionSource(); @@ -68,7 +69,7 @@ public async Task VerifyWaitForOnCosmosDBEmulatorBlocksDependentResources(bool u [RequiresDocker(Reason = "CosmosDB emulator is needed for this test")] public async Task VerifyCosmosResource(bool usePreview) { - var cts = new CancellationTokenSource(TimeSpan.FromMinutes(3)); + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10)); var pipeline = new ResiliencePipelineBuilder() .AddRetry(new() { @@ -86,12 +87,12 @@ public async Task VerifyCosmosResource(bool usePreview) var containerName = "container1"; var cosmos = builder.AddAzureCosmosDB("cosmos"); - var db = cosmos.AddDatabase(databaseName) + var db = cosmos.WithDatabase(databaseName) .RunAsEmulator(usePreview); using var app = builder.Build(); - await app.StartAsync(); + await app.StartAsync(cts.Token); var rns = app.Services.GetRequiredService(); await rns.WaitForResourceHealthyAsync(db.Resource.Name, cts.Token); @@ -140,7 +141,7 @@ public async Task WithDataVolumeShouldPersistStateBetweenUsages(bool usePreview) { // Use a volume to do a snapshot save - var cts = new CancellationTokenSource(TimeSpan.FromMinutes(3)); + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10)); var pipeline = new ResiliencePipelineBuilder() .AddRetry(new() { @@ -160,7 +161,7 @@ public async Task WithDataVolumeShouldPersistStateBetweenUsages(bool usePreview) // Use a deterministic volume name to prevent them from exhausting the machines if deletion fails var volumeName = VolumeNameGenerator.Generate(cosmos1, nameof(WithDataVolumeShouldPersistStateBetweenUsages)); - var db1 = cosmos1.AddDatabase(databaseName) + var db1 = cosmos1.WithDatabase(databaseName) .RunAsEmulator(usePreview, volumeName); // if the volume already exists (because of a crashing previous run), delete it @@ -170,7 +171,7 @@ public async Task WithDataVolumeShouldPersistStateBetweenUsages(bool usePreview) using (var app = builder1.Build()) { - await app.StartAsync(); + await app.StartAsync(cts.Token); var rns = app.Services.GetRequiredService(); await rns.WaitForResourceHealthyAsync(db1.Resource.Name, cts.Token); @@ -213,12 +214,12 @@ await pipeline.ExecuteAsync(async token => using var builder2 = TestDistributedApplicationBuilder.Create(options => { }, testOutputHelper); var cosmos2 = builder2.AddAzureCosmosDB("cosmos"); - var db2 = cosmos2.AddDatabase(databaseName) + var db2 = cosmos2.WithDatabase(databaseName) .RunAsEmulator(usePreview, volumeName); using (var app = builder2.Build()) { - await app.StartAsync(); + await app.StartAsync(cts.Token); var rns = app.Services.GetRequiredService(); await rns.WaitForResourceHealthyAsync(db2.Resource.Name, cts.Token); @@ -266,6 +267,54 @@ await pipeline.ExecuteAsync(async token => DockerUtils.AttemptDeleteDockerVolume(volumeName); } + + [Fact] + [RequiresDocker] + public async Task AddAzureCosmosDB_RunAsEmulator_CreatesDatabase() + { + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10)); + + using var builder = TestDistributedApplicationBuilder.Create(options => { }, testOutputHelper); + + var databaseName = "db1"; + var containerName = "container1"; + var partitionKeyPath = "/id"; + + var cosmos = builder.AddAzureCosmosDB("cosmos") + .WithDatabase(databaseName, db => db.Containers.Add(new(containerName, partitionKeyPath))) + .RunAsEmulator(); + + using var app = builder.Build(); + + await app.StartAsync(cts.Token); + + var rns = app.Services.GetRequiredService(); + await rns.WaitForResourceHealthyAsync(cosmos.Resource.Name, cts.Token); + + var hb = Host.CreateApplicationBuilder(); + hb.Configuration[$"ConnectionStrings:{cosmos.Resource.Name}"] = await cosmos.Resource.ConnectionStringExpression.GetValueAsync(default); + hb.AddAzureCosmosClient(cosmos.Resource.Name); + + using var host = hb.Build(); + + await host.StartAsync(cts.Token); + + using var cosmosClient = host.Services.GetRequiredService(); + + var database = cosmosClient.GetDatabase(databaseName); + var result1 = await database.ReadAsync(cancellationToken: cts.Token); + + var container = database.GetContainer(containerName); + var result2 = await container.ReadContainerAsync(cancellationToken: cts.Token); + + Assert.True(IsSuccess(result1.StatusCode)); + Assert.True(IsSuccess(result2.StatusCode)); + + static bool IsSuccess(HttpStatusCode httpStatusCode) + { + return ((int)httpStatusCode >= 200) && ((int)httpStatusCode <= 299); + } + } } public class EFCoreCosmosDbContext(DbContextOptions options) : DbContext(options)