From 722800fe108312ae56f5763c72a8e6617fbb8aa9 Mon Sep 17 00:00:00 2001 From: David Szabo Date: Wed, 22 Nov 2023 09:53:40 +0100 Subject: [PATCH] CDPCP-10789 Environment Proxy Configuration Create/Read/Delete --- .../resource_proxy_configuration.go | 261 ++++++++++++ .../resource_proxy_configuration_test.go | 384 ++++++++++++++++++ 2 files changed, 645 insertions(+) create mode 100644 resources/environments/resource_proxy_configuration.go create mode 100644 resources/environments/resource_proxy_configuration_test.go diff --git a/resources/environments/resource_proxy_configuration.go b/resources/environments/resource_proxy_configuration.go new file mode 100644 index 00000000..80240a51 --- /dev/null +++ b/resources/environments/resource_proxy_configuration.go @@ -0,0 +1,261 @@ +// Copyright 2023 Cloudera. All Rights Reserved. +// +// This file is licensed under the Apache License Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// +// This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS +// OF ANY KIND, either express or implied. Refer to the License for the specific +// permissions and limitations governing your use of the file. + +package environments + +import ( + "context" + + "github.com/cloudera/terraform-provider-cdp/cdp-sdk-go/cdp" + "github.com/cloudera/terraform-provider-cdp/cdp-sdk-go/gen/environments/client/operations" + "github.com/cloudera/terraform-provider-cdp/cdp-sdk-go/gen/environments/models" + "github.com/cloudera/terraform-provider-cdp/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-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +var ( + _ resource.Resource = &proxyConfigurationResource{} +) + +type proxyConfigurationResource struct { + client *cdp.Client +} + +func NewProxyConfigurationResource() resource.Resource { + return &proxyConfigurationResource{} +} + +type proxyConfigurationResourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + Protocol types.String `tfsdk:"protocol"` + Host types.String `tfsdk:"host"` + Port types.Int64 `tfsdk:"port"` + NoProxyHosts types.String `tfsdk:"no_proxy_hosts"` + User types.String `tfsdk:"user"` + Password types.String `tfsdk:"password"` +} + +var ProxyConfigurationSchema = schema.Schema{ + MarkdownDescription: "", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Required: true, + }, + "description": schema.StringAttribute{ + Optional: true, + }, + "protocol": schema.StringAttribute{ + Required: true, + }, + "host": schema.StringAttribute{ + Required: true, + }, + "port": schema.Int64Attribute{ + Required: true, + }, + "no_proxy_hosts": schema.StringAttribute{ + Optional: true, + }, + "user": schema.StringAttribute{ + Optional: true, + }, + "password": schema.StringAttribute{ + Optional: true, + }, + }, +} + +func (p *proxyConfigurationResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_environments_proxy_configuration" +} + +func (p *proxyConfigurationResource) Schema(_ context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = ProxyConfigurationSchema +} + +func (p *proxyConfigurationResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + p.client = utils.GetCdpClientForResource(req, resp) +} + +func (p *proxyConfigurationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data proxyConfigurationResourceModel + diags := req.Plan.Get(ctx, &data) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + tflog.Error(ctx, "Got Error while trying to get plan") + return + } + + client := p.client.Environments + + params := operations.NewCreateProxyConfigParamsWithContext(ctx) + params.WithInput(&models.CreateProxyConfigRequest{ + ProxyConfigName: data.Name.ValueStringPointer(), + Description: data.Description.ValueString(), + Protocol: data.Protocol.ValueStringPointer(), + Host: data.Host.ValueStringPointer(), + Port: func(i int32) *int32 { return &i }(int32(data.Port.ValueInt64())), + NoProxyHosts: data.NoProxyHosts.ValueString(), + User: data.User.ValueString(), + Password: data.Password.ValueString(), + }) + + responseOk, err := client.Operations.CreateProxyConfig(params) + if err != nil { + msg := err.Error() + if d, ok := err.(utils.EnvironmentErrorPayload); ok && d.GetPayload() != nil { + msg = d.GetPayload().Message + } + + resp.Diagnostics.AddError( + "Create Proxy Configuration", + "Failed to create proxy configuration, unexpected error: "+msg, + ) + return + } + + proxyConfig := responseOk.Payload.ProxyConfig + data.ID = types.StringValue(*proxyConfig.Crn) + + diags = resp.State.Set(ctx, data) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +func (p *proxyConfigurationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state proxyConfigurationResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + tflog.Error(ctx, "Got Error while trying to get state") + return + } + + client := p.client.Environments + + params := operations.NewDeleteProxyConfigParamsWithContext(ctx) + params.WithInput(&models.DeleteProxyConfigRequest{ProxyConfigName: state.Name.ValueStringPointer()}) + + _, err := client.Operations.DeleteProxyConfig(params) + if err != nil { + if envErr, ok := err.(*operations.DeleteProxyConfigDefault); ok { + if cdp.IsEnvironmentsError(envErr.GetPayload(), "NOT_FOUND", "") { + return + } else { + resp.Diagnostics.AddError( + "Delete Proxy Configuration", + "Failed to delete proxy configuration: "+envErr.Payload.Message, + ) + return + } + } + msg := err.Error() + if d, ok := err.(utils.EnvironmentErrorPayload); ok && d.GetPayload() != nil { + msg = d.GetPayload().Message + } + + resp.Diagnostics.AddError( + "Delete Proxy Configuration", + "Failed to delete proxy configuration, unexpected error: "+msg, + ) + } +} + +func (p *proxyConfigurationResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state proxyConfigurationResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + tflog.Error(ctx, "Got Error while trying to get state") + return + } + + client := p.client.Environments + + params := operations.NewListProxyConfigsParamsWithContext(ctx) + params.WithInput(&models.ListProxyConfigsRequest{ProxyConfigName: state.Name.ValueString()}) + + responseOk, err := client.Operations.ListProxyConfigs(params) + if err != nil { + if envErr, ok := err.(*operations.DeleteProxyConfigDefault); ok { + if cdp.IsEnvironmentsError(envErr.GetPayload(), "NOT_FOUND", "") { + removeProxyConfigFromState(ctx, &resp.Diagnostics, &resp.State, state) + return + } else { + resp.Diagnostics.AddError( + "Read Proxy Configuration", + "Failed to read proxy configuration: "+envErr.Payload.Message, + ) + return + } + } + msg := err.Error() + if d, ok := err.(utils.EnvironmentErrorPayload); ok && d.GetPayload() != nil { + msg = d.GetPayload().Message + } + + resp.Diagnostics.AddError( + "Read Proxy Configuration", + "Failed to read proxy configuration, unexpected error: "+msg, + ) + return + } + + if len(responseOk.Payload.ProxyConfigs) == 0 { + removeProxyConfigFromState(ctx, &resp.Diagnostics, &resp.State, state) + return + } + + proxyConfig := responseOk.Payload.ProxyConfigs[0] + + state.Name = types.StringValue(*proxyConfig.ProxyConfigName) + state.Description = types.StringValue(proxyConfig.Description) + state.Protocol = types.StringValue(*proxyConfig.Protocol) + state.Host = types.StringValue(*proxyConfig.Host) + state.Port = types.Int64Value(int64(*proxyConfig.Port)) + state.NoProxyHosts = types.StringValue(proxyConfig.NoProxyHosts) + state.User = types.StringValue(proxyConfig.User) + state.Password = types.StringValue(proxyConfig.Password) + + diags = resp.State.Set(ctx, state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +func removeProxyConfigFromState(ctx context.Context, diag *diag.Diagnostics, state *tfsdk.State, model proxyConfigurationResourceModel) { + diag.AddWarning("Resource not found on provider", "Proxy configuration not found, removing resource from state.") + tflog.Warn(ctx, "Proxy configuration not found, removing resource from state", map[string]interface{}{ + "id": model.ID.ValueString(), + }) + state.RemoveResource(ctx) +} + +func (p *proxyConfigurationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + resp.Diagnostics.AddWarning("Update Proxy Configuration Not Supported", "Update proxy configuration is not supported. Plan changes were not applied.") +} diff --git a/resources/environments/resource_proxy_configuration_test.go b/resources/environments/resource_proxy_configuration_test.go new file mode 100644 index 00000000..cb3b9853 --- /dev/null +++ b/resources/environments/resource_proxy_configuration_test.go @@ -0,0 +1,384 @@ +// Copyright 2023 Cloudera. All Rights Reserved. +// +// This file is licensed under the Apache License Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// +// This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS +// OF ANY KIND, either express or implied. Refer to the License for the specific +// permissions and limitations governing your use of the file. + +package environments + +import ( + "context" + "errors" + "testing" + + "github.com/cloudera/terraform-provider-cdp/cdp-sdk-go/cdp" + "github.com/cloudera/terraform-provider-cdp/cdp-sdk-go/gen/environments/client/operations" + "github.com/cloudera/terraform-provider-cdp/cdp-sdk-go/gen/environments/models" + mocks "github.com/cloudera/terraform-provider-cdp/mocks/github.com/cloudera/terraform-provider-cdp/cdp-sdk-go/gen/environments/client/operations" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func createRawProxyConfigResource(resourceID string, rcaaRole string) tftypes.Value { + return tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + "name": tftypes.String, + "description": tftypes.String, + "protocol": tftypes.String, + "host": tftypes.String, + "port": tftypes.Number, + "no_proxy_hosts": tftypes.String, + "user": tftypes.String, + "password": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, resourceID), + "name": tftypes.NewValue(tftypes.String, "test-name"), + "description": tftypes.NewValue(tftypes.String, "test-description"), + "protocol": tftypes.NewValue(tftypes.String, "test-protocol"), + "host": tftypes.NewValue(tftypes.String, "test-host"), + "port": tftypes.NewValue(tftypes.Number, 99), + "no_proxy_hosts": tftypes.NewValue(tftypes.String, "test-npc"), + "user": tftypes.NewValue(tftypes.String, "test-user"), + "password": tftypes.NewValue(tftypes.String, "test-password"), + }, + ) +} + +func TestCreateProxyConfiguration(t *testing.T) { + testCases := map[string]struct { + expectedResponse interface{} + expectedErrorResponse interface{} + expectedError bool + expectedSummary string + expectedDetail string + expectedID string + expectedName string + expectedHost string + }{ + "OK": { + expectedResponse: &operations.CreateProxyConfigOK{ + Payload: &models.CreateProxyConfigResponse{ + ProxyConfig: &models.ProxyConfig{ + Crn: func(s string) *string { return &s }("test-pc-crn"), + Description: "test_description", + Host: func(s string) *string { return &s }("test-host"), + NoProxyHosts: "test-npc", + Password: "test-password", + Port: func(i int32) *int32 { return &i }(99), + Protocol: func(s string) *string { return &s }("test-protocol"), + ProxyConfigName: func(s string) *string { return &s }("test-name"), + User: "test-user", + }, + }, + }, + expectedErrorResponse: nil, + expectedError: false, + expectedSummary: "", + expectedDetail: "", + expectedID: "test-pc-crn", + expectedName: "test-name", + expectedHost: "test-host", + }, + "BadRequest": { + expectedResponse: nil, + expectedErrorResponse: &operations.CreateProxyConfigDefault{ + Payload: &models.Error{ + Code: "BAD_REQUEST", + Message: "Missing name field", + }, + }, + expectedError: true, + expectedSummary: "Create Proxy Configuration", + expectedDetail: "Failed to create proxy configuration, unexpected error: Missing name field", + expectedID: "", + expectedName: "", + expectedHost: "", + }, + "TransportError": { + expectedResponse: nil, + expectedErrorResponse: errors.New("request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers)"), + expectedError: true, + expectedSummary: "Create Proxy Configuration", + expectedDetail: "Failed to create proxy configuration, unexpected error: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers)", + expectedID: "", + expectedName: "", + expectedHost: "", + }, + } + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + ctx := context.TODO() + + mockClient := new(mocks.MockEnvironmentClientService) + + createMatcher := func(params *operations.CreateProxyConfigParams) bool { + match := *params.Input.ProxyConfigName == "test-name" + match = match && params.Input.Description == "test-description" + match = match && *params.Input.Protocol == "test-protocol" + match = match && *params.Input.Host == "test-host" + match = match && *params.Input.Port == 99 + match = match && params.Input.NoProxyHosts == "test-npc" + match = match && params.Input.User == "test-user" + match = match && params.Input.Password == "test-password" + return match + } + mockClient.On("CreateProxyConfig", mock.MatchedBy(createMatcher)).Return(testCase.expectedResponse, testCase.expectedErrorResponse) + + pcResource := &proxyConfigurationResource{ + client: &cdp.Client{Environments: NewMockEnvironments(mockClient)}, + } + + req := resource.CreateRequest{ + Plan: tfsdk.Plan{ + Raw: createRawProxyConfigResource("", ""), + Schema: ProxyConfigurationSchema, + }, + } + + resp := &resource.CreateResponse{ + State: tfsdk.State{ + Schema: ProxyConfigurationSchema, + }, + } + + pcResource.Create(ctx, req, resp) + + assert.Equal(t, testCase.expectedError, resp.Diagnostics.HasError()) + if testCase.expectedError { + assert.Equal(t, testCase.expectedSummary, resp.Diagnostics.Errors()[0].Summary()) + assert.Equal(t, testCase.expectedDetail, resp.Diagnostics.Errors()[0].Detail()) + } + + var state proxyConfigurationResourceModel + resp.State.Get(ctx, &state) + + assert.Equal(t, testCase.expectedID, state.ID.ValueString()) + assert.Equal(t, testCase.expectedName, state.Name.ValueString()) + assert.Equal(t, testCase.expectedHost, state.Host.ValueString()) + + mockClient.AssertExpectations(t) + }) + } +} + +func TestReadProxyConfiguration(t *testing.T) { + testCases := map[string]struct { + expectedResponse interface{} + expectedErrorResponse interface{} + expectedError bool + expectedSummary string + expectedDetail string + expectedID string + expectedName string + expectedHost string + }{ + "OK": { + expectedResponse: &operations.ListProxyConfigsOK{ + Payload: &models.ListProxyConfigsResponse{ + ProxyConfigs: []*models.ProxyConfig{ + { + Crn: func(s string) *string { return &s }("test-pc-crn"), + Description: "test_description", + Host: func(s string) *string { return &s }("test-host"), + NoProxyHosts: "test-npc", + Password: "test-password", + Port: func(i int32) *int32 { return &i }(99), + Protocol: func(s string) *string { return &s }("test-protocol"), + ProxyConfigName: func(s string) *string { return &s }("test-name"), + User: "test-user", + }, + }, + }, + }, + expectedErrorResponse: nil, + expectedError: false, + expectedSummary: "", + expectedDetail: "", + expectedID: "test-pc-crn", + expectedName: "test-name", + expectedHost: "test-host", + }, + "BadRequest": { + expectedResponse: nil, + expectedErrorResponse: &operations.ListProxyConfigsDefault{ + Payload: &models.Error{ + Code: "BAD_REQUEST", + Message: "Missing name field", + }, + }, + expectedError: true, + expectedSummary: "Read Proxy Configuration", + expectedDetail: "Failed to read proxy configuration, unexpected error: Missing name field", + expectedID: "", + expectedName: "", + expectedHost: "", + }, + "TransportError": { + expectedResponse: nil, + expectedErrorResponse: errors.New("request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers)"), + expectedError: true, + expectedSummary: "Read Proxy Configuration", + expectedDetail: "Failed to read proxy configuration, unexpected error: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers)", + expectedID: "", + expectedName: "", + expectedHost: "", + }, + } + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + ctx := context.TODO() + + mockClient := new(mocks.MockEnvironmentClientService) + + createMatcher := func(params *operations.ListProxyConfigsParams) bool { + return params.Input.ProxyConfigName == "test-name" + } + mockClient.On("ListProxyConfigs", mock.MatchedBy(createMatcher)).Return(testCase.expectedResponse, testCase.expectedErrorResponse) + + pcResource := &proxyConfigurationResource{ + client: &cdp.Client{Environments: NewMockEnvironments(mockClient)}, + } + + req := resource.ReadRequest{ + State: tfsdk.State{ + Raw: createRawProxyConfigResource("test-pc-crn", ""), + Schema: ProxyConfigurationSchema, + }, + } + + resp := &resource.ReadResponse{ + State: tfsdk.State{ + Schema: ProxyConfigurationSchema, + }, + } + + pcResource.Read(ctx, req, resp) + + assert.Equal(t, testCase.expectedError, resp.Diagnostics.HasError()) + if testCase.expectedError { + assert.Equal(t, testCase.expectedSummary, resp.Diagnostics.Errors()[0].Summary()) + assert.Equal(t, testCase.expectedDetail, resp.Diagnostics.Errors()[0].Detail()) + } + + var state proxyConfigurationResourceModel + resp.State.Get(ctx, &state) + + assert.Equal(t, testCase.expectedID, state.ID.ValueString()) + assert.Equal(t, testCase.expectedName, state.Name.ValueString()) + assert.Equal(t, testCase.expectedHost, state.Host.ValueString()) + + mockClient.AssertExpectations(t) + }) + } +} + +type deleteProxyConfigResponseBody struct{} + +func TestDeleteProxyConfiguration(t *testing.T) { + testCases := map[string]struct { + expectedResponse interface{} + expectedErrorResponse interface{} + expectedError bool + expectedSummary string + expectedDetail string + expectedID string + expectedName string + expectedHost string + }{ + "OK": { + expectedResponse: &operations.DeleteProxyConfigOK{ + Payload: deleteProxyConfigResponseBody{}, + }, + expectedErrorResponse: nil, + expectedError: false, + expectedSummary: "", + expectedDetail: "", + expectedID: "", + expectedName: "", + expectedHost: "", + }, + "BadRequest": { + expectedResponse: nil, + expectedErrorResponse: &operations.DeleteProxyConfigDefault{ + Payload: &models.Error{ + Code: "BAD_REQUEST", + Message: "Missing name field", + }, + }, + expectedError: true, + expectedSummary: "Delete Proxy Configuration", + expectedDetail: "Failed to delete proxy configuration: Missing name field", + expectedID: "", + expectedName: "", + expectedHost: "", + }, + "TransportError": { + expectedResponse: nil, + expectedErrorResponse: errors.New("request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers)"), + expectedError: true, + expectedSummary: "Delete Proxy Configuration", + expectedDetail: "Failed to delete proxy configuration, unexpected error: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers)", + expectedID: "", + expectedName: "", + expectedHost: "", + }, + } + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + ctx := context.TODO() + + mockClient := new(mocks.MockEnvironmentClientService) + + createMatcher := func(params *operations.DeleteProxyConfigParams) bool { + return *params.Input.ProxyConfigName == "test-name" + } + mockClient.On("DeleteProxyConfig", mock.MatchedBy(createMatcher)).Return(testCase.expectedResponse, testCase.expectedErrorResponse) + + pcResource := &proxyConfigurationResource{ + client: &cdp.Client{Environments: NewMockEnvironments(mockClient)}, + } + + req := resource.DeleteRequest{ + State: tfsdk.State{ + Raw: createRawProxyConfigResource("test-pc-crn", ""), + Schema: ProxyConfigurationSchema, + }, + } + + resp := &resource.DeleteResponse{ + State: tfsdk.State{ + Schema: ProxyConfigurationSchema, + }, + } + + pcResource.Delete(ctx, req, resp) + + assert.Equal(t, testCase.expectedError, resp.Diagnostics.HasError()) + if testCase.expectedError { + assert.Equal(t, testCase.expectedSummary, resp.Diagnostics.Errors()[0].Summary()) + assert.Equal(t, testCase.expectedDetail, resp.Diagnostics.Errors()[0].Detail()) + } + + var state proxyConfigurationResourceModel + resp.State.Get(ctx, &state) + + assert.Equal(t, testCase.expectedID, state.ID.ValueString()) + assert.Equal(t, testCase.expectedName, state.Name.ValueString()) + assert.Equal(t, testCase.expectedHost, state.Host.ValueString()) + assert.True(t, resp.State.Raw.IsNull()) + + mockClient.AssertExpectations(t) + }) + } +}