diff --git a/.github/labeler-issue-triage.yml b/.github/labeler-issue-triage.yml index ba97f5f93250..78d10db98286 100644 --- a/.github/labeler-issue-triage.yml +++ b/.github/labeler-issue-triage.yml @@ -198,7 +198,7 @@ service/logic: - '### (|New or )Affected Resource\(s\)\/Data Source\(s\)((.|\n)*)azurerm_logic_app_((.|\n)*)###' service/machine-learning: - - '### (|New or )Affected Resource\(s\)\/Data Source\(s\)((.|\n)*)azurerm_machine_learning_((.|\n)*)###' + - '### (|New or )Affected Resource\(s\)\/Data Source\(s\)((.|\n)*)azurerm_(ai_foundry|machine_learning_)((.|\n)*)###' service/maintenance: - '### (|New or )Affected Resource\(s\)\/Data Source\(s\)((.|\n)*)azurerm_(maintenance_|public_maintenance_configurations)((.|\n)*)###' diff --git a/internal/services/cognitive/ai_services_resource.go b/internal/services/cognitive/ai_services_resource.go index 5d62acbc7954..455e4cf4e34d 100644 --- a/internal/services/cognitive/ai_services_resource.go +++ b/internal/services/cognitive/ai_services_resource.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "log" + "strings" "time" "github.com/hashicorp/go-azure-helpers/lang/pointer" @@ -34,50 +35,61 @@ import ( "github.com/hashicorp/terraform-provider-azurerm/utils" ) -var _ sdk.ResourceWithUpdate = AzureAIServicesResource{} +var _ sdk.ResourceWithUpdate = AIServices{} -var _ sdk.ResourceWithCustomImporter = AzureAIServicesResource{} +var _ sdk.ResourceWithCustomImporter = AIServices{} -type AzureAIServicesResource struct{} +type AIServices struct{} -func (r AzureAIServicesResource) CustomImporter() sdk.ResourceRunFunc { +func (r AIServices) CustomImporter() sdk.ResourceRunFunc { return func(ctx context.Context, metadata sdk.ResourceMetaData) error { - _, err := cognitiveservicesaccounts.ParseAccountID(metadata.ResourceData.Id()) + id, err := cognitiveservicesaccounts.ParseAccountID(metadata.ResourceData.Id()) if err != nil { return err } + + client := metadata.Client.Cognitive.AccountsClient + resp, err := client.AccountsGet(ctx, *id) + if err != nil || resp.Model == nil || resp.Model.Kind == nil { + return fmt.Errorf("retrieving %s: %+v", *id, err) + } + + if !strings.EqualFold(*resp.Model.Kind, "AIServices") { + return fmt.Errorf("importing %s: specified account is not of kind `AIServices`, got `%s`", id, *resp.Model.Kind) + } + return nil } } -type AzureAIServicesVirtualNetworkRules struct { +type VirtualNetworkRules struct { SubnetID string `tfschema:"subnet_id"` IgnoreMissingVnetServiceEndpoint bool `tfschema:"ignore_missing_vnet_service_endpoint"` } -type AzureAIServicesNetworkACLs struct { - DefaultAction string `tfschema:"default_action"` - IpRules []string `tfschema:"ip_rules"` - VirtualNetworkRules []AzureAIServicesVirtualNetworkRules `tfschema:"virtual_network_rules"` +type NetworkACLs struct { + DefaultAction string `tfschema:"default_action"` + IpRules []string `tfschema:"ip_rules"` + VirtualNetworkRules []VirtualNetworkRules `tfschema:"virtual_network_rules"` } -type AzureAIServicesCustomerManagedKey struct { +type CustomerManagedKey struct { IdentityClientID string `tfschema:"identity_client_id"` KeyVaultKeyID string `tfschema:"key_vault_key_id"` ManagedHsmKeyID string `tfschema:"managed_hsm_key_id"` } -type AzureAIServicesResourceResourceModel struct { +type AIServicesModel struct { Name string `tfschema:"name"` ResourceGroupName string `tfschema:"resource_group_name"` Location string `tfschema:"location"` SkuName string `tfschema:"sku_name"` CustomSubdomainName string `tfschema:"custom_subdomain_name"` - CustomerManagedKey []AzureAIServicesCustomerManagedKey `tfschema:"customer_managed_key"` + CustomerManagedKey []CustomerManagedKey `tfschema:"customer_managed_key"` Fqdns []string `tfschema:"fqdns"` Identity []identity.ModelSystemAssignedUserAssigned `tfschema:"identity"` LocalAuthorizationEnabled bool `tfschema:"local_authentication_enabled"` - NetworkACLs []AzureAIServicesNetworkACLs `tfschema:"network_acls"` + NetworkACLs []NetworkACLs `tfschema:"network_acls"` OutboundNetworkAccessRestricted bool `tfschema:"outbound_network_access_restricted"` PublicNetworkAccess string `tfschema:"public_network_access"` Tags map[string]string `tfschema:"tags"` @@ -86,7 +98,7 @@ type AzureAIServicesResourceResourceModel struct { SecondaryAccessKey string `tfschema:"secondary_access_key"` } -func (AzureAIServicesResource) Arguments() map[string]*pluginsdk.Schema { +func (AIServices) Arguments() map[string]*pluginsdk.Schema { return map[string]*pluginsdk.Schema{ "name": { Type: pluginsdk.TypeString, @@ -250,7 +262,7 @@ func (AzureAIServicesResource) Arguments() map[string]*pluginsdk.Schema { } } -func (AzureAIServicesResource) Attributes() map[string]*pluginsdk.Schema { +func (AIServices) Attributes() map[string]*pluginsdk.Schema { return map[string]*pluginsdk.Schema{ "endpoint": { Type: pluginsdk.TypeString, @@ -271,19 +283,19 @@ func (AzureAIServicesResource) Attributes() map[string]*pluginsdk.Schema { } } -func (AzureAIServicesResource) ModelObject() interface{} { - return &AzureAIServicesResourceResourceModel{} +func (AIServices) ModelObject() interface{} { + return &AIServicesModel{} } -func (AzureAIServicesResource) ResourceType() string { +func (AIServices) ResourceType() string { return "azurerm_ai_services" } -func (AzureAIServicesResource) Create() sdk.ResourceFunc { +func (AIServices) Create() sdk.ResourceFunc { return sdk.ResourceFunc{ Timeout: 180 * time.Minute, Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error { - var model AzureAIServicesResourceResourceModel + var model AIServicesModel if err := metadata.Decode(&model); err != nil { return err } @@ -303,7 +315,7 @@ func (AzureAIServicesResource) Create() sdk.ResourceFunc { return tf.ImportAsExistsError("azurerm_ai_services", id.ID()) } - networkACLs, subnetIds := expandAzureAIServicesNetworkACLs(model.NetworkACLs) + networkACLs, subnetIds := expandNetworkACLs(model.NetworkACLs) // also lock on the Virtual Network ID's since modifications in the networking stack are exclusive virtualNetworkNames := make([]string, 0) @@ -348,15 +360,17 @@ func (AzureAIServicesResource) Create() sdk.ResourceFunc { } // creating with KV HSM takes more time than expected, at least hours in most cases and eventually terminated by service - customerManagedKey, err := expandAzureAIServicesCustomerManagedKey(model.CustomerManagedKey) - if err != nil { - return fmt.Errorf("expanding `customer_managed_key`: %+v", err) - } + if len(model.CustomerManagedKey) > 0 { + customerManagedKey, err := expandCustomerManagedKey(model.CustomerManagedKey) + if err != nil { + return fmt.Errorf("expanding `customer_managed_key`: %+v", err) + } - if customerManagedKey != nil { - props.Properties.Encryption = customerManagedKey - if err := client.AccountsUpdateThenPoll(ctx, id, props); err != nil { - return fmt.Errorf("updating %s: %+v", id, err) + if customerManagedKey != nil { + props.Properties.Encryption = customerManagedKey + if err := client.AccountsUpdateThenPoll(ctx, id, props); err != nil { + return fmt.Errorf("updating %s: %+v", id, err) + } } } @@ -367,14 +381,14 @@ func (AzureAIServicesResource) Create() sdk.ResourceFunc { } } -func (AzureAIServicesResource) Read() sdk.ResourceFunc { +func (AIServices) Read() sdk.ResourceFunc { return sdk.ResourceFunc{ Timeout: 5 * time.Minute, Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error { client := metadata.Client.Cognitive.AccountsClient env := metadata.Client.Account.Environment - state := AzureAIServicesResourceResourceModel{} + state := AIServicesModel{} id, err := cognitiveservicesaccounts.ParseAccountID(metadata.ResourceData.Id()) if err != nil { return err @@ -406,7 +420,7 @@ func (AzureAIServicesResource) Read() sdk.ResourceFunc { if props := model.Properties; props != nil { state.Endpoint = pointer.From(props.Endpoint) state.CustomSubdomainName = pointer.From(props.CustomSubDomainName) - state.NetworkACLs = flattenAzureAIServicesNetworkACLs(props.NetworkAcls) + state.NetworkACLs = flattenNetworkACLs(props.NetworkAcls) state.Fqdns = pointer.From(props.AllowedFqdnList) state.PublicNetworkAccess = string(pointer.From(props.PublicNetworkAccess)) @@ -430,7 +444,7 @@ func (AzureAIServicesResource) Read() sdk.ResourceFunc { } state.LocalAuthorizationEnabled = localAuthEnabled - customerManagedKey, err := flattenAzureAIServicesCustomerManagedKey(props.Encryption, env) + customerManagedKey, err := flattenCustomerManagedKey(props.Encryption, env) if err != nil { return fmt.Errorf("flattening `customer_managed_key`: %+v", err) } @@ -445,13 +459,13 @@ func (AzureAIServicesResource) Read() sdk.ResourceFunc { } } -func (AzureAIServicesResource) Update() sdk.ResourceFunc { +func (AIServices) Update() sdk.ResourceFunc { return sdk.ResourceFunc{ Timeout: 180 * time.Minute, Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error { client := metadata.Client.Cognitive.AccountsClient - var model AzureAIServicesResourceResourceModel + var model AIServicesModel if err := metadata.Decode(&model); err != nil { return err @@ -472,7 +486,7 @@ func (AzureAIServicesResource) Update() sdk.ResourceFunc { props := resp.Model if metadata.ResourceData.HasChange("network_acls") { - networkACLs, subnetIds := expandAzureAIServicesNetworkACLs(model.NetworkACLs) + networkACLs, subnetIds := expandNetworkACLs(model.NetworkACLs) locks.MultipleByName(&subnetIds, network.VirtualNetworkResourceName) defer locks.UnlockMultipleByName(&subnetIds, network.VirtualNetworkResourceName) @@ -525,7 +539,7 @@ func (AzureAIServicesResource) Update() sdk.ResourceFunc { } if metadata.ResourceData.HasChange("customer_managed_key") { - customerManagedKey, err := expandAzureAIServicesCustomerManagedKey(model.CustomerManagedKey) + customerManagedKey, err := expandCustomerManagedKey(model.CustomerManagedKey) if err != nil { return fmt.Errorf("expanding `customer_managed_key`: %+v", err) } @@ -552,7 +566,7 @@ func (AzureAIServicesResource) Update() sdk.ResourceFunc { } } -func (AzureAIServicesResource) Delete() sdk.ResourceFunc { +func (AIServices) Delete() sdk.ResourceFunc { return sdk.ResourceFunc{ Timeout: 180 * time.Minute, Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error { @@ -592,11 +606,11 @@ func (AzureAIServicesResource) Delete() sdk.ResourceFunc { } } -func (AzureAIServicesResource) IDValidationFunc() pluginsdk.SchemaValidateFunc { +func (AIServices) IDValidationFunc() pluginsdk.SchemaValidateFunc { return cognitiveservicesaccounts.ValidateAccountID } -func expandAzureAIServicesCustomerManagedKey(input []AzureAIServicesCustomerManagedKey) (*cognitiveservicesaccounts.Encryption, error) { +func expandCustomerManagedKey(input []CustomerManagedKey) (*cognitiveservicesaccounts.Encryption, error) { if len(input) == 0 { return &cognitiveservicesaccounts.Encryption{ KeySource: pointer.To(cognitiveservicesaccounts.KeySourceMicrosoftPointCognitiveServices), @@ -638,15 +652,15 @@ func expandAzureAIServicesCustomerManagedKey(input []AzureAIServicesCustomerMana return encryption, nil } -func flattenAzureAIServicesCustomerManagedKey(input *cognitiveservicesaccounts.Encryption, env environments.Environment) ([]AzureAIServicesCustomerManagedKey, error) { +func flattenCustomerManagedKey(input *cognitiveservicesaccounts.Encryption, env environments.Environment) ([]CustomerManagedKey, error) { if input == nil || *input.KeySource == cognitiveservicesaccounts.KeySourceMicrosoftPointCognitiveServices { - return []AzureAIServicesCustomerManagedKey{}, nil + return []CustomerManagedKey{}, nil } keyName := "" keyVaultURI := "" keyVersion := "" - customerManagerKey := AzureAIServicesCustomerManagedKey{} + customerManagerKey := CustomerManagedKey{} if props := input.KeyVaultProperties; props != nil { if props.KeyName != nil { @@ -690,10 +704,10 @@ func flattenAzureAIServicesCustomerManagedKey(input *cognitiveservicesaccounts.E } } - return []AzureAIServicesCustomerManagedKey{customerManagerKey}, nil + return []CustomerManagedKey{customerManagerKey}, nil } -func expandAzureAIServicesNetworkACLs(input []AzureAIServicesNetworkACLs) (*cognitiveservicesaccounts.NetworkRuleSet, []string) { +func expandNetworkACLs(input []NetworkACLs) (*cognitiveservicesaccounts.NetworkRuleSet, []string) { subnetIds := make([]string, 0) if len(input) == 0 { return nil, subnetIds @@ -731,9 +745,9 @@ func expandAzureAIServicesNetworkACLs(input []AzureAIServicesNetworkACLs) (*cogn return &ruleSet, subnetIds } -func flattenAzureAIServicesNetworkACLs(input *cognitiveservicesaccounts.NetworkRuleSet) []AzureAIServicesNetworkACLs { +func flattenNetworkACLs(input *cognitiveservicesaccounts.NetworkRuleSet) []NetworkACLs { if input == nil { - return []AzureAIServicesNetworkACLs{} + return []NetworkACLs{} } ipRules := make([]string, 0) @@ -743,7 +757,7 @@ func flattenAzureAIServicesNetworkACLs(input *cognitiveservicesaccounts.NetworkR } } - virtualNetworkRules := make([]AzureAIServicesVirtualNetworkRules, 0) + virtualNetworkRules := make([]VirtualNetworkRules, 0) if input.VirtualNetworkRules != nil { for _, v := range *input.VirtualNetworkRules { id := v.Id @@ -752,14 +766,14 @@ func flattenAzureAIServicesNetworkACLs(input *cognitiveservicesaccounts.NetworkR id = subnetId.ID() } - virtualNetworkRules = append(virtualNetworkRules, AzureAIServicesVirtualNetworkRules{ + virtualNetworkRules = append(virtualNetworkRules, VirtualNetworkRules{ SubnetID: id, IgnoreMissingVnetServiceEndpoint: pointer.From(v.IgnoreMissingVnetServiceEndpoint), }) } } - return []AzureAIServicesNetworkACLs{{ + return []NetworkACLs{{ DefaultAction: string(*input.DefaultAction), IpRules: ipRules, VirtualNetworkRules: virtualNetworkRules, diff --git a/internal/services/cognitive/ai_services_resource_test.go b/internal/services/cognitive/ai_services_resource_test.go index 0c48ca4304ff..7aaf2772b443 100644 --- a/internal/services/cognitive/ai_services_resource_test.go +++ b/internal/services/cognitive/ai_services_resource_test.go @@ -17,11 +17,11 @@ import ( "github.com/hashicorp/terraform-provider-azurerm/internal/tf/pluginsdk" ) -type AzureAIServicesResource struct{} +type AIServices struct{} -func TestAccCognitiveAzureAIServices_basic(t *testing.T) { +func TestAccCognitiveAIServices_basic(t *testing.T) { data := acceptance.BuildTestData(t, "azurerm_ai_services", "test") - r := AzureAIServicesResource{} + r := AIServices{} data.ResourceTest(t, r, []acceptance.TestStep{ { @@ -37,9 +37,9 @@ func TestAccCognitiveAzureAIServices_basic(t *testing.T) { }) } -func TestAccCognitiveAzureAIServices_requiresImport(t *testing.T) { +func TestAccCognitiveAIServices_requiresImport(t *testing.T) { data := acceptance.BuildTestData(t, "azurerm_ai_services", "test") - r := AzureAIServicesResource{} + r := AIServices{} data.ResourceTest(t, r, []acceptance.TestStep{ { @@ -48,16 +48,13 @@ func TestAccCognitiveAzureAIServices_requiresImport(t *testing.T) { check.That(data.ResourceName).ExistsInAzure(r), ), }, - { - Config: r.requiresImport(data), - ExpectError: acceptance.RequiresImportError("azurerm_ai_services"), - }, + data.RequiresImportErrorStep(r.requiresImport), }) } -func TestAccCognitiveAzureAIServices_complete(t *testing.T) { +func TestAccCognitiveAIServices_complete(t *testing.T) { data := acceptance.BuildTestData(t, "azurerm_ai_services", "test") - r := AzureAIServicesResource{} + r := AIServices{} data.ResourceTest(t, r, []acceptance.TestStep{ { @@ -72,34 +69,29 @@ func TestAccCognitiveAzureAIServices_complete(t *testing.T) { }) } -func TestAccCognitiveAzureAIServices_update(t *testing.T) { +func TestAccCognitiveAIServices_update(t *testing.T) { data := acceptance.BuildTestData(t, "azurerm_ai_services", "test") - r := AzureAIServicesResource{} + r := AIServices{} data.ResourceTest(t, r, []acceptance.TestStep{ { - Config: r.basic(data), + Config: r.complete(data), Check: acceptance.ComposeTestCheckFunc( check.That(data.ResourceName).ExistsInAzure(r), - check.That(data.ResourceName).Key("tags.%").HasValue("0"), - check.That(data.ResourceName).Key("primary_access_key").Exists(), - check.That(data.ResourceName).Key("secondary_access_key").Exists(), ), }, { - Config: r.complete(data), + Config: r.update(data), Check: acceptance.ComposeTestCheckFunc( check.That(data.ResourceName).ExistsInAzure(r), - check.That(data.ResourceName).Key("tags.%").HasValue("1"), - check.That(data.ResourceName).Key("tags.Acceptance").HasValue("Test"), ), }, }) } -func TestAccCognitiveAzureAIServices_networkACLs(t *testing.T) { +func TestAccCognitiveAIServices_networkACLs(t *testing.T) { data := acceptance.BuildTestData(t, "azurerm_ai_services", "test") - r := AzureAIServicesResource{} + r := AIServices{} data.ResourceTest(t, r, []acceptance.TestStep{ { @@ -119,9 +111,9 @@ func TestAccCognitiveAzureAIServices_networkACLs(t *testing.T) { }) } -func TestAccCognitiveAzureAIServices_identity(t *testing.T) { +func TestAccCognitiveAIServices_identity(t *testing.T) { data := acceptance.BuildTestData(t, "azurerm_ai_services", "test") - r := AzureAIServicesResource{} + r := AIServices{} data.ResourceTest(t, r, []acceptance.TestStep{ { @@ -159,9 +151,9 @@ func TestAccCognitiveAzureAIServices_identity(t *testing.T) { }) } -func TestAccCognitiveAzureAIServices_customerManagedKey_update(t *testing.T) { +func TestAccCognitiveAIServices_customerManagedKey_update(t *testing.T) { data := acceptance.BuildTestData(t, "azurerm_ai_services", "test") - r := AzureAIServicesResource{} + r := AIServices{} data.ResourceTest(t, r, []acceptance.TestStep{ { @@ -192,13 +184,13 @@ func TestAccCognitiveAzureAIServices_customerManagedKey_update(t *testing.T) { }) } -func TestAccCognitiveAzureAIServices_KVHsmManagedKey(t *testing.T) { +func TestAccCognitiveAIServices_KVHsmManagedKey(t *testing.T) { if os.Getenv("ARM_TEST_HSM_KEY") == "" { t.Skip("Skipping as ARM_TEST_HSM_KEY is not specified") return } data := acceptance.BuildTestData(t, "azurerm_ai_services", "test") - r := AzureAIServicesResource{} + r := AIServices{} data.ResourceTest(t, r, []acceptance.TestStep{ { @@ -213,7 +205,7 @@ func TestAccCognitiveAzureAIServices_KVHsmManagedKey(t *testing.T) { }) } -func (AzureAIServicesResource) Exists(ctx context.Context, clients *clients.Client, state *pluginsdk.InstanceState) (*bool, error) { +func (AIServices) Exists(ctx context.Context, clients *clients.Client, state *pluginsdk.InstanceState) (*bool, error) { id, err := cognitiveservicesaccounts.ParseAccountID(state.ID) if err != nil { return nil, err @@ -227,7 +219,7 @@ func (AzureAIServicesResource) Exists(ctx context.Context, clients *clients.Clie return pointer.To(resp.Model != nil), nil } -func (AzureAIServicesResource) basic(data acceptance.TestData) string { +func (AIServices) basic(data acceptance.TestData) string { return fmt.Sprintf(` provider "azurerm" { features {} @@ -247,7 +239,7 @@ resource "azurerm_ai_services" "test" { `, data.RandomInteger, data.Locations.Primary, data.RandomInteger) } -func (AzureAIServicesResource) identitySystemAssigned(data acceptance.TestData) string { +func (AIServices) identitySystemAssigned(data acceptance.TestData) string { return fmt.Sprintf(` provider "azurerm" { features {} @@ -270,7 +262,7 @@ resource "azurerm_ai_services" "test" { `, data.RandomInteger, data.Locations.Primary, data.RandomInteger) } -func (AzureAIServicesResource) identityUserAssigned(data acceptance.TestData) string { +func (AIServices) identityUserAssigned(data acceptance.TestData) string { return fmt.Sprintf(` provider "azurerm" { features {} @@ -302,7 +294,7 @@ resource "azurerm_ai_services" "test" { `, data.RandomInteger, data.Locations.Primary, data.RandomInteger, data.RandomInteger) } -func (AzureAIServicesResource) identitySystemAssignedUserAssigned(data acceptance.TestData) string { +func (AIServices) identitySystemAssignedUserAssigned(data acceptance.TestData) string { return fmt.Sprintf(` provider "azurerm" { features {} @@ -334,8 +326,8 @@ resource "azurerm_ai_services" "test" { `, data.RandomInteger, data.Locations.Primary, data.RandomInteger, data.RandomInteger) } -func (AzureAIServicesResource) requiresImport(data acceptance.TestData) string { - template := AzureAIServicesResource{}.basic(data) +func (AIServices) requiresImport(data acceptance.TestData) string { + template := AIServices{}.basic(data) return fmt.Sprintf(` %s @@ -348,7 +340,7 @@ resource "azurerm_ai_services" "import" { `, template) } -func (AzureAIServicesResource) complete(data acceptance.TestData) string { +func (AIServices) complete(data acceptance.TestData) string { return fmt.Sprintf(` provider "azurerm" { features {} @@ -469,7 +461,129 @@ resource "azurerm_ai_services" "test" { `, data.RandomInteger, data.Locations.Primary, data.RandomString, data.RandomIntOfLength(8)) } -func (r AzureAIServicesResource) networkACLs(data acceptance.TestData) string { +func (AIServices) update(data acceptance.TestData) string { + return fmt.Sprintf(` +provider "azurerm" { + features {} +} + +data "azurerm_client_config" "current" {} + +resource "azurerm_resource_group" "test" { + name = "acctestRG-cognitive-%[1]d" + location = "%[2]s" +} + +resource "azurerm_user_assigned_identity" "test" { + resource_group_name = azurerm_resource_group.test.name + location = azurerm_resource_group.test.location + name = "%[3]s" +} + +resource "azurerm_key_vault" "test" { + name = "acctestkv%[3]s" + location = azurerm_resource_group.test.location + resource_group_name = azurerm_resource_group.test.name + tenant_id = data.azurerm_client_config.current.tenant_id + sku_name = "standard" + soft_delete_retention_days = 7 + purge_protection_enabled = true + + access_policy { + tenant_id = data.azurerm_client_config.current.tenant_id + object_id = data.azurerm_client_config.current.object_id + key_permissions = [ + "Get", "Create", "Delete", "List", "Restore", "Recover", "UnwrapKey", "WrapKey", "Purge", "Encrypt", "Decrypt", "Sign", "Verify", "GetRotationPolicy" + ] + secret_permissions = [ + "Get", + ] + } + + access_policy { + tenant_id = azurerm_user_assigned_identity.test.tenant_id + object_id = azurerm_user_assigned_identity.test.principal_id + key_permissions = [ + "Get", "Create", "Delete", "List", "Restore", "Recover", "UnwrapKey", "WrapKey", "Purge", "Encrypt", "Decrypt", "Sign", "Verify", "GetRotationPolicy" + ] + secret_permissions = [ + "Get", + ] + } +} + +resource "azurerm_key_vault_key" "test" { + name = "acctestkvkey%[3]s" + key_vault_id = azurerm_key_vault.test.id + key_type = "RSA" + key_size = 2048 + key_opts = ["decrypt", "encrypt", "sign", "unwrapKey", "verify", "wrapKey"] +} + +resource "azurerm_virtual_network" "test" { + name = "acctestvirtnet%[1]d" + address_space = ["10.0.0.0/16"] + location = azurerm_resource_group.test.location + resource_group_name = azurerm_resource_group.test.name +} + +resource "azurerm_subnet" "test_a" { + name = "acctestsubneta%[1]d" + resource_group_name = azurerm_resource_group.test.name + virtual_network_name = azurerm_virtual_network.test.name + address_prefixes = ["10.0.2.0/24"] + service_endpoints = ["Microsoft.CognitiveServices"] +} + +resource "azurerm_subnet" "test_b" { + name = "acctestsubnetb%[1]d" + resource_group_name = azurerm_resource_group.test.name + virtual_network_name = azurerm_virtual_network.test.name + address_prefixes = ["10.0.4.0/24"] + service_endpoints = ["Microsoft.CognitiveServices"] +} + +resource "azurerm_ai_services" "test" { + name = "acctestcogacc-%[1]d" + location = azurerm_resource_group.test.location + resource_group_name = azurerm_resource_group.test.name + sku_name = "S0" + fqdns = ["foo.com"] + local_authentication_enabled = true + outbound_network_access_restricted = true + public_network_access = "Enabled" + custom_subdomain_name = "acctestcogacc-%[1]d" + + customer_managed_key { + key_vault_key_id = azurerm_key_vault_key.test.id + identity_client_id = azurerm_user_assigned_identity.test.client_id + } + + identity { + type = "SystemAssigned, UserAssigned" + identity_ids = [ + azurerm_user_assigned_identity.test.id + ] + } + network_acls { + default_action = "Allow" + virtual_network_rules { + subnet_id = azurerm_subnet.test_a.id + } + virtual_network_rules { + subnet_id = azurerm_subnet.test_b.id + } + } + + tags = { + Acceptance = "Test" + Environment = "Dev" + } +} +`, data.RandomInteger, data.Locations.Primary, data.RandomString, data.RandomIntOfLength(8)) +} + +func (r AIServices) networkACLs(data acceptance.TestData) string { return fmt.Sprintf(` %s @@ -493,7 +607,7 @@ resource "azurerm_ai_services" "test" { `, r.networkACLsTemplate(data), data.RandomInteger, data.RandomInteger) } -func (r AzureAIServicesResource) networkACLsUpdated(data acceptance.TestData) string { +func (r AIServices) networkACLsUpdated(data acceptance.TestData) string { return fmt.Sprintf(` %s resource "azurerm_ai_services" "test" { @@ -517,7 +631,7 @@ resource "azurerm_ai_services" "test" { `, r.networkACLsTemplate(data), data.RandomInteger, data.RandomInteger) } -func (AzureAIServicesResource) networkACLsTemplate(data acceptance.TestData) string { +func (AIServices) networkACLsTemplate(data acceptance.TestData) string { return fmt.Sprintf(` provider "azurerm" { features {} @@ -556,7 +670,7 @@ resource "azurerm_subnet" "test_b" { `, data.RandomInteger, data.Locations.Primary, data.RandomInteger, data.RandomInteger, data.RandomInteger) } -func (AzureAIServicesResource) customerManagedKey(data acceptance.TestData) string { +func (AIServices) customerManagedKey(data acceptance.TestData) string { return fmt.Sprintf(` provider "azurerm" { features { @@ -642,7 +756,7 @@ resource "azurerm_ai_services" "test" { `, data.RandomInteger, data.Locations.Secondary, data.RandomString, data.RandomString, data.RandomString, data.RandomInteger, data.RandomInteger) } -func (AzureAIServicesResource) customerManagedKeyUpdate(data acceptance.TestData) string { +func (AIServices) customerManagedKeyUpdate(data acceptance.TestData) string { return fmt.Sprintf(` provider "azurerm" { features { @@ -723,7 +837,7 @@ resource "azurerm_ai_services" "test" { `, data.RandomInteger, data.Locations.Secondary, data.RandomString, data.RandomString, data.RandomString, data.RandomInteger, data.RandomInteger) } -func (AzureAIServicesResource) kvHsmManagedKey(data acceptance.TestData) string { +func (AIServices) kvHsmManagedKey(data acceptance.TestData) string { return fmt.Sprintf(` provider "azurerm" { features { diff --git a/internal/services/cognitive/registration.go b/internal/services/cognitive/registration.go index 5c3cd5d03c5a..31835821035f 100644 --- a/internal/services/cognitive/registration.go +++ b/internal/services/cognitive/registration.go @@ -54,7 +54,7 @@ func (r Registration) DataSources() []sdk.DataSource { // Resources returns a list of Resources supported by this Service func (r Registration) Resources() []sdk.Resource { return []sdk.Resource{ - AzureAIServicesResource{}, + AIServices{}, CognitiveDeploymentResource{}, } } diff --git a/internal/services/machinelearning/ai_foundry_project_resource.go b/internal/services/machinelearning/ai_foundry_project_resource.go new file mode 100644 index 000000000000..7fd6d7a87038 --- /dev/null +++ b/internal/services/machinelearning/ai_foundry_project_resource.go @@ -0,0 +1,339 @@ +package machinelearning + +import ( + "context" + "fmt" + "regexp" + "strings" + "time" + + "github.com/hashicorp/go-azure-helpers/lang/pointer" + "github.com/hashicorp/go-azure-helpers/lang/response" + "github.com/hashicorp/go-azure-helpers/resourcemanager/commonschema" + "github.com/hashicorp/go-azure-helpers/resourcemanager/identity" + "github.com/hashicorp/go-azure-helpers/resourcemanager/location" + "github.com/hashicorp/go-azure-helpers/resourcemanager/tags" + "github.com/hashicorp/go-azure-sdk/resource-manager/machinelearningservices/2024-04-01/workspaces" + "github.com/hashicorp/terraform-provider-azurerm/helpers/tf" + "github.com/hashicorp/terraform-provider-azurerm/internal/sdk" + "github.com/hashicorp/terraform-provider-azurerm/internal/tf/pluginsdk" + "github.com/hashicorp/terraform-provider-azurerm/internal/tf/validation" +) + +type AIFoundryProject struct{} + +type AIFoundryProjectModel struct { + Name string `tfschema:"name"` + Location string `tfschema:"location"` + AIServicesHubId string `tfschema:"ai_services_hub_id"` + Identity []identity.ModelSystemAssignedUserAssigned `tfschema:"identity"` + HighBusinessImpactEnabled bool `tfschema:"high_business_impact_enabled"` + Description string `tfschema:"description"` + FriendlyName string `tfschema:"friendly_name"` + ProjectId string `tfschema:"project_id"` + Tags map[string]interface{} `tfschema:"tags"` +} + +func (r AIFoundryProject) ModelObject() interface{} { + return &AIFoundryProjectModel{} +} + +func (r AIFoundryProject) ResourceType() string { + return "azurerm_ai_foundry_project" +} + +func (r AIFoundryProject) IDValidationFunc() pluginsdk.SchemaValidateFunc { + return workspaces.ValidateWorkspaceID +} + +func (r AIFoundryProject) CustomImporter() sdk.ResourceRunFunc { + return func(ctx context.Context, metadata sdk.ResourceMetaData) error { + id, err := workspaces.ParseWorkspaceID(metadata.ResourceData.Id()) + if err != nil { + return err + } + + client := metadata.Client.MachineLearning.Workspaces + resp, err := client.Get(ctx, *id) + if err != nil || resp.Model == nil || resp.Model.Kind == nil { + return fmt.Errorf("retrieving %s: %+v", *id, err) + } + + if !strings.EqualFold(*resp.Model.Kind, "Project") { + return fmt.Errorf("importing %s: specified workspace is not of kind `Project`, got `%s`", id, *resp.Model.Kind) + } + + return nil + } +} + +var _ sdk.ResourceWithUpdate = AIFoundryProject{} + +var _ sdk.ResourceWithCustomImporter = AIFoundryProject{} + +func (r AIFoundryProject) Arguments() map[string]*pluginsdk.Schema { + return map[string]*pluginsdk.Schema{ + "name": { + Type: pluginsdk.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.StringMatch( + regexp.MustCompile("^[a-zA-Z0-9][a-zA-Z0-9_-]{2,32}$"), + "AI Services Project name must be 2 - 32 characters long, contain only letters, numbers and hyphens.", + ), + }, + + "location": commonschema.Location(), + + "ai_services_hub_id": { + Type: pluginsdk.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: workspaces.ValidateWorkspaceID, + }, + + "identity": commonschema.SystemAssignedUserAssignedIdentityOptional(), + + "high_business_impact_enabled": { + Type: pluginsdk.TypeBool, + Optional: true, + // NOTE: O+C creating a project that has encryption enabled with system assigned identity will set this property to true + Computed: true, + ForceNew: true, + }, + + "description": { + Type: pluginsdk.TypeString, + Optional: true, + ValidateFunc: validation.StringIsNotEmpty, + }, + + "friendly_name": { + Type: pluginsdk.TypeString, + Optional: true, + ValidateFunc: validation.StringIsNotEmpty, + }, + + "tags": commonschema.Tags(), + } +} + +func (r AIFoundryProject) Attributes() map[string]*pluginsdk.Schema { + return map[string]*pluginsdk.Schema{ + "project_id": { + Type: pluginsdk.TypeString, + Computed: true, + }, + } +} + +func (r AIFoundryProject) Create() sdk.ResourceFunc { + return sdk.ResourceFunc{ + Timeout: 30 * time.Minute, + Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error { + client := metadata.Client.MachineLearning.Workspaces + subscriptionId := metadata.Client.Account.SubscriptionId + + var model AIFoundryProjectModel + if err := metadata.Decode(&model); err != nil { + return fmt.Errorf("decoding %+v", err) + } + + hubId, err := workspaces.ParseWorkspaceID(model.AIServicesHubId) + if err != nil { + return err + } + + id := workspaces.NewWorkspaceID(subscriptionId, hubId.ResourceGroupName, model.Name) + + existing, err := client.Get(ctx, id) + if err != nil { + if !response.WasNotFound(existing.HttpResponse) { + return fmt.Errorf("checking for presence of existing %s: %+v", id, err) + } + } + if !response.WasNotFound(existing.HttpResponse) { + return tf.ImportAsExistsError("azurerm_ai_foundry_project", id.ID()) + } + + payload := workspaces.Workspace{ + Name: pointer.To(id.WorkspaceName), + Location: pointer.To(location.Normalize(model.Location)), + Tags: tags.Expand(model.Tags), + Kind: pointer.To("Project"), + Properties: &workspaces.WorkspaceProperties{ + HubResourceId: pointer.To(hubId.ID()), + }, + } + + if len(model.Identity) > 0 { + expandedIdentity, err := identity.ExpandLegacySystemAndUserAssignedMap(metadata.ResourceData.Get("identity").([]interface{})) + if err != nil { + return fmt.Errorf("expanding `identity`: %+v", err) + } + payload.Identity = expandedIdentity + } + + if model.Description != "" { + payload.Properties.Description = pointer.To(model.Description) + } + + if model.FriendlyName != "" { + payload.Properties.FriendlyName = pointer.To(model.FriendlyName) + } + + if model.HighBusinessImpactEnabled { + payload.Properties.HbiWorkspace = pointer.To(model.HighBusinessImpactEnabled) + } + + if err = client.CreateOrUpdateThenPoll(ctx, id, payload); err != nil { + return fmt.Errorf("creating %s: %+v", id, err) + } + + metadata.SetID(id) + return nil + }, + } +} + +func (r AIFoundryProject) Update() sdk.ResourceFunc { + return sdk.ResourceFunc{ + Timeout: 30 * time.Minute, + Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error { + client := metadata.Client.MachineLearning.Workspaces + + id, err := workspaces.ParseWorkspaceID(metadata.ResourceData.Id()) + if err != nil { + return err + } + + var state AIFoundryProjectModel + if err := metadata.Decode(&state); err != nil { + return err + } + + existing, err := client.Get(ctx, *id) + if err != nil { + return fmt.Errorf("retrieving %s: %+v", id, err) + } + if existing.Model == nil { + return fmt.Errorf("retrieving %s: `model` was nil", id) + } + if existing.Model.Properties == nil { + return fmt.Errorf("retrieving %s: `properties` was nil", id) + } + + payload := existing.Model + + // Hubs and Projects share the same API where Projects inherit the KV/Storage/AppInsights/CR/Network/Encryption settings from the Hub + // When updating a Project the API will error when trying to send the inherited settings that get returned when we retrieve the resource for patching in changes + // This is a hack to work around this behaviour and design, so that we can continue to support the use of `ignore_changes` on the resource + payload.Properties.ManagedNetwork = nil + payload.Properties.KeyVault = nil + payload.Properties.StorageAccount = nil + payload.Properties.ContainerRegistry = nil + payload.Properties.ApplicationInsights = nil + payload.Properties.Encryption = nil + + if metadata.ResourceData.HasChange("description") { + payload.Properties.Description = pointer.To(state.Description) + } + + if metadata.ResourceData.HasChange("friendly_name") { + payload.Properties.FriendlyName = pointer.To(state.FriendlyName) + } + + if metadata.ResourceData.HasChange("identity") { + expandedIdentity, err := identity.ExpandLegacySystemAndUserAssignedMap(metadata.ResourceData.Get("identity").([]interface{})) + if err != nil { + return fmt.Errorf("expanding `identity`: %+v", err) + } + payload.Identity = expandedIdentity + } + + if metadata.ResourceData.HasChange("tags") { + payload.Tags = tags.Expand(state.Tags) + } + + if err = client.CreateOrUpdateThenPoll(ctx, *id, *payload); err != nil { + return fmt.Errorf("updating %s: %+v", id, err) + } + + return nil + }, + } +} + +func (r AIFoundryProject) Read() sdk.ResourceFunc { + return sdk.ResourceFunc{ + Timeout: 5 * time.Minute, + Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error { + client := metadata.Client.MachineLearning.Workspaces + + id, err := workspaces.ParseWorkspaceID(metadata.ResourceData.Id()) + if err != nil { + return err + } + + resp, err := client.Get(ctx, *id) + if err != nil { + if response.WasNotFound(resp.HttpResponse) { + return metadata.MarkAsGone(id) + } + return fmt.Errorf("retrieving %s: %+v", *id, err) + } + + hub := AIFoundryProjectModel{ + Name: id.WorkspaceName, + } + + if model := resp.Model; model != nil { + hub.Location = location.NormalizeNilable(model.Location) + flattenedIdentity, err := identity.FlattenLegacySystemAndUserAssignedMapToModel(model.Identity) + if err != nil { + return fmt.Errorf("flattening `identity`: %+v", err) + } + hub.Identity = flattenedIdentity + + hub.Tags = tags.Flatten(model.Tags) + + if props := model.Properties; props != nil { + if v := pointer.From(props.HubResourceId); v != "" { + hubId, err := workspaces.ParseWorkspaceID(v) + if err != nil { + return err + } + hub.AIServicesHubId = hubId.ID() + } + + hub.Description = pointer.From(props.Description) + hub.FriendlyName = pointer.From(props.FriendlyName) + hub.HighBusinessImpactEnabled = pointer.From(props.HbiWorkspace) + hub.ProjectId = pointer.From(props.WorkspaceId) + } + } + + return metadata.Encode(&hub) + }, + } +} + +func (r AIFoundryProject) Delete() sdk.ResourceFunc { + return sdk.ResourceFunc{ + Timeout: 30 * time.Minute, + Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error { + client := metadata.Client.MachineLearning.Workspaces + + id, err := workspaces.ParseWorkspaceID(metadata.ResourceData.Id()) + if err != nil { + return err + } + + if err := client.DeleteThenPoll(ctx, *id, workspaces.DefaultDeleteOperationOptions()); err != nil { + return fmt.Errorf("deleting %s: %+v", *id, err) + } + + return nil + }, + } +} diff --git a/internal/services/machinelearning/ai_foundry_project_resource_test.go b/internal/services/machinelearning/ai_foundry_project_resource_test.go new file mode 100644 index 000000000000..d2d26933b75a --- /dev/null +++ b/internal/services/machinelearning/ai_foundry_project_resource_test.go @@ -0,0 +1,185 @@ +package machinelearning_test + +import ( + "context" + "fmt" + "testing" + + "github.com/hashicorp/go-azure-helpers/lang/pointer" + "github.com/hashicorp/go-azure-sdk/resource-manager/machinelearningservices/2024-04-01/workspaces" + "github.com/hashicorp/terraform-provider-azurerm/internal/acceptance" + "github.com/hashicorp/terraform-provider-azurerm/internal/acceptance/check" + "github.com/hashicorp/terraform-provider-azurerm/internal/clients" + "github.com/hashicorp/terraform-provider-azurerm/internal/tf/pluginsdk" +) + +type AIFoundryProject struct{} + +func TestAccAIFoundryProject_basic(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_ai_foundry_project", "test") + r := AIFoundryProject{} + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + Config: r.basic(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep(), + }) +} + +func TestAccAIFoundryProject_requiresImport(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_ai_foundry_project", "test") + r := AIFoundryProject{} + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + Config: r.basic(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.RequiresImportErrorStep(r.requiresImport), + }) +} + +func TestAccAIFoundryProject_complete(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_ai_foundry_project", "test") + r := AIFoundryProject{} + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + Config: r.complete(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep(), + }) +} + +func TestAccAIFoundryProject_update(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_ai_foundry_project", "test") + r := AIFoundryProject{} + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + Config: r.complete(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep(), + { + Config: r.update(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep(), + }) +} + +func (AIFoundryProject) Exists(ctx context.Context, clients *clients.Client, state *pluginsdk.InstanceState) (*bool, error) { + id, err := workspaces.ParseWorkspaceID(state.ID) + if err != nil { + return nil, err + } + + resp, err := clients.MachineLearning.Workspaces.Get(ctx, *id) + if err != nil { + return nil, fmt.Errorf("retrieving %s: %+v", *id, err) + } + + return pointer.To(resp.Model != nil), nil +} + +func (r AIFoundryProject) basic(data acceptance.TestData) string { + return fmt.Sprintf(` +%s + +resource "azurerm_ai_foundry_project" "test" { + name = "acctestaip-%[2]d" + location = azurerm_ai_foundry.test.location + ai_services_hub_id = azurerm_ai_foundry.test.id + + identity { + type = "SystemAssigned" + } +} +`, AIFoundry{}.basic(data), data.RandomInteger) +} + +func (r AIFoundryProject) complete(data acceptance.TestData) string { + return fmt.Sprintf(` +%s + +resource "azurerm_ai_foundry_project" "test" { + name = "acctestaip-%[2]d" + location = azurerm_ai_foundry.test.location + ai_services_hub_id = azurerm_ai_foundry.test.id + + description = "AI Project created by Terraform" + friendly_name = "AI Project" + high_business_impact_enabled = false + + identity { + type = "SystemAssigned" + } + + tags = { + model = "regression" + } +} +`, AIFoundry{}.complete(data), data.RandomInteger) +} + +func (r AIFoundryProject) update(data acceptance.TestData) string { + return fmt.Sprintf(` +%s + +resource "azurerm_user_assigned_identity" "test_project" { + name = "acctestuaip-%[2]d" + resource_group_name = azurerm_resource_group.test.name + location = azurerm_resource_group.test.location +} + +resource "azurerm_ai_foundry_project" "test" { + name = "acctestaip-%[2]d" + location = azurerm_ai_foundry.test.location + ai_services_hub_id = azurerm_ai_foundry.test.id + + description = "AI Project updated by Terraform" + friendly_name = "AI Project for OS models" + high_business_impact_enabled = false + + identity { + type = "SystemAssigned" + } + + tags = { + model = "regression" + env = "test" + } +} +`, AIFoundry{}.complete(data), data.RandomInteger) +} + +func (AIFoundryProject) requiresImport(data acceptance.TestData) string { + template := AIFoundryProject{}.basic(data) + return fmt.Sprintf(` +%s + +resource "azurerm_ai_foundry_project" "import" { + name = azurerm_ai_foundry_project.test.name + location = azurerm_ai_foundry_project.test.location + ai_services_hub_id = azurerm_ai_foundry_project.test.ai_services_hub_id + + identity { + type = "SystemAssigned" + } +} +`, template) +} diff --git a/internal/services/machinelearning/ai_foundry_resource.go b/internal/services/machinelearning/ai_foundry_resource.go new file mode 100644 index 000000000000..0e6f22267431 --- /dev/null +++ b/internal/services/machinelearning/ai_foundry_resource.go @@ -0,0 +1,617 @@ +package machinelearning + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/hashicorp/go-azure-helpers/lang/pointer" + "github.com/hashicorp/go-azure-helpers/lang/response" + "github.com/hashicorp/go-azure-helpers/resourcemanager/commonids" + "github.com/hashicorp/go-azure-helpers/resourcemanager/commonschema" + "github.com/hashicorp/go-azure-helpers/resourcemanager/identity" + "github.com/hashicorp/go-azure-helpers/resourcemanager/location" + "github.com/hashicorp/go-azure-helpers/resourcemanager/tags" + components "github.com/hashicorp/go-azure-sdk/resource-manager/applicationinsights/2020-02-02/componentsapis" + "github.com/hashicorp/go-azure-sdk/resource-manager/containerregistry/2023-11-01-preview/registries" + "github.com/hashicorp/go-azure-sdk/resource-manager/machinelearningservices/2024-04-01/workspaces" + "github.com/hashicorp/terraform-provider-azurerm/helpers/tf" + "github.com/hashicorp/terraform-provider-azurerm/internal/sdk" + keyvaultParse "github.com/hashicorp/terraform-provider-azurerm/internal/services/keyvault/parse" + keyvaultValidate "github.com/hashicorp/terraform-provider-azurerm/internal/services/keyvault/validate" + "github.com/hashicorp/terraform-provider-azurerm/internal/services/machinelearning/validate" + "github.com/hashicorp/terraform-provider-azurerm/internal/tf/pluginsdk" + "github.com/hashicorp/terraform-provider-azurerm/internal/tf/suppress" + "github.com/hashicorp/terraform-provider-azurerm/internal/tf/validation" +) + +type AIFoundry struct{} + +type AIFoundryModel struct { + Name string `tfschema:"name"` + Location string `tfschema:"location"` + ResourceGroupName string `tfschema:"resource_group_name"` + ApplicationInsightsId string `tfschema:"application_insights_id"` + StorageAccountId string `tfschema:"storage_account_id"` + KeyVaultId string `tfschema:"key_vault_id"` + ContainerRegistryId string `tfschema:"container_registry_id"` + Encryption []Encryption `tfschema:"encryption"` + ManagedNetwork []ManagedNetwork `tfschema:"managed_network"` + PublicNetworkAccess string `tfschema:"public_network_access"` + Identity []identity.ModelSystemAssignedUserAssigned `tfschema:"identity"` + PrimaryUserAssignedIdentity string `tfschema:"primary_user_assigned_identity"` + HighBusinessImpactEnabled bool `tfschema:"high_business_impact_enabled"` + Description string `tfschema:"description"` + FriendlyName string `tfschema:"friendly_name"` + DiscoveryUrl string `tfschema:"discovery_url"` + WorkspaceId string `tfschema:"workspace_id"` + Tags map[string]interface{} `tfschema:"tags"` +} + +type ManagedNetwork struct { + IsolationMode string `tfschema:"isolation_mode"` +} + +type Encryption struct { + IdentityClientID string `tfschema:"user_assigned_identity_id"` + KeyVaultID string `tfschema:"key_vault_id"` + KeyID string `tfschema:"key_id"` +} + +func (r AIFoundry) ModelObject() interface{} { + return &AIFoundryModel{} +} + +func (r AIFoundry) ResourceType() string { + return "azurerm_ai_foundry" +} + +func (r AIFoundry) IDValidationFunc() pluginsdk.SchemaValidateFunc { + return workspaces.ValidateWorkspaceID +} + +func (r AIFoundry) CustomImporter() sdk.ResourceRunFunc { + return func(ctx context.Context, metadata sdk.ResourceMetaData) error { + id, err := workspaces.ParseWorkspaceID(metadata.ResourceData.Id()) + if err != nil { + return err + } + + client := metadata.Client.MachineLearning.Workspaces + resp, err := client.Get(ctx, *id) + if err != nil || resp.Model == nil || resp.Model.Kind == nil { + return fmt.Errorf("retrieving %s: %+v", *id, err) + } + + if !strings.EqualFold(*resp.Model.Kind, "Hub") { + return fmt.Errorf("importing %s: specified workspace is not of kind `Hub`, got `%s`", id, *resp.Model.Kind) + } + + return nil + } +} + +var _ sdk.ResourceWithUpdate = AIFoundry{} + +var _ sdk.ResourceWithCustomImporter = AIFoundry{} + +func (r AIFoundry) Arguments() map[string]*pluginsdk.Schema { + return map[string]*pluginsdk.Schema{ + "name": { + Type: pluginsdk.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validate.WorkspaceName, + }, + + "location": commonschema.Location(), + + "resource_group_name": commonschema.ResourceGroupName(), + + "key_vault_id": { + Type: pluginsdk.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: commonids.ValidateKeyVaultID, + }, + + "storage_account_id": { + Type: pluginsdk.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: commonids.ValidateStorageAccountID, + }, + + "identity": commonschema.SystemAssignedUserAssignedIdentityRequired(), + + "high_business_impact_enabled": { + Type: pluginsdk.TypeBool, + Optional: true, + // NOTE: O+C creating a hub that has encryption enabled will set this property to true + Computed: true, + ForceNew: true, + }, + + "encryption": { + Type: pluginsdk.TypeList, + Optional: true, + ForceNew: true, + MaxItems: 1, + Elem: &pluginsdk.Resource{ + Schema: map[string]*pluginsdk.Schema{ + "key_vault_id": commonschema.ResourceIDReferenceRequired(&commonids.KeyVaultId{}), + "key_id": { + Type: pluginsdk.TypeString, + Required: true, + ValidateFunc: keyvaultValidate.NestedItemId, + }, + "user_assigned_identity_id": { + Type: pluginsdk.TypeString, + Optional: true, + ValidateFunc: commonids.ValidateUserAssignedIdentityID, + // Can be removed when https://github.com/Azure/azure-rest-api-specs/issues/30625 has been fixed + DiffSuppressFunc: suppress.CaseDifference, + }, + }, + }, + }, + + "application_insights_id": { + Type: pluginsdk.TypeString, + Optional: true, + ValidateFunc: components.ValidateComponentID, + }, + + "container_registry_id": { + Type: pluginsdk.TypeString, + Optional: true, + ValidateFunc: registries.ValidateRegistryID, + }, + + "managed_network": { + Type: pluginsdk.TypeList, + Optional: true, + Computed: true, + MaxItems: 1, + Elem: &pluginsdk.Resource{ + Schema: map[string]*pluginsdk.Schema{ + "isolation_mode": { + Type: pluginsdk.TypeString, + Optional: true, + Computed: true, + ValidateFunc: validation.StringInSlice(workspaces.PossibleValuesForIsolationMode(), false), + }, + }, + }, + }, + + "primary_user_assigned_identity": { + Type: pluginsdk.TypeString, + Optional: true, + ValidateFunc: commonids.ValidateUserAssignedIdentityID, + }, + + "public_network_access": { + Type: pluginsdk.TypeString, + Optional: true, + Default: workspaces.PublicNetworkAccessEnabled, + ValidateFunc: validation.StringInSlice(workspaces.PossibleValuesForPublicNetworkAccess(), false), + }, + + "description": { + Type: pluginsdk.TypeString, + Optional: true, + ValidateFunc: validation.StringIsNotEmpty, + }, + + "friendly_name": { + Type: pluginsdk.TypeString, + Optional: true, + ValidateFunc: validation.StringIsNotEmpty, + }, + + "tags": commonschema.Tags(), + } +} + +func (r AIFoundry) Attributes() map[string]*pluginsdk.Schema { + return map[string]*pluginsdk.Schema{ + "discovery_url": { + Type: pluginsdk.TypeString, + Computed: true, + }, + + "workspace_id": { + Type: pluginsdk.TypeString, + Computed: true, + }, + } +} + +func (r AIFoundry) Create() sdk.ResourceFunc { + return sdk.ResourceFunc{ + Timeout: 60 * time.Minute, + Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error { + client := metadata.Client.MachineLearning.Workspaces + subscriptionId := metadata.Client.Account.SubscriptionId + + var model AIFoundryModel + if err := metadata.Decode(&model); err != nil { + return fmt.Errorf("decoding %+v", err) + } + + id := workspaces.NewWorkspaceID(subscriptionId, model.ResourceGroupName, model.Name) + + existing, err := client.Get(ctx, id) + if err != nil { + if !response.WasNotFound(existing.HttpResponse) { + return fmt.Errorf("checking for presence of existing %s: %+v", id, err) + } + } + if !response.WasNotFound(existing.HttpResponse) { + return tf.ImportAsExistsError("azurerm_ai_foundry", id.ID()) + } + + storageAccountId, err := commonids.ParseStorageAccountID(model.StorageAccountId) + if err != nil { + return err + } + + keyVaultId, err := commonids.ParseKeyVaultID(model.KeyVaultId) + if err != nil { + return err + } + + expandedIdentity, err := identity.ExpandLegacySystemAndUserAssignedMap(metadata.ResourceData.Get("identity").([]interface{})) + if err != nil { + return fmt.Errorf("expanding `identity`: %+v", err) + } + + payload := workspaces.Workspace{ + Name: pointer.To(id.WorkspaceName), + Location: pointer.To(location.Normalize(model.Location)), + Identity: expandedIdentity, + Tags: tags.Expand(model.Tags), + Kind: pointer.To("Hub"), + Properties: &workspaces.WorkspaceProperties{ + KeyVault: pointer.To(keyVaultId.ID()), + PublicNetworkAccess: pointer.To(workspaces.PublicNetworkAccess(model.PublicNetworkAccess)), + StorageAccount: pointer.To(storageAccountId.ID()), + }, + } + + if model.ApplicationInsightsId != "" { + applicationInsightsId, err := components.ParseComponentID(model.ApplicationInsightsId) + if err != nil { + return err + } + payload.Properties.ApplicationInsights = pointer.To(applicationInsightsId.ID()) + } + + if model.ContainerRegistryId != "" { + containerRegistryId, err := registries.ParseRegistryID(model.ContainerRegistryId) + if err != nil { + return err + } + payload.Properties.ContainerRegistry = pointer.To(containerRegistryId.ID()) + } + + if model.Description != "" { + payload.Properties.Description = pointer.To(model.Description) + } + + if model.FriendlyName != "" { + payload.Properties.FriendlyName = pointer.To(model.FriendlyName) + } + + if model.HighBusinessImpactEnabled { + payload.Properties.HbiWorkspace = pointer.To(model.HighBusinessImpactEnabled) + } + + if model.PrimaryUserAssignedIdentity != "" { + userAssignedId, err := commonids.ParseUserAssignedIdentityID(model.PrimaryUserAssignedIdentity) + if err != nil { + return err + } + payload.Properties.PrimaryUserAssignedIdentity = pointer.To(userAssignedId.ID()) + } + + if len(model.Encryption) > 0 { + encryption := expandEncryption(model.Encryption) + payload.Properties.Encryption = encryption + } + + if len(model.ManagedNetwork) > 0 { + payload.Properties.ManagedNetwork = expandManagedNetwork(model.ManagedNetwork) + } + + if err = client.CreateOrUpdateThenPoll(ctx, id, payload); err != nil { + return fmt.Errorf("creating %s: %+v", id, err) + } + + metadata.SetID(id) + return nil + }, + } +} + +func (r AIFoundry) Update() sdk.ResourceFunc { + return sdk.ResourceFunc{ + Timeout: 30 * time.Minute, + Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error { + client := metadata.Client.MachineLearning.Workspaces + + id, err := workspaces.ParseWorkspaceID(metadata.ResourceData.Id()) + if err != nil { + return err + } + + var state AIFoundryModel + if err := metadata.Decode(&state); err != nil { + return err + } + + existing, err := client.Get(ctx, *id) + if err != nil { + return fmt.Errorf("retrieving %s: %+v", id, err) + } + if existing.Model == nil { + return fmt.Errorf("retrieving %s: `model` was nil", id) + } + if existing.Model.Properties == nil { + return fmt.Errorf("retrieving %s: `properties` was nil", id) + } + + payload := existing.Model + + if metadata.ResourceData.HasChange("application_insights_id") { + applicationInsightsId, err := components.ParseComponentID(state.ApplicationInsightsId) + if err != nil { + return err + } + payload.Properties.ApplicationInsights = pointer.To(applicationInsightsId.ID()) + } + + if metadata.ResourceData.HasChange("container_registry_id") { + containerRegistryId, err := registries.ParseRegistryID(state.ContainerRegistryId) + if err != nil { + return err + } + payload.Properties.ContainerRegistry = pointer.To(containerRegistryId.ID()) + } + + if metadata.ResourceData.HasChange("public_network_access") { + payload.Properties.PublicNetworkAccess = pointer.To(workspaces.PublicNetworkAccess(state.PublicNetworkAccess)) + } + + if metadata.ResourceData.HasChange("description") { + payload.Properties.Description = pointer.To(state.Description) + } + + if metadata.ResourceData.HasChange("friendly_name") { + payload.Properties.FriendlyName = pointer.To(state.FriendlyName) + } + + if metadata.ResourceData.HasChange("identity") { + expandedIdentity, err := identity.ExpandLegacySystemAndUserAssignedMap(metadata.ResourceData.Get("identity").([]interface{})) + if err != nil { + return fmt.Errorf("expanding `identity`: %+v", err) + } + payload.Identity = expandedIdentity + } + + if metadata.ResourceData.HasChange("primary_user_assigned_identity") { + userAssignedId, err := commonids.ParseUserAssignedIdentityID(state.PrimaryUserAssignedIdentity) + if err != nil { + return err + } + payload.Properties.PrimaryUserAssignedIdentity = pointer.To(userAssignedId.ID()) + } + + if metadata.ResourceData.HasChange("managed_network") { + payload.Properties.ManagedNetwork = expandManagedNetwork(state.ManagedNetwork) + } + + if metadata.ResourceData.HasChange("tags") { + payload.Tags = tags.Expand(state.Tags) + } + + if err = client.CreateOrUpdateThenPoll(ctx, *id, *payload); err != nil { + return fmt.Errorf("updating %s: %+v", id, err) + } + + return nil + }, + } +} + +func (r AIFoundry) Read() sdk.ResourceFunc { + return sdk.ResourceFunc{ + Timeout: 5 * time.Minute, + Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error { + client := metadata.Client.MachineLearning.Workspaces + + id, err := workspaces.ParseWorkspaceID(metadata.ResourceData.Id()) + if err != nil { + return err + } + + resp, err := client.Get(ctx, *id) + if err != nil { + if response.WasNotFound(resp.HttpResponse) { + return metadata.MarkAsGone(id) + } + return fmt.Errorf("retrieving %s: %+v", *id, err) + } + + hub := AIFoundryModel{ + Name: id.WorkspaceName, + ResourceGroupName: id.ResourceGroupName, + } + + if model := resp.Model; model != nil { + hub.Location = location.NormalizeNilable(model.Location) + + flattenedIdentity, err := identity.FlattenLegacySystemAndUserAssignedMapToModel(model.Identity) + if err != nil { + return fmt.Errorf("flattening `identity`: %+v", err) + } + + hub.Identity = flattenedIdentity + hub.Tags = tags.Flatten(model.Tags) + + if props := model.Properties; props != nil { + if v := pointer.From(props.ApplicationInsights); v != "" { + applicationInsightsId, err := components.ParseComponentIDInsensitively(v) + if err != nil { + return err + } + hub.ApplicationInsightsId = applicationInsightsId.ID() + } + + if v := pointer.From(props.ContainerRegistry); v != "" { + containerRegistryId, err := registries.ParseRegistryID(v) + if err != nil { + return err + } + hub.ContainerRegistryId = containerRegistryId.ID() + } + + storageAccountId, err := commonids.ParseStorageAccountID(*props.StorageAccount) + if err != nil { + return err + } + hub.StorageAccountId = storageAccountId.ID() + + keyVaultId, err := commonids.ParseKeyVaultID(*props.KeyVault) + if err != nil { + return err + } + hub.KeyVaultId = keyVaultId.ID() + + hub.Description = pointer.From(props.Description) + hub.FriendlyName = pointer.From(props.FriendlyName) + hub.HighBusinessImpactEnabled = pointer.From(props.HbiWorkspace) + hub.PublicNetworkAccess = string(*props.PublicNetworkAccess) + hub.DiscoveryUrl = pointer.From(props.DiscoveryURL) + hub.WorkspaceId = pointer.From(props.WorkspaceId) + hub.ManagedNetwork = flattenManagedNetwork(props.ManagedNetwork) + + if v := pointer.From(props.PrimaryUserAssignedIdentity); v != "" { + userAssignedId, err := commonids.ParseUserAssignedIdentityID(v) + if err != nil { + return err + } + hub.PrimaryUserAssignedIdentity = userAssignedId.ID() + } + + encryption, err := flattenEncryption(props.Encryption) + if err != nil { + return fmt.Errorf("flattening `encryption`: %+v", err) + } + hub.Encryption = encryption + } + } + + return metadata.Encode(&hub) + }, + } +} + +func (r AIFoundry) Delete() sdk.ResourceFunc { + return sdk.ResourceFunc{ + Timeout: 30 * time.Minute, + Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error { + client := metadata.Client.MachineLearning.Workspaces + + id, err := workspaces.ParseWorkspaceID(metadata.ResourceData.Id()) + if err != nil { + return err + } + + opts := workspaces.DefaultDeleteOperationOptions() + + if metadata.Client.Features.MachineLearning.PurgeSoftDeletedWorkspaceOnDestroy { + opts.ForceToPurge = pointer.To(true) + } + + if err := client.DeleteThenPoll(ctx, *id, opts); err != nil { + return fmt.Errorf("deleting %s: %+v", *id, err) + } + + return nil + }, + } +} + +func expandEncryption(input []Encryption) *workspaces.EncryptionProperty { + encryption := input[0] + out := workspaces.EncryptionProperty{ + Identity: &workspaces.IdentityForCmk{}, + KeyVaultProperties: workspaces.EncryptionKeyVaultProperties{ + KeyVaultArmId: encryption.KeyVaultID, + KeyIdentifier: encryption.KeyID, + }, + Status: workspaces.EncryptionStatusEnabled, + } + + if encryption.IdentityClientID != "" { + out.Identity.UserAssignedIdentity = pointer.To(encryption.IdentityClientID) + } + + return &out +} + +func flattenEncryption(input *workspaces.EncryptionProperty) ([]Encryption, error) { + out := make([]Encryption, 0) + + if input == nil || input.Status != workspaces.EncryptionStatusEnabled { + return out, nil + } + + encryption := Encryption{} + if v := input.KeyVaultProperties.KeyVaultArmId; v != "" { + keyVaultId, err := commonids.ParseKeyVaultID(v) + if err != nil { + return nil, err + } + encryption.KeyVaultID = keyVaultId.ID() + } + if v := input.KeyVaultProperties.KeyIdentifier; v != "" { + keyId, err := keyvaultParse.ParseNestedItemID(v) + if err != nil { + return nil, err + } + encryption.KeyID = keyId.ID() + } + + if input.Identity != nil && input.Identity.UserAssignedIdentity != nil { + userAssignedId, err := commonids.ParseUserAssignedIdentityIDInsensitively(*input.Identity.UserAssignedIdentity) + if err != nil { + return nil, err + } + encryption.IdentityClientID = userAssignedId.ID() + } + + return append(out, encryption), nil +} + +func expandManagedNetwork(input []ManagedNetwork) *workspaces.ManagedNetworkSettings { + network := input[0] + + return &workspaces.ManagedNetworkSettings{ + IsolationMode: pointer.To(workspaces.IsolationMode(network.IsolationMode)), + } +} + +func flattenManagedNetwork(input *workspaces.ManagedNetworkSettings) []ManagedNetwork { + out := make([]ManagedNetwork, 0) + if input == nil { + return out + } + + return append(out, ManagedNetwork{ + IsolationMode: string(pointer.From(input.IsolationMode)), + }) +} diff --git a/internal/services/machinelearning/ai_foundry_resource_test.go b/internal/services/machinelearning/ai_foundry_resource_test.go new file mode 100644 index 000000000000..cb2d92d932d6 --- /dev/null +++ b/internal/services/machinelearning/ai_foundry_resource_test.go @@ -0,0 +1,497 @@ +package machinelearning_test + +import ( + "context" + "fmt" + "testing" + + "github.com/hashicorp/go-azure-helpers/lang/pointer" + "github.com/hashicorp/go-azure-sdk/resource-manager/machinelearningservices/2024-04-01/workspaces" + "github.com/hashicorp/terraform-provider-azurerm/internal/acceptance" + "github.com/hashicorp/terraform-provider-azurerm/internal/acceptance/check" + "github.com/hashicorp/terraform-provider-azurerm/internal/clients" + "github.com/hashicorp/terraform-provider-azurerm/internal/tf/pluginsdk" +) + +type AIFoundry struct{} + +func TestAccAIFoundry_basic(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_ai_foundry", "test") + r := AIFoundry{} + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + Config: r.basic(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep(), + }) +} + +func TestAccAIFoundry_requiresImport(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_ai_foundry", "test") + r := AIFoundry{} + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + Config: r.basic(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.RequiresImportErrorStep(r.requiresImport), + }) +} + +func TestAccAIFoundry_complete(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_ai_foundry", "test") + r := AIFoundry{} + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + Config: r.complete(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep(), + }) +} + +func TestAccAIFoundry_update(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_ai_foundry", "test") + r := AIFoundry{} + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + Config: r.complete(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep(), + { + Config: r.update(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep(), + }) +} + +func TestAccAIFoundry_encryptionWithSystemAssignedId(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_ai_foundry", "test") + r := AIFoundry{} + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + Config: r.encryptionWithSystemAssignedId(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep(), + }) +} + +func TestAccAIFoundry_encryptionWithUserAssignedId(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_ai_foundry", "test") + r := AIFoundry{} + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + Config: r.encryptionWithUserAssignedId(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep(), + }) +} + +func (AIFoundry) Exists(ctx context.Context, clients *clients.Client, state *pluginsdk.InstanceState) (*bool, error) { + id, err := workspaces.ParseWorkspaceID(state.ID) + if err != nil { + return nil, err + } + + resp, err := clients.MachineLearning.Workspaces.Get(ctx, *id) + if err != nil { + return nil, fmt.Errorf("retrieving %s: %+v", *id, err) + } + + return pointer.To(resp.Model != nil), nil +} + +func (r AIFoundry) basic(data acceptance.TestData) string { + return fmt.Sprintf(` +provider "azurerm" { + features { + key_vault { + purge_soft_delete_on_destroy = false + purge_soft_deleted_keys_on_destroy = false + } + } +} + +%s + +resource "azurerm_ai_foundry" "test" { + name = "acctestaihub-%[2]d" + location = azurerm_ai_services.test.location + resource_group_name = azurerm_resource_group.test.name + storage_account_id = azurerm_storage_account.test.id + key_vault_id = azurerm_key_vault.test.id + + identity { + type = "SystemAssigned" + } +} +`, r.template(data), data.RandomInteger) +} + +func (r AIFoundry) complete(data acceptance.TestData) string { + return fmt.Sprintf(` +provider "azurerm" { + features { + key_vault { + purge_soft_delete_on_destroy = false + purge_soft_deleted_keys_on_destroy = false + } + } +} + +%s + +resource "azurerm_application_insights" "test" { + name = "acctestai-%[2]d" + location = azurerm_resource_group.test.location + resource_group_name = azurerm_resource_group.test.name + application_type = "web" +} + +resource "azurerm_container_registry" "test" { + name = "testacccr%[2]d" + resource_group_name = azurerm_resource_group.test.name + location = azurerm_resource_group.test.location + // Premium sku is required when creating a hub with AllowInternetOutbound or AllowOnlyApprovedOutbound isolation mode + sku = "Premium" +} + +resource "azurerm_user_assigned_identity" "test" { + name = "acctestuai-%[2]d" + resource_group_name = azurerm_resource_group.test.name + location = azurerm_resource_group.test.location +} + +resource "azurerm_ai_foundry" "test" { + name = "acctestaihub-%[2]d" + location = azurerm_ai_services.test.location + resource_group_name = azurerm_resource_group.test.name + storage_account_id = azurerm_storage_account.test.id + key_vault_id = azurerm_key_vault.test.id + + identity { + type = "UserAssigned" + identity_ids = [ + azurerm_user_assigned_identity.test.id, + ] + } + + application_insights_id = azurerm_application_insights.test.id + container_registry_id = azurerm_container_registry.test.id + primary_user_assigned_identity = azurerm_user_assigned_identity.test.id + public_network_access = "Disabled" + description = "AI Hub created by Terraform" + friendly_name = "AI Hub" + high_business_impact_enabled = true + + managed_network { + isolation_mode = "AllowInternetOutbound" + } + + tags = { + env = "test" + } +} +`, r.template(data), data.RandomInteger) +} + +func (r AIFoundry) update(data acceptance.TestData) string { + return fmt.Sprintf(` +provider "azurerm" { + features { + key_vault { + purge_soft_delete_on_destroy = false + purge_soft_deleted_keys_on_destroy = false + } + } +} + +%s + +resource "azurerm_application_insights" "test" { + name = "acctestai-%[2]d" + location = azurerm_resource_group.test.location + resource_group_name = azurerm_resource_group.test.name + application_type = "web" +} + +resource "azurerm_container_registry" "test" { + name = "testacccr%[2]d" + resource_group_name = azurerm_resource_group.test.name + location = azurerm_resource_group.test.location + sku = "Premium" +} + +resource "azurerm_user_assigned_identity" "test" { + name = "acctestuai-%[2]d" + resource_group_name = azurerm_resource_group.test.name + location = azurerm_resource_group.test.location +} + +resource "azurerm_user_assigned_identity" "test2" { + name = "acctestuai2-%[2]d" + resource_group_name = azurerm_resource_group.test.name + location = azurerm_resource_group.test.location +} + +resource "azurerm_ai_foundry" "test" { + name = "acctestaihub-%[2]d" + location = azurerm_ai_services.test.location + resource_group_name = azurerm_resource_group.test.name + storage_account_id = azurerm_storage_account.test.id + key_vault_id = azurerm_key_vault.test.id + + identity { + type = "UserAssigned" + identity_ids = [ + azurerm_user_assigned_identity.test.id, + azurerm_user_assigned_identity.test2.id + ] + } + + application_insights_id = azurerm_application_insights.test.id + container_registry_id = azurerm_container_registry.test.id + primary_user_assigned_identity = azurerm_user_assigned_identity.test2.id + public_network_access = "Enabled" + description = "AI Hub for Projects" + friendly_name = "AI Hub for OS models" + high_business_impact_enabled = true + + managed_network { + isolation_mode = "AllowOnlyApprovedOutbound" + } + + tags = { + env = "prod" + model = "regression" + } +} +`, r.template(data), data.RandomInteger) +} + +func (r AIFoundry) encryptionWithUserAssignedId(data acceptance.TestData) string { + return fmt.Sprintf(` +provider "azurerm" { + features { + key_vault { + purge_soft_delete_on_destroy = false + purge_soft_deleted_keys_on_destroy = false + } + } +} + +%s + +resource "azurerm_user_assigned_identity" "test" { + name = "acctestuai-%[2]d" + location = azurerm_resource_group.test.location + resource_group_name = azurerm_resource_group.test.name +} + +resource "azurerm_key_vault_access_policy" "test-uai-policy" { + key_vault_id = azurerm_key_vault.test.id + tenant_id = azurerm_user_assigned_identity.test.tenant_id + object_id = azurerm_user_assigned_identity.test.principal_id + key_permissions = [ + "Get", + "Recover", + "UnwrapKey", + "WrapKey", + ] +} + +resource "azurerm_key_vault_key" "test" { + name = "acckvKey-%[2]d" + key_vault_id = azurerm_key_vault.test.id + key_type = "RSA" + key_size = 2048 + key_opts = [ + "decrypt", + "encrypt", + "sign", + "unwrapKey", + "verify", + "wrapKey", + ] + depends_on = [azurerm_key_vault.test, azurerm_key_vault_access_policy.test] +} + +resource "azurerm_role_assignment" "test_kv" { + scope = azurerm_key_vault.test.id + role_definition_name = "Contributor" + principal_id = azurerm_user_assigned_identity.test.principal_id +} + +resource "azurerm_ai_foundry" "test" { + name = "acctestaihub-%[2]d" + location = azurerm_ai_services.test.location + resource_group_name = azurerm_resource_group.test.name + storage_account_id = azurerm_storage_account.test.id + key_vault_id = azurerm_key_vault.test.id + + primary_user_assigned_identity = azurerm_user_assigned_identity.test.id + + encryption { + user_assigned_identity_id = azurerm_user_assigned_identity.test.id + key_vault_id = azurerm_key_vault.test.id + key_id = azurerm_key_vault_key.test.id + } + + identity { + type = "UserAssigned" + identity_ids = [ + azurerm_user_assigned_identity.test.id + ] + } + + depends_on = [ + azurerm_role_assignment.test_kv, + azurerm_key_vault_access_policy.test-uai-policy + ] +} +`, r.template(data), data.RandomInteger) +} + +func (r AIFoundry) encryptionWithSystemAssignedId(data acceptance.TestData) string { + return fmt.Sprintf(` +provider "azurerm" { + features { + key_vault { + purge_soft_delete_on_destroy = false + purge_soft_deleted_keys_on_destroy = false + } + } +} + +%s + +resource "azurerm_key_vault_key" "test" { + name = "acckvkey-%[2]d" + key_vault_id = azurerm_key_vault.test.id + key_type = "RSA" + key_size = 2048 + key_opts = [ + "decrypt", + "encrypt", + "sign", + "unwrapKey", + "verify", + "wrapKey", + ] + depends_on = [azurerm_key_vault.test, azurerm_key_vault_access_policy.test] +} + +resource "azurerm_ai_foundry" "test" { + name = "acctestaihub-%[2]d" + location = azurerm_ai_services.test.location + resource_group_name = azurerm_resource_group.test.name + storage_account_id = azurerm_storage_account.test.id + key_vault_id = azurerm_key_vault.test.id + + encryption { + key_vault_id = azurerm_key_vault.test.id + key_id = azurerm_key_vault_key.test.id + } + + identity { + type = "SystemAssigned" + } +} +`, r.template(data), data.RandomInteger) +} + +func (AIFoundry) requiresImport(data acceptance.TestData) string { + template := AIFoundry{}.basic(data) + return fmt.Sprintf(` +%s + +resource "azurerm_ai_foundry" "import" { + name = azurerm_ai_foundry.test.name + location = azurerm_ai_foundry.test.location + resource_group_name = azurerm_ai_foundry.test.resource_group_name + storage_account_id = azurerm_ai_foundry.test.storage_account_id + key_vault_id = azurerm_ai_foundry.test.key_vault_id + + identity { + type = "SystemAssigned" + } +} +`, template) +} + +func (AIFoundry) template(data acceptance.TestData) string { + return fmt.Sprintf(` +data "azurerm_client_config" "current" {} + +resource "azurerm_resource_group" "test" { + name = "acctestRG-aiservices-%[1]d" + location = "%[2]s" +} + +resource "azurerm_key_vault" "test" { + name = "acctestvault%[3]s" + location = azurerm_resource_group.test.location + resource_group_name = azurerm_resource_group.test.name + tenant_id = data.azurerm_client_config.current.tenant_id + + sku_name = "standard" + + purge_protection_enabled = true + soft_delete_retention_days = 7 +} + +resource "azurerm_key_vault_access_policy" "test" { + key_vault_id = azurerm_key_vault.test.id + tenant_id = data.azurerm_client_config.current.tenant_id + object_id = data.azurerm_client_config.current.object_id + + key_permissions = [ + "Create", + "Get", + "Delete", + "Purge", + "GetRotationPolicy", + ] +} + +resource "azurerm_storage_account" "test" { + name = "acctestsa%[3]s" + location = azurerm_resource_group.test.location + resource_group_name = azurerm_resource_group.test.name + account_tier = "Standard" + account_replication_type = "LRS" +} + +resource "azurerm_ai_services" "test" { + name = "acctestaiservices-%[1]d" + location = azurerm_resource_group.test.location + resource_group_name = azurerm_resource_group.test.name + sku_name = "S0" +} +`, data.RandomInteger, data.Locations.Primary, data.RandomString) +} diff --git a/internal/services/machinelearning/registration.go b/internal/services/machinelearning/registration.go index 3b2b51fcae3c..065b560361e6 100644 --- a/internal/services/machinelearning/registration.go +++ b/internal/services/machinelearning/registration.go @@ -56,6 +56,8 @@ func (r Registration) DataSources() []sdk.DataSource { // Resources returns the typed Resources supported by this service func (r Registration) Resources() []sdk.Resource { return []sdk.Resource{ + AIFoundry{}, + AIFoundryProject{}, MachineLearningDataStoreBlobStorage{}, MachineLearningDataStoreDataLakeGen2{}, MachineLearningDataStoreFileShare{}, diff --git a/website/docs/r/ai_foundry.html.markdown b/website/docs/r/ai_foundry.html.markdown new file mode 100644 index 000000000000..8f19569a4361 --- /dev/null +++ b/website/docs/r/ai_foundry.html.markdown @@ -0,0 +1,176 @@ +--- +subcategory: "Machine Learning" +layout: "azurerm" +page_title: "Azure Resource Manager: azurerm_ai_foundry" +description: |- + Manages an AI Foundry Hub. +--- + +# azurerm_ai_foundry + +Manages an AI Foundry Hub. + +## Example Usage + +```hcl +data "azurerm_client_config" "current" {} + +resource "azurerm_resource_group" "example" { + name = "example" + location = "westeurope" +} + +resource "azurerm_key_vault" "example" { + name = "examplekv" + location = azurerm_resource_group.example.location + resource_group_name = azurerm_resource_group.example.name + tenant_id = data.azurerm_client_config.current.tenant_id + + sku_name = "standard" + purge_protection_enabled = true +} + +resource "azurerm_key_vault_access_policy" "test" { + key_vault_id = azurerm_key_vault.example.id + tenant_id = data.azurerm_client_config.current.tenant_id + object_id = data.azurerm_client_config.current.object_id + + key_permissions = [ + "Create", + "Get", + "Delete", + "Purge", + "GetRotationPolicy", + ] +} + +resource "azurerm_storage_account" "example" { + name = "examplesa" + location = azurerm_resource_group.example.location + resource_group_name = azurerm_resource_group.example.name + account_tier = "Standard" + account_replication_type = "LRS" +} + +resource "azurerm_ai_services" "example" { + name = "exampleaiservices" + location = azurerm_resource_group.example.location + resource_group_name = azurerm_resource_group.example.name + sku_name = "S0" +} + +resource "azurerm_ai_foundry" "example" { + name = "exampleaihub" + location = azurerm_ai_services.example.location + resource_group_name = azurerm_resource_group.example.name + storage_account_id = azurerm_storage_account.example.id + key_vault_id = azurerm_key_vault.example.id + + identity { + type = "SystemAssigned" + } +} +``` + +## Arguments Reference + +The following arguments are supported: + +* `name` - (Required) The name which should be used for this AI Foundry Hub. Changing this forces a new AI Foundry Hub to be created. + +* `location` - (Required) The Azure Region where the AI Foundry Hub should exist. Changing this forces a new AI Foundry Hub to be created. + +* `resource_group_name` - (Required) The name of the Resource Group where the AI Foundry Hub should exist. Changing this forces a new AI Foundry Hub to be created. + +* `identity` - (Required) A `identity` block as defined below. + +* `key_vault_id` - (Required) The Key Vault ID that should be used by this AI Foundry Hub. Changing this forces a new AI Foundry Hub to be created. + +* `storage_account_id` - (Required) The Storage Account ID that should be used by this AI Foundry Hub. Changing this forces a new AI Foundry Hub to be created. + +--- + +* `application_insights_id` - (Optional) The Application Insights ID that should be used by this AI Foundry Hub. + +* `container_registry_id` - (Optional) The Container Registry ID that should be used by this AI Foundry Hub. + +* `description` - (Optional) The description of this AI Foundry Hub. + +* `encryption` - (Optional) An `encryption` block as defined below. Changing this forces a new AI Foundry Hub to be created. + +* `friendly_name` - (Optional) The display name of this AI Foundry Hub. + +* `high_business_impact_enabled` - (Optional) Whether High Business Impact (HBI) should be enabled or not. Enabling this setting will reduce diagnostic data collected by the service. Changing this forces a new AI Foundry Hub to be created. Defaults to `false`. + +-> **Note:** `high_business_impact_enabled` will be enabled by default when creating an AI Foundry Hub with `encryption` enabled. + +* `managed_network` - (Optional) A `managed_network` block as defined below. + +* `primary_user_assigned_identity` - (Optional) The user assigned identity ID that represents the AI Foundry Hub identity. This must be set when enabling encryption with a user assigned identity. + +* `public_network_access` - (Optional) Whether public network access for this AI Service Hub should be enabled. Possible values include `Enabled` and `Disabled`. Defaults to `Enabled`. + +* `tags` - (Optional) A mapping of tags which should be assigned to the AI Foundry Hub. + +--- + +A `encryption` block supports the following: + +* `key_id` - (Required) The Key Vault URI to access the encryption key. + +* `key_vault_id` - (Required) The Key Vault ID where the customer owned encryption key exists. + +* `user_assigned_identity_id` - (Optional) The user assigned identity ID that has access to the encryption key. + +~> **Note:** `user_assigned_identity_id` must be set when`identity.type` is `UserAssigned` in order for the service to find the assigned permissions. + +--- + +A `identity` block supports the following: + +* `type` - (Required) Specifies the type of Managed Service Identity that should be configured on this AI Foundry Hub. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned` (to enable both). + +* `identity_ids` - (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this AI Foundry Hub. + +~> **NOTE:** This is required when `type` is set to `UserAssigned` or `SystemAssigned, UserAssigned`. + +--- + +A `managed_network` block supports the following: + +* `isolation_mode` - (Optional) The isolation mode of the AI Foundry Hub. Possible values are `Disabled`, `AllowOnlyApprovedOutbound`, and `AllowInternetOutbound`. + +## Attributes Reference + +In addition to the Arguments listed above - the following Attributes are exported: + +* `id` - The ID of the AI Foundry Hub. + +* `discovery_url` - The URL for the discovery service to identify regional endpoints for AI Foundry Hub services. + +* `workspace_id` - The immutable ID associated with this AI Foundry Hub. + +--- + +An `identity` block exports the following: + +* `principal_id` - The Principal ID associated with this Managed Service Identity. + +* `tenant_id` - The Tenant ID associated with this Managed Service Identity. + +## Timeouts + +The `timeouts` block allows you to specify [timeouts](https://www.terraform.io/language/resources/syntax#operation-timeouts) for certain actions: + +* `create` - (Defaults to 1 hour) Used when creating the AI Foundry Hub. +* `read` - (Defaults to 5 minutes) Used when retrieving the AI Foundry Hub. +* `update` - (Defaults to 30 minutes) Used when updating the AI Foundry Hub. +* `delete` - (Defaults to 30 minutes) Used when deleting the AI Foundry Hub. + +## Import + +AI Foundry Hubs can be imported using the `resource id`, e.g. + +```shell +terraform import azurerm_ai_foundry.example /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/group1/providers/Microsoft.MachineLearningServices/workspaces/hub1 +``` diff --git a/website/docs/r/ai_foundry_project.html.markdown b/website/docs/r/ai_foundry_project.html.markdown new file mode 100644 index 000000000000..d00543b58114 --- /dev/null +++ b/website/docs/r/ai_foundry_project.html.markdown @@ -0,0 +1,144 @@ +--- +subcategory: "Machine Learning" +layout: "azurerm" +page_title: "Azure Resource Manager: azurerm_ai_foundry_project" +description: |- + Manages an AI Foundry Project. +--- + +# azurerm_ai_foundry_project + +Manages an AI Foundry Project. + +## Example Usage + +```hcl +data "azurerm_client_config" "current" {} + +resource "azurerm_resource_group" "example" { + name = "example" + location = "westeurope" +} + +resource "azurerm_key_vault" "example" { + name = "examplekv" + location = azurerm_resource_group.example.location + resource_group_name = azurerm_resource_group.example.name + tenant_id = data.azurerm_client_config.current.tenant_id + + sku_name = "standard" + purge_protection_enabled = true +} + +resource "azurerm_key_vault_access_policy" "test" { + key_vault_id = azurerm_key_vault.example.id + tenant_id = data.azurerm_client_config.current.tenant_id + object_id = data.azurerm_client_config.current.object_id + + key_permissions = [ + "Create", + "Get", + "Delete", + "Purge", + "GetRotationPolicy", + ] +} + +resource "azurerm_storage_account" "example" { + name = "examplesa" + location = azurerm_resource_group.example.location + resource_group_name = azurerm_resource_group.example.name + account_tier = "Standard" + account_replication_type = "LRS" +} + +resource "azurerm_ai_services" "example" { + name = "exampleaiservices" + location = azurerm_resource_group.example.location + resource_group_name = azurerm_resource_group.example.name + sku_name = "S0" +} + +resource "azurerm_ai_foundry" "example" { + name = "exampleaihub" + location = azurerm_ai_services.example.location + resource_group_name = azurerm_resource_group.example.name + storage_account_id = azurerm_storage_account.example.id + key_vault_id = azurerm_key_vault.example.id + + identity { + type = "SystemAssigned" + } +} + +resource "azurerm_ai_foundry_project" "example" { + name = "example" + location = azurerm_ai_services_hub.example.location + ai_services_hub_id = azurerm_ai_services_hub.example.id +} +``` + +## Arguments Reference + +The following arguments are supported: + +* `name` - (Required) The name which should be used for this AI Foundry Project. Changing this forces a new AI Foundry Project to be created. + +* `location` - (Required) The Azure Region where the AI Foundry Project should exist. Changing this forces a new AI Foundry Project to be created. + +* `ai_services_hub_id` - (Required) The AI Services Hub ID under which this Project should be created. Changing this forces a new AI Foundry Project to be created. + +--- + +* `description` - (Optional) The description of this AI Foundry Project. + +* `friendly_name` - (Optional) The display name of this AI Foundry Project. + +* `high_business_impact_enabled` - (Optional) Whether High Business Impact (HBI) should be enabled or not. Enabling this setting will reduce diagnostic data collected by the service. Changing this forces a new AI Foundry Project to be created. Defaults to `false`. + +* `identity` - (Optional) A `identity` block as defined below. + +* `tags` - (Optional) A mapping of tags which should be assigned to the AI Foundry Project. + +--- + +A `identity` block supports the following: + +* `type` - (Required) Specifies the type of Managed Service Identity that should be configured on this AI Foundry Project. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned` (to enable both). + +* `identity_ids` - (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this AI Foundry Project. + +~> **NOTE:** This is required when `type` is set to `UserAssigned` or `SystemAssigned, UserAssigned`. + +## Attributes Reference + +In addition to the Arguments listed above - the following Attributes are exported: + +* `id` - The ID of the AI Foundry Project. + +* `project_id` - The immutable project ID associated with this AI Foundry Project. + +--- + +An `identity` block exports the following: + +* `principal_id` - The Principal ID associated with this Managed Service Identity. + +* `tenant_id` - The Tenant ID associated with this Managed Service Identity. + +## Timeouts + +The `timeouts` block allows you to specify [timeouts](https://www.terraform.io/language/resources/syntax#operation-timeouts) for certain actions: + +* `create` - (Defaults to 30 hour) Used when creating the AI Foundry Project. +* `read` - (Defaults to 5 minutes) Used when retrieving the AI Foundry Project. +* `update` - (Defaults to 30 minutes) Used when updating the AI Foundry Project. +* `delete` - (Defaults to 30 minutes) Used when deleting the AI Foundry Project. + +## Import + +AI Foundry Projects can be imported using the `resource id`, e.g. + +```shell +terraform import azurerm_ai_foundry_project.example /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/group1/providers/Microsoft.MachineLearningServices/workspaces/project1 +``` diff --git a/website/docs/r/ai_services.html.markdown b/website/docs/r/ai_services.html.markdown index 9133364e0008..9e5ac2eda0fb 100644 --- a/website/docs/r/ai_services.html.markdown +++ b/website/docs/r/ai_services.html.markdown @@ -8,7 +8,7 @@ description: |- # azurerm_ai_services -Manages an AI Services account. +Manages an AI Services Account. ## Example Usage