diff --git a/.github/workflows/testacc.yml b/.github/workflows/testacc.yml index bb9fe23d..fd64c1eb 100644 --- a/.github/workflows/testacc.yml +++ b/.github/workflows/testacc.yml @@ -70,6 +70,7 @@ jobs: "internal/provider/resources/resource_cluster.go" "internal/provider/resources/resource_cluster_test.go" "internal/provider/resources/resource_deployment.go" + "internal/provider/resources/common_cluster.go" ) for file in "${FILES_TO_CHECK[@]}"; do if git diff --name-only remotes/origin/${{ github.base_ref }} remotes/origin/${{ github.head_ref }} | grep -q "$file"; then @@ -90,6 +91,7 @@ jobs: ASTRO_API_HOST: https://api.astronomer-dev.io SKIP_CLUSTER_RESOURCE_TESTS: ${{ env.SKIP_CLUSTER_RESOURCE_TESTS }} HOSTED_TEAM_ID: clwbclrc100bl01ozjj5s4jmq + HYBRID_WORKSPACE_IDS: cl70oe7cu445571iynrkthtybl,cl8wpve4993871i37qe1k152c TESTARGS: "-failfast" run: make testacc @@ -131,6 +133,7 @@ jobs: HYBRID_NODE_POOL_ID: clqqongl40fmu01m94pwp4kct ASTRO_API_HOST: https://api.astronomer-stage.io HOSTED_TEAM_ID: clwv0r0x7091n01l0t1fm4vxy + HYBRID_WORKSPACE_IDS: clwv06sva08vg01hovu1j7znw TESTARGS: "-failfast" run: make testacc @@ -172,5 +175,6 @@ jobs: HYBRID_NODE_POOL_ID: clnp86ly5000301ndzfxz895w ASTRO_API_HOST: https://api.astronomer-dev.io HOSTED_TEAM_ID: clwbclrc100bl01ozjj5s4jmq + HYBRID_WORKSPACE_IDS: cl70oe7cu445571iynrkthtybl,cl8wpve4993871i37qe1k152c TESTARGS: "-failfast" run: make testacc \ No newline at end of file diff --git a/docs/resources/hybrid_cluster_workspace_authorization.md b/docs/resources/hybrid_cluster_workspace_authorization.md new file mode 100644 index 00000000..3055a7fd --- /dev/null +++ b/docs/resources/hybrid_cluster_workspace_authorization.md @@ -0,0 +1,31 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "astro_hybrid_cluster_workspace_authorization Resource - astro" +subcategory: "" +description: |- + Hybrid cluster workspace authorization resource +--- + +# astro_hybrid_cluster_workspace_authorization (Resource) + +Hybrid cluster workspace authorization resource + +## Example Usage + +```terraform +resource "astro_hybrid_cluster_workspace_authorization" "example" { + cluster_id = "clk8h0fv1006801j8yysfybbt" + workspace_ids = ["cl70oe7cu445571iynrkthtybl", "cl70oe7cu445571iynrkthacsd"] +} +``` + + +## Schema + +### Required + +- `cluster_id` (String) The ID of the hybrid cluster to set authorizations for + +### Optional + +- `workspace_ids` (Set of String) The IDs of the workspaces to authorize for the hybrid cluster diff --git a/examples/resources/astro_hybrid_cluster_workspace_authorization/resource.tf b/examples/resources/astro_hybrid_cluster_workspace_authorization/resource.tf new file mode 100644 index 00000000..6125f72c --- /dev/null +++ b/examples/resources/astro_hybrid_cluster_workspace_authorization/resource.tf @@ -0,0 +1,4 @@ +resource "astro_hybrid_cluster_workspace_authorization" "example" { + cluster_id = "clk8h0fv1006801j8yysfybbt" + workspace_ids = ["cl70oe7cu445571iynrkthtybl", "cl70oe7cu445571iynrkthacsd"] +} \ No newline at end of file diff --git a/internal/provider/models/hybrid_cluster_workspace_authorization.go b/internal/provider/models/hybrid_cluster_workspace_authorization.go new file mode 100644 index 00000000..4a2aa7ac --- /dev/null +++ b/internal/provider/models/hybrid_cluster_workspace_authorization.go @@ -0,0 +1,30 @@ +package models + +import ( + "github.com/astronomer/terraform-provider-astro/internal/clients/platform" + "github.com/astronomer/terraform-provider-astro/internal/utils" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type HybridClusterWorkspaceAuthorizationResource struct { + ClusterId types.String `tfsdk:"cluster_id"` + WorkspaceIds types.Set `tfsdk:"workspace_ids"` +} + +func (data *HybridClusterWorkspaceAuthorizationResource) ReadFromResponse( + cluster *platform.Cluster, +) diag.Diagnostics { + var diags diag.Diagnostics + data.ClusterId = types.StringValue(cluster.Id) + if cluster.WorkspaceIds == nil || len(*cluster.WorkspaceIds) == 0 { + data.WorkspaceIds = types.SetNull(types.StringType) + } else { + data.WorkspaceIds, diags = utils.StringSet(cluster.WorkspaceIds) + if diags.HasError() { + return diags + } + } + + return nil +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 82c664ec..3066a66d 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -124,6 +124,7 @@ func (p *AstroProvider) Resources(ctx context.Context) []func() resource.Resourc resources.NewDeploymentResource, resources.NewClusterResource, resources.NewTeamRolesResource, + resources.NewHybridClusterWorkspaceAuthorizationResource, } } diff --git a/internal/provider/provider_test_utils.go b/internal/provider/provider_test_utils.go index 3e218c3e..a4b2fcb4 100644 --- a/internal/provider/provider_test_utils.go +++ b/internal/provider/provider_test_utils.go @@ -35,6 +35,9 @@ func TestAccPreCheck(t *testing.T) { if hybridOrgId := os.Getenv("HYBRID_ORGANIZATION_ID"); len(hybridOrgId) == 0 { missingEnvVars = append(missingEnvVars, "HYBRID_ORGANIZATION_ID") } + if hybridWorkspaceIds := os.Getenv("HYBRID_WORKSPACE_IDS"); len(hybridWorkspaceIds) == 0 { + missingEnvVars = append(missingEnvVars, "HYBRID_WORKSPACE_IDS") + } if host := os.Getenv("ASTRO_API_HOST"); len(host) == 0 { missingEnvVars = append(missingEnvVars, "ASTRO_API_HOST") } diff --git a/internal/provider/resources/common_cluster.go b/internal/provider/resources/common_cluster.go new file mode 100644 index 00000000..9e915f3d --- /dev/null +++ b/internal/provider/resources/common_cluster.go @@ -0,0 +1,47 @@ +package resources + +import ( + "context" + "fmt" + "net/http" + + "github.com/astronomer/terraform-provider-astro/internal/clients" + "github.com/astronomer/terraform-provider-astro/internal/clients/platform" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" +) + +// ClusterResourceRefreshFunc returns a retry.StateRefreshFunc that polls the platform API for the cluster status +// If the cluster is not found, it returns "DELETED" status +// If the cluster is found, it returns the cluster status +// If there is an error, it returns the error +// WaitForStateContext will keep polling until the target status is reached, the timeout is reached or an err is returned +func ClusterResourceRefreshFunc(ctx context.Context, platformClient *platform.ClientWithResponses, organizationId string, clusterId string) retry.StateRefreshFunc { + return func() (any, string, error) { + cluster, err := platformClient.GetClusterWithResponse(ctx, organizationId, clusterId) + if err != nil { + tflog.Error(ctx, "failed to get cluster while polling for cluster 'CREATED' status", map[string]interface{}{"error": err}) + return nil, "", err + } + statusCode, diagnostic := clients.NormalizeAPIError(ctx, cluster.HTTPResponse, cluster.Body) + if statusCode == http.StatusNotFound { + return &platform.Cluster{}, "DELETED", nil + } + if diagnostic != nil { + return nil, "", fmt.Errorf("error getting cluster %s", diagnostic.Detail()) + } + if cluster != nil && cluster.JSON200 != nil { + switch cluster.JSON200.Status { + case platform.ClusterStatusCREATED: + return cluster.JSON200, string(cluster.JSON200.Status), nil + case platform.ClusterStatusUPDATEFAILED, platform.ClusterStatusCREATEFAILED: + return cluster.JSON200, string(cluster.JSON200.Status), fmt.Errorf("cluster mutation failed for cluster '%v'", cluster.JSON200.Id) + case platform.ClusterStatusCREATING, platform.ClusterStatusUPDATING: + return cluster.JSON200, string(cluster.JSON200.Status), nil + default: + return cluster.JSON200, string(cluster.JSON200.Status), fmt.Errorf("unexpected cluster status '%v' for cluster '%v'", cluster.JSON200.Status, cluster.JSON200.Id) + } + } + return nil, "", fmt.Errorf("error getting cluster %s", clusterId) + } +} diff --git a/internal/provider/resources/resource_cluster.go b/internal/provider/resources/resource_cluster.go index 6bace7e2..6b1bf705 100644 --- a/internal/provider/resources/resource_cluster.go +++ b/internal/provider/resources/resource_cluster.go @@ -217,7 +217,7 @@ func (r *ClusterResource) Create( stateConf := &retry.StateChangeConf{ Pending: []string{string(platform.ClusterStatusCREATING), string(platform.ClusterStatusUPDATING)}, Target: []string{string(platform.ClusterStatusCREATED), string(platform.ClusterStatusUPDATEFAILED), string(platform.ClusterStatusCREATEFAILED)}, - Refresh: r.resourceRefreshFunc(ctx, cluster.JSON200.Id), + Refresh: ClusterResourceRefreshFunc(ctx, r.platformClient, r.organizationId, cluster.JSON200.Id), Timeout: 3 * time.Hour, MinTimeout: 1 * time.Minute, } @@ -370,7 +370,7 @@ func (r *ClusterResource) Update( stateConf := &retry.StateChangeConf{ Pending: []string{string(platform.ClusterStatusCREATING), string(platform.ClusterStatusUPDATING)}, Target: []string{string(platform.ClusterStatusCREATED), string(platform.ClusterStatusUPDATEFAILED), string(platform.ClusterStatusCREATEFAILED)}, - Refresh: r.resourceRefreshFunc(ctx, cluster.JSON200.Id), + Refresh: ClusterResourceRefreshFunc(ctx, r.platformClient, r.organizationId, cluster.JSON200.Id), Timeout: 3 * time.Hour, MinTimeout: 1 * time.Minute, } @@ -441,7 +441,7 @@ func (r *ClusterResource) Delete( stateConf := &retry.StateChangeConf{ Pending: []string{string(platform.ClusterStatusCREATING), string(platform.ClusterStatusUPDATING), string(platform.ClusterStatusCREATED), string(platform.ClusterStatusUPDATEFAILED), string(platform.ClusterStatusCREATEFAILED)}, Target: []string{"DELETED"}, - Refresh: r.resourceRefreshFunc(ctx, data.Id.ValueString()), + Refresh: ClusterResourceRefreshFunc(ctx, r.platformClient, r.organizationId, data.Id.ValueString()), Timeout: 1 * time.Hour, MinTimeout: 30 * time.Second, } @@ -576,38 +576,3 @@ func validateGcpConfig(ctx context.Context, data *models.ClusterResource) diag.D } return diags } - -// resourceRefreshFunc returns a retry.StateRefreshFunc that polls the platform API for the cluster status -// If the cluster is not found, it returns "DELETED" status -// If the cluster is found, it returns the cluster status -// If there is an error, it returns the error -// WaitForStateContext will keep polling until the target status is reached, the timeout is reached or an err is returned -func (r *ClusterResource) resourceRefreshFunc(ctx context.Context, clusterId string) retry.StateRefreshFunc { - return func() (any, string, error) { - cluster, err := r.platformClient.GetClusterWithResponse(ctx, r.organizationId, clusterId) - if err != nil { - tflog.Error(ctx, "failed to get cluster while polling for cluster 'CREATED' status", map[string]interface{}{"error": err}) - return nil, "", err - } - statusCode, diagnostic := clients.NormalizeAPIError(ctx, cluster.HTTPResponse, cluster.Body) - if statusCode == http.StatusNotFound { - return &platform.Cluster{}, "DELETED", nil - } - if diagnostic != nil { - return nil, "", fmt.Errorf("error getting cluster %s", diagnostic.Detail()) - } - if cluster != nil && cluster.JSON200 != nil { - switch cluster.JSON200.Status { - case platform.ClusterStatusCREATED: - return cluster.JSON200, string(cluster.JSON200.Status), nil - case platform.ClusterStatusUPDATEFAILED, platform.ClusterStatusCREATEFAILED: - return cluster.JSON200, string(cluster.JSON200.Status), fmt.Errorf("cluster mutation failed for cluster '%v'", cluster.JSON200.Id) - case platform.ClusterStatusCREATING, platform.ClusterStatusUPDATING: - return cluster.JSON200, string(cluster.JSON200.Status), nil - default: - return cluster.JSON200, string(cluster.JSON200.Status), fmt.Errorf("unexpected cluster status '%v' for cluster '%v'", cluster.JSON200.Status, cluster.JSON200.Id) - } - } - return nil, "", fmt.Errorf("error getting cluster %s", clusterId) - } -} diff --git a/internal/provider/resources/resource_deployment_test.go b/internal/provider/resources/resource_deployment_test.go index 841ff1b4..291b22e2 100644 --- a/internal/provider/resources/resource_deployment_test.go +++ b/internal/provider/resources/resource_deployment_test.go @@ -20,8 +20,6 @@ import ( "github.com/stretchr/testify/assert" ) -// We will test dedicated deployment resources once dedicated_cluster_resource is implemented - func TestAcc_ResourceDeploymentHybrid(t *testing.T) { namePrefix := utils.GenerateTestResourceName(10) @@ -576,13 +574,9 @@ func hybridDeployment(input hybridDeploymentInput) string { } else { taskPodNodePoolIdStr = fmt.Sprintf(`task_pod_node_pool_id = "%v"`, input.NodePoolId) } - return fmt.Sprintf(` -resource "astro_workspace" "%v_workspace" { - name = "%s" - description = "%s" - cicd_enforced_default = true -} + workspaceId := strings.Split(os.Getenv("HYBRID_WORKSPACE_IDS"), ",")[0] + return fmt.Sprintf(` resource "astro_deployment" "%v" { name = "%s" description = "%s" @@ -594,13 +588,13 @@ resource "astro_deployment" "%v" { is_dag_deploy_enabled = true scheduler_au = %v scheduler_replicas = 1 - workspace_id = astro_workspace.%v_workspace.id + workspace_id = "%v" %v %v %v } `, - input.Name, input.Name, utils.TestResourceDescription, input.Name, input.Name, input.Description, input.ClusterId, input.Executor, input.SchedulerAu, input.Name, + input.Name, input.Name, utils.TestResourceDescription, input.ClusterId, input.Executor, input.SchedulerAu, workspaceId, envVarsStr(input.IncludeEnvironmentVariables), wqStr, taskPodNodePoolIdStr) } diff --git a/internal/provider/resources/resource_hybrid_cluster_workspace_authorization.go b/internal/provider/resources/resource_hybrid_cluster_workspace_authorization.go new file mode 100644 index 00000000..e87cfb99 --- /dev/null +++ b/internal/provider/resources/resource_hybrid_cluster_workspace_authorization.go @@ -0,0 +1,318 @@ +package resources + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/hashicorp/terraform-plugin-framework/path" + + "github.com/astronomer/terraform-provider-astro/internal/clients" + "github.com/astronomer/terraform-provider-astro/internal/clients/platform" + "github.com/astronomer/terraform-provider-astro/internal/provider/models" + "github.com/astronomer/terraform-provider-astro/internal/provider/schemas" + "github.com/astronomer/terraform-provider-astro/internal/utils" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" +) + +var _ resource.Resource = &hybridClusterWorkspaceAuthorizationResource{} +var _ resource.ResourceWithImportState = &hybridClusterWorkspaceAuthorizationResource{} +var _ resource.ResourceWithConfigure = &hybridClusterWorkspaceAuthorizationResource{} + +func NewHybridClusterWorkspaceAuthorizationResource() resource.Resource { + return &hybridClusterWorkspaceAuthorizationResource{} +} + +// hybridClusterWorkspaceAuthorizationResource represents a hybrid cluster workspace authorization resource. +type hybridClusterWorkspaceAuthorizationResource struct { + platformClient *platform.ClientWithResponses + organizationId string +} + +func (r *hybridClusterWorkspaceAuthorizationResource) Metadata( + ctx context.Context, + req resource.MetadataRequest, + resp *resource.MetadataResponse, +) { + resp.TypeName = req.ProviderTypeName + "_hybrid_cluster_workspace_authorization" +} + +func (r *hybridClusterWorkspaceAuthorizationResource) Schema( + ctx context.Context, + req resource.SchemaRequest, + resp *resource.SchemaResponse, +) { + resp.Schema = schema.Schema{ + // This description is used by the documentation generator and the language server. + MarkdownDescription: "Hybrid cluster workspace authorization resource", + Attributes: schemas.ResourceHybridClusterWorkspaceAuthorizationSchemaAttributes(), + } +} + +func (r *hybridClusterWorkspaceAuthorizationResource) Configure( + ctx context.Context, + req resource.ConfigureRequest, + resp *resource.ConfigureResponse, +) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + apiClients, ok := req.ProviderData.(models.ApiClientsModel) + if !ok { + utils.ResourceApiClientConfigureError(ctx, req, resp) + return + } + + r.platformClient = apiClients.PlatformClient + r.organizationId = apiClients.OrganizationId +} + +func (r *hybridClusterWorkspaceAuthorizationResource) MutateRoles( + ctx context.Context, + data *models.HybridClusterWorkspaceAuthorizationResource, +) diag.Diagnostics { + diags := diag.Diagnostics{} + var updateClusterRequest platform.UpdateClusterRequest + updateHybridClusterRequest := platform.UpdateHybridClusterRequest{ + ClusterType: platform.UpdateHybridClusterRequestClusterTypeHYBRID, + } + + // workspaceIds + if !data.WorkspaceIds.IsNull() { + workspaceIds, diags := utils.TypesSetToStringSlice(ctx, data.WorkspaceIds) + updateHybridClusterRequest.WorkspaceIds = &workspaceIds + if diags.HasError() { + return diags + } + } else { + updateHybridClusterRequest.WorkspaceIds = &[]string{} + } + + err := updateClusterRequest.FromUpdateHybridClusterRequest(updateHybridClusterRequest) + if err != nil { + tflog.Error(ctx, fmt.Sprintf("Failed to mutate hybrid cluster workspace authorization error: %v", err)) + diags.AddError( + "Client Error", + fmt.Sprintf("Failed to mutate hybrid cluster workspace authorization, got error: %s", err), + ) + return diags + } + + cluster, err := r.platformClient.UpdateClusterWithResponse(ctx, r.organizationId, data.ClusterId.ValueString(), updateClusterRequest) + if err != nil { + tflog.Error(ctx, "failed to mutate hybrid cluster workspace authorization", map[string]interface{}{"error": err}) + diags.AddError( + "Client Error", + fmt.Sprintf("Unable to mutate hybrid cluster workspace authorization, got error: %s", err), + ) + return diags + } + _, diagnostic := clients.NormalizeAPIError(ctx, cluster.HTTPResponse, cluster.Body) + if diagnostic != nil { + diags.Append(diagnostic) + return diags + } + + // Wait for the cluster to be updated (or fail) + stateConf := &retry.StateChangeConf{ + Pending: []string{string(platform.ClusterStatusCREATING), string(platform.ClusterStatusUPDATING)}, + Target: []string{string(platform.ClusterStatusCREATED), string(platform.ClusterStatusUPDATEFAILED), string(platform.ClusterStatusCREATEFAILED)}, + Refresh: ClusterResourceRefreshFunc(ctx, r.platformClient, r.organizationId, cluster.JSON200.Id), + Timeout: 1 * time.Hour, + MinTimeout: 1 * time.Minute, + } + + // readyCluster is the final state of the cluster after it has reached a target status + readyCluster, err := stateConf.WaitForStateContext(ctx) + if err != nil { + diags.AddError("Hybrid cluster authorization mutation", err.Error()) + return diags + } + + diags = data.ReadFromResponse(readyCluster.(*platform.Cluster)) + if diags.HasError() { + return diags + } + + return nil +} + +func (r *hybridClusterWorkspaceAuthorizationResource) Create( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, +) { + var data models.HybridClusterWorkspaceAuthorizationResource + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + diags := r.MutateRoles(ctx, &data) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + tflog.Trace(ctx, fmt.Sprintf("Created hybrid cluster workspace authorization for cluster: %v", data.ClusterId.ValueString())) + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *hybridClusterWorkspaceAuthorizationResource) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { + var data models.HybridClusterWorkspaceAuthorizationResource + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + cluster, err := r.platformClient.GetClusterWithResponse(ctx, r.organizationId, data.ClusterId.ValueString()) + if err != nil { + tflog.Error(ctx, "failed to get cluster", map[string]interface{}{"error": err}) + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get cluster, got error: %s", err)) + return + } + statusCode, diagnostic := clients.NormalizeAPIError(ctx, cluster.HTTPResponse, cluster.Body) + // If the resource no longer exists, it is recommended to ignore the errors + // and call RemoveResource to remove the resource from the state. The next Terraform plan will recreate the resource. + if statusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + if diagnostic != nil { + resp.Diagnostics.Append(diagnostic) + return + } + + diags := data.ReadFromResponse(cluster.JSON200) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + tflog.Trace(ctx, fmt.Sprintf("Read cluster workspace authorization for: %v", data.ClusterId.ValueString())) + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *hybridClusterWorkspaceAuthorizationResource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + var data models.HybridClusterWorkspaceAuthorizationResource + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + diags := r.MutateRoles(ctx, &data) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + tflog.Trace(ctx, fmt.Sprintf("Updated hybrid cluster workspace authorization for cluster: %v", data.ClusterId.ValueString())) + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *hybridClusterWorkspaceAuthorizationResource) Delete( + ctx context.Context, + req resource.DeleteRequest, + resp *resource.DeleteResponse, +) { + var data models.HybridClusterWorkspaceAuthorizationResource + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + var diags diag.Diagnostics + var updateClusterRequest platform.UpdateClusterRequest + updateHybridClusterRequest := platform.UpdateHybridClusterRequest{ + ClusterType: platform.UpdateHybridClusterRequestClusterTypeHYBRID, + WorkspaceIds: &[]string{}, + } + + err := updateClusterRequest.FromUpdateHybridClusterRequest(updateHybridClusterRequest) + if err != nil { + tflog.Error(ctx, fmt.Sprintf("failed to delete hybrid cluster workspace authorization error: %v", err)) + resp.Diagnostics.AddError( + "Client Error", + fmt.Sprintf("Unable to delete hybrid cluster workspace authorization, got error: %s", err), + ) + return + } + + cluster, err := r.platformClient.UpdateClusterWithResponse(ctx, r.organizationId, data.ClusterId.ValueString(), updateClusterRequest) + if err != nil { + tflog.Error(ctx, "failed to delete hybrid cluster workspace authorization", map[string]interface{}{"error": err}) + resp.Diagnostics.AddError( + "Client Error", + fmt.Sprintf("Unable to delete hybrid cluster workspace authorization, got error: %s", err), + ) + return + } + _, diagnostic := clients.NormalizeAPIError(ctx, cluster.HTTPResponse, cluster.Body) + if diagnostic != nil { + resp.Diagnostics.Append(diagnostic) + return + } + + // Wait for the cluster to be updated (or fail) + stateConf := &retry.StateChangeConf{ + Pending: []string{string(platform.ClusterStatusCREATING), string(platform.ClusterStatusUPDATING)}, + Target: []string{string(platform.ClusterStatusCREATED), string(platform.ClusterStatusUPDATEFAILED), string(platform.ClusterStatusCREATEFAILED)}, + Refresh: ClusterResourceRefreshFunc(ctx, r.platformClient, r.organizationId, cluster.JSON200.Id), + Timeout: 1 * time.Hour, + MinTimeout: 1 * time.Minute, + } + + // readyCluster is the final state of the cluster after it has reached a target status + readyCluster, err := stateConf.WaitForStateContext(ctx) + if err != nil { + resp.Diagnostics.AddError("Hybrid cluster workspace authorization delete failed", err.Error()) + return + } + + diags = data.ReadFromResponse(readyCluster.(*platform.Cluster)) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + tflog.Trace(ctx, fmt.Sprintf("Deleted hybrid cluster workspace authorization for cluster: %v", data.ClusterId.ValueString())) + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *hybridClusterWorkspaceAuthorizationResource) ImportState( + ctx context.Context, + req resource.ImportStateRequest, + resp *resource.ImportStateResponse, +) { + resource.ImportStatePassthroughID(ctx, path.Root("cluster_id"), req, resp) +} diff --git a/internal/provider/resources/resource_hybrid_cluster_workspace_authorization_test.go b/internal/provider/resources/resource_hybrid_cluster_workspace_authorization_test.go new file mode 100644 index 00000000..c73dd173 --- /dev/null +++ b/internal/provider/resources/resource_hybrid_cluster_workspace_authorization_test.go @@ -0,0 +1,161 @@ +package resources_test + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/samber/lo" + + "github.com/astronomer/terraform-provider-astro/internal/clients" + astronomerprovider "github.com/astronomer/terraform-provider-astro/internal/provider" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/lucsky/cuid" + "github.com/stretchr/testify/assert" + + "os" + "testing" + + "github.com/astronomer/terraform-provider-astro/internal/utils" +) + +func TestAcc_ResourceHybridClusterWorkspaceAuthorization(t *testing.T) { + namePrefix := utils.GenerateTestResourceName(10) + + workspaceName := fmt.Sprintf("%v_workspace", namePrefix) + workspaceResourceVar := fmt.Sprintf("astro_workspace.%v", workspaceName) + hybridWorkspaceIdsStr := os.Getenv("HYBRID_WORKSPACE_IDS") + hybridWorkspaceIds := strings.Split(hybridWorkspaceIdsStr, ",") + + clusterId := os.Getenv("HYBRID_CLUSTER_ID") + clusterWorkspaceAuth := fmt.Sprintf("%v_auth", namePrefix) + resourceVar := fmt.Sprintf("astro_hybrid_cluster_workspace_authorization.%v", clusterWorkspaceAuth) + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: astronomerprovider.TestAccProtoV6ProviderFactories, + PreCheck: func() { astronomerprovider.TestAccPreCheck(t) }, + CheckDestroy: resource.ComposeTestCheckFunc( + // Check that cluster workspace authorizations have been removed + testAccCheckHybridClusterWorkspaceAuthorizationExistence(t, clusterWorkspaceAuth, false), + ), + Steps: []resource.TestStep{ + // Test with existing workspaces and one created through terraform + { + Config: astronomerprovider.ProviderConfig(t, false) + + workspace(workspaceName, workspaceName, utils.TestResourceDescription, false) + + hybridClusterWorkspaceAuthorization(hybridClusterWorkspaceAuthorizationInput{ + Name: clusterWorkspaceAuth, + ClusterId: clusterId, + WorkspaceIds: append(hybridWorkspaceIds, fmt.Sprintf("%v.id", workspaceResourceVar)), + }), + Check: resource.ComposeTestCheckFunc( + // Check hybrid cluster workspace authorization + resource.TestCheckResourceAttr(resourceVar, "cluster_id", clusterId), + resource.TestCheckResourceAttr(resourceVar, "workspace_ids.#", strconv.Itoa(len(hybridWorkspaceIds)+1)), + + testAccCheckHybridClusterWorkspaceAuthorizationExistence(t, clusterWorkspaceAuth, true), + ), + }, + // Remove terraform created workspace from cluster workspace authorization + { + Config: astronomerprovider.ProviderConfig(t, false) + + hybridClusterWorkspaceAuthorization(hybridClusterWorkspaceAuthorizationInput{ + Name: clusterWorkspaceAuth, + ClusterId: clusterId, + WorkspaceIds: hybridWorkspaceIds, + }), + Check: resource.ComposeTestCheckFunc( + // Check hybrid cluster workspace authorization + resource.TestCheckResourceAttr(resourceVar, "cluster_id", clusterId), + resource.TestCheckResourceAttr(resourceVar, "workspace_ids.#", strconv.Itoa(len(hybridWorkspaceIds))), + + testAccCheckHybridClusterWorkspaceAuthorizationExistence(t, clusterWorkspaceAuth, true), + ), + }, + // Import existing hybrid cluster workspace authorization + { + ResourceName: resourceVar, + ImportState: true, + ImportStateVerify: true, + ImportStateId: clusterId, + ImportStateVerifyIdentifierAttribute: "cluster_id", + }, + // Test with no workspaceIds + { + Config: astronomerprovider.ProviderConfig(t, false) + + hybridClusterWorkspaceAuthorization(hybridClusterWorkspaceAuthorizationInput{ + Name: clusterWorkspaceAuth, + ClusterId: clusterId, + }), + Check: resource.ComposeTestCheckFunc( + // Check hybrid cluster workspace authorization + resource.TestCheckResourceAttr(resourceVar, "cluster_id", clusterId), + resource.TestCheckNoResourceAttr(resourceVar, "workspace_ids"), + + testAccCheckHybridClusterWorkspaceAuthorizationExistence(t, clusterWorkspaceAuth, false), + ), + }, + }, + }) +} + +type hybridClusterWorkspaceAuthorizationInput struct { + Name string + ClusterId string + WorkspaceIds []string +} + +func hybridClusterWorkspaceAuthorization(input hybridClusterWorkspaceAuthorizationInput) string { + workspaceIds := lo.Map(input.WorkspaceIds, func(id string, _ int) string { + if cuid.IsCuid(id) == nil { + return fmt.Sprintf(`"%v"`, id) + } + return id + }) + var workspaceIdsString string + if len(workspaceIds) > 0 { + workspaceIdsString = fmt.Sprintf("workspace_ids = [%v]", strings.Join(workspaceIds, ", ")) + } + + return fmt.Sprintf(` + resource "astro_hybrid_cluster_workspace_authorization" "%s" { + cluster_id = "%s" + %v + }`, input.Name, input.ClusterId, workspaceIdsString) +} + +func testAccCheckHybridClusterWorkspaceAuthorizationExistence(t *testing.T, name string, shouldExist bool) func(state *terraform.State) error { + t.Helper() + return func(state *terraform.State) error { + client, err := utils.GetTestHybridPlatformClient() + assert.NoError(t, err) + + organizationId := os.Getenv("HYBRID_ORGANIZATION_ID") + clusterId := os.Getenv("HYBRID_CLUSTER_ID") + + ctx := context.Background() + resp, err := client.GetClusterWithResponse(ctx, organizationId, clusterId) + if err != nil { + return fmt.Errorf("failed to get cluster: %w", err) + } + if resp == nil { + return fmt.Errorf("response is nil") + } + if resp.JSON200 == nil { + status, diag := clients.NormalizeAPIError(ctx, resp.HTTPResponse, resp.Body) + return fmt.Errorf("response JSON200 is nil status: %v, err: %v", status, diag.Detail()) + } + if shouldExist { + if resp.JSON200.WorkspaceIds == nil || len(*resp.JSON200.WorkspaceIds) < 1 { + return fmt.Errorf("cluster workspace authorization %s should exist", name) + } + } else { + if resp.JSON200.WorkspaceIds != nil && len(*resp.JSON200.WorkspaceIds) != 0 { + return fmt.Errorf("cluster workspace authorization %s should not exist", name) + } + } + return nil + } +} diff --git a/internal/provider/schemas/hybrid_cluster_workspace_authorization.go b/internal/provider/schemas/hybrid_cluster_workspace_authorization.go new file mode 100644 index 00000000..db7320ac --- /dev/null +++ b/internal/provider/schemas/hybrid_cluster_workspace_authorization.go @@ -0,0 +1,29 @@ +package schemas + +import ( + "github.com/astronomer/terraform-provider-astro/internal/provider/validators" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + resourceSchema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ResourceHybridClusterWorkspaceAuthorizationSchemaAttributes() map[string]resourceSchema.Attribute { + return map[string]resourceSchema.Attribute{ + "cluster_id": resourceSchema.StringAttribute{ + MarkdownDescription: "The ID of the hybrid cluster to set authorizations for", + Required: true, + Validators: []validator.String{ + validators.IsCuid(), + }, + }, + "workspace_ids": resourceSchema.SetAttribute{ + ElementType: types.StringType, + MarkdownDescription: "The IDs of the workspaces to authorize for the hybrid cluster", + Optional: true, + Validators: []validator.Set{ + setvalidator.SizeAtLeast(1), + }, + }, + } +}