Implement Secrets Manager ACL (#93)

* Add CIDR validator

* Implement `syncACL`, add it to creation

* Rename function

* Rename variables

* Add mapACLs

* Implement instance update

* Add ACLs to acc test

* Add ACL to schema

* Add new line

* Fix not using the ACLs read from config

* Add test case where ACLs aren't set

* Fix lint

* Generate docs

* Add uniqueness check for ACLs

* Add repeated ACLs test cases

* Remove debug leftover

* Change test cases

* Rename data

* Add ACL description

* Generate docs

* Change ACL attribute type

* Remove test case

---------

Co-authored-by: Henrique Santos <henrique.santos@freiheit.com>
This commit is contained in:
Henrique Santos 2023-10-18 13:25:54 +01:00 committed by GitHub
parent 3c6748545d
commit e1265578ce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 770 additions and 13 deletions

View file

@ -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 generated by tfplugindocs -->
## 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.

View file

@ -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 generated by tfplugindocs -->
## 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.

1
go.mod
View file

@ -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

2
go.sum
View file

@ -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=

View file

@ -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

View file

@ -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
}

View file

@ -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)
}
}
})
}
}

View file

@ -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
},
})

View file

@ -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(),
))
}
},
}
}

View file

@ -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())
}
})
}
}