diff --git a/azurerm/internal/clients/client.go b/azurerm/internal/clients/client.go index 05ab689cc52f..15413be17433 100644 --- a/azurerm/internal/clients/client.go +++ b/azurerm/internal/clients/client.go @@ -12,6 +12,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" + blueprint "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/blueprint/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" @@ -83,6 +84,7 @@ type Client struct { Authorization *authorization.Client Automation *automation.Client Batch *batch.Client + Blueprint *blueprint.Client Bot *bot.Client Cdn *cdn.Client Cognitive *cognitiveServices.Client @@ -153,6 +155,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.Blueprint = blueprint.NewClient(o) client.Bot = bot.NewClient(o) client.Cdn = cdn.NewClient(o) client.Cognitive = cognitiveServices.NewClient(o) diff --git a/azurerm/internal/provider/services.go b/azurerm/internal/provider/services.go index 2414de7030e1..861daa0b206e 100644 --- a/azurerm/internal/provider/services.go +++ b/azurerm/internal/provider/services.go @@ -8,6 +8,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/blueprint" "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" @@ -77,6 +78,7 @@ func SupportedServices() []common.ServiceRegistration { authorization.Registration{}, automation.Registration{}, batch.Registration{}, + blueprint.Registration{}, bot.Registration{}, cdn.Registration{}, cognitive.Registration{}, diff --git a/azurerm/internal/services/blueprint/client/client.go b/azurerm/internal/services/blueprint/client/client.go new file mode 100644 index 000000000000..564490cd08dc --- /dev/null +++ b/azurerm/internal/services/blueprint/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 { + BlueprintClient *blueprint.BlueprintsClient + AssignmentClient *blueprint.AssignmentsClient + PublishClient *blueprint.PublishedBlueprintsClient +} + +func NewClient(o *common.ClientOptions) *Client { + blueprintsClient := blueprint.NewBlueprintsClientWithBaseURI(o.ResourceManagerEndpoint) + o.ConfigureClient(&blueprintsClient.Client, o.ResourceManagerAuthorizer) + + assignmentsClient := blueprint.NewAssignmentsClientWithBaseURI(o.ResourceManagerEndpoint) + o.ConfigureClient(&assignmentsClient.Client, o.ResourceManagerAuthorizer) + + publishClient := blueprint.NewPublishedBlueprintsClientWithBaseURI(o.ResourceManagerEndpoint) + o.ConfigureClient(&publishClient.Client, o.ResourceManagerAuthorizer) + + return &Client{ + BlueprintClient: &blueprintsClient, + AssignmentClient: &assignmentsClient, + PublishClient: &publishClient, + } +} diff --git a/azurerm/internal/services/blueprint/parse/assignment.go b/azurerm/internal/services/blueprint/parse/assignment.go new file mode 100644 index 000000000000..bff3c481ad2f --- /dev/null +++ b/azurerm/internal/services/blueprint/parse/assignment.go @@ -0,0 +1,68 @@ +package parse + +import ( + "fmt" + "regexp" + + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/helpers/azure" +) + +type BlueprintAssignmentId struct { + Name string + BlueprintAssignmentScopeId +} + +func BlueprintAssignmentID(input string) (*BlueprintAssignmentId, error) { + regex := regexp.MustCompile(`/providers/Microsoft\.Blueprint/blueprintAssignments/`) + if !regex.MatchString(input) { + return nil, fmt.Errorf("unable to parse Blueprint Assignment ID %q", input) + } + + segments := regex.Split(input, -1) + + if len(segments) != 2 { + return nil, fmt.Errorf("unable to parse Blueprint Assignment ID %q: expeceted 2 segmetns", input) + } + + scope := segments[0] + scopeId, err := BlueprintAssignmentScopeID(scope) + if err != nil { + return nil, fmt.Errorf("unable to parse Blueprint Assignment ID %q: %+v", input, err) + } + + name := segments[1] + if name == "" { + return nil, fmt.Errorf("unable to parse Blueprint Assignment ID %q: asssignment name is empty", input) + } + + id := BlueprintAssignmentId{ + Name: name, + BlueprintAssignmentScopeId: *scopeId, + } + + return &id, nil +} + +type BlueprintAssignmentScopeId struct { + ScopeId string + SubscriptionId string +} + +func BlueprintAssignmentScopeID(input string) (*BlueprintAssignmentScopeId, error) { + id, err := azure.ParseAzureResourceID(input) + if err != nil { + return nil, fmt.Errorf("unable to parse Blueprint Assignment Scope ID %q: %+v", input, err) + } + if err := id.ValidateNoEmptySegments(input); err != nil { + return nil, fmt.Errorf("unable to parse Blueprint Assignment Scope ID %q: %+v", input, err) + } + if id.ResourceGroup != "" { + return nil, fmt.Errorf("unable to parse Blueprint Assignment Scope ID %q: scope cannot have resource groups", input) + } + scopeId := BlueprintAssignmentScopeId{ + ScopeId: input, + SubscriptionId: id.SubscriptionID, + } + + return &scopeId, nil +} diff --git a/azurerm/internal/services/blueprint/parse/assignment_test.go b/azurerm/internal/services/blueprint/parse/assignment_test.go new file mode 100644 index 000000000000..d78ff0baf938 --- /dev/null +++ b/azurerm/internal/services/blueprint/parse/assignment_test.go @@ -0,0 +1,140 @@ +package parse + +import "testing" + +func TestBlueprintAssignmentID(t *testing.T) { + testData := []struct { + Name string + Input string + Error bool + Expected *BlueprintAssignmentId + }{ + { + Name: "Empty", + Input: "", + Error: true, + }, + { + Name: "Missing management group ID", + Input: "/providers/Microsoft.Management/managementGroups/providers/Microsoft.Blueprint/blueprintAssignments/assignment1", + Error: true, + }, + { + Name: "Missing assignment name", + Input: "/providers/Microsoft.Management/managementGroups/00000000-0000-0000-0000-000000000000/providers/Microsoft.Blueprint/blueprintAssignments/", + Error: true, + }, + { + Name: "Blueprint Assignment ID in subscription", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Blueprint/blueprintAssignments/assignment1", + Expected: &BlueprintAssignmentId{ + Name: "assignment1", + BlueprintAssignmentScopeId: BlueprintAssignmentScopeId{ + ScopeId: "/subscriptions/00000000-0000-0000-0000-000000000000", + SubscriptionId: "00000000-0000-0000-0000-000000000000", + }, + }, + }, + { + Name: "Missing assignment name in subscription", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Blueprint/blueprintAssignments/", + Error: true, + }, + { + Name: "Assignment ID in resource group", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/group1/providers/Microsoft.Blueprint/blueprintAssignments/assignment1", + Error: true, + }, + { + Name: "missing resource group name", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/providers/Microsoft.Blueprint/blueprintAssignments/assignment1", + Error: true, + }, + } + + for _, v := range testData { + t.Logf("[DEBUG] Testing %q", v.Name) + + actual, err := BlueprintAssignmentID(v.Input) + if err != nil { + if v.Error { + continue + } + + t.Fatalf("Expected a value but got an error: %+v", err) + } + + if actual.Name != v.Expected.Name { + t.Fatalf("Expected %q but got %q", v.Expected.Name, actual.Name) + } + + if actual.ScopeId != v.Expected.ScopeId { + t.Fatalf("Expected %q but got %q", v.Expected.ScopeId, actual.ScopeId) + } + + if actual.SubscriptionId != v.Expected.SubscriptionId { + t.Fatalf("Expected %q but got %q", v.Expected.SubscriptionId, actual.SubscriptionId) + } + } +} + +func TestBlueprintAssignmentScopeID(t *testing.T) { + testData := []struct { + Name string + Input string + Error bool + Expected *BlueprintAssignmentScopeId + }{ + { + Name: "Empty", + Input: "", + Error: true, + }, + { + Name: "Management group ID but missing components", + Input: "/providers/Microsoft.Management/managementGroups/", + Error: true, + }, + { + Name: "Resource Group ID", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/group1", + Error: true, + }, + { + Name: "Incomplete resource group ID", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/", + Error: true, + }, + { + Name: "Resource ID", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/group1/providers/Microsoft.Compute/virtualMachines/vm1", + Error: true, + }, + { + Name: "Incomplete resource ID", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/group1/providers/Microsoft.Compute/virtualMachines/", + Error: true, + }, + } + + for _, v := range testData { + t.Logf("[DEBUG] Testing %q", v.Name) + + actual, err := BlueprintAssignmentScopeID(v.Input) + if err != nil { + if v.Error { + continue + } + + t.Fatalf("Expected a value but got an error: %+v", err) + } + + if actual.ScopeId != v.Expected.ScopeId { + t.Fatalf("Expected %q but got %q", v.Expected.ScopeId, actual.ScopeId) + } + + if actual.SubscriptionId != v.Expected.SubscriptionId { + t.Fatalf("Expected %q but got %q", v.Expected.SubscriptionId, actual.SubscriptionId) + } + } +} diff --git a/azurerm/internal/services/blueprint/parse/definition.go b/azurerm/internal/services/blueprint/parse/definition.go new file mode 100644 index 000000000000..2cfa57d051fa --- /dev/null +++ b/azurerm/internal/services/blueprint/parse/definition.go @@ -0,0 +1,157 @@ +package parse + +import ( + "fmt" + "regexp" + + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/helpers/azure" +) + +type BlueprintDefinitionId struct { + ID string + Name string + BlueprintDefinitionScopeId +} + +func BlueprintDefinitionID(input string) (*BlueprintDefinitionId, error) { + regex := regexp.MustCompile(`/providers/Microsoft\.Blueprint/blueprints/`) + if !regex.MatchString(input) { + return nil, fmt.Errorf("unable to parse Blueprint Definition ID %q", input) + } + + segments := regex.Split(input, -1) + + if len(segments) != 2 { + return nil, fmt.Errorf("unable to parse Blueprint Definition ID %q: Expected 2 segments after splition", input) + } + + scope := segments[0] + scopeId, err := BlueprintDefinitionScopeID(scope) + if err != nil { + return nil, fmt.Errorf("unable to parse Blueprint Definition ID %q: %+v", input, err) + } + + name := segments[1] + if name == "" { + return nil, fmt.Errorf("unable to parse Blueprint Definition ID %q: blueprint name is empty", input) + } + + id := BlueprintDefinitionId{ + ID: input, + Name: name, + BlueprintDefinitionScopeId: *scopeId, + } + + return &id, nil +} + +type ScopeType int + +const ( + AtSubscription ScopeType = iota + AtManagementGroup +) + +type BlueprintDefinitionScopeId struct { + ScopeId string + Type ScopeType + SubscriptionId string + ManagementGroupId string +} + +func BlueprintDefinitionScopeID(input string) (*BlueprintDefinitionScopeId, error) { + if input == "" { + return nil, fmt.Errorf("unable to parse Blueprint Definition Scope ID: input is empty") + } + scopeId := BlueprintDefinitionScopeId{ + ScopeId: input, + } + + if isManagementGroupId(input) { + managementGroupId, _ := ManagementGroupID(input) // if this is a management group ID, there should not be any error. + scopeId.ManagementGroupId = managementGroupId.GroupId + scopeId.Type = AtManagementGroup + } else { + id, err := azure.ParseAzureResourceID(input) + if err != nil { + return nil, fmt.Errorf("unable to parse Blueprint Definition Scope ID %q: %+v", input, err) + } + if err := id.ValidateNoEmptySegments(input); err != nil { + return nil, fmt.Errorf("unable to parse Blueprint Definition Scope ID %q: %+v", input, err) + } + if id.ResourceGroup != "" { + return nil, fmt.Errorf("unable to parse Blueprint Definition Scope ID %q: scope cannot have resource groups", input) + } + scopeId.SubscriptionId = id.SubscriptionID + scopeId.Type = AtSubscription + } + + return &scopeId, nil +} + +type PublishedBlueprintId struct { + Version string + BlueprintDefinitionId +} + +func PublishedBlueprintID(input string) (*PublishedBlueprintId, error) { + regex := regexp.MustCompile(`/versions/`) + if !regex.MatchString(input) { + return nil, fmt.Errorf("unable to parse Published Blueprint ID %q", input) + } + + segments := regex.Split(input, -1) + + if len(segments) != 2 { + return nil, fmt.Errorf("unable to parse Published Blueprint ID %q: Expected 2 segments after splition", input) + } + + definitionId, err := BlueprintDefinitionID(segments[0]) + if err != nil { + return nil, fmt.Errorf("unable to parse Published Blueprint ID %q: %+v", input, err) + } + + version := segments[1] + if version == "" { + return nil, fmt.Errorf("unable to parse Published Blueprint ID %q: version is empty", input) + } + + return &PublishedBlueprintId{ + Version: version, + BlueprintDefinitionId: *definitionId, + }, nil +} + +func isManagementGroupId(input string) bool { + _, err := ManagementGroupID(input) + return err == nil +} + +// TODO -- move this to management group RP directory +type ManagementGroupId struct { + GroupId string +} + +func ManagementGroupID(input string) (*ManagementGroupId, error) { + regex := regexp.MustCompile(`^/providers/[Mm]icrosoft\.[Mm]anagement/management[Gg]roups/`) + if !regex.MatchString(input) { + return nil, fmt.Errorf("unable to parse Management Group ID %q", input) + } + + // Split the input ID by the regex + segments := regex.Split(input, -1) + if len(segments) != 2 { + return nil, fmt.Errorf("unable to parse Management Group ID %q: expected id to have two segments after splitting", input) + } + + groupID := segments[1] + if groupID == "" { + return nil, fmt.Errorf("unable to parse Management Group ID %q: group ID is empty", input) + } + + id := ManagementGroupId{ + GroupId: groupID, + } + + return &id, nil +} diff --git a/azurerm/internal/services/blueprint/parse/definition_test.go b/azurerm/internal/services/blueprint/parse/definition_test.go new file mode 100644 index 000000000000..965b7c1ccba1 --- /dev/null +++ b/azurerm/internal/services/blueprint/parse/definition_test.go @@ -0,0 +1,446 @@ +package parse + +import "testing" + +func TestBlueprintDefinitionID(t *testing.T) { + testData := []struct { + Name string + Input string + Error bool + Expected *BlueprintDefinitionId + }{ + { + Name: "Empty", + Input: "", + Error: true, + }, + { + Name: "Blueprint ID in Management group", + Input: "/providers/Microsoft.Management/managementGroups/00000000-0000-0000-0000-000000000000/providers/Microsoft.Blueprint/blueprints/blueprint1", + Expected: &BlueprintDefinitionId{ + ID: "/providers/Microsoft.Management/managementGroups/00000000-0000-0000-0000-000000000000/providers/Microsoft.Blueprint/blueprints/blueprint1", + Name: "blueprint1", + BlueprintDefinitionScopeId: BlueprintDefinitionScopeId{ + ScopeId: "/providers/Microsoft.Management/managementGroups/00000000-0000-0000-0000-000000000000", + Type: AtManagementGroup, + SubscriptionId: "", + ManagementGroupId: "00000000-0000-0000-0000-000000000000", + }, + }, + }, + { + Name: "Blueprint ID in Management group but with wrong casing in management group part", + Input: "/providers/microsoft.management/managementgroups/00000000-0000-0000-0000-000000000000/providers/Microsoft.Blueprint/blueprints/blueprint1", + Expected: &BlueprintDefinitionId{ + ID: "/providers/microsoft.management/managementgroups/00000000-0000-0000-0000-000000000000/providers/Microsoft.Blueprint/blueprints/blueprint1", + Name: "blueprint1", + BlueprintDefinitionScopeId: BlueprintDefinitionScopeId{ + ScopeId: "/providers/microsoft.management/managementgroups/00000000-0000-0000-0000-000000000000", + Type: AtManagementGroup, + SubscriptionId: "", + ManagementGroupId: "00000000-0000-0000-0000-000000000000", + }, + }, + }, + { + Name: "Missing management group ID", + Input: "/providers/Microsoft.Management/managementGroups/providers/Microsoft.Blueprint/blueprints/blueprint1", + Error: true, + }, + { + Name: "Missing blueprint name", + Input: "/providers/Microsoft.Management/managementGroups/00000000-0000-0000-0000-000000000000/providers/Microsoft.Blueprint/blueprints/", + Error: true, + }, + { + Name: "Blueprint ID in subscription", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Blueprint/blueprints/blueprint1", + Expected: &BlueprintDefinitionId{ + ID: "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Blueprint/blueprints/blueprint1", + Name: "blueprint1", + BlueprintDefinitionScopeId: BlueprintDefinitionScopeId{ + ScopeId: "/subscriptions/00000000-0000-0000-0000-000000000000", + Type: AtSubscription, + SubscriptionId: "00000000-0000-0000-0000-000000000000", + ManagementGroupId: "", + }, + }, + }, + { + Name: "Missing blueprint name in subscription", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Blueprint/blueprints/", + Error: true, + }, + { + Name: "BlueprintID in resource group", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/group1/providers/Microsoft.Blueprint/blueprints/blueprint1", + Error: true, + }, + { + Name: "Invalid resource ID", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/providers/Microsoft.Blueprint/blueprints/blueprint1", + Error: true, + }, + } + + for _, v := range testData { + t.Logf("[DEBUG] Testing %q", v.Name) + + actual, err := BlueprintDefinitionID(v.Input) + if err != nil { + if v.Error { + continue + } + + t.Fatalf("Expected a value but got an error: %+v", err) + } + + if actual.ID != v.Expected.ID { + t.Fatalf("Expected %q but got %q", v.Expected.ID, actual.ID) + } + + if actual.Name != v.Expected.Name { + t.Fatalf("Expected %q but got %q", v.Expected.Name, actual.Name) + } + + if actual.Type != v.Expected.Type { + t.Fatalf("Expected type %q but got type %q", v.Expected.Type, actual.Type) + } + + if actual.ScopeId != v.Expected.ScopeId { + t.Fatalf("Expected %q but got %q", v.Expected.ScopeId, actual.ScopeId) + } + + if actual.SubscriptionId != v.Expected.SubscriptionId { + t.Fatalf("Expected %q but got %q", v.Expected.SubscriptionId, actual.SubscriptionId) + } + + if actual.ManagementGroupId != v.Expected.ManagementGroupId { + t.Fatalf("Expected %q but got %q", v.Expected.ManagementGroupId, actual.ManagementGroupId) + } + } +} + +func TestBlueprintDefinitionScopeID(t *testing.T) { + testData := []struct { + Name string + Input string + Error bool + Expected *BlueprintDefinitionScopeId + }{ + { + Name: "Empty", + Input: "", + Error: true, + }, + { + Name: "Management group ID", + Input: "/providers/Microsoft.Management/managementGroups/00000000-0000-0000-0000-000000000000", + Expected: &BlueprintDefinitionScopeId{ + ScopeId: "/providers/Microsoft.Management/managementGroups/00000000-0000-0000-0000-000000000000", + Type: AtManagementGroup, + SubscriptionId: "", + ManagementGroupId: "00000000-0000-0000-0000-000000000000", + }, + }, + { + Name: "Management group ID with wrong casing", + Input: "/providers/microsoft.management/managementgroups/00000000-0000-0000-0000-000000000000", + Expected: &BlueprintDefinitionScopeId{ + ScopeId: "/providers/microsoft.management/managementgroups/00000000-0000-0000-0000-000000000000", + Type: AtManagementGroup, + SubscriptionId: "", + ManagementGroupId: "00000000-0000-0000-0000-000000000000", + }, + }, + { + Name: "Management group ID but missing components", + Input: "/providers/Microsoft.Management/managementGroups/", + Error: true, + }, + { + Name: "Subscription ID", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000", + Expected: &BlueprintDefinitionScopeId{ + ScopeId: "/subscriptions/00000000-0000-0000-0000-000000000000", + Type: AtSubscription, + SubscriptionId: "00000000-0000-0000-0000-000000000000", + ManagementGroupId: "", + }, + }, + { + Name: "Resource Group ID", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/group1", + Error: true, + }, + { + Name: "Incomplete resource group ID", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/", + Error: true, + }, + { + Name: "Resource ID", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/group1/providers/Microsoft.Compute/virtualMachines/vm1", + Error: true, + }, + { + Name: "Incomplete resource ID", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/group1/providers/Microsoft.Compute/virtualMachines/", + Error: true, + }, + } + + for _, v := range testData { + t.Logf("[DEBUG] Testing %q", v.Name) + + actual, err := BlueprintDefinitionScopeID(v.Input) + if err != nil { + if v.Error { + continue + } + + t.Fatalf("Expected a value but got an error: %+v", err) + } + + if actual.Type != v.Expected.Type { + t.Fatalf("Expected type %q but got type %q", v.Expected.Type, actual.Type) + } + + if actual.ScopeId != v.Expected.ScopeId { + t.Fatalf("Expected %q but got %q", v.Expected.ScopeId, actual.ScopeId) + } + + if actual.SubscriptionId != v.Expected.SubscriptionId { + t.Fatalf("Expected %q but got %q", v.Expected.SubscriptionId, actual.SubscriptionId) + } + + if actual.ManagementGroupId != v.Expected.ManagementGroupId { + t.Fatalf("Expected %q but got %q", v.Expected.ManagementGroupId, actual.ManagementGroupId) + } + } +} + +func TestPublishedBlueprintID(t *testing.T) { + testData := []struct { + Name string + Input string + Error bool + Expected *PublishedBlueprintId + }{ + { + Name: "Empty", + Input: "", + Error: true, + }, + { + Name: "Published Blueprint ID in Management group", + Input: "/providers/Microsoft.Management/managementGroups/00000000-0000-0000-0000-000000000000/providers/Microsoft.Blueprint/blueprints/blueprint1/versions/v1", + Expected: &PublishedBlueprintId{ + Version: "v1", + BlueprintDefinitionId: BlueprintDefinitionId{ + ID: "/providers/Microsoft.Management/managementGroups/00000000-0000-0000-0000-000000000000/providers/Microsoft.Blueprint/blueprints/blueprint1", + Name: "blueprint1", + BlueprintDefinitionScopeId: BlueprintDefinitionScopeId{ + ScopeId: "/providers/Microsoft.Management/managementGroups/00000000-0000-0000-0000-000000000000", + Type: AtManagementGroup, + SubscriptionId: "", + ManagementGroupId: "00000000-0000-0000-0000-000000000000", + }, + }, + }, + }, + { + Name: "Published Blueprint ID in Management group but with wrong casing in management group part", + Input: "/providers/microsoft.management/managementgroups/00000000-0000-0000-0000-000000000000/providers/Microsoft.Blueprint/blueprints/blueprint1/versions/v1", + Expected: &PublishedBlueprintId{ + Version: "v1", + BlueprintDefinitionId: BlueprintDefinitionId{ + ID: "/providers/microsoft.management/managementgroups/00000000-0000-0000-0000-000000000000/providers/Microsoft.Blueprint/blueprints/blueprint1", + Name: "blueprint1", + BlueprintDefinitionScopeId: BlueprintDefinitionScopeId{ + ScopeId: "/providers/microsoft.management/managementgroups/00000000-0000-0000-0000-000000000000", + Type: AtManagementGroup, + SubscriptionId: "", + ManagementGroupId: "00000000-0000-0000-0000-000000000000", + }, + }, + }, + }, + { + Name: "Missing version", + Input: "/providers/Microsoft.Management/managementGroups/00000000-0000-0000-0000-000000000000/providers/Microsoft.Blueprint/blueprints/blueprint1/versions/", + Error: true, + }, + { + Name: "Unpublished Blueprint ID", + Input: "/providers/Microsoft.Management/managementGroups/00000000-0000-0000-0000-000000000000/providers/Microsoft.Blueprint/blueprints/blueprint1", + Error: true, + }, + { + Name: "Missing management group ID", + Input: "/providers/Microsoft.Management/managementGroups/providers/Microsoft.Blueprint/blueprints/blueprint1", + Error: true, + }, + { + Name: "Missing blueprint name", + Input: "/providers/Microsoft.Management/managementGroups/00000000-0000-0000-0000-000000000000/providers/Microsoft.Blueprint/blueprints/", + Error: true, + }, + { + Name: "Blueprint ID in subscription", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Blueprint/blueprints/blueprint1/versions/v1", + Expected: &PublishedBlueprintId{ + Version: "v1", + BlueprintDefinitionId: BlueprintDefinitionId{ + ID: "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Blueprint/blueprints/blueprint1", + Name: "blueprint1", + BlueprintDefinitionScopeId: BlueprintDefinitionScopeId{ + ScopeId: "/subscriptions/00000000-0000-0000-0000-000000000000", + Type: AtSubscription, + SubscriptionId: "00000000-0000-0000-0000-000000000000", + ManagementGroupId: "", + }, + }, + }, + }, + { + Name: "Missing version in subscription", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Blueprint/blueprints/blueprint1/versions/", + Error: true, + }, + { + Name: "Published BlueprintID in resource group", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/group1/providers/Microsoft.Blueprint/blueprints/blueprint1/versions/v1", + Error: true, + }, + { + Name: "Invalid resource ID", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/providers/Microsoft.Blueprint/blueprints/blueprint1/versions/v1", + Error: true, + }, + } + + for _, v := range testData { + t.Logf("[DEBUG] Testing %q", v.Name) + + actual, err := PublishedBlueprintID(v.Input) + if err != nil { + if v.Error { + continue + } + + t.Fatalf("Expected a value but got an error: %+v", err) + } + + if actual.Version != v.Expected.Version { + t.Fatalf("Expected %q but got %q", v.Expected.Version, actual.Version) + } + + if actual.Name != v.Expected.Name { + t.Fatalf("Expected %q but got %q", v.Expected.Name, actual.Name) + } + + if actual.ID != v.Expected.ID { + t.Fatalf("Expected %q but got %q", v.Expected.ID, actual.Name) + } + + if actual.Type != v.Expected.Type { + t.Fatalf("Expected type %q but got type %q", v.Expected.Type, actual.Type) + } + + if actual.ScopeId != v.Expected.ScopeId { + t.Fatalf("Expected %q but got %q", v.Expected.ScopeId, actual.ScopeId) + } + + if actual.SubscriptionId != v.Expected.SubscriptionId { + t.Fatalf("Expected %q but got %q", v.Expected.SubscriptionId, actual.SubscriptionId) + } + + if actual.ManagementGroupId != v.Expected.ManagementGroupId { + t.Fatalf("Expected %q but got %q", v.Expected.ManagementGroupId, actual.ManagementGroupId) + } + } +} + +func TestManagementGroupID(t *testing.T) { + testData := []struct { + Name string + Input string + Error bool + Expected *ManagementGroupId + }{ + { + Name: "Empty", + Input: "", + Error: true, + }, + { + Name: "Missing management group segment", + Input: "/providers/Microsoft.Management/resourceGroups/group1", + Error: true, + }, + { + Name: "Missing right provider", + Input: "/managementGroups/00000000-0000-0000-0000-000000000000", + Error: true, + }, + { + Name: "Subscription ID", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000", + Error: true, + }, + { + Name: "Resource Group ID", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/group1", + Error: true, + }, + { + Name: "Resource ID", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/group1/providers/Microsoft.Compute/virtualMachines/vm1", + Error: true, + }, + { + Name: "Resource ID-like", + Input: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/group1/virtualMachines/vm1", + Error: true, + }, + { + Name: "Management Group ID", + Input: "/providers/Microsoft.Management/managementGroups/00000000-0000-0000-0000-000000000000", + Expected: &ManagementGroupId{ + GroupId: "00000000-0000-0000-0000-000000000000", + }, + }, + { + Name: "Management Group ID with wrong casing in provider", + Input: "/providers/microsoft.management/managementGroups/00000000-0000-0000-0000-000000000000", + Expected: &ManagementGroupId{ + GroupId: "00000000-0000-0000-0000-000000000000", + }, + }, + { + Name: "Management Group ID with wrong casing in provider and managementGroup segment", + Input: "/providers/microsoft.management/managementgroups/00000000-0000-0000-0000-000000000000", + Expected: &ManagementGroupId{ + GroupId: "00000000-0000-0000-0000-000000000000", + }, + }, + } + + for _, v := range testData { + t.Logf("[DEBUG] Testing %q", v.Name) + + actual, err := ManagementGroupID(v.Input) + if err != nil { + if v.Error { + continue + } + + t.Fatalf("Expected a value but got an error: %+v", err) + } + + if actual.GroupId != v.Expected.GroupId { + t.Fatalf("Expected %q but got %q", v.Expected.GroupId, actual.GroupId) + } + } +} diff --git a/azurerm/internal/services/blueprint/registration.go b/azurerm/internal/services/blueprint/registration.go new file mode 100644 index 000000000000..f67d9effb0a8 --- /dev/null +++ b/azurerm/internal/services/blueprint/registration.go @@ -0,0 +1,31 @@ +package blueprint + +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 "Blueprint" +} + +// WebsiteCategories returns a list of categories which can be used for the sidebar +func (r Registration) WebsiteCategories() []string { + return []string{ + "Blueprint", + } +} + +// SupportedDataSources returns the supported Data Sources supported by this Service +func (r Registration) SupportedDataSources() map[string]*schema.Resource { + return map[string]*schema.Resource{} +} + +// 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/blueprint/resource_arm_blueprint_assignment.go b/azurerm/internal/services/blueprint/resource_arm_blueprint_assignment.go new file mode 100644 index 000000000000..163575c0d563 --- /dev/null +++ b/azurerm/internal/services/blueprint/resource_arm_blueprint_assignment.go @@ -0,0 +1,605 @@ +package blueprint + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strconv" + "time" + + "github.com/Azure/azure-sdk-for-go/services/preview/authorization/mgmt/2018-09-01-preview/authorization" + "github.com/Azure/azure-sdk-for-go/services/preview/blueprint/mgmt/2018-11-01-preview/blueprint" + "github.com/hashicorp/go-uuid" + "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/set" + "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/features" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/blueprint/parse" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/blueprint/validate" + azSchema "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/tf/schema" + "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, + Read: resourceArmBlueprintAssignmentRead, + Update: resourceArmBlueprintAssignmentCreateUpdate, + Delete: resourceArmBlueprintAssignmentDelete, + + Importer: azSchema.ValidateResourceIDPriorToImport(func(id string) error { + _, err := parse.BlueprintAssignmentID(id) + return err + }), + + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(30 * time.Minute), + Read: schema.DefaultTimeout(5 * time.Minute), + Update: schema.DefaultTimeout(30 * time.Minute), + Delete: schema.DefaultTimeout(30 * time.Minute), + }, + + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validate.BlueprintAssignmentName, + }, + + "location": azure.SchemaLocation(), + + // The scope of the resource. Valid scopes are: + // management group (format: '/providers/Microsoft.Management/managementGroups/{managementGroup}') + // subscription (format: '/subscriptions/{subscriptionId}') + "scope": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validate.BlueprintAssignmentScopeID, + }, + + "blueprint_definition_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validate.BlueprintDefinitionID, + }, + + "identity": { + Type: schema.TypeList, + Required: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "type": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice([]string{ + // None is listed as possible values in go SDK, but the service will reject type none of identity + string(blueprint.ManagedServiceIdentityTypeSystemAssigned), + string(blueprint.ManagedServiceIdentityTypeUserAssigned), + }, false), + // The first character of value returned by the service is always in lower case. + DiffSuppressFunc: suppress.CaseDifference, + }, + + "identity_ids": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + // TODO: validation for a UAI which requires an ID Parser/Validator + }, + }, + + "principal_id": { + Type: schema.TypeString, + Computed: true, + }, + + "tenant_id": { + Type: schema.TypeString, + Computed: true, + }, + }, + }, + }, + + "description": { + Type: schema.TypeString, + Optional: true, + }, + + "display_name": { + Type: schema.TypeString, + Optional: true, + }, + + "parameter_values": { + Type: schema.TypeString, + Optional: true, + // This state function is used to normalize the format of the input JSON string, + // and strip any extra field comparing to the allowed fields in the swagger + // to avoid unnecessary diff in the state and config + StateFunc: normalizeAssignmentParameterValuesJSON, + ValidateFunc: validation.StringIsJSON, + // Suppress the differences caused by JSON formatting or ordering + DiffSuppressFunc: structure.SuppressJsonDiff, + }, + + "resource_groups": { + Type: schema.TypeString, + Optional: true, + // This state function is used to normalize the format of the input JSON string, + // and strip any extra field comparing to the allowed fields in the swagger + // to avoid unnecessary diff in the state and config + StateFunc: normalizeAssignmentResourceGroupValuesJSON, + ValidateFunc: validation.StringIsJSON, + // Suppress the differences caused by JSON formatting or ordering + 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.TypeSet, + Optional: true, + MaxItems: 5, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Set: schema.HashString, + }, + + "published_blueprint_id": { + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func resourceArmBlueprintAssignmentCreateUpdate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*clients.Client).Blueprint.AssignmentClient + ctx, cancel := timeouts.ForCreateUpdate(meta.(*clients.Client).StopContext, d) + defer cancel() + + name := d.Get("name").(string) + scope := d.Get("scope").(string) + + if features.ShouldResourcesBeImported() && d.IsNewResource() { + existing, err := client.Get(ctx, scope, name) + if err != nil { + if !utils.ResponseWasNotFound(existing.Response) { + return fmt.Errorf("unable to check for presence of existing Blueprint Assignment %q (Scope %q): %+v", name, scope, err) + } + } + + if existing.ID != nil && *existing.ID != "" { + return tf.ImportAsExistsError("azurerm_blueprint_assignment", *existing.ID) + } + } + + location := azure.NormalizeLocation(d.Get("location").(string)) + + blueprintID := d.Get("blueprint_definition_id").(string) + + identityRaw := d.Get("identity").([]interface{}) + identity, err := expandArmBlueprintAssignmentIdentity(identityRaw) + if err != nil { + return fmt.Errorf("unable to expand `identity`: %+v", err) + } + + lockMode := d.Get("lock_mode").(string) + + excludedPrincipalsRaw := d.Get("lock_exclude_principals").(*schema.Set) + excludedPrincipals := utils.ExpandStringSlice(excludedPrincipalsRaw.List()) + + assignment := blueprint.Assignment{ + Location: utils.String(location), + Identity: identity, + AssignmentProperties: &blueprint.AssignmentProperties{ + BlueprintID: &blueprintID, + Locks: &blueprint.AssignmentLockSettings{ + Mode: blueprint.AssignmentLockMode(lockMode), + ExcludedPrincipals: excludedPrincipals, + }, + }, + } + + if v, ok := d.GetOk("description"); ok { + assignment.Description = utils.String(v.(string)) + } + + if v, ok := d.GetOk("display_name"); ok { + assignment.DisplayName = utils.String(v.(string)) + } + + if v, ok := d.GetOk("parameter_values"); ok { + assignment.Parameters = expandArmBlueprintAssignmentParameters(v.(string)) + } + + if v, ok := d.GetOk("resource_groups"); ok { + assignment.ResourceGroups = expandArmBlueprintAssignmentResourceGroups(v.(string)) + } + + // if the identity of blueprint assignment is SystemAssigned, owner permission needs to be granted + if assignment.Identity.Type == blueprint.ManagedServiceIdentityTypeSystemAssigned { + log.Printf("[DEBUG] Need to grant owner permission for blueprint assignment when identity is set to SystemAssigned") + // get SPN object ID of the blueprint + resp, err := client.WhoIsBlueprint(ctx, scope, name) + if err != nil { + return fmt.Errorf("unable to create Blueprint Assignment %q (Scope %q) with SystemAssigned identity: %+v", name, scope, err) + } + if resp.ObjectID == nil { + return fmt.Errorf("unable to create Blueprint Assignment %q (Scope %q) with SystemAssigned identity: The SPN Object ID of the assignment is nil", name, scope) + } + + // get owner role definition id + log.Printf("[DEBUG] query the ID of the owner role definition") + roleDefinitionClient := meta.(*clients.Client).Authorization.RoleDefinitionsClient + roleDefinitions, err := roleDefinitionClient.List(ctx, scope, "roleName eq 'owner'") + if err != nil { + return fmt.Errorf("unable to create Blueprint Assignment %q (Scope %q) with SystemAssigned identity: %+v", name, scope, err) + } + if len(roleDefinitions.Values()) != 1 { + return fmt.Errorf("unable to create Blueprint Assignment %q (Scope %q) with SystemAssigned identity: cannot load the owner role definition id", name, scope) + } + ownerDefinitionId := roleDefinitions.Values()[0].ID + log.Printf("[DEBUG] owner definition ID: %s", *ownerDefinitionId) + + // assign owner permission to blueprint assignment + roleAssignmentClient := meta.(*clients.Client).Authorization.RoleAssignmentsClient + // query if ownership assignment already exists + log.Printf("[DEBUG] querying whether is owner permission has already assigned") + result, err := roleAssignmentClient.List(ctx, fmt.Sprintf("principalId eq '%s'", *resp.ObjectID)) + if err != nil { + return fmt.Errorf("unable to create Blueprint Assignment %q (Scope %q) with SystemAssigned identity: %+v", name, scope, err) + } + ownerPermissionAssigned := false + for _, v := range result.Values() { + if v.RoleAssignmentPropertiesWithScope == nil || v.RoleAssignmentPropertiesWithScope.RoleDefinitionID == nil { + continue + } + if *v.RoleAssignmentPropertiesWithScope.RoleDefinitionID == *ownerDefinitionId { + ownerPermissionAssigned = true + break + } + } + log.Printf("[DEBUG] owner permission assigned: %v", ownerPermissionAssigned) + if !ownerPermissionAssigned { + // assign owner permission + roleAssignmentName, err := uuid.GenerateUUID() + if err != nil { + return fmt.Errorf("unable to create Blueprint Assignment %q (Scope %q) with SystemAssigned identity: %+v", name, scope, err) + } + log.Printf("[DEBUG] assigning owner role for blueprint with uuid '%s'", roleAssignmentName) + roleAssignmentParameters := authorization.RoleAssignmentCreateParameters{ + RoleAssignmentProperties: &authorization.RoleAssignmentProperties{ + RoleDefinitionID: ownerDefinitionId, + PrincipalID: resp.ObjectID, + }, + } + _, err = roleAssignmentClient.Create(ctx, scope, roleAssignmentName, roleAssignmentParameters) + if err != nil { + return fmt.Errorf("unable to create Blueprint Assignment %q (Scope %q) with SystemAssigned identity: %+v", roleAssignmentName, scope, err) + } + } + } + + if _, err := client.CreateOrUpdate(ctx, scope, name, assignment); err != nil { + return fmt.Errorf("unable to create Blueprint Assignment %q (Scope %q): %+v", name, scope, err) + } + + // the blueprint assignment is not ready after creation until its provisioning state turns to "succeeded" + 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, scope, 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, scope, err) + } + + resp, err := client.Get(ctx, scope, name) + if err != nil { + return fmt.Errorf("unable to retrieve Blueprint Assignment %q (Scope %q): %+v", name, scope, err) + } + + d.SetId(*resp.ID) + + return resourceArmBlueprintAssignmentRead(d, meta) +} + +func resourceArmBlueprintAssignmentRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*clients.Client).Blueprint.AssignmentClient + ctx, cancel := timeouts.ForRead(meta.(*clients.Client).StopContext, d) + defer cancel() + + id, err := parse.BlueprintAssignmentID(d.Id()) + if err != nil { + return err + } + + resp, err := client.Get(ctx, id.ScopeId, id.Name) + if err != nil { + if utils.ResponseWasNotFound(resp.Response) { + log.Printf("[INFO] Blueprint Assignment %q does not exist - removing from state", d.Id()) + d.SetId("") + return nil + } + return fmt.Errorf("unable to read Blueprint Assignment %q (Scope %q): %+v", id.Name, id.ScopeId, err) + } + + d.Set("name", id.Name) + d.Set("scope", id.ScopeId) + if location := resp.Location; location != nil { + d.Set("location", azure.NormalizeLocation(*location)) + } + + if err := d.Set("identity", flattenArmBlueprintAssignmentIdentity(resp.Identity)); err != nil { + return fmt.Errorf("unable to set `identity`: %+v", err) + } + + if resp.AssignmentProperties == nil { + return fmt.Errorf("unable to retrieve Blueprint Assignment %q (Scope %q): `properties` was nil", id.Name, id.ScopeId) + } + + props := *resp.AssignmentProperties + // the `BlueprintID` field in response is the ID of the published blueprint (with version) which is different with the blueprint ID in user's input + if props.BlueprintID == nil { + return fmt.Errorf("unable to retrieve Blueprint Assignment %q (Scope %q): BlueprintID is nil", id.Name, id.ScopeId) + } + + publishedID, err := parse.PublishedBlueprintID(*props.BlueprintID) + if err != nil { + return fmt.Errorf("unable to retrieve Blueprint Assignment %q (Scope %q): %+v", id.Name, id.ScopeId, err) + } + + d.Set("blueprint_definition_id", publishedID.BlueprintDefinitionId.ID) + d.Set("published_blueprint_id", props.BlueprintID) + d.Set("description", props.Description) + d.Set("display_name", props.DisplayName) + + // flatten parameter_values + paramValues, err := flattenArmBlueprintAssignmentParameters(props.Parameters) + if err != nil { + return fmt.Errorf("unable to flatten `parameter_values`: %+v", err) + } + if err := d.Set("parameter_values", paramValues); err != nil { + return fmt.Errorf("unable to set `parameter_values`: %+v", err) + } + + // flatten resource_groups + resGroups, err := flattenArmBlueprintAssignmentResourceGroups(props.ResourceGroups) + if err != nil { + return fmt.Errorf("unable to flatten `resource_groups`: %+v", err) + } + if err := d.Set("resource_groups", resGroups); err != nil { + return fmt.Errorf("unable to set `resource_groups`: %+v", err) + } + + lockMode := string(blueprint.None) + excludePrincipals := new(schema.Set) + if lock := props.Locks; lock != nil { + lockMode = string(lock.Mode) + if lock.ExcludedPrincipals != nil { + excludePrincipals = set.FromStringSlice(*lock.ExcludedPrincipals) + } + } + d.Set("lock_mode", lockMode) + d.Set("lock_exclude_principals", excludePrincipals) + + return nil +} + +func resourceArmBlueprintAssignmentDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*clients.Client).Blueprint.AssignmentClient + ctx, cancel := timeouts.ForDelete(meta.(*clients.Client).StopContext, d) + defer cancel() + + id, err := parse.BlueprintAssignmentID(d.Id()) + if err != nil { + return err + } + + _, err = client.Delete(ctx, id.ScopeId, id.Name) + if err != nil { + return fmt.Errorf("unable to delete Blueprint Assignment %q (Scope %q): %+v", id.Name, id.ScopeId, err) + } + + // the blueprint assignment is not deleted immediately after the Delete func returns. + // There is a short period that the provisioning state will turn to "deleting" before it is really deleted. + stateConf := &resource.StateChangeConf{ + Pending: []string{"200"}, + Target: []string{"404"}, + Refresh: blueprintAssignmentDeleteStateRefreshFunc(ctx, client, id.ScopeId, id.Name), + Timeout: d.Timeout(schema.TimeoutDelete), + } + + if _, err := stateConf.WaitForState(); err != nil { + return fmt.Errorf("Failed waiting for the deletion of Blueprint Assignment %q (Scope %q): %+v", id.Name, id.ScopeId, err) + } + + return nil +} + +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) + } + + 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 nil, "", fmt.Errorf("unable to retrieve Blueprint Assignment %q (Scope %q): %+v", name, scope, err) + } + } + + return resp, strconv.Itoa(resp.StatusCode), nil + } +} + +func expandArmBlueprintAssignmentIdentity(input []interface{}) (*blueprint.ManagedServiceIdentity, error) { + if len(input) == 0 { + return nil, nil + } + + raw := input[0].(map[string]interface{}) + + identity := blueprint.ManagedServiceIdentity{ + Type: blueprint.ManagedServiceIdentityType(raw["type"].(string)), + } + + identityIdsRaw := raw["identity_ids"].(*schema.Set).List() + identityIds := make(map[string]*blueprint.UserAssignedIdentity) + for _, v := range identityIdsRaw { + identityIds[v.(string)] = &blueprint.UserAssignedIdentity{} + } + + if len(identityIds) > 0 { + if identity.Type != blueprint.ManagedServiceIdentityTypeUserAssigned { + return nil, fmt.Errorf("`identity_ids` can only be specified when `type` includes `UserAssigned`") + } + + identity.UserAssignedIdentities = identityIds + } + + return &identity, nil +} + +func flattenArmBlueprintAssignmentIdentity(input *blueprint.ManagedServiceIdentity) []interface{} { + if input == nil || input.Type == blueprint.ManagedServiceIdentityTypeNone { + 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 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 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 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 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 +} + +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) +} diff --git a/azurerm/internal/services/blueprint/tests/resource_arm_blueprint_assignment_test.go b/azurerm/internal/services/blueprint/tests/resource_arm_blueprint_assignment_test.go new file mode 100644 index 000000000000..89281a1996f2 --- /dev/null +++ b/azurerm/internal/services/blueprint/tests/resource_arm_blueprint_assignment_test.go @@ -0,0 +1,363 @@ +package tests + +import ( + "fmt" + "log" + "testing" + + "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/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/features" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/blueprint/parse" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/utils" +) + +const ( + blueprintDefinitionNamePattern = "acctest-definition-%d" + resourceGroupNamePattern = "acctest-RG-blueprint-%d" +) + +func TestAccAzureRMBlueprintAssignment_basic(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_blueprint_assignment", "test") + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + Providers: acceptance.SupportedProviders, + CheckDestroy: testCheckAzureRMBlueprintAssignmentAndDependenciesDestroy(data), + Steps: []resource.TestStep{ + // create and publish a blueprint definition using go SDK, since the blueprint definition resource is not implemented in terraform yet + { + Config: testAccAzureRMBlueprintAssignment_subscription(), + Check: resource.ComposeTestCheckFunc( + createBlueprintDefinition(data, "data.azurerm_subscription.current"), + publishBlueprintDefinition(data, "data.azurerm_subscription.current"), + ), + }, + // create and test the blueprint assignment resource + { + Config: testAccAzureRMBlueprintAssignment_basic(data), + Check: resource.ComposeTestCheckFunc( + testCheckAzureRMBlueprintAssignmentExists(data.ResourceName), + // test the resources created by the blueprint + testCheckAzureRMAssignedResourceGroupsExists(data), + ), + }, + // clean up definitions and artifacts + data.ImportStep(), + }, + }) +} + +func TestAccAzureRMBlueprintAssignment_requiresImport(t *testing.T) { + if !features.ShouldResourcesBeImported() { + t.Skip("Skipping since resources aren't required to be imported") + return + } + + data := acceptance.BuildTestData(t, "azurerm_blueprint_assignment", "test") + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + Providers: acceptance.SupportedProviders, + CheckDestroy: testCheckAzureRMBlueprintAssignmentAndDependenciesDestroy(data), + Steps: []resource.TestStep{ + // create and publish a blueprint definition using go SDK, since the blueprint definition resource is not implemented in terraform yet + { + Config: testAccAzureRMBlueprintAssignment_subscription(), + Check: resource.ComposeTestCheckFunc( + createBlueprintDefinition(data, "data.azurerm_subscription.current"), + publishBlueprintDefinition(data, "data.azurerm_subscription.current"), + ), + }, + // create and test the blueprint assignment resource + { + Config: testAccAzureRMBlueprintAssignment_basic(data), + Check: resource.ComposeTestCheckFunc( + testCheckAzureRMBlueprintAssignmentExists(data.ResourceName), + // test the resources created by the blueprint + testCheckAzureRMAssignedResourceGroupsExists(data), + ), + }, + // clean up definitions and artifacts + data.RequiresImportErrorStep(testAccAzureRMBlueprintAssignment_requiresImport), + }, + }) +} + +func testCheckAzureRMBlueprintAssignmentExists(resourceName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("bad: Blueprint Assignment not found: %s", resourceName) + } + + id, err := parse.BlueprintAssignmentID(rs.Primary.ID) + if err != nil { + return err + } + + client := acceptance.AzureProvider.Meta().(*clients.Client).Blueprint.AssignmentClient + ctx := acceptance.AzureProvider.Meta().(*clients.Client).StopContext + + if resp, err := client.Get(ctx, id.ScopeId, id.Name); err != nil { + if utils.ResponseWasNotFound(resp.Response) { + return fmt.Errorf("bad: Blueprint Assignment %q (Scope %q) does not exist", id.Name, id.ScopeId) + } + return fmt.Errorf("bad: Get on Blueprint.AssignmentClient: %+v", err) + } + + return nil + } +} + +func testCheckAzureRMAssignedResourceGroupsExists(data acceptance.TestData) resource.TestCheckFunc { + return func(s *terraform.State) error { + client := acceptance.AzureProvider.Meta().(*clients.Client).Resource.GroupsClient + ctx := acceptance.AzureProvider.Meta().(*clients.Client).StopContext + + resourceGroupName := fmt.Sprintf(resourceGroupNamePattern, data.RandomInteger) + if resp, err := client.Get(ctx, resourceGroupName); err != nil { + if utils.ResponseWasNotFound(resp.Response) { + return fmt.Errorf("bad: Resource Group %q does not exist", resourceGroupName) + } + return fmt.Errorf("bad: Get on GroupsClient: %+v", err) + } + + return nil + } +} + +func testCheckAzureRMBlueprintAssignmentAndDependenciesDestroy(data acceptance.TestData) resource.TestCheckFunc { + return func(s *terraform.State) error { + if err := testCheckAzureRMBlueprintAssignmentDestroy(s); err != nil { + return err + } + + // destroy the resource group that is created by the blueprint + resourceGroupName := fmt.Sprintf(resourceGroupNamePattern, data.RandomInteger) + if err := destroyAzureRMAssignedResourceGroups(resourceGroupName); err != nil { + return err + } + + // destroy the blueprint definition + definitionName := fmt.Sprintf(blueprintDefinitionNamePattern, data.RandomInteger) + if err := destroyAzureRMBlueprintAssignmentDependencies(definitionName); err != nil { + return err + } + + return nil + } +} + +func testCheckAzureRMBlueprintAssignmentDestroy(s *terraform.State) error { + client := acceptance.AzureProvider.Meta().(*clients.Client).Blueprint.AssignmentClient + ctx := acceptance.AzureProvider.Meta().(*clients.Client).StopContext + + for _, rs := range s.RootModule().Resources { + if rs.Type != "azurerm_blueprint_assignment" { + continue + } + + id, err := parse.BlueprintAssignmentID(rs.Primary.ID) + if err != nil { + return err + } + + if resp, err := client.Get(ctx, id.ScopeId, id.Name); err != nil { + if !utils.ResponseWasNotFound(resp.Response) { + return fmt.Errorf("Bad: Get on Blueprint.AssignmentClient: %+v", err) + } + } + + return nil + } + + return nil +} + +func destroyAzureRMBlueprintAssignmentDependencies(definitionName string) error { + client := acceptance.AzureProvider.Meta().(*clients.Client).Blueprint.BlueprintClient + ctx := acceptance.AzureProvider.Meta().(*clients.Client).StopContext + + subscriptionId := acceptance.AzureProvider.Meta().(*clients.Client).Account.SubscriptionId + + log.Printf("[DEBUG] Deleting the blueprint definition") + scope := fmt.Sprintf("/subscriptions/%s", subscriptionId) + _, err := client.Delete(ctx, scope, definitionName) + if err != nil { + return fmt.Errorf("bad: error deleting Blueprint Definition %q (Scope %q): %+v", definitionName, scope, err) + } + + return nil +} + +func destroyAzureRMAssignedResourceGroups(name string) error { + client := acceptance.AzureProvider.Meta().(*clients.Client).Resource.GroupsClient + ctx := acceptance.AzureProvider.Meta().(*clients.Client).StopContext + + log.Printf("[DEBUG] Deleting the resource group created by the assignment") + future, err := client.Delete(ctx, name) + if err != nil { + return fmt.Errorf("bad: error deleting Resource Group %q: %+v", name, err) + } + if err := future.WaitForCompletionRef(ctx, client.Client); err != nil { + return fmt.Errorf("bad: error when waiting for Resource Group %q to be deleted: %+v", name, err) + } + + return nil +} + +func createBlueprintDefinition(data acceptance.TestData, scopeSource string) resource.TestCheckFunc { + return func(s *terraform.State) error { + definitionClient := acceptance.AzureProvider.Meta().(*clients.Client).Blueprint.BlueprintClient + ctx := acceptance.AzureProvider.Meta().(*clients.Client).StopContext + + rs, ok := s.RootModule().Resources[scopeSource] + if !ok { + return fmt.Errorf("bad: Not found: %s", scopeSource) + } + + log.Printf("[DEBUG] Creating a blueprint definition") + scope := rs.Primary.ID + definitionName := fmt.Sprintf(blueprintDefinitionNamePattern, data.RandomInteger) + model := blueprint.Model{ + Properties: &blueprint.Properties{ + TargetScope: blueprint.Subscription, + Parameters: map[string]*blueprint.ParameterDefinition{ + "tagName": { + Type: blueprint.String, + DefaultValue: utils.String("ENV"), + ParameterDefinitionMetadata: &blueprint.ParameterDefinitionMetadata{ + DisplayName: utils.String("Tag Name"), + Description: utils.String("Tag name for each resource that gets created"), + }, + }, + "tagValue": { + Type: blueprint.String, + DefaultValue: utils.String("Acc-test"), + ParameterDefinitionMetadata: &blueprint.ParameterDefinitionMetadata{ + DisplayName: utils.String("Tag Value"), + Description: utils.String("Tag value for each resource that gets created"), + }, + }, + }, + ResourceGroups: map[string]*blueprint.ResourceGroupDefinition{ + "ProdRG": { + ParameterDefinitionMetadata: &blueprint.ParameterDefinitionMetadata{ + DisplayName: utils.String("Production resource group"), + }, + }, + }, + DisplayName: utils.String("Common Policies"), + Description: utils.String("A set of popular policies to apply to a subscription"), + }, + } + _, err := definitionClient.CreateOrUpdate(ctx, scope, definitionName, model) + if err != nil { + return fmt.Errorf("bad: error creating Blueprint Definition %q (Scope %q): %+v", definitionName, scope, err) + } + + return nil + } +} + +func publishBlueprintDefinition(data acceptance.TestData, scopeSource string) resource.TestCheckFunc { + return func(s *terraform.State) error { + client := acceptance.AzureProvider.Meta().(*clients.Client).Blueprint.PublishClient + ctx := acceptance.AzureProvider.Meta().(*clients.Client).StopContext + + rs, ok := s.RootModule().Resources[scopeSource] + if !ok { + return fmt.Errorf("bad: Not found: %s", scopeSource) + } + + log.Printf("[DEBUG] Publish a blueprint definition") + scope := rs.Primary.ID + definitionName := fmt.Sprintf(blueprintDefinitionNamePattern, data.RandomInteger) + _, err := client.Create(ctx, scope, definitionName, "v1", nil) + if err != nil { + return fmt.Errorf("bad: error publishing Blueprint Definition %q (Scope %q): %+v", definitionName, scope, err) + } + + return nil + } +} + +func testAccAzureRMBlueprintAssignment_subscription() string { + return ` +provider "azurerm" { + features {} +} + +data "azurerm_subscription" "current" {} +` +} + +func testAccAzureRMBlueprintAssignment_basic(data acceptance.TestData) string { + resourceGroupName := fmt.Sprintf(resourceGroupNamePattern, data.RandomInteger) + return fmt.Sprintf(` +provider "azurerm" { + features {} +} + +data "azurerm_subscription" "current" {} + +resource "azurerm_blueprint_assignment" "test" { + name = "acctest-blueprint-%[1]d" + location = "%[2]s" + scope = data.azurerm_subscription.current.id + + blueprint_definition_id = "${data.azurerm_subscription.current.id}/providers/Microsoft.Blueprint/blueprints/acctest-definition-%[1]d" + + identity { + type = "SystemAssigned" + } + + resource_groups = < +
  • + Blueprint Resources + +
  • +
  • Bot Resources