From cec07ecfdc4616855aba9e6b16ddf49a724d71e4 Mon Sep 17 00:00:00 2001 From: Tomer Heber Date: Sun, 17 Dec 2023 10:09:54 -0600 Subject: [PATCH] Feat: add new OIDC credentials creation and assigment (Azure Resource) (#769) --- client/cloud_credentials.go | 5 +- env0/credentials.go | 18 +- env0/provider.go | 1 + env0/resource_azure_oidc_credentials.go | 99 +++++++ env0/resource_azure_oidc_credentials_test.go | 241 ++++++++++++++++++ .../env0_azure_oidc_credentials/import.sh | 2 + .../env0_azure_oidc_credentials/resource.tf | 6 + .../integration/016_azure_credentials/main.tf | 7 + 8 files changed, 369 insertions(+), 10 deletions(-) create mode 100644 env0/resource_azure_oidc_credentials.go create mode 100644 env0/resource_azure_oidc_credentials_test.go create mode 100644 examples/resources/env0_azure_oidc_credentials/import.sh create mode 100644 examples/resources/env0_azure_oidc_credentials/resource.tf diff --git a/client/cloud_credentials.go b/client/cloud_credentials.go index 51c43ce5..e2d93247 100644 --- a/client/cloud_credentials.go +++ b/client/cloud_credentials.go @@ -96,14 +96,15 @@ func (c *AzureCredentialsCreatePayload) SetOrganizationId(organizationId string) } const ( - GoogleCostCredentialsType GcpCredentialsType = "GCP_CREDENTIALS" - AzureCostCredentialsType AzureCredentialsType = "AZURE_CREDENTIALS" AwsCostCredentialsType AwsCredentialsType = "AWS_ASSUMED_ROLE" AwsAssumedRoleCredentialsType AwsCredentialsType = "AWS_ASSUMED_ROLE_FOR_DEPLOYMENT" AwsAccessKeysCredentialsType AwsCredentialsType = "AWS_ACCESS_KEYS_FOR_DEPLOYMENT" AwsOidcCredentialsType AwsCredentialsType = "AWS_OIDC" + GoogleCostCredentialsType GcpCredentialsType = "GCP_CREDENTIALS" GcpServiceAccountCredentialsType GcpCredentialsType = "GCP_SERVICE_ACCOUNT_FOR_DEPLOYMENT" + AzureCostCredentialsType AzureCredentialsType = "AZURE_CREDENTIALS" AzureServicePrincipalCredentialsType AzureCredentialsType = "AZURE_SERVICE_PRINCIPAL_FOR_DEPLOYMENT" + AzureOidcCredentialsType AzureCredentialsType = "AZURE_OIDC" ) func (client *ApiClient) CloudCredentials(id string) (Credentials, error) { diff --git a/env0/credentials.go b/env0/credentials.go index cb892b9d..47d94037 100644 --- a/env0/credentials.go +++ b/env0/credentials.go @@ -15,23 +15,25 @@ import ( type CloudType string const ( - GCP_TYPE CloudType = "gcp" - AZURE_TYPE CloudType = "azure" AWS_TYPE CloudType = "aws" - GCP_COST_TYPE CloudType = "google_cost" - AZURE_COST_TYPE CloudType = "azure_cost" AWS_COST_TYPE CloudType = "aws_cost" AWS_OIDC_TYPE CloudType = "aws_oidc" + AZURE_TYPE CloudType = "azure" + AZURE_COST_TYPE CloudType = "azure_cost" + AZURE_OIDC_TYPE CloudType = "azure_oidc" + GCP_TYPE CloudType = "gcp" + GCP_COST_TYPE CloudType = "google_cost" ) var credentialsTypeToPrefixList map[CloudType][]string = map[CloudType][]string{ - GCP_TYPE: {string(client.GcpServiceAccountCredentialsType)}, - AZURE_TYPE: {string(client.AzureServicePrincipalCredentialsType)}, AWS_TYPE: {string(client.AwsAssumedRoleCredentialsType), string(client.AwsAccessKeysCredentialsType)}, - GCP_COST_TYPE: {string(client.GoogleCostCredentialsType)}, - AZURE_COST_TYPE: {string(client.AzureCostCredentialsType)}, AWS_COST_TYPE: {string(client.AwsCostCredentialsType)}, AWS_OIDC_TYPE: {string(client.AwsOidcCredentialsType)}, + AZURE_TYPE: {string(client.AzureServicePrincipalCredentialsType)}, + AZURE_COST_TYPE: {string(client.AzureCostCredentialsType)}, + AZURE_OIDC_TYPE: {string(client.AzureOidcCredentialsType)}, + GCP_TYPE: {string(client.GcpServiceAccountCredentialsType)}, + GCP_COST_TYPE: {string(client.GoogleCostCredentialsType)}, } func getCredentialsByName(name string, prefixList []string, meta interface{}) (client.Credentials, error) { diff --git a/env0/provider.go b/env0/provider.go index aecd0028..39fe5c36 100644 --- a/env0/provider.go +++ b/env0/provider.go @@ -104,6 +104,7 @@ func Provider(version string) plugin.ProviderFunc { "env0_aws_oidc_credentials": resourceAwsOidcCredentials(), "env0_aws_cost_credentials": resourceCostCredentials("aws"), "env0_azure_cost_credentials": resourceCostCredentials("azure"), + "env0_azure_oidc_credentials": resourceAzureOidcCredentials(), "env0_gcp_cost_credentials": resourceCostCredentials("google"), "env0_gcp_credentials": resourceGcpCredentials(), "env0_azure_credentials": resourceAzureCredentials(), diff --git a/env0/resource_azure_oidc_credentials.go b/env0/resource_azure_oidc_credentials.go new file mode 100644 index 00000000..14fff26e --- /dev/null +++ b/env0/resource_azure_oidc_credentials.go @@ -0,0 +1,99 @@ +package env0 + +import ( + "context" + "fmt" + + "github.com/env0/terraform-provider-env0/client" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceAzureOidcCredentials() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceAzureOidcCredentialsCreate, + UpdateContext: resourceAzureOidcCredentialsUpdate, + ReadContext: resourceCredentialsRead(AZURE_OIDC_TYPE), + DeleteContext: resourceCredentialsDelete, + + Importer: &schema.ResourceImporter{StateContext: resourceCredentialsImport(AZURE_OIDC_TYPE)}, + + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Description: "name for the oidc credentials", + Required: true, + ForceNew: true, + }, + "subscription_id": { + Type: schema.TypeString, + Description: "the azure subscription id", + Required: true, + }, + "tenant_id": { + Type: schema.TypeString, + Description: "the azure tenant id", + Required: true, + }, + "client_id": { + Type: schema.TypeString, + Description: "the azure client id", + Required: true, + }, + }, + } +} + +func azureOidcCredentialsGetValue(d *schema.ResourceData) (client.AzureCredentialsValuePayload, error) { + value := client.AzureCredentialsValuePayload{} + + if err := readResourceData(&value, d); err != nil { + return value, fmt.Errorf("schema resource data deserialization failed: %w", err) + } + + return value, nil +} + +func resourceAzureOidcCredentialsCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + apiClient := meta.(client.ApiClientInterface) + + value, err := azureOidcCredentialsGetValue(d) + if err != nil { + return diag.FromErr(err) + } + + request := client.AzureCredentialsCreatePayload{ + Name: d.Get("name").(string), + Value: value, + Type: client.AzureOidcCredentialsType, + } + + credentials, err := apiClient.CredentialsCreate(&request) + if err != nil { + return diag.Errorf("could not create azure oidc credentials: %v", err) + } + + d.SetId(credentials.Id) + + return nil +} + +func resourceAzureOidcCredentialsUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + apiClient := meta.(client.ApiClientInterface) + + value, err := azureOidcCredentialsGetValue(d) + if err != nil { + return diag.FromErr(err) + } + + request := client.AzureCredentialsCreatePayload{ + Value: value, + Type: client.AzureOidcCredentialsType, + } + + if _, err := apiClient.CredentialsUpdate(d.Id(), &request); err != nil { + return diag.Errorf("could not update azure oidc credentials: %s %v", d.Id(), err) + } + + return nil +} diff --git a/env0/resource_azure_oidc_credentials_test.go b/env0/resource_azure_oidc_credentials_test.go new file mode 100644 index 00000000..9f547926 --- /dev/null +++ b/env0/resource_azure_oidc_credentials_test.go @@ -0,0 +1,241 @@ +package env0 + +import ( + "fmt" + "regexp" + "testing" + + "github.com/env0/terraform-provider-env0/client" + "github.com/env0/terraform-provider-env0/client/http" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "go.uber.org/mock/gomock" +) + +func TestUnitAzureOidcCredentialsResource(t *testing.T) { + resourceType := "env0_azure_oidc_credentials" + resourceName := "test" + resourceNameImport := resourceType + "." + resourceName + accessor := resourceAccessor(resourceType, resourceName) + + azureCredentialsResource := map[string]interface{}{ + "name": "test", + "tenant_id": "tenantid1", + "subscription_id": "subscriptionid1", + "client_id": "clientid1", + } + + updatedAzureCredentialsResource := map[string]interface{}{ + "name": "test", + "tenant_id": "tenantid2", + "subscription_id": "subscriptionid2", + "client_id": "clientid2", + } + + createPayload := client.AzureCredentialsCreatePayload{ + Name: azureCredentialsResource["name"].(string), + Value: client.AzureCredentialsValuePayload{ + TenantId: azureCredentialsResource["tenant_id"].(string), + SubscriptionId: azureCredentialsResource["subscription_id"].(string), + ClientId: azureCredentialsResource["client_id"].(string), + }, + Type: client.AzureOidcCredentialsType, + } + + updatePayload := client.AzureCredentialsCreatePayload{ + Value: client.AzureCredentialsValuePayload{ + TenantId: updatedAzureCredentialsResource["tenant_id"].(string), + SubscriptionId: updatedAzureCredentialsResource["subscription_id"].(string), + ClientId: updatedAzureCredentialsResource["client_id"].(string), + }, + Type: client.AzureOidcCredentialsType, + } + + returnValues := client.Credentials{ + Id: "f595c4b6-0a24-4c22-89f7-7030045de30f", + Name: "test", + OrganizationId: "id", + Type: string(client.AzureOidcCredentialsType), + } + + otherTypeReturnValues := client.Credentials{ + Id: "f595c4b6-0a24-4c22-89f7-7030045de30a", + Name: "test", + OrganizationId: "id", + Type: "AWS_....", + } + + updateReturnValues := client.Credentials{ + Id: returnValues.Id, + Name: returnValues.Name, + OrganizationId: "id", + Type: string(client.AzureOidcCredentialsType), + } + + testCaseForCreateAndUpdate := resource.TestCase{ + Steps: []resource.TestStep{ + { + Config: resourceConfigCreate(resourceType, resourceName, azureCredentialsResource), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(accessor, "id", returnValues.Id), + resource.TestCheckResourceAttr(accessor, "name", azureCredentialsResource["name"].(string)), + resource.TestCheckResourceAttr(accessor, "tenant_id", azureCredentialsResource["tenant_id"].(string)), + resource.TestCheckResourceAttr(accessor, "subscription_id", azureCredentialsResource["subscription_id"].(string)), + resource.TestCheckResourceAttr(accessor, "client_id", azureCredentialsResource["client_id"].(string)), + ), + }, + { + Config: resourceConfigCreate(resourceType, resourceName, updatedAzureCredentialsResource), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(accessor, "id", updateReturnValues.Id), + resource.TestCheckResourceAttr(accessor, "name", updatedAzureCredentialsResource["name"].(string)), + resource.TestCheckResourceAttr(accessor, "tenant_id", updatedAzureCredentialsResource["tenant_id"].(string)), + resource.TestCheckResourceAttr(accessor, "subscription_id", updatedAzureCredentialsResource["subscription_id"].(string)), + resource.TestCheckResourceAttr(accessor, "client_id", updatedAzureCredentialsResource["client_id"].(string)), + ), + }, + }, + } + + t.Run("create and update", func(t *testing.T) { + runUnitTest(t, testCaseForCreateAndUpdate, func(mock *client.MockApiClientInterface) { + gomock.InOrder( + mock.EXPECT().CredentialsCreate(&createPayload).Times(1).Return(returnValues, nil), + mock.EXPECT().CloudCredentials(returnValues.Id).Times(2).Return(returnValues, nil), + mock.EXPECT().CredentialsUpdate(returnValues.Id, &updatePayload).Times(1).Return(updateReturnValues, nil), + mock.EXPECT().CloudCredentials(updateReturnValues.Id).Times(1).Return(updateReturnValues, nil), + mock.EXPECT().CloudCredentialsDelete(returnValues.Id).Times(1).Return(nil), + ) + }) + }) + + t.Run("drift", func(t *testing.T) { + stepConfig := resourceConfigCreate(resourceType, resourceName, azureCredentialsResource) + + createTestCase := resource.TestCase{ + Steps: []resource.TestStep{ + { + Config: stepConfig, + }, + { + Config: stepConfig, + }, + }, + } + + runUnitTest(t, createTestCase, func(mock *client.MockApiClientInterface) { + gomock.InOrder( + mock.EXPECT().CredentialsCreate(&createPayload).Times(1).Return(returnValues, nil), + mock.EXPECT().CloudCredentials(returnValues.Id).Times(1).Return(returnValues, nil), + mock.EXPECT().CloudCredentials(returnValues.Id).Times(1).Return(returnValues, http.NewMockFailedResponseError(404)), + mock.EXPECT().CredentialsCreate(&createPayload).Times(1).Return(returnValues, nil), + mock.EXPECT().CloudCredentials(returnValues.Id).Times(1).Return(returnValues, nil), + mock.EXPECT().CloudCredentialsDelete(returnValues.Id).Times(1).Return(nil), + ) + }) + }) + + t.Run("import by name", func(t *testing.T) { + testCase := resource.TestCase{ + Steps: []resource.TestStep{ + { + Config: resourceConfigCreate(resourceType, resourceName, azureCredentialsResource), + }, + { + ResourceName: resourceNameImport, + ImportState: true, + ImportStateId: azureCredentialsResource["name"].(string), + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"tenant_id", "subscription_id", "client_id"}, + }, + }, + } + + runUnitTest(t, testCase, func(mock *client.MockApiClientInterface) { + gomock.InOrder( + mock.EXPECT().CredentialsCreate(&createPayload).Times(1).Return(returnValues, nil), + mock.EXPECT().CloudCredentials(returnValues.Id).Times(1).Return(returnValues, nil), + mock.EXPECT().CloudCredentialsList().Times(1).Return([]client.Credentials{otherTypeReturnValues, returnValues}, nil), + mock.EXPECT().CloudCredentials(returnValues.Id).Times(1).Return(returnValues, nil), + mock.EXPECT().CloudCredentialsDelete(returnValues.Id).Times(1).Return(nil), + ) + }) + }) + + t.Run("import by id", func(t *testing.T) { + testCase := resource.TestCase{ + Steps: []resource.TestStep{ + { + Config: resourceConfigCreate(resourceType, resourceName, azureCredentialsResource), + }, + { + ResourceName: resourceNameImport, + ImportState: true, + ImportStateId: returnValues.Id, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"tenant_id", "subscription_id", "client_id"}, + }, + }, + } + + runUnitTest(t, testCase, func(mock *client.MockApiClientInterface) { + gomock.InOrder( + mock.EXPECT().CredentialsCreate(&createPayload).Times(1).Return(returnValues, nil), + mock.EXPECT().CloudCredentials(returnValues.Id).Times(3).Return(returnValues, nil), + mock.EXPECT().CloudCredentialsDelete(returnValues.Id).Times(1).Return(nil), + ) + }) + }) + + t.Run("import by id not found", func(t *testing.T) { + testCase := resource.TestCase{ + Steps: []resource.TestStep{ + { + Config: resourceConfigCreate(resourceType, resourceName, azureCredentialsResource), + }, + { + ResourceName: resourceNameImport, + ImportState: true, + ImportStateId: otherTypeReturnValues.Id, + ImportStateVerify: true, + ExpectError: regexp.MustCompile("credentials not found"), + }, + }, + } + + runUnitTest(t, testCase, func(mock *client.MockApiClientInterface) { + gomock.InOrder( + mock.EXPECT().CredentialsCreate(&createPayload).Times(1).Return(returnValues, nil), + mock.EXPECT().CloudCredentials(returnValues.Id).Times(1).Return(returnValues, nil), + mock.EXPECT().CloudCredentials(otherTypeReturnValues.Id).Times(1).Return(client.Credentials{}, &client.NotFoundError{}), + mock.EXPECT().CloudCredentialsDelete(returnValues.Id).Times(1).Return(nil), + ) + }) + }) + + t.Run("import by name not found", func(t *testing.T) { + testCase := resource.TestCase{ + Steps: []resource.TestStep{ + { + Config: resourceConfigCreate(resourceType, resourceName, azureCredentialsResource), + }, + { + ResourceName: resourceNameImport, + ImportState: true, + ImportStateId: azureCredentialsResource["name"].(string), + ImportStateVerify: true, + ExpectError: regexp.MustCompile(fmt.Sprintf("credentials with name %v not found", azureCredentialsResource["name"].(string))), + }, + }, + } + + runUnitTest(t, testCase, func(mock *client.MockApiClientInterface) { + gomock.InOrder( + mock.EXPECT().CredentialsCreate(&createPayload).Times(1).Return(returnValues, nil), + mock.EXPECT().CloudCredentials(returnValues.Id).Times(1).Return(returnValues, nil), + mock.EXPECT().CloudCredentialsList().Times(1).Return([]client.Credentials{otherTypeReturnValues}, nil), + mock.EXPECT().CloudCredentialsDelete(returnValues.Id).Times(1).Return(nil), + ) + }) + }) +} diff --git a/examples/resources/env0_azure_oidc_credentials/import.sh b/examples/resources/env0_azure_oidc_credentials/import.sh new file mode 100644 index 00000000..9262ff10 --- /dev/null +++ b/examples/resources/env0_azure_oidc_credentials/import.sh @@ -0,0 +1,2 @@ +terraform import env0_aws_oidc_credentials.by_id d31a6b30-5f69-4d24-937c-22322754934e +terraform import env0_aws_oidc_credentials.by_name "credentials name" diff --git a/examples/resources/env0_azure_oidc_credentials/resource.tf b/examples/resources/env0_azure_oidc_credentials/resource.tf new file mode 100644 index 00000000..6a0b12fa --- /dev/null +++ b/examples/resources/env0_azure_oidc_credentials/resource.tf @@ -0,0 +1,6 @@ +resource "env0_azure_oidc_credentials" "credentials" { + name = "example" + tenant_id = "4234-2343-24234234234-42343" + client_id = "fff333-345555-4444" + subscription_id = "f1111-222-2222" +} diff --git a/tests/integration/016_azure_credentials/main.tf b/tests/integration/016_azure_credentials/main.tf index cb170168..2076de1f 100644 --- a/tests/integration/016_azure_credentials/main.tf +++ b/tests/integration/016_azure_credentials/main.tf @@ -12,6 +12,13 @@ resource "env0_azure_credentials" "azure_cred" { tenant_id = "tenant_id" } +resource "env0_azure_oidc_credentials" "oidc_credentials" { + name = "test azure oidc credentials ${random_string.random.result}" + client_id = "client_id" + subscription_id = "subscription_id" + tenant_id = "tenant_id" +} + data "env0_azure_credentials" "azure_cred" { name = env0_azure_credentials.azure_cred.name }