Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(subscription): optional use of AzAPI for Subscription creation #234

Closed
wants to merge 19 commits into from
Closed
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,33 @@ Type: `map(string)`

Default: `{}`

### <a name="input_subscription_use_azapi"></a> [subscription\_use\_azapi](#input\_subscription\_use\_azapi)

Description: Whether to create a new subscription using the azapi provider. This may be required if the principal running
terraform does not have the required permissions to create a subscription under the default management group.

If enabled, the following must also be supplied:

- `subscription_alias_name`
- `subscription_display_name`
- `subscription_billing_scope`
- `subscription_workload`

Optionally, supply the following to enable the placement of the subscription into a management group:

- `subscription_management_group_id`
- `subscription_management_group_association_enabled`

If disabled, supply the `subscription_id` variable to use an existing subscription instead.

> **Note**: When the subscription is destroyed, this module will try to remove the NetworkWatcherRG resource group using `az cli`.
> This requires the `az cli` tool be installed and authenticated.
> If the command fails for any reason, the provider will attempt to cancel the subscription anyway.

Type: `bool`

Default: `false`

### <a name="input_subscription_workload"></a> [subscription\_workload](#input\_subscription\_workload)

Description: The billing scope for the new subscription alias.
Expand Down
1 change: 1 addition & 0 deletions main.subscription.tf
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ module "subscription" {
count = var.subscription_alias_enabled || var.subscription_management_group_association_enabled ? 1 : 0

subscription_alias_enabled = var.subscription_alias_enabled
subscription_use_azapi = var.subscription_use_azapi
subscription_alias_name = var.subscription_alias_name
subscription_billing_scope = var.subscription_billing_scope
subscription_display_name = var.subscription_display_name
Expand Down
28 changes: 28 additions & 0 deletions modules/subscription/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,33 @@ Type: `map(string)`

Default: `{}`

### <a name="input_subscription_use_azapi"></a> [subscription\_use\_azapi](#input\_subscription\_use\_azapi)

Description: Whether to create a new subscription using the azapi provider. This may be required if the principal running
terraform does not have the required permissions to create a subscription under the default management group.

If enabled, the following must also be supplied:

- `subscription_alias_name`
- `subscription_display_name`
- `subscription_billing_scope`
- `subscription_workload`

Optionally, supply the following to enable the placement of the subscription into a management group:

- `subscription_management_group_id`
- `subscription_management_group_association_enabled`

If disabled, supply the `subscription_id` variable to use an existing subscription instead.

> **Note**: When the subscription is destroyed, this module will try to remove the NetworkWatcherRG resource group using `az cli`.
> This requires the `az cli` tool be installed and authenticated.
> If the command fails for any reason, the provider will attempt to cancel the subscription anyway.

Type: `bool`

Default: `false`

### <a name="input_subscription_workload"></a> [subscription\_workload](#input\_subscription\_workload)

Description: The billing scope for the new subscription alias.
Expand All @@ -188,6 +215,7 @@ Default: `""`

The following resources are used by this module:

- [azapi_resource.subscription](https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/resource) (resource)
- [azurerm_management_group_subscription_association.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/management_group_subscription_association) (resource)
- [azurerm_subscription.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/subscription) (resource)

Expand Down
2 changes: 1 addition & 1 deletion modules/subscription/locals.tf
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
locals {
# subscription_id_alias is the id of the newly created subscription, if it exists.
subscription_id_alias = try(azurerm_subscription.this[0].subscription_id, null)
subscription_id_alias = try(azurerm_subscription.this[0].subscription_id, azapi_resource.subscription[0].id, null)

# subscription_id is the id of the newly created subscription, or the id supplied by var.subscription_id.
subscription_id = coalesce(local.subscription_id_alias, var.subscription_id)
Expand Down
27 changes: 26 additions & 1 deletion modules/subscription/main.tf
Original file line number Diff line number Diff line change
@@ -1,17 +1,42 @@
# The azurerm_subscription resource represents the subscription alias that is being created.
resource "azurerm_subscription" "this" {
count = var.subscription_alias_enabled ? 1 : 0
count = var.subscription_alias_enabled && !(var.subscription_use_azapi) ? 1 : 0
subscription_name = var.subscription_display_name
alias = var.subscription_alias_name
billing_scope_id = var.subscription_billing_scope
workload = var.subscription_workload
tags = var.subscription_tags
}

# Optionally make use of AzAPI to create the subscription to allow creation without access
# to the default management group.
resource "azapi_resource" "subscription" {
count = var.subscription_alias_enabled && var.subscription_use_azapi ? 1 : 0

type = "Microsoft.Subscription/aliases@2021-10-01"
name = var.subscription_alias_name
parent_id = "/"

body = jsonencode({
properties = {
displayName = var.subscription_display_name
workload = var.subscription_workload
billingScope = var.subscription_billing_scope
subscriptionId = null
additionalProperties = {
managementGroupId = var.subscription_management_group_association_enabled ? var.subscription_management_group_id : null
tags = var.subscription_tags
}
}
})
}

# This resource ensures that we can manage the management group for the subscription
# throughout its lifecycle.
resource "azurerm_management_group_subscription_association" "this" {
count = var.subscription_management_group_association_enabled ? 1 : 0
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@luke-taylor You don't need this block if using AzAPI, so you can count = 0 it.

management_group_id = "/providers/Microsoft.Management/managementGroups/${var.subscription_management_group_id}"
subscription_id = "/subscriptions/${local.subscription_id}"
}


Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
variable "subscription_billing_scope" {
type = string
}

variable "subscription_management_group_id" {
type = string
}

variable "subscription_alias_name" {
type = string
}

variable "subscription_display_name" {
type = string
}

variable "subscription_workload" {
type = string
}

variable "subscription_management_group_association_enabled" {
type = bool
}

variable "subscription_alias_enabled" {
type = bool
}

variable "subscription_use_azapi" {
type = bool
}

resource "azapi_resource" "mg" {
type = "Microsoft.Management/managementGroups@2021-04-01"
parent_id = "/"
name = var.subscription_management_group_id
}

module "subscription_test" {
source = "../../"
subscription_alias_name = var.subscription_alias_name
subscription_display_name = var.subscription_display_name
subscription_workload = var.subscription_workload
subscription_management_group_id = azapi_resource.mg.name
subscription_billing_scope = var.subscription_billing_scope
subscription_management_group_association_enabled = var.subscription_management_group_association_enabled
subscription_alias_enabled = var.subscription_alias_enabled
subscription_use_azapi = var.subscription_use_azapi
}

output "subscription_id" {
value = module.subscription_test.subscription_id
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
terraform {
required_version = ">= 1.3.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = ">= 3.7.0"
}
azapi = {
source = "Azure/azapi"
version = ">= 1.0.0"
}
}
}
28 changes: 28 additions & 0 deletions modules/subscription/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,34 @@ DESCRIPTION
default = false
}

