diff --git a/docs/data-sources/secretsmanager_instance.md b/docs/data-sources/secretsmanager_instance.md new file mode 100644 index 00000000..4003968d --- /dev/null +++ b/docs/data-sources/secretsmanager_instance.md @@ -0,0 +1,27 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_secretsmanager_instance Data Source - stackit" +subcategory: "" +description: |- + Secrets Manager instance data source schema. Must have a region specified in the provider configuration. +--- + +# stackit_secretsmanager_instance (Data Source) + +Secrets Manager instance data source schema. Must have a `region` specified in the provider configuration. + + + + +## Schema + +### Required + +- `instance_id` (String) ID of the Secrets Manager instance. +- `project_id` (String) STACKIT project ID to which the instance is associated. + +### Read-Only + +- `acls` (List of String) The access control list for this instance. Each entry is an IP or IP range that is permitted to access, in CIDR notation +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`instance_id`". +- `name` (String) Instance name. diff --git a/docs/resources/secretsmanager_instance.md b/docs/resources/secretsmanager_instance.md new file mode 100644 index 00000000..d2a7792f --- /dev/null +++ b/docs/resources/secretsmanager_instance.md @@ -0,0 +1,30 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_secretsmanager_instance Resource - stackit" +subcategory: "" +description: |- + Secrets Manager instance resource schema. Must have a region specified in the provider configuration. +--- + +# stackit_secretsmanager_instance (Resource) + +Secrets Manager instance resource schema. Must have a `region` specified in the provider configuration. + + + + +## Schema + +### Required + +- `name` (String) Instance name. +- `project_id` (String) STACKIT project ID to which the instance is associated. + +### Optional + +- `acls` (List of String) The access control list for this instance. Each entry is an IP or IP range that is permitted to access, in CIDR notation + +### Read-Only + +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`instance_id`". +- `instance_id` (String) ID of the Secrets Manager instance. diff --git a/go.mod b/go.mod index d730c62e..fb301b51 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.20 require ( github.com/google/go-cmp v0.6.0 github.com/google/uuid v1.3.1 + github.com/gorilla/mux v1.8.0 github.com/hashicorp/terraform-plugin-framework v1.4.1 github.com/hashicorp/terraform-plugin-framework-validators v0.12.0 github.com/hashicorp/terraform-plugin-go v0.19.0 diff --git a/go.sum b/go.sum index 04962e26..02df2670 100644 --- a/go.sum +++ b/go.sum @@ -42,6 +42,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-checkpoint v0.5.0 h1:MFYpPZCnQqQTE18jFwSII6eUQrD/oxMFp3mlgcqk5mU= diff --git a/stackit/internal/services/secretsmanager/instance/datasource.go b/stackit/internal/services/secretsmanager/instance/datasource.go index 97ab1698..3636a22a 100644 --- a/stackit/internal/services/secretsmanager/instance/datasource.go +++ b/stackit/internal/services/secretsmanager/instance/datasource.go @@ -6,6 +6,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" @@ -79,6 +80,7 @@ func (r *instanceDataSource) Schema(_ context.Context, _ datasource.SchemaReques "instance_id": "ID of the Secrets Manager instance.", "project_id": "STACKIT project ID to which the instance is associated.", "name": "Instance name.", + "acls": "The access control list for this instance. Each entry is an IP or IP range that is permitted to access, in CIDR notation", } resp.Schema = schema.Schema{ @@ -108,6 +110,11 @@ func (r *instanceDataSource) Schema(_ context.Context, _ datasource.SchemaReques Description: descriptions["name"], Computed: true, }, + "acls": schema.ListAttribute{ + Description: descriptions["acls"], + ElementType: types.StringType, + Computed: true, + }, }, } } @@ -130,8 +137,13 @@ func (r *instanceDataSource) Read(ctx context.Context, req datasource.ReadReques core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instance", fmt.Sprintf("Calling API: %v", err)) return } + aclList, err := r.client.GetAcls(ctx, projectId, instanceId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instance", fmt.Sprintf("Calling API for ACLs data: %v", err)) + return + } - err = mapFields(instanceResp, &model) + err = mapFields(instanceResp, aclList, &model) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instance", fmt.Sprintf("Processing API payload: %v", err)) return diff --git a/stackit/internal/services/secretsmanager/instance/resource.go b/stackit/internal/services/secretsmanager/instance/resource.go index 40136ec6..99d8a0c2 100644 --- a/stackit/internal/services/secretsmanager/instance/resource.go +++ b/stackit/internal/services/secretsmanager/instance/resource.go @@ -5,7 +5,9 @@ import ( "fmt" "strings" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" @@ -18,6 +20,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/utils" "github.com/stackitcloud/stackit-sdk-go/services/secretsmanager" ) @@ -33,6 +36,7 @@ type Model struct { InstanceId types.String `tfsdk:"instance_id"` ProjectId types.String `tfsdk:"project_id"` Name types.String `tfsdk:"name"` + ACLs types.List `tfsdk:"acls"` } // NewInstanceResource is a helper function to simplify the provider implementation. @@ -94,6 +98,7 @@ func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, r "instance_id": "ID of the Secrets Manager instance.", "project_id": "STACKIT project ID to which the instance is associated.", "name": "Instance name.", + "acls": "The access control list for this instance. Each entry is an IP or IP range that is permitted to access, in CIDR notation", } resp.Schema = schema.Schema{ @@ -138,6 +143,17 @@ func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, r stringvalidator.LengthAtLeast(1), }, }, + "acls": schema.ListAttribute{ + Description: descriptions["acls"], + ElementType: types.StringType, + Optional: true, + Validators: []validator.List{ + listvalidator.UniqueValues(), + listvalidator.ValueStringsAre( + validate.CIDR(), + ), + }, + }, }, } } @@ -153,6 +169,15 @@ func (r *instanceResource) Create(ctx context.Context, req resource.CreateReques projectId := model.ProjectId.ValueString() ctx = tflog.SetField(ctx, "project_id", projectId) + var acls []string + if !(model.ACLs.IsNull() || model.ACLs.IsUnknown()) { + diags = model.ACLs.ElementsAs(ctx, &acls, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } + // Generate API request body from model payload, err := toCreatePayload(&model) if err != nil { @@ -168,8 +193,20 @@ func (r *instanceResource) Create(ctx context.Context, req resource.CreateReques instanceId := *createResp.Id ctx = tflog.SetField(ctx, "instance_id", instanceId) + // Create ACLs + err = updateACLs(ctx, projectId, instanceId, acls, r.client) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Creating ACLs: %v", err)) + return + } + aclList, err := r.client.GetAcls(ctx, projectId, instanceId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Calling API for ACLs data: %v", err)) + return + } + // Map response body to schema - err = mapFields(createResp, &model) + err = mapFields(createResp, aclList, &model) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Processing API payload: %v", err)) return @@ -202,9 +239,14 @@ func (r *instanceResource) Read(ctx context.Context, req resource.ReadRequest, r core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instance", fmt.Sprintf("Calling API: %v", err)) return } + aclList, err := r.client.GetAcls(ctx, projectId, instanceId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instance", fmt.Sprintf("Calling API for ACLs data: %v", err)) + return + } // Map response body to schema - err = mapFields(instanceResp, &model) + err = mapFields(instanceResp, aclList, &model) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instance", fmt.Sprintf("Processing API payload: %v", err)) return @@ -220,9 +262,58 @@ func (r *instanceResource) Read(ctx context.Context, req resource.ReadRequest, r } // Update updates the resource and sets the updated Terraform state on success. -func (r *instanceResource) Update(ctx context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform - // Update shouldn't be called - core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", "Instance can't be updated") +func (r *instanceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + instanceId := model.InstanceId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + + var acls []string + if !(model.ACLs.IsNull() || model.ACLs.IsUnknown()) { + diags = model.ACLs.ElementsAs(ctx, &acls, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } + + // Update ACLs + err := updateACLs(ctx, projectId, instanceId, acls, r.client) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", fmt.Sprintf("Updating ACLs: %v", err)) + return + } + + instanceResp, err := r.client.GetInstance(ctx, projectId, instanceId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", fmt.Sprintf("Calling API: %v", err)) + return + } + aclList, err := r.client.GetAcls(ctx, projectId, instanceId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", fmt.Sprintf("Calling API for ACLs data: %v", err)) + return + } + + // Map response body to schema + err = mapFields(instanceResp, aclList, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Secrets Manager instance updated") } // Delete deletes the resource and removes the Terraform state on success. @@ -266,7 +357,7 @@ func (r *instanceResource) ImportState(ctx context.Context, req resource.ImportS tflog.Info(ctx, "Secrets Manager instance state imported") } -func mapFields(instance *secretsmanager.Instance, model *Model) error { +func mapFields(instance *secretsmanager.Instance, aclList *secretsmanager.AclList, model *Model) error { if instance == nil { return fmt.Errorf("response input is nil") } @@ -293,6 +384,32 @@ func mapFields(instance *secretsmanager.Instance, model *Model) error { model.InstanceId = types.StringValue(instanceId) model.Name = types.StringPointerValue(instance.Name) + err := mapACLs(aclList, model) + if err != nil { + return err + } + + return nil +} + +func mapACLs(aclList *secretsmanager.AclList, model *Model) error { + if aclList == nil { + return fmt.Errorf("nil ACL list") + } + if aclList.Acls == nil || len(*aclList.Acls) == 0 { + model.ACLs = types.ListNull(types.StringType) + return nil + } + + acls := []attr.Value{} + for _, acl := range *aclList.Acls { + acls = append(acls, types.StringValue(*acl.Cidr)) + } + aclsList, diags := types.ListValue(types.StringType, acls) + if diags.HasError() { + return fmt.Errorf("mapping ACLs: %w", core.DiagsToError(diags)) + } + model.ACLs = aclsList return nil } @@ -304,3 +421,54 @@ func toCreatePayload(model *Model) (*secretsmanager.CreateInstancePayload, error Name: model.Name.ValueStringPointer(), }, nil } + +// updateACLs creates and deletes ACLs so that the instance's ACLs are the ones in the model +func updateACLs(ctx context.Context, projectId, instanceId string, acls []string, client *secretsmanager.APIClient) error { + // Get ACLs current state + currentACLsResp, err := client.GetAcls(ctx, projectId, instanceId).Execute() + if err != nil { + return fmt.Errorf("fetching current ACLs: %w", err) + } + + type aclState struct { + isInModel bool + isCreated bool + id string + } + aclsState := make(map[string]*aclState) + for _, cidr := range acls { + aclsState[cidr] = &aclState{ + isInModel: true, + } + } + for _, acl := range *currentACLsResp.Acls { + cidr := *acl.Cidr + if _, ok := aclsState[cidr]; !ok { + aclsState[cidr] = &aclState{} + } + aclsState[cidr].isCreated = true + aclsState[cidr].id = *acl.Id + } + + // Create/delete ACLs + for cidr, state := range aclsState { + if state.isInModel && !state.isCreated { + payload := secretsmanager.CreateAclPayload{ + Cidr: utils.Ptr(cidr), + } + _, err := client.CreateAcl(ctx, projectId, instanceId).CreateAclPayload(payload).Execute() + if err != nil { + return fmt.Errorf("creating ACL '%v': %w", cidr, err) + } + } + + if !state.isInModel && state.isCreated { + err := client.DeleteAcl(ctx, projectId, instanceId, state.id).Execute() + if err != nil { + return fmt.Errorf("deleting ACL '%v': %w", cidr, err) + } + } + } + + return nil +} diff --git a/stackit/internal/services/secretsmanager/instance/resource_test.go b/stackit/internal/services/secretsmanager/instance/resource_test.go index 3b133c02..eeb3dbf7 100644 --- a/stackit/internal/services/secretsmanager/instance/resource_test.go +++ b/stackit/internal/services/secretsmanager/instance/resource_test.go @@ -1,10 +1,20 @@ package secretsmanager import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" "testing" + "time" "github.com/google/go-cmp/cmp" + "github.com/gorilla/mux" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/core/utils" "github.com/stackitcloud/stackit-sdk-go/services/secretsmanager" ) @@ -13,17 +23,20 @@ func TestMapFields(t *testing.T) { tests := []struct { description string input *secretsmanager.Instance + aclList *secretsmanager.AclList expected Model isValid bool }{ { "default_values", &secretsmanager.Instance{}, + &secretsmanager.AclList{}, Model{ Id: types.StringValue("pid,iid"), InstanceId: types.StringValue("iid"), ProjectId: types.StringValue("pid"), Name: types.StringNull(), + ACLs: types.ListNull(types.StringType), }, true, }, @@ -32,23 +45,53 @@ func TestMapFields(t *testing.T) { &secretsmanager.Instance{ Name: utils.Ptr("name"), }, + &secretsmanager.AclList{ + Acls: &[]secretsmanager.Acl{ + { + Cidr: utils.Ptr("cidr-1"), + Id: utils.Ptr("id-cidr-1"), + }, + { + Cidr: utils.Ptr("cidr-2"), + Id: utils.Ptr("id-cidr-2"), + }, + { + Cidr: utils.Ptr("cidr-3"), + Id: utils.Ptr("id-cidr-3"), + }, + }, + }, Model{ Id: types.StringValue("pid,iid"), InstanceId: types.StringValue("iid"), ProjectId: types.StringValue("pid"), Name: types.StringValue("name"), + ACLs: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("cidr-1"), + types.StringValue("cidr-2"), + types.StringValue("cidr-3"), + }), }, true, }, { "nil_response", nil, + &secretsmanager.AclList{}, + Model{}, + false, + }, + { + "nil_acli_list", + &secretsmanager.Instance{}, + nil, Model{}, false, }, { "no_resource_id", &secretsmanager.Instance{}, + &secretsmanager.AclList{}, Model{}, false, }, @@ -59,7 +102,7 @@ func TestMapFields(t *testing.T) { ProjectId: tt.expected.ProjectId, InstanceId: tt.expected.InstanceId, } - err := mapFields(tt.input, state) + err := mapFields(tt.input, tt.aclList, state) if !tt.isValid && err == nil { t.Fatalf("Should have failed") } @@ -134,3 +177,313 @@ func TestToCreatePayload(t *testing.T) { }) } } + +func TestUpdateACLs(t *testing.T) { + // This is the response used when getting all ACLs currently, across all tests + getAllACLsResp := secretsmanager.AclList{ + Acls: &[]secretsmanager.Acl{ + { + Cidr: utils.Ptr("acl-1"), + Id: utils.Ptr("id-acl-1"), + }, + { + Cidr: utils.Ptr("acl-2"), + Id: utils.Ptr("id-acl-2"), + }, + { + Cidr: utils.Ptr("acl-3"), + Id: utils.Ptr("id-acl-3"), + }, + { + Cidr: utils.Ptr("acl-2"), + Id: utils.Ptr("id-acl-2-repeated"), + }, + }, + } + getAllACLsRespBytes, err := json.Marshal(getAllACLsResp) + if err != nil { + t.Fatalf("Failed to marshal get all ACLs response: %v", err) + } + + // This is the response used whenever an API returns a failure response + failureRespBytes := []byte("{\"message\": \"Something bad happened\"") + + tests := []struct { + description string + acls []string + getAllACLsFails bool + createACLFails bool + deleteACLFails bool + isValid bool + expectedACLsStates map[string]bool // Keys are CIDR; value is true if CIDR should exist at the end, false if should be deleted + }{ + { + description: "no_changes", + acls: []string{"acl-3", "acl-2", "acl-1"}, + expectedACLsStates: map[string]bool{ + "acl-1": true, + "acl-2": true, + "acl-3": true, + }, + isValid: true, + }, + { + description: "create_acl", + acls: []string{"acl-1", "acl-2", "acl-3", "acl-4"}, + expectedACLsStates: map[string]bool{ + "acl-1": true, + "acl-2": true, + "acl-3": true, + "acl-4": true, + }, + isValid: true, + }, + { + description: "delete_acl", + acls: []string{"acl-3", "acl-1"}, + expectedACLsStates: map[string]bool{ + "acl-1": true, + "acl-2": false, + "acl-3": true, + }, + isValid: true, + }, + { + description: "multiple_changes", + acls: []string{"acl-4", "acl-3", "acl-1", "acl-5"}, + expectedACLsStates: map[string]bool{ + "acl-1": true, + "acl-2": false, + "acl-3": true, + "acl-4": true, + "acl-5": true, + }, + isValid: true, + }, + { + description: "multiple_changes_repetition", + acls: []string{"acl-4", "acl-3", "acl-1", "acl-5", "acl-5"}, + expectedACLsStates: map[string]bool{ + "acl-1": true, + "acl-2": false, + "acl-3": true, + "acl-4": true, + "acl-5": true, + }, + isValid: true, + }, + { + description: "multiple_changes_2", + acls: []string{"acl-4", "acl-5"}, + expectedACLsStates: map[string]bool{ + "acl-1": false, + "acl-2": false, + "acl-3": false, + "acl-4": true, + "acl-5": true, + }, + isValid: true, + }, + { + description: "multiple_changes_3", + acls: []string{}, + expectedACLsStates: map[string]bool{ + "acl-1": false, + "acl-2": false, + "acl-3": false, + }, + isValid: true, + }, + { + description: "get_fails", + acls: []string{"acl-1", "acl-2", "acl-3"}, + getAllACLsFails: true, + isValid: false, + }, + { + description: "create_fails_1", + acls: []string{"acl-1", "acl-2", "acl-3", "acl-4"}, + createACLFails: true, + isValid: false, + }, + { + description: "create_fails_2", + acls: []string{"acl-1", "acl-2"}, + createACLFails: true, + expectedACLsStates: map[string]bool{ + "acl-1": true, + "acl-2": true, + "acl-3": false, + }, + isValid: true, + }, + { + description: "delete_fails_1", + acls: []string{"acl-1", "acl-2"}, + deleteACLFails: true, + isValid: false, + }, + { + description: "delete_fails_2", + acls: []string{"acl-1", "acl-2", "acl-3", "acl-4"}, + deleteACLFails: true, + expectedACLsStates: map[string]bool{ + "acl-1": true, + "acl-2": true, + "acl-3": true, + "acl-4": true, + }, + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + // Will be compared to tt.expectedACLsStates at the end + aclsStates := make(map[string]bool) + aclsStates["acl-1"] = true + aclsStates["acl-2"] = true + aclsStates["acl-3"] = true + + // Handler for getting all ACLs + getAllACLsHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if tt.getAllACLsFails { + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write(failureRespBytes) + if err != nil { + t.Errorf("Get all ACLs handler: failed to write bad response: %v", err) + } + return + } + + _, err := w.Write(getAllACLsRespBytes) + if err != nil { + t.Errorf("Get all ACLs handler: failed to write response: %v", err) + } + }) + + // Handler for creating ACL + createACLHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + decoder := json.NewDecoder(r.Body) + var payload secretsmanager.CreateAclPayload + err := decoder.Decode(&payload) + if err != nil { + t.Errorf("Create ACL handler: failed to parse payload") + return + } + if payload.Cidr == nil { + t.Errorf("Create ACL handler: nil CIDR") + return + } + cidr := *payload.Cidr + if cidrExists, cidrWasCreated := aclsStates[cidr]; cidrWasCreated && cidrExists { + t.Errorf("Create ACL handler: attempted to create CIDR '%v' that already exists", *payload.Cidr) + return + } + + w.Header().Set("Content-Type", "application/json") + if tt.createACLFails { + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write(failureRespBytes) + if err != nil { + t.Errorf("Create ACL handler: failed to write bad response: %v", err) + } + return + } + + resp := secretsmanager.Acl{ + Cidr: utils.Ptr(cidr), + Id: utils.Ptr(fmt.Sprintf("id-%s", cidr)), + } + respBytes, err := json.Marshal(resp) + if err != nil { + t.Errorf("Create ACL handler: failed to marshal response: %v", err) + return + } + _, err = w.Write(respBytes) + if err != nil { + t.Errorf("Create ACL handler: failed to write response: %v", err) + } + aclsStates[cidr] = true + }) + + // Handler for deleting ACL + deleteACLHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + aclId, ok := vars["aclId"] + if !ok { + t.Errorf("Delete ACL handler: no ACL ID") + return + } + cidr, ok := strings.CutPrefix(aclId, "id-") + if !ok { + t.Errorf("Delete ACL handler: got unexpected ACL ID '%v'", aclId) + return + } + cidr, _ = strings.CutSuffix(cidr, "-repeated") + cidrExists, cidrWasCreated := aclsStates[cidr] + if !cidrWasCreated { + t.Errorf("Delete ACL handler: attempted to delete CIDR '%v' that wasn't created", cidr) + return + } + if cidrWasCreated && !cidrExists { + t.Errorf("Delete ACL handler: attempted to delete CIDR '%v' that was already deleted", cidr) + return + } + + w.Header().Set("Content-Type", "application/json") + if tt.deleteACLFails { + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write(failureRespBytes) + if err != nil { + t.Errorf("Delete ACL handler: failed to write bad response: %v", err) + } + return + } + + _, err = w.Write([]byte("{}")) + if err != nil { + t.Errorf("Delete ACL handler: failed to write response: %v", err) + } + aclsStates[cidr] = false + }) + + // Setup server and client + router := mux.NewRouter() + router.HandleFunc("/v1/projects/{projectId}/instances/{instanceId}/acls", func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + getAllACLsHandler(w, r) + } else if r.Method == "POST" { + createACLHandler(w, r) + } + }) + router.HandleFunc("/v1/projects/{projectId}/instances/{instanceId}/acls/{aclId}", deleteACLHandler) + mockedServer := httptest.NewServer(router) + defer mockedServer.Close() + client, err := secretsmanager.NewAPIClient( + config.WithEndpoint(mockedServer.URL), + config.WithoutAuthentication(), + config.WithRetryTimeout(time.Millisecond), + ) + if err != nil { + t.Fatalf("Failed to initialize client: %v", err) + } + + // Run test + err = updateACLs(context.Background(), "pid", "iid", tt.acls, client) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(aclsStates, tt.expectedACLsStates) + if diff != "" { + t.Fatalf("ACL states do not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/secretsmanager/secretsmanager_acc_test.go b/stackit/internal/services/secretsmanager/secretsmanager_acc_test.go index 5b268cca..6b9ff540 100644 --- a/stackit/internal/services/secretsmanager/secretsmanager_acc_test.go +++ b/stackit/internal/services/secretsmanager/secretsmanager_acc_test.go @@ -18,24 +18,45 @@ import ( // Instance resource data var instanceResource = map[string]string{ - "project_id": testutil.ProjectId, - "name": fmt.Sprintf("acc-test-%s", acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum)), + "project_id": testutil.ProjectId, + "name": fmt.Sprintf("acc-test-%s", acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum)), + "acl-0": "1.2.3.4/5", + "acl-1": "111.222.111.222/11", + "acl-1-updated": "111.222.111.222/22", } -func resourceConfig() string { +func resourceConfig(acls *string) string { + if acls == nil { + return fmt.Sprintf(` + %s + + resource "stackit_secretsmanager_instance" "instance" { + project_id = "%s" + name = "%s" + } + `, + testutil.SecretsManagerProviderConfig(), + instanceResource["project_id"], + instanceResource["name"], + ) + } + return fmt.Sprintf(` %s resource "stackit_secretsmanager_instance" "instance" { project_id = "%s" name = "%s" + acls = %s } `, testutil.SecretsManagerProviderConfig(), instanceResource["project_id"], instanceResource["name"], + *acls, ) } + func TestAccSecretsManager(t *testing.T) { resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, @@ -44,12 +65,19 @@ func TestAccSecretsManager(t *testing.T) { // Creation { - Config: resourceConfig(), + Config: resourceConfig(utils.Ptr(fmt.Sprintf( + "[%q, %q]", + instanceResource["acl-0"], + instanceResource["acl-1"], + ))), Check: resource.ComposeAggregateTestCheckFunc( // Instance data resource.TestCheckResourceAttr("stackit_secretsmanager_instance.instance", "project_id", instanceResource["project_id"]), resource.TestCheckResourceAttrSet("stackit_secretsmanager_instance.instance", "instance_id"), resource.TestCheckResourceAttr("stackit_secretsmanager_instance.instance", "name", instanceResource["name"]), + resource.TestCheckResourceAttr("stackit_secretsmanager_instance.instance", "acls.#", "2"), + resource.TestCheckResourceAttr("stackit_secretsmanager_instance.instance", "acls.0", instanceResource["acl-0"]), + resource.TestCheckResourceAttr("stackit_secretsmanager_instance.instance", "acls.1", instanceResource["acl-1"]), ), }, { // Data source @@ -60,7 +88,11 @@ func TestAccSecretsManager(t *testing.T) { project_id = stackit_secretsmanager_instance.instance.project_id instance_id = stackit_secretsmanager_instance.instance.instance_id }`, - resourceConfig(), + resourceConfig(utils.Ptr(fmt.Sprintf( + "[%q, %q]", + instanceResource["acl-0"], + instanceResource["acl-1"], + ))), ), Check: resource.ComposeAggregateTestCheckFunc( // Instance data @@ -70,6 +102,8 @@ func TestAccSecretsManager(t *testing.T) { "data.stackit_secretsmanager_instance.instance", "instance_id", ), resource.TestCheckResourceAttr("data.stackit_secretsmanager_instance.instance", "name", instanceResource["name"]), + resource.TestCheckResourceAttr("data.stackit_secretsmanager_instance.instance", "acls.0", instanceResource["acl-0"]), + resource.TestCheckResourceAttr("data.stackit_secretsmanager_instance.instance", "acls.1", instanceResource["acl-1"]), ), }, // Import @@ -89,6 +123,34 @@ func TestAccSecretsManager(t *testing.T) { ImportState: true, ImportStateVerify: true, }, + // Update + { + Config: resourceConfig(utils.Ptr(fmt.Sprintf( + "[%q, %q]", + instanceResource["acl-0"], + instanceResource["acl-1-updated"], + ))), + Check: resource.ComposeAggregateTestCheckFunc( + // Instance data + resource.TestCheckResourceAttr("stackit_secretsmanager_instance.instance", "project_id", instanceResource["project_id"]), + resource.TestCheckResourceAttrSet("stackit_secretsmanager_instance.instance", "instance_id"), + resource.TestCheckResourceAttr("stackit_secretsmanager_instance.instance", "name", instanceResource["name"]), + resource.TestCheckResourceAttr("stackit_secretsmanager_instance.instance", "acls.#", "2"), + resource.TestCheckResourceAttr("stackit_secretsmanager_instance.instance", "acls.0", instanceResource["acl-0"]), + resource.TestCheckResourceAttr("stackit_secretsmanager_instance.instance", "acls.1", instanceResource["acl-1-updated"]), + ), + }, + // Update, no ACLs + { + Config: resourceConfig(nil), + Check: resource.ComposeAggregateTestCheckFunc( + // Instance data + resource.TestCheckResourceAttr("stackit_secretsmanager_instance.instance", "project_id", instanceResource["project_id"]), + resource.TestCheckResourceAttrSet("stackit_secretsmanager_instance.instance", "instance_id"), + resource.TestCheckResourceAttr("stackit_secretsmanager_instance.instance", "name", instanceResource["name"]), + resource.TestCheckResourceAttr("stackit_secretsmanager_instance.instance", "acls.#", "0"), + ), + }, // Deletion is done by the framework implicitly }, }) diff --git a/stackit/internal/validate/validate.go b/stackit/internal/validate/validate.go index 8ebd6652..7858c71a 100644 --- a/stackit/internal/validate/validate.go +++ b/stackit/internal/validate/validate.go @@ -137,3 +137,21 @@ func RFC3339SecondsOnly() *Validator { }, } } + +func CIDR() *Validator { + description := "value must be in CIDR notation" + + return &Validator{ + description: description, + validate: func(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + _, _, err := net.ParseCIDR(req.ConfigValue.ValueString()) + if err != nil { + resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + req.Path, + fmt.Sprintf("parsing value in CIDR notation: %s", err.Error()), + req.ConfigValue.ValueString(), + )) + } + }, + } +} diff --git a/stackit/internal/validate/validate_test.go b/stackit/internal/validate/validate_test.go index d4f12212..a639f3db 100644 --- a/stackit/internal/validate/validate_test.go +++ b/stackit/internal/validate/validate_test.go @@ -262,3 +262,87 @@ func TestRFC3339SecondsOnly(t *testing.T) { }) } } + +func TestCIDR(t *testing.T) { + tests := []struct { + description string + input string + isValid bool + }{ + { + "IPv4_block", + "198.51.100.14/24", + true, + }, + { + "IPv4_block_2", + "111.222.111.222/22", + true, + }, + { + "IPv4_single", + "198.51.100.14/32", + true, + }, + { + "IPv4_entire_internet", + "0.0.0.0/0", + true, + }, + { + "IPv4_block_invalid", + "198.51.100.14/33", + false, + }, + { + "IPv4_no_block", + "111.222.111.222", + false, + }, + { + "IPv6_block", + "2001:db8::/48", + true, + }, + { + "IPv6_single", + "2001:0db8:85a3:08d3::0370:7344/128", + true, + }, + { + "IPv6_all", + "::/0", + true, + }, + { + "IPv6_block_invalid", + "2001:0db8:85a3:08d3::0370:7344/129", + false, + }, + { + "IPv6_no_block", + "2001:0db8:85a3:08d3::0370:7344", + false, + }, + { + "empty", + "", + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + r := validator.StringResponse{} + CIDR().ValidateString(context.Background(), validator.StringRequest{ + ConfigValue: types.StringValue(tt.input), + }, &r) + + if !tt.isValid && !r.Diagnostics.HasError() { + t.Fatalf("Should have failed") + } + if tt.isValid && r.Diagnostics.HasError() { + t.Fatalf("Should not have failed: %v", r.Diagnostics.Errors()) + } + }) + } +}