diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index a0e3aae96e5..b198b1e3d2b 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -152,3 +152,4 @@ overrides: ignorePaths: - "**/*_test.go" - "**/mock*.go" + - "internal/cmd/add/add_configure_locations.go" diff --git a/cli/azd/internal/scaffold/scaffold.go b/cli/azd/internal/scaffold/scaffold.go index a82507e9da2..501ffe20dbb 100644 --- a/cli/azd/internal/scaffold/scaffold.go +++ b/cli/azd/internal/scaffold/scaffold.go @@ -75,6 +75,10 @@ func supportingFiles(spec InfraSpec) []string { files = append(files, "/modules/fetch-container-image.bicep") } + if spec.AiProject != nil { + files = append(files, "/modules/role.bicep") + } + return files } @@ -149,7 +153,7 @@ func ExecInfraFs( return nil, fmt.Errorf("scaffolding main.parameters.json: %w", err) } - if spec.AiFoundryProject != nil { + if spec.AiProject != nil { err = executeToFS(fs, t, "ai-project.bicep", "ai-project.bicep", spec) if err != nil { return nil, fmt.Errorf("scaffolding ai-foundry-models.bicep: %w", err) diff --git a/cli/azd/internal/scaffold/scaffold_test.go b/cli/azd/internal/scaffold/scaffold_test.go index 2d2df1f5d75..b006bfd959d 100644 --- a/cli/azd/internal/scaffold/scaffold_test.go +++ b/cli/azd/internal/scaffold/scaffold_test.go @@ -83,7 +83,7 @@ func TestExecInfra(t *testing.T) { { "All", InfraSpec{ - AiFoundryProject: &AiFoundrySpec{ + AiProject: &AiProjectSpec{ Name: "project", Models: []AiFoundryModel{ { diff --git a/cli/azd/internal/scaffold/spec.go b/cli/azd/internal/scaffold/spec.go index 72174caba2b..88f71530695 100644 --- a/cli/azd/internal/scaffold/spec.go +++ b/cli/azd/internal/scaffold/spec.go @@ -32,7 +32,7 @@ type InfraSpec struct { AIModels []AIModel // ai foundry models - AiFoundryProject *AiFoundrySpec + AiProject *AiProjectSpec } type Parameter struct { @@ -64,9 +64,10 @@ type AIModel struct { } // AIModel represents a deployed, ready to use AI model. -type AiFoundrySpec struct { - Name string - Models []AiFoundryModel +type AiProjectSpec struct { + Name string + ConnStringFromEnvVar *string + Models []AiFoundryModel } type AiFoundryModel struct { @@ -135,7 +136,7 @@ type ServiceSpec struct { ServiceBus *ServiceBus EventHubs *EventHubs - HasAiFoundryProject *AiFoundrySpec + AiProject *AiProjectSpec } type Frontend struct { diff --git a/cli/azd/pkg/project/resources.go b/cli/azd/pkg/project/resources.go index 6ea05e2c2db..22646a4a999 100644 --- a/cli/azd/pkg/project/resources.go +++ b/cli/azd/pkg/project/resources.go @@ -61,7 +61,7 @@ func (r ResourceType) String() string { case ResourceTypeStorage: return "Storage Account" case ResourceTypeAiProject: - return "AI Foundry" + return "AI Project" case ResourceTypeKeyVault: return "Key Vault" } @@ -237,5 +237,6 @@ type AiServicesModelSku struct { } type AiFoundryModelProps struct { - Models []AiServicesModel `yaml:"models,omitempty"` + ConnStringFromEnvVar *string `yaml:"connStringFromEnvVar,omitempty"` + Models []AiServicesModel `yaml:"models,omitempty"` } diff --git a/cli/azd/pkg/project/scaffold_gen.go b/cli/azd/pkg/project/scaffold_gen.go index 8e3d3ed4acf..ba2c83b9593 100644 --- a/cli/azd/pkg/project/scaffold_gen.go +++ b/cli/azd/pkg/project/scaffold_gen.go @@ -231,8 +231,9 @@ func infraSpec(projectConfig *ProjectConfig) (*scaffold.InfraSpec, error) { props := res.Props.(AiFoundryModelProps) foundryName := res.Name var foundryModels []scaffold.AiFoundryModel - foundrySpec := scaffold.AiFoundrySpec{ - Name: foundryName, + foundrySpec := scaffold.AiProjectSpec{ + Name: foundryName, + ConnStringFromEnvVar: props.ConnStringFromEnvVar, } for _, model := range props.Models { foundryModels = append(foundryModels, scaffold.AiFoundryModel{ @@ -249,7 +250,7 @@ func infraSpec(projectConfig *ProjectConfig) (*scaffold.InfraSpec, error) { }) } foundrySpec.Models = foundryModels - infraSpec.AiFoundryProject = &foundrySpec + infraSpec.AiProject = &foundrySpec case ResourceTypeKeyVault: infraSpec.KeyVault = &scaffold.KeyVault{} } @@ -352,7 +353,7 @@ func mapHostUses( case ResourceTypeStorage: svcSpec.StorageAccount = &scaffold.StorageReference{} case ResourceTypeAiProject: - svcSpec.HasAiFoundryProject = &scaffold.AiFoundrySpec{} + svcSpec.AiProject = &scaffold.AiProjectSpec{} case ResourceTypeKeyVault: svcSpec.KeyVault = &scaffold.KeyVaultReference{} } diff --git a/cli/azd/resources/scaffold/base/modules/role.bicep b/cli/azd/resources/scaffold/base/modules/role.bicep new file mode 100644 index 00000000000..001290ee45d --- /dev/null +++ b/cli/azd/resources/scaffold/base/modules/role.bicep @@ -0,0 +1,22 @@ +metadata description = 'Creates a role assignment for a service principal.' +param principalId string + +@allowed([ + 'Device' + 'ForeignGroup' + 'Group' + 'ServicePrincipal' + 'User' + '' +]) +param principalType string = '' +param roleDefinitionId string + +resource role 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(subscription().id, resourceGroup().id, principalId, roleDefinitionId) + properties: { + principalId: principalId + principalType: principalType + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionId) + } +} diff --git a/cli/azd/resources/scaffold/templates/ai-project.bicept b/cli/azd/resources/scaffold/templates/ai-project.bicept index 6b7ae2e032a..d57fd9bae2c 100644 --- a/cli/azd/resources/scaffold/templates/ai-project.bicept +++ b/cli/azd/resources/scaffold/templates/ai-project.bicept @@ -1,10 +1,15 @@ {{define "ai-project.bicep" -}} -{{- if .AiFoundryProject }} -{{- range .AiFoundryProject.Models }} +{{- if .AiProject }} +{{- if .AiProject.ConnStringFromEnvVar }} +@description('Use this parameter to use an existing AI project connection string') +param {{bicepName .AiProject.Name}}ConnectionString string +{{- end }} +{{- range .AiProject.Models }} param {{bicepName .Name}}{{bicepName .Version}}Location string {{- end }} {{- end }} +{{- if .AiProject.Models }} @description('Tags that will be applied to all resources') param tags object = {} @@ -16,8 +21,14 @@ var resourceToken = uniqueString(subscription().id, resourceGroup().id, location @description('The name of the environment') param envName string +{{- end }} +{{- if .AiProject.Models }} +{{- if .AiProject.ConnStringFromEnvVar }} +module keyVault 'br/public:avm/res/key-vault/vault:0.6.1' = if (empty({{bicepName .AiProject.Name}}ConnectionString)){ +{{- else }} module keyVault 'br/public:avm/res/key-vault/vault:0.6.1' = { +{{- end }} name: 'keyvaultForHub' params: { name: '${abbrs.keyVaultVaults}hub${resourceToken}' @@ -27,11 +38,15 @@ module keyVault 'br/public:avm/res/key-vault/vault:0.6.1' = { } } +{{- if .AiProject.ConnStringFromEnvVar }} +module storage 'br/public:avm/res/storage/storage-account:0.17.2' = if (empty({{bicepName .AiProject.Name}}ConnectionString)) { +{{- else }} module storage 'br/public:avm/res/storage/storage-account:0.17.2' = { +{{- end }} name: 'storageAccountForHub' params: { tags: tags - name: '${abbrs.storageStorageAccounts}hub${resourceToken}' + name: '${abbrs.storageStorageAccounts}${resourceToken}' allowSharedKeyAccess: true allowBlobPublicAccess: true allowCrossTenantReplication: true @@ -73,9 +88,12 @@ module storage 'br/public:avm/res/storage/storage-account:0.17.2' = { } } -{{- if .AiFoundryProject }} -{{- range .AiFoundryProject.Models }} +{{- range .AiProject.Models }} +{{- if $.AiProject.ConnStringFromEnvVar }} +resource {{bicepName .Name}}{{bicepName .Version}}Deploy 'Microsoft.CognitiveServices/accounts@2023-05-01' = if (empty({{bicepName $.AiProject.Name}}ConnectionString)) { +{{- else }} resource {{bicepName .Name}}{{bicepName .Version}}Deploy 'Microsoft.CognitiveServices/accounts@2023-05-01' = { +{{- end }} name: '{{bicepName .Name}}{{bicepName .Version}}${resourceToken}' location: {{bicepName .Name}}{{bicepName .Version}}Location tags: tags @@ -105,9 +123,12 @@ resource {{bicepName .Name}}{{bicepName .Version}}Deploy 'Microsoft.CognitiveSer } } {{- end }} -{{- end }} +{{- if .AiProject.ConnStringFromEnvVar }} +resource hub 'Microsoft.MachineLearningServices/workspaces@2024-10-01' = if (empty({{bicepName .AiProject.Name}}ConnectionString)) { +{{- else }} resource hub 'Microsoft.MachineLearningServices/workspaces@2024-10-01' = { +{{- end }} name: take('${envName}${resourceToken}',32) location: location tags: tags @@ -127,8 +148,7 @@ resource hub 'Microsoft.MachineLearningServices/workspaces@2024-10-01' = { publicNetworkAccess: 'Enabled' } -{{- if .AiFoundryProject }} -{{- range .AiFoundryProject.Models }} +{{- range .AiProject.Models }} resource {{bicepName .Name}}{{bicepName .Version}}connection 'connections' = { name: '{{bicepName .Name}}{{bicepName .Version}}-connection' properties: { @@ -146,10 +166,13 @@ resource hub 'Microsoft.MachineLearningServices/workspaces@2024-10-01' = { } } {{- end }} -{{- end }} } +{{- if .AiProject.ConnStringFromEnvVar }} +resource project 'Microsoft.MachineLearningServices/workspaces@2024-10-01' = if (empty({{bicepName .AiProject.Name}}ConnectionString)) { +{{- else }} resource project 'Microsoft.MachineLearningServices/workspaces@2024-10-01' = { +{{- end }} name: envName location: location tags: tags @@ -170,9 +193,12 @@ resource project 'Microsoft.MachineLearningServices/workspaces@2024-10-01' = { } } +{{- if .AiProject.ConnStringFromEnvVar }} +resource mlServiceRoleDataScientist 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = if (empty({{bicepName .AiProject.Name}}ConnectionString)) { +{{- else }} resource mlServiceRoleDataScientist 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = { +{{- end }} name: guid(subscription().id, resourceGroup().id, project.id, 'mlServiceRoleDataScientist', 'f6c7c914-8db3-469d-8ca1-694a8f32e121') - scope: resourceGroup() properties: { roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'f6c7c914-8db3-469d-8ca1-694a8f32e121') principalId: project.identity.principalId @@ -180,19 +206,47 @@ resource mlServiceRoleDataScientist 'Microsoft.Authorization/roleAssignments@202 } } +{{- if .AiProject.ConnStringFromEnvVar }} +resource mlServiceRoleSecretsReader 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = if (empty({{bicepName .AiProject.Name}}ConnectionString)) { +{{- else }} resource mlServiceRoleSecretsReader 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = { +{{- end }} name: guid(subscription().id, resourceGroup().id, project.id, 'mlServiceRoleSecretsReader','ea01e6af-a1c1-4350-9563-ad00f8c72ec5') - scope: resourceGroup() properties: { roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ea01e6af-a1c1-4350-9563-ad00f8c72ec5') principalId: project.identity.principalId principalType: 'ServicePrincipal' } } +{{- end }} + +{{- if and .AiProject.Models .AiProject.ConnStringFromEnvVar }} +var hostName = empty({{bicepName .AiProject.Name}}ConnectionString) ? split(project.properties.discoveryUrl, '/')[2] : '' +var projectConnectionString = empty(hostName) + ? {{bicepName .AiProject.Name}}ConnectionString + : '${hostName};${subscription().subscriptionId};${resourceGroup().name};${project.name}' +var projectId = empty(hostName) + ? {{bicepName .AiProject.Name}}ConnectionString + : project.id +var projectRG = empty(hostName) + ? split({{bicepName .AiProject.Name}}ConnectionString, ';')[2] + : resourceGroup().name +{{- end }} + +{{- if and (not .AiProject.Models) .AiProject.ConnStringFromEnvVar }} +var projectConnectionString = {{bicepName .AiProject.Name}}ConnectionString +var projectId = {{bicepName .AiProject.Name}}ConnectionString +var projectRG = split({{bicepName .AiProject.Name}}ConnectionString, ';')[2] +{{- end }} -output projectDiscoveryUrl string = project.properties.discoveryUrl -output projectId string = project.id -output projectName string = project.name -output aiFoundryProjectConnectionString string = '${split(project.properties.discoveryUrl, '/')[2]};${subscription().subscriptionId};${resourceGroup().name};${project.name}' +{{- if and .AiProject.Models (not .AiProject.ConnStringFromEnvVar) }} +var hostName = split(project.properties.discoveryUrl, '/')[2] +var projectConnectionString = '${hostName};${subscription().subscriptionId};${resourceGroup().name};${project.name}' +var projectId = project.id +var projectRG = resourceGroup().name +{{- end }} -{{ end}} +output projectId string = projectId +output aiProjectConnectionString string = projectConnectionString +output aiProjectRG string = projectRG +{{ end}} \ No newline at end of file diff --git a/cli/azd/resources/scaffold/templates/main.bicept b/cli/azd/resources/scaffold/templates/main.bicept index 19154b60922..2a46952b76a 100644 --- a/cli/azd/resources/scaffold/templates/main.bicept +++ b/cli/azd/resources/scaffold/templates/main.bicept @@ -10,8 +10,16 @@ param environmentName string @description('Primary location for all resources') param location string -{{- if .AiFoundryProject }} -{{- range .AiFoundryProject.Models }} +{{- if .AiProject }} +{{- if .AiProject.ConnStringFromEnvVar }} +@description('Use this parameter to use an existing AI project connection string') +{{- if .AiProject.Models }} +param {{bicepName .AiProject.Name}}ConnectionString string = '' +{{- else }} +param {{bicepName .AiProject.Name}}ConnectionString string +{{- end }} +{{- end }} +{{- range .AiProject.Models }} @metadata({azd: { type: 'location' usageName: '{{ .Sku.UsageName }},{{ .Sku.Capacity }}' @@ -56,24 +64,29 @@ module resources 'resources.bicep' = { {{- range .Parameters}} {{.Name}}: {{.Name}} {{- end }} -{{- if .AiFoundryProject }} - aiFoundryProjectConnectionString: aiModelsDeploy.outputs.aiFoundryProjectConnectionString +{{- if .AiProject }} + aiProjectConnectionString: aiModelsDeploy.outputs.aiProjectConnectionString {{- end}} } } -{{- if .AiFoundryProject }} +{{- if .AiProject }} module aiModelsDeploy 'ai-project.bicep' = { scope: rg - name: '{{.AiFoundryProject.Name}}' + name: '{{.AiProject.Name}}' params: { -{{- range .AiFoundryProject.Models }} +{{- range .AiProject.Models }} {{bicepName .Name}}{{bicepName .Version}}Location: {{bicepName .Name}}{{bicepName .Version}}Location -{{- end }} +{{- end }} +{{- if .AiProject.Models }} tags: tags location: location envName: environmentName +{{- end }} +{{- if .AiProject.ConnStringFromEnvVar }} + {{bicepName .AiProject.Name}}ConnectionString: {{bicepName .AiProject.Name}}ConnectionString +{{- end }} } } {{- end}} @@ -83,6 +96,7 @@ module aiModelsDeploy 'ai-project.bicep' = { output AZURE_CONTAINER_REGISTRY_ENDPOINT string = resources.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT {{- range .Services}} output AZURE_RESOURCE_{{alphaSnakeUpper .Name}}_ID string = resources.outputs.AZURE_RESOURCE_{{alphaSnakeUpper .Name}}_ID +output AZURE_RESOURCE_{{alphaSnakeUpper .Name}}_IDENTITY_ID string = resources.outputs.AZURE_RESOURCE_{{alphaSnakeUpper .Name}}_IDENTITY_ID {{- end}} {{- end}} {{- if .KeyVault}} @@ -113,8 +127,8 @@ output AZURE_RESOURCE_EVENT_HUBS_ID string = resources.outputs.AZURE_RESOURCE_EV {{- if .ServiceBus}} output AZURE_RESOURCE_SERVICE_BUS_ID string = resources.outputs.AZURE_RESOURCE_SERVICE_BUS_ID {{- end}} -{{- if .AiFoundryProject }} -output AZURE_AIPROJECT_CONNECTION_STRING string = aiModelsDeploy.outputs.aiFoundryProjectConnectionString +{{- if .AiProject }} +output AZURE_AIPROJECT_CONNECTION_STRING string = aiModelsDeploy.outputs.aiProjectConnectionString output AZURE_RESOURCE_AI_PROJECT_ID string = aiModelsDeploy.outputs.projectId {{- end}} {{ end}} diff --git a/cli/azd/resources/scaffold/templates/main.parameters.jsont b/cli/azd/resources/scaffold/templates/main.parameters.jsont index e4c1ba2bf22..d5feb1a38e7 100644 --- a/cli/azd/resources/scaffold/templates/main.parameters.jsont +++ b/cli/azd/resources/scaffold/templates/main.parameters.jsont @@ -14,6 +14,13 @@ "value": {{formatParam " " " " .Value}} }, {{- end}} + {{- if .AiProject }} + {{- if .AiProject.ConnStringFromEnvVar }} + "{{bicepName .AiProject.Name}}ConnectionString": { + "value": "${{`{`}}{{.AiProject.ConnStringFromEnvVar}}{{`}`}}" + }, + {{- end }} + {{- end }} "principalId": { "value": "${AZURE_PRINCIPAL_ID}" } diff --git a/cli/azd/resources/scaffold/templates/resources.bicept b/cli/azd/resources/scaffold/templates/resources.bicept index de76ae930bd..9a2e45870a0 100644 --- a/cli/azd/resources/scaffold/templates/resources.bicept +++ b/cli/azd/resources/scaffold/templates/resources.bicept @@ -12,8 +12,8 @@ param tags object = {} param {{.Name}} {{.Type}} {{- end}} -{{- if .AiFoundryProject }} -param aiFoundryProjectConnectionString string +{{- if .AiProject }} +param aiProjectConnectionString string {{- end}} @description('Id of the user or app to assign application roles') @@ -569,10 +569,10 @@ module {{bicepName .Name}} 'br/public:avm/res/app/container-app:0.8.0' = { value: account.outputs.endpoint } {{- end}} - {{- if .HasAiFoundryProject }} + {{- if .AiProject }} { name: 'AZURE_AIPROJECT_CONNECTION_STRING' - value: aiFoundryProjectConnectionString + value: aiProjectConnectionString } {{- end}} {{- if .Frontend}} @@ -618,18 +618,7 @@ module {{bicepName .Name}} 'br/public:avm/res/app/container-app:0.8.0' = { tags: union(tags, { 'azd-service-name': '{{.Name}}' }) } } -{{- if .HasAiFoundryProject}} -resource {{bicepName .Name}}backendRoleAzureAIDeveloperRG 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = { - name: guid(subscription().id, resourceGroup().id, {{bicepName .Name}}Identity.name, '64702f94-c441-49e6-a78b-ef80e0188fee') - scope: resourceGroup() - properties: { - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '64702f94-c441-49e6-a78b-ef80e0188fee') - principalId: {{bicepName .Name}}Identity.outputs.principalId - principalType: 'ServicePrincipal' - } -} -{{- end }} {{- end}} {{- if .DbRedis}} @@ -693,10 +682,31 @@ module keyVault 'br/public:avm/res/key-vault/vault:0.12.0' = { } {{- end}} +{{- if .AiProject }} +resource aiProjectRG 'Microsoft.Resources/resourceGroups@2021-04-01' existing = { + scope: subscription() + name: split(aiProjectConnectionString, ';')[2] +} +{{- range .Services}} +{{- if .AiProject}} +module {{bicepName .Name}}backendRoleAzureAIDeveloperRG 'modules/role.bicep' = { + name: '{{bicepName .Name}}backendRoleAzureAIDeveloperRG' + scope: aiProjectRG + params: { + principalId: {{bicepName .Name}}Identity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: '64702f94-c441-49e6-a78b-ef80e0188fee' + } +} +{{- end }} +{{- end }} +{{- end }} + {{- if .Services}} output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerRegistry.outputs.loginServer {{- range .Services}} output AZURE_RESOURCE_{{alphaSnakeUpper .Name}}_ID string = {{bicepName .Name}}.outputs.resourceId +output AZURE_RESOURCE_{{alphaSnakeUpper .Name}}_IDENTITY_ID string = {{bicepName .Name}}Identity.outputs.principalId {{- end}} {{- end}} {{- if .KeyVault}} @@ -727,4 +737,5 @@ output AZURE_RESOURCE_EVENT_HUBS_ID string = eventHubNamespace.outputs.resourceI {{- if .ServiceBus}} output AZURE_RESOURCE_SERVICE_BUS_ID string = serviceBusNamespace.outputs.resourceId {{- end}} +output principalId string = principalId {{ end}}