variable "subscription_use_azapi" {
type = bool
description = <<DESCRIPTION
Whether to create a new subscription using the azapi provider. This may be required if the principal running
terraform does not have the required permissions to create a subscription under the default management group.

If enabled, the following must also be supplied:

- `subscription_alias_name`
- `subscription_display_name`
- `subscription_billing_scope`
- `subscription_workload`

Optionally, supply the following to enable the placement of the subscription into a management group:

- `subscription_management_group_id`
- `subscription_management_group_association_enabled`

If disabled, supply the `subscription_id` variable to use an existing subscription instead.

> **Note**: When the subscription is destroyed, this module will try to remove the NetworkWatcherRG resource group using `az cli`.
> This requires the `az cli` tool be installed and authenticated.
> If the command fails for any reason, the provider will attempt to cancel the subscription anyway.
DESCRIPTION

default = false
}

variable "subscription_alias_name" {
type = string
default = ""
Expand Down
81 changes: 81 additions & 0 deletions tests/subscription/subscriptionDeploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,87 @@ func TestDeploySubscriptionAliasManagementGroupValid(t *testing.T) {
assert.NoErrorf(t, err, "subscription %s is not in management group %s", sid, v["subscription_management_group_id"].(string))
}

// TestDeploySubscriptionAliasValidAzAPI tests the deployment of a subscription alias
// with valid input variables using the azapi_resource resource type.
func TestDeploySubscriptionAliasValidAzAPI(t *testing.T) {
t.Parallel()

utils.PreCheckDeployTests(t)

v, err := getValidInputVariables(billingScope)
v["subscription_use_azapi"] = true
require.NoError(t, err)
test, err := setuptest.Dirs(moduleDir, "").WithVars(v).InitPlanShowWithPrepFunc(t, utils.AzureRmAndRequiredProviders)
require.NoError(t, err)
defer test.Cleanup()

check.InPlan(test.PlanStruct).NumberOfResourcesEquals(1).ErrorIsNil(t)

// Defer the cleanup of the subscription alias to the end of the test.
// Should be run after the Terraform destroy.
// We don't know the sub ID yet, so use zeros for now and then
// update it after the apply.
u := uuid.MustParse("00000000-0000-0000-0000-000000000000")
defer func() {
err := azureutils.CancelSubscription(t, &u)
t.Logf("cannot cancel subscription: %v", err)
}()

defer test.DestroyRetry(setuptest.DefaultRetry) //nolint:errcheck
test.ApplyIdempotent().ErrorIsNil(t)

sid, err := test.Output("subscription_id").GetValue()
assert.NoError(t, err)
sids, ok := sid.(string)
assert.True(t, ok, "subscription_id is not a string")
u, err = uuid.Parse(sids)
require.NoErrorf(t, err, "subscription id %s is not a valid uuid", sid)
}

// TestDeploySubscriptionAliasValidAzAPIManagementGroupValid tests the deployment of a subscription alias
// with valid input variables using the azapi_resource resource type and a management group association.
func TestDeploySubscriptionAliasAzAPIManagementGroupValid(t *testing.T) {
t.Parallel()
utils.PreCheckDeployTests(t)

v, err := getValidInputVariables(billingScope)
require.NoError(t, err)
v["subscription_billing_scope"] = billingScope
v["subscription_management_group_id"] = v["subscription_alias_name"]
v["subscription_management_group_association_enabled"] = true
v["subscription_use_azapi"] = true

testDir := filepath.Join("testdata", t.Name())
test, err := setuptest.Dirs(moduleDir, testDir).WithVars(v).InitPlanShowWithPrepFunc(t, utils.AzureRmAndRequiredProviders)
require.NoError(t, err)
defer test.Cleanup()
require.NoError(t, err)

// Defer the cleanup of the subscription alias to the end of the test.
// Should be run after the Terraform destroy.
// We don't know the sub ID yet, so use zeros for now and then
// update it after the apply.
u := uuid.MustParse("00000000-0000-0000-0000-000000000000")
defer func() {
err := azureutils.CancelSubscription(t, &u)
t.Logf("cannot cancel subscription: %v", err)
}()

// defer terraform destroy, but wrap in a try.Do to retry a few times
// due to eventual consistency of the subscription aliases API
defer test.DestroyRetry(setuptest.DefaultRetry) //nolint:errcheck
test.ApplyIdempotent().ErrorIsNil(t)

sid, err := terraform.OutputE(t, test.Options, "subscription_id")
assert.NoError(t, err)

u, err = uuid.Parse(sid)
assert.NoErrorf(t, err, "subscription id %s is not a valid uuid", sid)

err = azureutils.IsSubscriptionInManagementGroup(t, u, v["subscription_management_group_id"].(string))
assert.NoErrorf(t, err, "subscription %s is not in management group %s", sid, v["subscription_management_group_id"].(string))
}

// getValidInputVariables returns a set of valid input variables that can be used and modified for testing scenarios.
func getValidInputVariables(billingScope string) (map[string]any, error) {
r, err := utils.RandomHex(4)
Expand Down
47 changes: 47 additions & 0 deletions tests/subscription/subscription_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,53 @@ func TestSubscriptionAliasCreateInvalidManagementGroupIdLength(t *testing.T) {
assert.Contains(t, utils.SanitiseErrorMessage(err), "The management group ID must be between 1 and 90 characters in length and formed of the following characters: a-z, A-Z, 0-9, -, _, (, ), and a period (.).")
}

// TestSubscriptionAliasCreateWithAzAPI tests the creation of a subscription
// using the azapi_resource resource type.
func TestSubscriptionAliasCreateWithAzAPI(t *testing.T) {
t.Parallel()

v := getMockInputVariables()
v["subscription_use_azapi"] = true
test, err := setuptest.Dirs(moduleDir, "").WithVars(v).InitPlanShowWithPrepFunc(t, utils.AzureRmAndRequiredProviders)
require.NoError(t, err)
defer test.Cleanup()

check.InPlan(test.PlanStruct).NumberOfResourcesEquals(1).ErrorIsNil(t)
check.InPlan(test.PlanStruct).That("azapi_resource.subscription[0]").Key("name").HasValue(v["subscription_alias_name"]).ErrorIsNil(t)
check.InPlan(test.PlanStruct).That("azapi_resource.subscription[0]").Key("body").Query("properties.billingScope").HasValue(v["subscription_billing_scope"]).ErrorIsNil(t)
check.InPlan(test.PlanStruct).That("azapi_resource.subscription[0]").Key("body").Query("properties.displayName").HasValue(v["subscription_display_name"]).ErrorIsNil(t)
check.InPlan(test.PlanStruct).That("azapi_resource.subscription[0]").Key("body").Query("properties.workload").HasValue(v["subscription_workload"]).ErrorIsNil(t)
check.InPlan(test.PlanStruct).That("azapi_resource.subscription[0]").Key("body").Query("properties.additionalProperties.tags").HasValue(v["subscription_tags"]).ErrorIsNil(t)

}

// TestSubscriptionAliasCreateWithAzAPIAndManagementGroup tests the creation of a subscription
// using the azapi_resource resource type and management group association.
func TestSubscriptionAliasCreateWithAzAPIAndManagementGroup(t *testing.T) {
t.Parallel()

v := getMockInputVariables()
v["subscription_use_azapi"] = true
v["subscription_management_group_id"] = "testdeploy"
v["subscription_management_group_association_enabled"] = true

test, err := setuptest.Dirs(moduleDir, "").WithVars(v).InitPlanShowWithPrepFunc(t, utils.AzureRmAndRequiredProviders)
require.NoError(t, err)
defer test.Cleanup()

check.InPlan(test.PlanStruct).NumberOfResourcesEquals(2).ErrorIsNil(t)
check.InPlan(test.PlanStruct).That("azapi_resource.subscription[0]").Key("name").HasValue(v["subscription_alias_name"]).ErrorIsNil(t)
check.InPlan(test.PlanStruct).That("azapi_resource.subscription[0]").Key("body").Query("properties.billingScope").HasValue(v["subscription_billing_scope"]).ErrorIsNil(t)
check.InPlan(test.PlanStruct).That("azapi_resource.subscription[0]").Key("body").Query("properties.displayName").HasValue(v["subscription_display_name"]).ErrorIsNil(t)
check.InPlan(test.PlanStruct).That("azapi_resource.subscription[0]").Key("body").Query("properties.workload").HasValue(v["subscription_workload"]).ErrorIsNil(t)
check.InPlan(test.PlanStruct).That("azapi_resource.subscription[0]").Key("body").Query("properties.additionalProperties.tags").HasValue(v["subscription_tags"]).ErrorIsNil(t)
check.InPlan(test.PlanStruct).That("azapi_resource.subscription[0]").Key("body").Query("properties.additionalProperties.managementGroupId").HasValue(v["subscription_management_group_id"]).ErrorIsNil(t)

mgResId := "/providers/Microsoft.Management/managementGroups/" + v["subscription_management_group_id"].(string)
check.InPlan(test.PlanStruct).That("azurerm_management_group_subscription_association.this[0]").Key("management_group_id").HasValue(mgResId).ErrorIsNil(t)

}

// getMockInputVariables returns a set of mock input variables that can be used and modified for testing scenarios.
func getMockInputVariables() map[string]any {
return map[string]any{
Expand Down
Loading