diff --git a/.teamcity/components/generated/services.kt b/.teamcity/components/generated/services.kt index 12da944f4e5b..6fb6df7bc9bf 100644 --- a/.teamcity/components/generated/services.kt +++ b/.teamcity/components/generated/services.kt @@ -10,6 +10,7 @@ var services = mapOf( "authorization" to "Authorization", "automation" to "Automation", "batch" to "Batch", + "blueprints" to "Blueprints", "bot" to "Bot", "cdn" to "CDN", "cognitive" to "Cognitive Services", diff --git a/azurerm/internal/clients/client.go b/azurerm/internal/clients/client.go index 305e8b78c514..d2e8ccd52f87 100644 --- a/azurerm/internal/clients/client.go +++ b/azurerm/internal/clients/client.go @@ -15,6 +15,7 @@ import ( authorization "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/authorization/client" automation "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/automation/client" batch "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/batch/client" + blueprints "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/blueprints/client" bot "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/bot/client" cdn "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/cdn/client" cognitiveServices "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/cognitive/client" @@ -97,6 +98,7 @@ type Client struct { Authorization *authorization.Client Automation *automation.Client Batch *batch.Client + Blueprints *blueprints.Client Bot *bot.Client Cdn *cdn.Client Cognitive *cognitiveServices.Client @@ -180,6 +182,7 @@ func (client *Client) Build(ctx context.Context, o *common.ClientOptions) error client.Authorization = authorization.NewClient(o) client.Automation = automation.NewClient(o) client.Batch = batch.NewClient(o) + client.Blueprints = blueprints.NewClient(o) client.Bot = bot.NewClient(o) client.Cdn = cdn.NewClient(o) client.Cognitive = cognitiveServices.NewClient(o) diff --git a/azurerm/internal/provider/required_resource_providers.go b/azurerm/internal/provider/required_resource_providers.go index 3bc31001bd0e..c012fbf175ca 100644 --- a/azurerm/internal/provider/required_resource_providers.go +++ b/azurerm/internal/provider/required_resource_providers.go @@ -20,6 +20,7 @@ func RequiredResourceProviders() map[string]struct{} { "Microsoft.AppPlatform": {}, "Microsoft.Authorization": {}, "Microsoft.Automation": {}, + "Microsoft.Blueprints": {}, "Microsoft.BotService": {}, "Microsoft.Cache": {}, "Microsoft.Cdn": {}, diff --git a/azurerm/internal/provider/services.go b/azurerm/internal/provider/services.go index b95deb04e9ef..cf83395153c9 100644 --- a/azurerm/internal/provider/services.go +++ b/azurerm/internal/provider/services.go @@ -10,6 +10,7 @@ import ( "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/authorization" "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/automation" "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/batch" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/blueprints" "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/bot" "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/cdn" "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/cognitive" @@ -90,6 +91,7 @@ func SupportedServices() []common.ServiceRegistration { authorization.Registration{}, automation.Registration{}, batch.Registration{}, + blueprints.Registration{}, bot.Registration{}, cdn.Registration{}, cognitive.Registration{}, diff --git a/azurerm/internal/services/blueprints/blueprint.go b/azurerm/internal/services/blueprints/blueprint.go new file mode 100644 index 000000000000..64ffaddcdbd1 --- /dev/null +++ b/azurerm/internal/services/blueprints/blueprint.go @@ -0,0 +1,211 @@ +package blueprints + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/Azure/azure-sdk-for-go/services/preview/blueprint/mgmt/2018-11-01-preview/blueprint" + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/helper/validation" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/helpers/suppress" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/msi/validate" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/utils" +) + +func ManagedIdentitySchema() *schema.Schema { + return &schema.Schema{ + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "type": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice([]string{ + // ManagedServiceIdentityTypeNone is not valid; a valid and privileged Identity is required for the service to apply the changes. + // SystemAssigned type not currently supported - The Portal performs significant activity in temporary escalation of permissions to Owner on the target scope + // Such activity in the Provider would be brittle + // string(blueprint.ManagedServiceIdentityTypeSystemAssigned), + string(blueprint.ManagedServiceIdentityTypeUserAssigned), + }, true), + // The first character of value returned by the service is always in lower case - bug? + DiffSuppressFunc: suppress.CaseDifference, + }, + + "identity_ids": { + // The API only seems to care about the "key" portion of this struct, which is the ResourceID of the Identity + Type: schema.TypeList, + Required: true, + MinItems: 1, + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: validate.UserAssignedIdentityId, + }, + }, + + "principal_id": { + Type: schema.TypeString, + Computed: true, + }, + + "tenant_id": { + Type: schema.TypeString, + Computed: true, + }, + }, + }, + } +} + +func blueprintAssignmentCreateStateRefreshFunc(ctx context.Context, client *blueprint.AssignmentsClient, scope, name string) resource.StateRefreshFunc { + return func() (interface{}, string, error) { + resp, err := client.Get(ctx, scope, name) + if err != nil { + return nil, "", fmt.Errorf("unable to retrieve Blueprint Assignment %q (Scope %q): %+v", name, scope, err) + } + if resp.ProvisioningState == blueprint.Failed { + return resp, string(resp.ProvisioningState), fmt.Errorf("Blueprint Assignment provisioning entered a Failed state.") + } + + return resp, string(resp.ProvisioningState), nil + } +} + +func blueprintAssignmentDeleteStateRefreshFunc(ctx context.Context, client *blueprint.AssignmentsClient, scope, name string) resource.StateRefreshFunc { + return func() (interface{}, string, error) { + resp, err := client.Get(ctx, scope, name) + if err != nil { + if utils.ResponseWasNotFound(resp.Response) { + return resp, "NotFound", nil + } else { + return nil, "", fmt.Errorf("unable to retrieve Blueprint Assignment %q (Scope %q): %+v", name, scope, err) + } + } + + return resp, string(resp.ProvisioningState), nil + } +} + +func normalizeAssignmentParameterValuesJSON(jsonString interface{}) string { + if jsonString == nil || jsonString == "" { + return "" + } + + var values map[string]*blueprint.ParameterValue + if err := json.Unmarshal([]byte(jsonString.(string)), &values); err != nil { + return fmt.Sprintf("unable to parse JSON: %+v", err) + } + + b, _ := json.Marshal(values) + return string(b) +} + +func normalizeAssignmentResourceGroupValuesJSON(jsonString interface{}) string { + if jsonString == nil || jsonString == "" { + return "" + } + + var values map[string]*blueprint.ResourceGroupValue + if err := json.Unmarshal([]byte(jsonString.(string)), &values); err != nil { + return fmt.Sprintf("unable to parse JSON: %+v", err) + } + + b, _ := json.Marshal(values) + return string(b) +} + +func expandArmBlueprintAssignmentParameters(input string) map[string]*blueprint.ParameterValue { + var result map[string]*blueprint.ParameterValue + // the string has been validated by the schema, therefore the error is ignored here, since it will never happen. + _ = json.Unmarshal([]byte(input), &result) + return result +} + +func expandArmBlueprintAssignmentResourceGroups(input string) map[string]*blueprint.ResourceGroupValue { + var result map[string]*blueprint.ResourceGroupValue + // the string has been validated by the schema, therefore the error is ignored here, since it will never happen. + _ = json.Unmarshal([]byte(input), &result) + return result +} + +func expandArmBlueprintAssignmentIdentity(input []interface{}) (*blueprint.ManagedServiceIdentity, error) { + if len(input) == 0 || input[0] == nil { + return nil, fmt.Errorf("Managed Service Identity was empty") + } + + raw := input[0].(map[string]interface{}) + + identity := blueprint.ManagedServiceIdentity{ + Type: blueprint.ManagedServiceIdentityType(raw["type"].(string)), + } + + identityIdsRaw := raw["identity_ids"].([]interface{}) + identityIds := make(map[string]*blueprint.UserAssignedIdentity) + for _, v := range identityIdsRaw { + identityIds[v.(string)] = &blueprint.UserAssignedIdentity{} + } + identity.UserAssignedIdentities = identityIds + + return &identity, nil +} + +func flattenArmBlueprintAssignmentIdentity(input *blueprint.ManagedServiceIdentity) []interface{} { + if input == nil { + return []interface{}{} + } + + identityIds := make([]string, 0) + if input.UserAssignedIdentities != nil { + for k := range input.UserAssignedIdentities { + identityIds = append(identityIds, k) + } + } + + principalId := "" + if input.PrincipalID != nil { + principalId = *input.PrincipalID + } + + tenantId := "" + if input.TenantID != nil { + tenantId = *input.TenantID + } + + return []interface{}{ + map[string]interface{}{ + "type": string(input.Type), + "identity_ids": identityIds, + "principal_id": principalId, + "tenant_id": tenantId, + }, + } +} + +func flattenArmBlueprintAssignmentParameters(input map[string]*blueprint.ParameterValue) (string, error) { + if len(input) == 0 { + return "", nil + } + + b, err := json.Marshal(input) + if err != nil { + return "", err + } + + return string(b), nil +} + +func flattenArmBlueprintAssignmentResourceGroups(input map[string]*blueprint.ResourceGroupValue) (string, error) { + if len(input) == 0 { + return "", nil + } + + b, err := json.Marshal(input) + if err != nil { + return "", err + } + + return string(b), nil +} diff --git a/azurerm/internal/services/blueprints/blueprint_assignment_resource.go b/azurerm/internal/services/blueprints/blueprint_assignment_resource.go new file mode 100644 index 000000000000..39eede932570 --- /dev/null +++ b/azurerm/internal/services/blueprints/blueprint_assignment_resource.go @@ -0,0 +1,340 @@ +package blueprints + +import ( + "fmt" + "log" + "time" + + "github.com/Azure/azure-sdk-for-go/services/preview/blueprint/mgmt/2018-11-01-preview/blueprint" + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/helper/structure" + "github.com/hashicorp/terraform-plugin-sdk/helper/validation" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/helpers/azure" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/helpers/suppress" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/helpers/tf" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/clients" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/location" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/blueprints/parse" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/blueprints/validate" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/timeouts" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/utils" +) + +func resourceArmBlueprintAssignment() *schema.Resource { + return &schema.Resource{ + Create: resourceArmBlueprintAssignmentCreateUpdate, + Update: resourceArmBlueprintAssignmentCreateUpdate, + Read: resourceArmBlueprintAssignmentRead, + Delete: resourceArmBlueprintAssignmentDelete, + + Importer: &schema.ResourceImporter{ + State: schema.ImportStatePassthrough, + }, + + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(30 * time.Minute), + Update: schema.DefaultTimeout(30 * time.Minute), + Read: schema.DefaultTimeout(5 * time.Minute), + Delete: schema.DefaultTimeout(5 * time.Minute), + }, + + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.StringIsNotEmpty, + }, + + "target_subscription_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: azure.ValidateResourceID, + }, + + "location": location.Schema(), + + "identity": ManagedIdentitySchema(), + + "version_id": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validate.BlueprintVersionID, + }, + + "parameter_values": { + Type: schema.TypeString, + Optional: true, + StateFunc: normalizeAssignmentParameterValuesJSON, + ValidateFunc: validation.StringIsJSON, + DiffSuppressFunc: structure.SuppressJsonDiff, + }, + + "resource_groups": { + Type: schema.TypeString, + Optional: true, + StateFunc: normalizeAssignmentResourceGroupValuesJSON, + ValidateFunc: validation.StringIsJSON, + DiffSuppressFunc: structure.SuppressJsonDiff, + }, + + "lock_mode": { + Type: schema.TypeString, + Optional: true, + Default: string(blueprint.None), + ValidateFunc: validation.StringInSlice([]string{ + string(blueprint.None), + string(blueprint.AllResourcesReadOnly), + string(blueprint.AllResourcesDoNotDelete), + }, false), + // The first character of value returned by the service is always in lower case. + DiffSuppressFunc: suppress.CaseDifference, + }, + + "lock_exclude_principals": { + Type: schema.TypeList, + Optional: true, + MaxItems: 5, + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: validation.IsUUID, + }, + }, + + "description": { + Type: schema.TypeString, + Computed: true, + }, + + "display_name": { + Type: schema.TypeString, + Computed: true, + }, + + "blueprint_name": { + Type: schema.TypeString, + Computed: true, + }, + + "type": { + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func resourceArmBlueprintAssignmentCreateUpdate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*clients.Client).Blueprints.AssignmentsClient + ctx, cancel := timeouts.ForCreateUpdate(meta.(*clients.Client).StopContext, d) + defer cancel() + + name := d.Get("name").(string) + blueprintId := d.Get("version_id").(string) + targetScope := d.Get("target_subscription_id").(string) + + if d.IsNewResource() { + resp, err := client.Get(ctx, targetScope, name) + if err != nil { + if !utils.ResponseWasNotFound(resp.Response) { + return fmt.Errorf("failure checking for existing Blueprint Assignment %q in scope %q", name, targetScope) + } + } + if !utils.ResponseWasNotFound(resp.Response) { + return tf.ImportAsExistsError("azurerm_blueprint_assignment", *resp.ID) + } + } + + assignment := blueprint.Assignment{ + AssignmentProperties: &blueprint.AssignmentProperties{ + BlueprintID: utils.String(blueprintId), // This is mislabeled - The ID is that of the Published Version, not just the Blueprint + Scope: utils.String(targetScope), + }, + Location: utils.String(azure.NormalizeLocation(d.Get("location"))), + } + + if lockModeRaw, ok := d.GetOk("lock_mode"); ok { + assignmentLockSettings := &blueprint.AssignmentLockSettings{} + lockMode := lockModeRaw.(string) + assignmentLockSettings.Mode = blueprint.AssignmentLockMode(lockMode) + if lockMode != "None" { + excludedPrincipalsRaw := d.Get("lock_exclude_principals").([]interface{}) + if len(excludedPrincipalsRaw) != 0 { + assignmentLockSettings.ExcludedPrincipals = utils.ExpandStringSlice(excludedPrincipalsRaw) + } + } + assignment.AssignmentProperties.Locks = assignmentLockSettings + } + + identity, err := expandArmBlueprintAssignmentIdentity(d.Get("identity").([]interface{})) + if err != nil { + return err + } + assignment.Identity = identity + + if paramValuesRaw := d.Get("parameter_values"); paramValuesRaw != "" { + assignment.Parameters = expandArmBlueprintAssignmentParameters(paramValuesRaw.(string)) + } else { + assignment.Parameters = expandArmBlueprintAssignmentParameters("{}") + } + + if resourceGroupsRaw := d.Get("resource_groups"); resourceGroupsRaw != "" { + assignment.ResourceGroups = expandArmBlueprintAssignmentResourceGroups(resourceGroupsRaw.(string)) + } else { + assignment.ResourceGroups = expandArmBlueprintAssignmentResourceGroups("{}") + } + + resp, err := client.CreateOrUpdate(ctx, targetScope, name, assignment) + if err != nil { + return err + } + + stateConf := &resource.StateChangeConf{ + Pending: []string{ + string(blueprint.Waiting), + string(blueprint.Validating), + string(blueprint.Creating), + string(blueprint.Deploying), + string(blueprint.Locking), + }, + Target: []string{string(blueprint.Succeeded)}, + Refresh: blueprintAssignmentCreateStateRefreshFunc(ctx, client, targetScope, name), + Timeout: d.Timeout(schema.TimeoutCreate), + } + + if _, err := stateConf.WaitForState(); err != nil { + return fmt.Errorf("failed waiting for Blueprint Assignment %q (Scope %q): %+v", name, targetScope, err) + } + + if resp.ID == nil || *resp.ID == "" { + return fmt.Errorf("could not read ID from Blueprint Assignment %q on scope %q", name, targetScope) + } + + d.SetId(*resp.ID) + + return resourceArmBlueprintAssignmentRead(d, meta) +} + +func resourceArmBlueprintAssignmentRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*clients.Client).Blueprints.AssignmentsClient + ctx, cancel := timeouts.ForRead(meta.(*clients.Client).StopContext, d) + defer cancel() + + id, err := parse.AssignmentID(d.Id()) + if err != nil { + return err + } + + resourceScope := id.Scope + assignmentName := id.Name + + resp, err := client.Get(ctx, resourceScope, assignmentName) + if err != nil { + if utils.ResponseWasNotFound(resp.Response) { + log.Printf("[INFO] the Blueprint Assignment %q does not exist - removing from state", assignmentName) + d.SetId("") + return nil + } + + return fmt.Errorf("Read failed for Blueprint Assignment (%q): %+v", assignmentName, err) + } + + if resp.Name != nil { + d.Set("name", resp.Name) + } + + if resp.Scope != nil { + d.Set("target_subscription_id", resp.Scope) + } + + if resp.Location != nil { + d.Set("location", azure.NormalizeLocation(*resp.Location)) + } + + if resp.Identity != nil { + d.Set("identity", flattenArmBlueprintAssignmentIdentity(resp.Identity)) + } + + if resp.AssignmentProperties != nil { + if resp.AssignmentProperties.BlueprintID != nil { + d.Set("version_id", resp.AssignmentProperties.BlueprintID) + } + + if resp.AssignmentProperties.Parameters != nil { + params, err := flattenArmBlueprintAssignmentParameters(resp.Parameters) + if err != nil { + return err + } + d.Set("parameter_values", params) + } + + if resp.AssignmentProperties.ResourceGroups != nil { + resourceGroups, err := flattenArmBlueprintAssignmentResourceGroups(resp.ResourceGroups) + if err != nil { + return err + } + d.Set("resource_groups", resourceGroups) + } + + // Locks + if locks := resp.Locks; locks != nil { + d.Set("lock_mode", locks.Mode) + if locks.ExcludedPrincipals != nil { + d.Set("lock_exclude_principals", locks.ExcludedPrincipals) + } + } + } + + if resp.DisplayName != nil { + d.Set("display_name", resp.DisplayName) + } + + if resp.Description != nil { + d.Set("description", resp.Description) + } + + return nil +} + +func resourceArmBlueprintAssignmentDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*clients.Client).Blueprints.AssignmentsClient + ctx, cancel := timeouts.ForDelete(meta.(*clients.Client).StopContext, d) + defer cancel() + + assignmentID, err := parse.AssignmentID(d.Id()) + if err != nil { + return err + } + + name := assignmentID.Name + targetScope := assignmentID.Scope + + resp, err := client.Delete(ctx, targetScope, name) + if err != nil { + if utils.ResponseWasNotFound(resp.Response) { + return nil + } + return fmt.Errorf("failed to delete Blueprint Assignment %q from scope %q: %+v", name, targetScope, err) + } + + stateConf := &resource.StateChangeConf{ + Pending: []string{ + string(blueprint.Waiting), + string(blueprint.Validating), + string(blueprint.Locking), + string(blueprint.Deleting), + string(blueprint.Failed), + }, + Target: []string{"NotFound"}, + Refresh: blueprintAssignmentDeleteStateRefreshFunc(ctx, client, targetScope, name), + Timeout: d.Timeout(schema.TimeoutDelete), + } + + if _, err := stateConf.WaitForState(); err != nil { + return fmt.Errorf("Failed waiting for Blueprint Assignment %q (Scope %q): %+v", name, targetScope, err) + } + + return nil +} diff --git a/azurerm/internal/services/blueprints/blueprint_definition_datasource.go b/azurerm/internal/services/blueprints/blueprint_definition_datasource.go new file mode 100644 index 000000000000..f0b24893b93e --- /dev/null +++ b/azurerm/internal/services/blueprints/blueprint_definition_datasource.go @@ -0,0 +1,120 @@ +package blueprints + +import ( + "fmt" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/helper/validation" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/helpers/azure" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/clients" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/blueprints/validate" + mgValidate "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/managementgroup/validate" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/timeouts" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/utils" +) + +func dataSourceArmBlueprintDefinition() *schema.Resource { + return &schema.Resource{ + Read: dataSourceArmBlueprintDefinitionRead, + + Timeouts: &schema.ResourceTimeout{ + Read: schema.DefaultTimeout(5 * time.Minute), + }, + + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validate.BlueprintName, + }, + + "scope_id": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.Any( + azure.ValidateResourceID, + mgValidate.ManagementGroupID, + ), + }, + + // Computed + "description": { + Type: schema.TypeString, + Computed: true, + }, + + "display_name": { + Type: schema.TypeString, + Computed: true, + }, + + "last_modified": { + Type: schema.TypeString, + Computed: true, + }, + + "target_scope": { + Type: schema.TypeString, + Computed: true, + }, + + "time_created": { + Type: schema.TypeString, + Computed: true, + }, + + "versions": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + }, + } +} + +func dataSourceArmBlueprintDefinitionRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*clients.Client).Blueprints.BlueprintsClient + ctx, cancel := timeouts.ForRead(meta.(*clients.Client).StopContext, d) + defer cancel() + + name := d.Get("name").(string) + scope := d.Get("scope_id").(string) + + resp, err := client.Get(ctx, scope, name) + if err != nil { + if utils.ResponseWasNotFound(resp.Response) { + return fmt.Errorf("Blueprint Definition %q not found in Scope (%q): %+v", name, scope, err) + } + + return fmt.Errorf("Read failed for Blueprint Definition (%q) in Sccope (%q): %+v", name, scope, err) + } + + if resp.ID == nil || *resp.ID == "" { + return fmt.Errorf("Failed to retrieve ID for Blueprint %q", name) + } else { + d.SetId(*resp.ID) + } + + if resp.Description != nil { + d.Set("description", resp.Description) + } + + if resp.DisplayName != nil { + d.Set("display_name", resp.DisplayName) + } + + d.Set("last_modified", resp.Status.LastModified.String()) + + d.Set("time_created", resp.Status.TimeCreated.String()) + + d.Set("target_scope", resp.TargetScope) + + if resp.Versions != nil { + d.Set("versions", resp.Versions) + } + + return nil +} diff --git a/azurerm/internal/services/blueprints/blueprint_published_version_datasource.go b/azurerm/internal/services/blueprints/blueprint_published_version_datasource.go new file mode 100644 index 000000000000..6baf3e53ba1d --- /dev/null +++ b/azurerm/internal/services/blueprints/blueprint_published_version_datasource.go @@ -0,0 +1,130 @@ +package blueprints + +import ( + "fmt" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/helper/validation" + "github.com/terraform-providers/terraform-provider-azuread/azuread/helpers/validate" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/helpers/azure" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/clients" + mgValidate "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/managementgroup/validate" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/timeouts" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/utils" +) + +func dataSourceArmBlueprintPublishedVersion() *schema.Resource { + return &schema.Resource{ + Read: dataSourceArmBlueprintPublishedVersionRead, + + Timeouts: &schema.ResourceTimeout{ + Read: schema.DefaultTimeout(5 * time.Minute), + }, + + Schema: map[string]*schema.Schema{ + "blueprint_name": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validate.NoEmptyStrings, + }, + + "scope_id": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.Any( + azure.ValidateResourceID, + mgValidate.ManagementGroupID, + ), + }, + + "version": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validate.NoEmptyStrings, + }, + + // Computed + "description": { + Type: schema.TypeString, + Computed: true, + }, + + "display_name": { + Type: schema.TypeString, + Computed: true, + }, + + "last_modified": { + Type: schema.TypeString, + Computed: true, + }, + + "target_scope": { + Type: schema.TypeString, + Computed: true, + }, + + "time_created": { + Type: schema.TypeString, + Computed: true, + }, + + "type": { + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func dataSourceArmBlueprintPublishedVersionRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*clients.Client).Blueprints.PublishedBlueprintsClient + ctx, cancel := timeouts.ForRead(meta.(*clients.Client).StopContext, d) + defer cancel() + + scope := d.Get("scope_id").(string) + blueprintName := d.Get("blueprint_name").(string) + versionID := d.Get("version").(string) + + resp, err := client.Get(ctx, scope, blueprintName, versionID) + if err != nil { + if utils.ResponseWasNotFound(resp.Response) { + return fmt.Errorf("Published Blueprint Version %q not found: %+v", versionID, err) + } + + return fmt.Errorf("Read failed for Published Blueprint (%q) Version (%q): %+v", blueprintName, versionID, err) + } + + if resp.ID == nil || *resp.ID == "" { + return fmt.Errorf("Failed to retrieve ID for Blueprint %q Version %q", blueprintName, versionID) + } else { + d.SetId(*resp.ID) + } + + if resp.Type != nil { + d.Set("type", resp.Type) + } + + if resp.Status != nil { + if resp.Status.TimeCreated != nil { + d.Set("time_created", resp.Status.TimeCreated.String()) + } + + if resp.Status.LastModified != nil { + d.Set("last_modified", resp.Status.LastModified.String()) + } + } + + d.Set("target_scope", resp.TargetScope) + + if resp.DisplayName != nil { + d.Set("display_name", resp.DisplayName) + } + + if resp.Description != nil { + d.Set("description", resp.Description) + } + + return nil +} diff --git a/azurerm/internal/services/blueprints/client/client.go b/azurerm/internal/services/blueprints/client/client.go new file mode 100644 index 000000000000..3011d41145ab --- /dev/null +++ b/azurerm/internal/services/blueprints/client/client.go @@ -0,0 +1,29 @@ +package client + +import ( + "github.com/Azure/azure-sdk-for-go/services/preview/blueprint/mgmt/2018-11-01-preview/blueprint" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/common" +) + +type Client struct { + AssignmentsClient *blueprint.AssignmentsClient + BlueprintsClient *blueprint.BlueprintsClient + PublishedBlueprintsClient *blueprint.PublishedBlueprintsClient +} + +func NewClient(o *common.ClientOptions) *Client { + assignmentsClient := blueprint.NewAssignmentsClientWithBaseURI(o.ResourceManagerEndpoint) + o.ConfigureClient(&assignmentsClient.Client, o.ResourceManagerAuthorizer) + + blueprintsClient := blueprint.NewBlueprintsClientWithBaseURI(o.ResourceManagerEndpoint) + o.ConfigureClient(&blueprintsClient.Client, o.ResourceManagerAuthorizer) + + publishedBlueprintsClient := blueprint.NewPublishedBlueprintsClientWithBaseURI(o.ResourceManagerEndpoint) + o.ConfigureClient(&publishedBlueprintsClient.Client, o.ResourceManagerAuthorizer) + + return &Client{ + AssignmentsClient: &assignmentsClient, + BlueprintsClient: &blueprintsClient, + PublishedBlueprintsClient: &publishedBlueprintsClient, + } +} diff --git a/azurerm/internal/services/blueprints/parse/blueprint_assignment.go b/azurerm/internal/services/blueprints/parse/blueprint_assignment.go new file mode 100644 index 000000000000..0c5e930f69b1 --- /dev/null +++ b/azurerm/internal/services/blueprints/parse/blueprint_assignment.go @@ -0,0 +1,64 @@ +package parse + +import ( + "fmt" + "strings" + + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/helpers/azure" +) + +type AssignmentId struct { + // "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Blueprint/blueprintAssignments/assignSimpleBlueprint", + // "/managementGroups/ContosoOnlineGroup/providers/Microsoft.Blueprint/blueprintAssignments/assignSimpleBlueprint", + + Scope string + Subscription string + ManagementGroup string + Name string +} + +func AssignmentID(input string) (*AssignmentId, error) { + if len(input) == 0 { + return nil, fmt.Errorf("Bad: Assignment ID is empty string") + } + + assignmentID := AssignmentId{} + + idParts := strings.Split(strings.Trim(input, "/"), "/") + if len(idParts) != 6 { + return nil, fmt.Errorf("Bad: Blueprint Assignment ID invalid: %q", input) + } + + // check casing on segments + if idParts[2] != "providers" || idParts[3] != "Microsoft.Blueprint" { + return nil, fmt.Errorf("ID has invalid provider segment (should be `providers/Microsoft.Blueprint` case sensitive): %q", input) + } + + if idParts[4] != "blueprintAssignments" { + return nil, fmt.Errorf("ID missing `blueprintAssignments` segment (case sensitive): %q", input) + } + + switch idParts[0] { + case "managementGroups": + assignmentID = AssignmentId{ + Scope: fmt.Sprintf("%s/%s", idParts[0], idParts[1]), + ManagementGroup: idParts[1], + } + + case "subscriptions": + id, err := azure.ParseAzureResourceID(input) + if err != nil { + return nil, fmt.Errorf("[ERROR] Unable to parse Image ID %q: %+v", input, err) + } + + assignmentID.Scope = fmt.Sprintf("subscriptions/%s", id.SubscriptionID) + assignmentID.Subscription = id.SubscriptionID + + default: + return nil, fmt.Errorf("Bad: Invalid ID, should start with one of `/managementGroups` or `/subscriptions`: %q", input) + } + + assignmentID.Name = idParts[5] + + return &assignmentID, nil +} diff --git a/azurerm/internal/services/blueprints/parse/blueprint_assignment_test.go b/azurerm/internal/services/blueprints/parse/blueprint_assignment_test.go new file mode 100644 index 000000000000..09ea4c756484 --- /dev/null +++ b/azurerm/internal/services/blueprints/parse/blueprint_assignment_test.go @@ -0,0 +1,90 @@ +package parse + +import ( + "testing" +) + +func TestBlueprintAssignmentID(t *testing.T) { + testData := []struct { + Name string + Input string + Error bool + Expected *AssignmentId + }{ + { + Name: "Empty", + Input: "", + Error: true, + }, + { + Name: "No Resource Groups Segment", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000", + Error: true, + }, + { + Name: "Invalid scope", + Input: "/providers/Microsoft.Management/managementGroups/testAccManagementGroup", + Error: true, + }, + // We have two valid possibilities to check for + { + Name: "Valid subscription scoped", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Blueprint/blueprintAssignments/assignSimpleBlueprint", + Error: false, + Expected: &AssignmentId{ + Scope: "subscriptions/00000000-0000-0000-0000-000000000000", + Subscription: "00000000-0000-0000-0000-000000000000", + Name: "assignSimpleBlueprint", + }, + }, + { + Name: "Valid managementGroup scoped", + Input: "/managementGroups/testAccManagementGroup/providers/Microsoft.Blueprint/blueprintAssignments/assignSimpleBlueprint", + Error: false, + Expected: &AssignmentId{ + Scope: "managementGroups/testAccManagementGroup", + ManagementGroup: "testAccManagementGroup", + Name: "assignSimpleBlueprint", + }, + }, + { + Name: "wrong case - subscription scoped", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Blueprint/blueprintassignments/assignSimpleBlueprint", + Error: true, + }, + { + Name: "wrong case - managementGroup scoped", + Input: "/managementGroups/testAccManagementGroup/providers/Microsoft.Blueprint/blueprintassignments/assignSimpleBlueprint", + Error: true, + }, + } + + for _, v := range testData { + t.Logf("[DEBUG] Testing %q", v.Name) + + actual, err := AssignmentID(v.Input) + if err != nil { + if v.Error { + continue + } + + t.Fatalf("Expected a value but got an error: %s", err) + } + + if actual.Scope != v.Expected.Scope { + t.Fatalf("Expected %q but got %q for Scope", v.Expected.Scope, actual.Scope) + } + + if actual.Name != v.Expected.Name { + t.Fatalf("Expected %q but got %q for Name", v.Expected.Name, actual.Name) + } + + if actual.ManagementGroup == "" && actual.Subscription != v.Expected.Subscription { + t.Fatalf("Expected %q but got %q for Subscription", v.Expected.Subscription, actual.Subscription) + } + + if actual.Subscription == "" && actual.ManagementGroup != v.Expected.ManagementGroup { + t.Fatalf("Expected %q but got %q for ManagementGroup", v.Expected.ManagementGroup, actual.ManagementGroup) + } + } +} diff --git a/azurerm/internal/services/blueprints/parse/blueprint_definition.go b/azurerm/internal/services/blueprints/parse/blueprint_definition.go new file mode 100644 index 000000000000..6f5133e23c07 --- /dev/null +++ b/azurerm/internal/services/blueprints/parse/blueprint_definition.go @@ -0,0 +1,59 @@ +package parse + +import ( + "fmt" + "strings" + + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/helpers/azure" +) + +type DefinitionId struct { + // "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Blueprint/blueprints/simpleBlueprint" + // "/providers/Microsoft.Management/managementGroups/ContosoOnlineGroup/providers/Microsoft.Blueprint/blueprints/simpleBlueprint" + + Name string + Scope string +} + +func DefinitionID(input string) (*DefinitionId, error) { + if len(input) == 0 { + return nil, fmt.Errorf("Bad: Blueprint ID cannot be an empty string") + } + + definitionId := DefinitionId{} + + idParts := strings.Split(strings.Trim(input, "/"), "/") + if len(idParts) != 6 && len(idParts) != 8 { + return nil, fmt.Errorf("Bad: Blueprint Version ID invalid: %q", input) + } + + switch idParts[0] { + case "providers": + // check casing on segments + if idParts[1] != "Microsoft.Management" || idParts[2] != "managementGroups" { + return nil, fmt.Errorf("ID has invalid provider scope segment (should be `/providers/Microsoft.Management/managementGroups` case sensitive): %q", input) + } + if idParts[4] != "providers" || idParts[5] != "Microsoft.Blueprint" || idParts[6] != "blueprints" { + return nil, fmt.Errorf("Bad: ID has invalid resource provider segment(s), shoud be `/providers/Microsoft.Blueprint/blueprints/`, case sensitive: %q", input) + } + + definitionId = DefinitionId{ + Scope: fmt.Sprintf("providers/Microsoft.Management/managementGroups/%s", idParts[3]), + Name: idParts[6], + } + + case "subscriptions": + id, err := azure.ParseAzureResourceID(input) + if err != nil { + return nil, fmt.Errorf("[ERROR] Unable to parse Blueprint Definition ID %q: %+v", input, err) + } + + definitionId.Scope = fmt.Sprintf("subscriptions/%s", id.SubscriptionID) + definitionId.Name = idParts[5] + + default: + return nil, fmt.Errorf("Bad: Invalid ID, should start with one of `/provider` or `/subscriptions`: %q", input) + } + + return &definitionId, nil +} diff --git a/azurerm/internal/services/blueprints/parse/blueprint_version.go b/azurerm/internal/services/blueprints/parse/blueprint_version.go new file mode 100644 index 000000000000..6b0ee6776f70 --- /dev/null +++ b/azurerm/internal/services/blueprints/parse/blueprint_version.go @@ -0,0 +1,64 @@ +package parse + +import ( + "fmt" + "strings" + + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/helpers/azure" +) + +type VersionId struct { + // "/{resourceScope}/providers/Microsoft.Blueprint/blueprints/{blueprintName}/versions/{versionId}" + // "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Blueprint/blueprints/simpleBlueprint/versions/v1" + // "/providers/Microsoft.Management/managementGroups/ContosoOnlineGroup/providers/Microsoft.Blueprint/blueprints/simpleBlueprint/versions/v1" + + Scope string + Blueprint string + Name string +} + +func VersionID(input string) (*VersionId, error) { + if len(input) == 0 { + return nil, fmt.Errorf("Bad: Blueprint version ID cannot be an empty string") + } + + versionId := VersionId{} + + idParts := strings.Split(strings.Trim(input, "/"), "/") + if len(idParts) != 8 && len(idParts) != 10 { + return nil, fmt.Errorf("Bad: Blueprint Version ID invalid: %q", input) + } + + switch idParts[0] { + case "providers": + // check casing on segments + if idParts[1] != "Microsoft.Management" || idParts[2] != "managementGroups" { + return nil, fmt.Errorf("ID has invalid provider scope segment (should be `/providers/Microsoft.Management/managementGroups` case sensitive): %q", input) + } + + if idParts[4] != "providers" || idParts[5] != "Microsoft.Blueprint" || idParts[6] != "blueprints" { + return nil, fmt.Errorf("Bad: ID has invalid resource provider segment(s), shoud be `/providers/Microsoft.Blueprint/blueprints/`, case sensitive: %q", input) + } + + versionId = VersionId{ + Scope: fmt.Sprintf("providers/Microsoft.Management/managementGroups/%s", idParts[3]), + Blueprint: idParts[6], + Name: idParts[9], + } + + case "subscriptions": + id, err := azure.ParseAzureResourceID(input) + if err != nil { + return nil, fmt.Errorf("unable to parse Resource ID %q: %+v", input, err) + } + + versionId.Scope = fmt.Sprintf("subscriptions/%s", id.SubscriptionID) + versionId.Blueprint = idParts[5] + versionId.Name = idParts[7] + + default: + return nil, fmt.Errorf("Bad: Invalid ID, should start with one of `/provider` or `/subscriptions`: %q", input) + } + + return &versionId, nil +} diff --git a/azurerm/internal/services/blueprints/parse/blueprint_version_test.go b/azurerm/internal/services/blueprints/parse/blueprint_version_test.go new file mode 100644 index 000000000000..44d2de1683cd --- /dev/null +++ b/azurerm/internal/services/blueprints/parse/blueprint_version_test.go @@ -0,0 +1,68 @@ +package parse + +import "testing" + +func TestBlueprintVersionID(t *testing.T) { + testData := []struct { + Name string + Input string + Error bool + Expected *VersionId + }{ + { + Name: "Empty", + Input: "", + Error: true, + }, + { + Name: "No Resource Groups Segment", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000", + Error: true, + }, + { + Name: "Invalid scope", + Input: "/managementGroups/testAccManagementGroup", + Error: true, + }, + // We have two valid possibilities to check for + { + Name: "Valid subscription scoped", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Blueprint/blueprints/simpleBlueprint/versions/v1-test", + Expected: &VersionId{ + Scope: "subscriptions/00000000-0000-0000-0000-000000000000", + Blueprint: "simpleBlueprint", + Name: "v1-test", + }, + }, + { + Name: "Valid management group scoped", + Input: "/providers/Microsoft.Management/managementGroups/testAccManagementGroup/providers/Microsoft.Blueprint/blueprints/simpleBlueprint/versions/v1-test", + Expected: &VersionId{ + Scope: "providers/Microsoft.Management/managementGroups/testAccManagementGroup", + Blueprint: "simpleBlueprint", + Name: "v1-test", + }, + }, + } + + for _, v := range testData { + t.Logf("[DEBUG] Testing %q", v.Name) + + actual, err := VersionID(v.Input) + if err != nil { + if v.Error { + continue + } + + t.Fatalf("Expected a value but got an error: %s", err) + } + + if actual.Name != v.Expected.Name { + t.Fatalf("Expected %q but got %q for Scope", v.Expected.Name, actual.Name) + } + + if actual.Scope != v.Expected.Scope { + t.Fatalf("Expected %q but got %q for Scope", v.Expected.Scope, actual.Scope) + } + } +} diff --git a/azurerm/internal/services/blueprints/registration.go b/azurerm/internal/services/blueprints/registration.go new file mode 100644 index 000000000000..b9a310f3ac97 --- /dev/null +++ b/azurerm/internal/services/blueprints/registration.go @@ -0,0 +1,32 @@ +package blueprints + +import "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + +type Registration struct{} + +// Name is the name of this Service +func (r Registration) Name() string { + return "Blueprints" +} + +// WebsiteCategories returns a list of categories which can be used for the sidebar +func (r Registration) WebsiteCategories() []string { + return []string{ + "Blueprints", + } +} + +// SupportedDataSources returns the supported Data Sources supported by this Service +func (r Registration) SupportedDataSources() map[string]*schema.Resource { + return map[string]*schema.Resource{ + "azurerm_blueprint_definition": dataSourceArmBlueprintDefinition(), + "azurerm_blueprint_published_version": dataSourceArmBlueprintPublishedVersion(), + } +} + +// SupportedResources returns the supported Resources supported by this Service +func (r Registration) SupportedResources() map[string]*schema.Resource { + return map[string]*schema.Resource{ + "azurerm_blueprint_assignment": resourceArmBlueprintAssignment(), + } +} diff --git a/azurerm/internal/services/blueprints/tests/blueprint_assignment_resource_test.go b/azurerm/internal/services/blueprints/tests/blueprint_assignment_resource_test.go new file mode 100644 index 000000000000..1a21c21f2d51 --- /dev/null +++ b/azurerm/internal/services/blueprints/tests/blueprint_assignment_resource_test.go @@ -0,0 +1,421 @@ +package tests + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/terraform" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/acceptance" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/clients" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/blueprints/parse" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/utils" +) + +// Scenario: Basic BP, no artifacts etc. Stored and applied at Subscription. +func TestAccBlueprintAssignment_basic(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_blueprint_assignment", "test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + Providers: acceptance.SupportedProviders, + CheckDestroy: testCheckAzureRMBlueprintAssignmentDestroy, + Steps: []resource.TestStep{ + { + Config: testAccBlueprintAssignment_basic(data, "testAcc_basicSubscription", "v0.1_testAcc"), + Check: resource.ComposeTestCheckFunc( + testCheckBlueprintAssignmentExists(data.ResourceName), + ), + }, + data.ImportStep(), + }, + }) +} + +func TestAccBlueprintAssignment_basicUpdated(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_blueprint_assignment", "test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + Providers: acceptance.SupportedProviders, + CheckDestroy: testCheckAzureRMBlueprintAssignmentDestroy, + Steps: []resource.TestStep{ + { + Config: testAccBlueprintAssignment_basic(data, "testAcc_basicSubscription", "v0.1_testAcc"), + Check: resource.ComposeTestCheckFunc( + testCheckBlueprintAssignmentExists(data.ResourceName), + ), + }, + data.ImportStep(), + { + Config: testAccBlueprintAssignment_basic(data, "testAcc_basicSubscription", "v0.2_testAcc"), + Check: resource.ComposeTestCheckFunc( + testCheckBlueprintAssignmentExists(data.ResourceName), + ), + }, + data.ImportStep(), + }, + }) +} + +func TestAccBlueprintAssignment_requiresImport(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_blueprint_assignment", "test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + Providers: acceptance.SupportedProviders, + CheckDestroy: testCheckAzureRMBlueprintAssignmentDestroy, + Steps: []resource.TestStep{ + { + Config: testAccBlueprintAssignment_basic(data, "testAcc_basicSubscription", "v0.1_testAcc"), + Check: resource.ComposeTestCheckFunc( + testCheckBlueprintAssignmentExists(data.ResourceName), + ), + }, + { + Config: testAccBlueprintAssignment_requiresImport(data, "testAcc_basicSubscription", "v0.1_testAcc"), + ExpectError: acceptance.RequiresImportError("azurerm_blueprint_assignment"), + }, + }, + }) +} + +// Scenario: BP with RG's, locking and parameters/policies stored at Subscription, applied to subscription +func TestAccBlueprintAssignment_subscriptionComplete(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_blueprint_assignment", "test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + Providers: acceptance.SupportedProviders, + CheckDestroy: testCheckAzureRMBlueprintAssignmentDestroy, + Steps: []resource.TestStep{ + { + Config: testAccBlueprintAssignment_subscriptionComplete(data, "testAcc_subscriptionComplete", "v0.1_testAcc"), + Check: resource.ComposeTestCheckFunc( + testCheckBlueprintAssignmentExists(data.ResourceName), + ), + }, + data.ImportStep(), + }, + }) +} + +// Scenario: BP stored at Root Management Group, applied to Subscription +func TestAccBlueprintAssignment_managementGroup(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_blueprint_assignment", "test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + Providers: acceptance.SupportedProviders, + CheckDestroy: testCheckAzureRMBlueprintAssignmentDestroy, + Steps: []resource.TestStep{ + { + Config: testAccBlueprintAssignment_rootManagementGroup(data, "testAcc_basicRootManagementGroup", "v0.1_testAcc"), + Check: resource.ComposeTestCheckFunc( + testCheckBlueprintAssignmentExists(data.ResourceName), + ), + }, + data.ImportStep(), + }, + }) +} + +func testCheckBlueprintAssignmentExists(resourceName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("Blueprint Assignment not found: %s", resourceName) + } + id, err := parse.AssignmentID(rs.Primary.ID) + if err != nil { + return err + } + + client := acceptance.AzureProvider.Meta().(*clients.Client).Blueprints.AssignmentsClient + ctx := acceptance.AzureProvider.Meta().(*clients.Client).StopContext + + if resp, err := client.Get(ctx, id.Scope, id.Name); err != nil { + if utils.ResponseWasNotFound(resp.Response) { + return fmt.Errorf("Bad: Blueprint Assignment %q (scope %q) was not found", id.Name, id.Scope) + } + return fmt.Errorf("Bad: Get on Blueprint Assignment %q (scope %q): %+v", id.Name, id.Scope, err) + } + return nil + } +} + +func testCheckAzureRMBlueprintAssignmentDestroy(s *terraform.State) error { + client := acceptance.AzureProvider.Meta().(*clients.Client).Blueprints.AssignmentsClient + ctx := acceptance.AzureProvider.Meta().(*clients.Client).StopContext + + for _, rs := range s.RootModule().Resources { + if rs.Type != "azurerm_blueprint_assignment" { + continue + } + + id, err := parse.AssignmentID(rs.Primary.ID) + if err != nil { + return err + } + + if resp, err := client.Get(ctx, id.Scope, id.Name); err != nil { + if !utils.ResponseWasNotFound(resp.Response) { + return fmt.Errorf("Bad: Get on Blueprint.AssignmentClient: %+v", err) + } + } + + return nil + } + + return nil +} + +func testAccBlueprintAssignment_basic(data acceptance.TestData, bpName string, version string) string { + subscription := data.Client().SubscriptionIDAlt + return fmt.Sprintf(` +provider "azurerm" { + subscription_id = "%s" + features {} +} + +data "azurerm_client_config" "current" {} + +data "azurerm_subscription" "test" {} + +data "azurerm_blueprint_definition" "test" { + name = "%s" + scope_id = data.azurerm_subscription.test.id +} + +data "azurerm_blueprint_published_version" "test" { + scope_id = data.azurerm_blueprint_definition.test.scope_id + blueprint_name = data.azurerm_blueprint_definition.test.name + version = "%s" +} + +resource "azurerm_resource_group" "test" { + name = "accTestRG-bp-%d" + location = "westeurope" +} + +resource "azurerm_user_assigned_identity" "test" { + resource_group_name = azurerm_resource_group.test.name + location = azurerm_resource_group.test.location + name = "bp-user-%d" +} + +resource "azurerm_role_assignment" "test" { + scope = data.azurerm_subscription.test.id + role_definition_name = "Blueprint Operator" + principal_id = azurerm_user_assigned_identity.test.principal_id +} + +resource "azurerm_blueprint_assignment" "test" { + name = "testAccBPAssignment" + target_subscription_id = data.azurerm_subscription.test.id + version_id = data.azurerm_blueprint_published_version.test.id + location = "%s" + + identity { + type = "UserAssigned" + identity_ids = [azurerm_user_assigned_identity.test.id] + } + + depends_on = [ + azurerm_role_assignment.test + ] +} +`, subscription, bpName, version, data.RandomInteger, data.RandomInteger, data.Locations.Primary) +} + +// This test config creates a UM-MSI and assigns Owner to the target subscription. This is necessary due to the changes +// the referenced Blueprint Version needs to make to successfully apply. If the test does not exit cleanly, "dangling" +// resources can include the Role Assignment(s) at the Subscription, which will need to be removed +func testAccBlueprintAssignment_subscriptionComplete(data acceptance.TestData, bpName string, version string) string { + subscription := data.Client().SubscriptionIDAlt + + return fmt.Sprintf(` +provider "azurerm" { + subscription_id = "%s" + features {} +} + +data "azurerm_client_config" "current" {} + +data "azurerm_subscription" "test" {} + +data "azurerm_blueprint_definition" "test" { + name = "%s" + scope_id = data.azurerm_subscription.test.id +} + +data "azurerm_blueprint_published_version" "test" { + scope_id = data.azurerm_blueprint_definition.test.scope_id + blueprint_name = data.azurerm_blueprint_definition.test.name + version = "%s" +} + +resource "azurerm_resource_group" "test" { + name = "accTestRG-bp-%d" + location = "%s" + + tags = { + testAcc = "true" + } +} + +resource "azurerm_user_assigned_identity" "test" { + resource_group_name = azurerm_resource_group.test.name + location = azurerm_resource_group.test.location + name = "bp-user-%d" +} + +resource "azurerm_role_assignment" "operator" { + scope = data.azurerm_subscription.test.id + role_definition_name = "Blueprint Operator" + principal_id = azurerm_user_assigned_identity.test.principal_id +} + +resource "azurerm_role_assignment" "owner" { + scope = data.azurerm_subscription.test.id + role_definition_name = "Owner" + principal_id = azurerm_user_assigned_identity.test.principal_id +} + +resource "azurerm_blueprint_assignment" "test" { + name = "testAccBPAssignment" + target_subscription_id = data.azurerm_subscription.test.id + version_id = data.azurerm_blueprint_published_version.test.id + location = "%s" + + lock_mode = "AllResourcesDoNotDelete" + + lock_exclude_principals = [ + data.azurerm_client_config.current.object_id, + ] + + identity { + type = "UserAssigned" + identity_ids = [azurerm_user_assigned_identity.test.id] + } + + resource_groups = <azurerm_batch_pool +
  • + azurerm_blueprint_definition +
  • + +
  • + azurerm_blueprint_published_version +
  • +
  • azurerm_cdn_profile
  • @@ -971,6 +979,16 @@ +
  • + Blueprints Resources + +
  • +
  • Bot Resources