From cad0a462d077733b6de47f595977e375994f68fc Mon Sep 17 00:00:00 2001 From: Volodymyr Manilo <35466116+vmanilo@users.noreply.github.com> Date: Tue, 20 Feb 2024 20:13:35 +0100 Subject: [PATCH] Feature: add filtering support for twingate resources datasource (#443) * added new attributes for resources datasource * added optional name attributes for resources datasource * added feature branch * fix test * remove feature branch * enable tests * remove feature branch * refactore * updated docs * update resources datasource: allow to list all resources * fix docs --------- Co-authored-by: Bob Lee --- docs/data-sources/resources.md | 14 +- .../twingate_resources/data-source.tf | 5 + twingate/internal/attr/common.go | 6 + twingate/internal/client/query/groups-read.go | 35 +++- twingate/internal/client/query/query_test.go | 2 +- .../client/query/resources-by-name-read.go | 12 +- twingate/internal/client/resource.go | 4 +- .../internal/provider/datasource/helper.go | 13 ++ .../internal/provider/datasource/resources.go | 81 +++++++- .../acctests/datasource/resources_test.go | 189 ++++++++++++++++++ .../internal/test/client/resource_test.go | 12 +- 11 files changed, 351 insertions(+), 22 deletions(-) diff --git a/docs/data-sources/resources.md b/docs/data-sources/resources.md index 9db92f72..85c064ab 100644 --- a/docs/data-sources/resources.md +++ b/docs/data-sources/resources.md @@ -15,6 +15,11 @@ Resources in Twingate represent servers on the private network that clients can ```terraform data "twingate_resources" "foo" { name = "" + # name_regexp = "" + # name_contains = "" + # name_exclude = "" + # name_prefix = "" + # name_suffix = "" } # Resource names are not constrained to be unique within Twingate, @@ -24,9 +29,14 @@ data "twingate_resources" "foo" { ## Schema -### Required +### Optional -- `name` (String) The name of the Resource +- `name` (String) Returns only resources that exactly match this name. If no options are passed it will return all resources. Only one option can be used at a time. +- `name_contains` (String) Match when the value exist in the name of the resource. +- `name_exclude` (String) Match when the value does not exist in the name of the resource. +- `name_prefix` (String) The name of the resource must start with the value. +- `name_regexp` (String) The regular expression match of the name of the resource. +- `name_suffix` (String) The name of the resource must end with the value. ### Read-Only diff --git a/examples/data-sources/twingate_resources/data-source.tf b/examples/data-sources/twingate_resources/data-source.tf index bb0b7c39..89091f1b 100644 --- a/examples/data-sources/twingate_resources/data-source.tf +++ b/examples/data-sources/twingate_resources/data-source.tf @@ -1,5 +1,10 @@ data "twingate_resources" "foo" { name = "" + # name_regexp = "" + # name_contains = "" + # name_exclude = "" + # name_prefix = "" + # name_suffix = "" } # Resource names are not constrained to be unique within Twingate, diff --git a/twingate/internal/attr/common.go b/twingate/internal/attr/common.go index c10e2d15..2adebf6c 100644 --- a/twingate/internal/attr/common.go +++ b/twingate/internal/attr/common.go @@ -6,4 +6,10 @@ const ( RemoteNetworkID = "remote_network_id" Type = "type" IsActive = "is_active" + + FilterByRegexp = "_regexp" + FilterByContains = "_contains" + FilterByExclude = "_exclude" + FilterByPrefix = "_prefix" + FilterBySuffix = "_suffix" ) diff --git a/twingate/internal/client/query/groups-read.go b/twingate/internal/client/query/groups-read.go index e9c255d2..2059a6a7 100644 --- a/twingate/internal/client/query/groups-read.go +++ b/twingate/internal/client/query/groups-read.go @@ -1,6 +1,7 @@ package query import ( + "github.com/Twingate/terraform-provider-twingate/twingate/internal/attr" "github.com/Twingate/terraform-provider-twingate/twingate/internal/model" "github.com/Twingate/terraform-provider-twingate/twingate/internal/utils" ) @@ -36,7 +37,37 @@ type GroupFilterInput struct { } type StringFilterOperationInput struct { - Eq string `json:"eq"` + Eq *string `json:"eq"` + Ne *string `json:"ne"` + StartsWith *string `json:"startsWith"` + EndsWith *string `json:"endsWith"` + Regexp *string `json:"regexp"` + Contains *string `json:"contains"` +} + +func NewStringFilterOperationInput(name, filter string) *StringFilterOperationInput { + if filter == "" && name == "" { + return nil + } + + var stringFilter StringFilterOperationInput + + switch filter { + case attr.FilterByRegexp: + stringFilter.Regexp = &name + case attr.FilterByContains: + stringFilter.Contains = &name + case attr.FilterByExclude: + stringFilter.Ne = &name + case attr.FilterByPrefix: + stringFilter.StartsWith = &name + case attr.FilterBySuffix: + stringFilter.EndsWith = &name + default: + stringFilter.Eq = &name + } + + return &stringFilter } type GroupTypeFilterOperatorInput struct { @@ -66,7 +97,7 @@ func NewGroupFilterInput(input *model.GroupsFilter) *GroupFilterInput { if input.Name != nil { filter.Name = &StringFilterOperationInput{ - Eq: *input.Name, + Eq: input.Name, } } diff --git a/twingate/internal/client/query/query_test.go b/twingate/internal/client/query/query_test.go index 9720cbae..d157db12 100644 --- a/twingate/internal/client/query/query_test.go +++ b/twingate/internal/client/query/query_test.go @@ -847,7 +847,7 @@ func TestBuildGroupsFilter(t *testing.T) { filter: &model.GroupsFilter{Name: optionalString("Group")}, expected: &GroupFilterInput{ Name: &StringFilterOperationInput{ - Eq: "Group", + Eq: optionalString("Group"), }, Type: defaultType, IsActive: defaultActive, diff --git a/twingate/internal/client/query/resources-by-name-read.go b/twingate/internal/client/query/resources-by-name-read.go index 09a9220a..82331937 100644 --- a/twingate/internal/client/query/resources-by-name-read.go +++ b/twingate/internal/client/query/resources-by-name-read.go @@ -1,9 +1,19 @@ package query type ReadResourcesByName struct { - Resources `graphql:"resources(filter: {name: {eq: $name}}, after: $resourcesEndCursor, first: $pageLimit)"` + Resources `graphql:"resources(filter: $filter, after: $resourcesEndCursor, first: $pageLimit)"` } func (q ReadResourcesByName) IsEmpty() bool { return len(q.Edges) == 0 } + +type ResourceFilterInput struct { + Name *StringFilterOperationInput `json:"name"` +} + +func NewResourceFilterInput(name, filter string) *ResourceFilterInput { + return &ResourceFilterInput{ + Name: NewStringFilterOperationInput(name, filter), + } +} diff --git a/twingate/internal/client/resource.go b/twingate/internal/client/resource.go index f2f074c4..ae07541c 100644 --- a/twingate/internal/client/resource.go +++ b/twingate/internal/client/resource.go @@ -243,11 +243,11 @@ func (client *Client) UpdateResourceActiveState(ctx context.Context, resource *m return client.mutate(ctx, &response, variables, opr, attr{id: resource.ID}) } -func (client *Client) ReadResourcesByName(ctx context.Context, name string) ([]*model.Resource, error) { +func (client *Client) ReadResourcesByName(ctx context.Context, name, filter string) ([]*model.Resource, error) { opr := resourceResource.read() variables := newVars( - gqlVar(name, "name"), + gqlNullable(query.NewResourceFilterInput(name, filter), "filter"), cursor(query.CursorResources), pageLimit(client.pageLimit), ) diff --git a/twingate/internal/provider/datasource/helper.go b/twingate/internal/provider/datasource/helper.go index 150a1de9..6dc0d13c 100644 --- a/twingate/internal/provider/datasource/helper.go +++ b/twingate/internal/provider/datasource/helper.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" ) func addErr(diagnostics *diag.Diagnostics, err error, resource string) { @@ -16,3 +17,15 @@ func addErr(diagnostics *diag.Diagnostics, err error, resource string) { err.Error(), ) } + +func countOptionalAttributes(attributes ...types.String) int { + var count int + + for _, attr := range attributes { + if attr.ValueString() != "" { + count++ + } + } + + return count +} diff --git a/twingate/internal/provider/datasource/resources.go b/twingate/internal/provider/datasource/resources.go index 83969bda..27a24330 100644 --- a/twingate/internal/provider/datasource/resources.go +++ b/twingate/internal/provider/datasource/resources.go @@ -13,6 +13,8 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" ) +var ErrResourcesDatasourceShouldSetOneOptionalNameAttribute = errors.New("Only one of name, name_regex, name_contains, name_exclude, name_prefix or name_suffix must be set.") + // Ensure the implementation satisfies the desired interfaces. var _ datasource.DataSource = &resources{} @@ -25,9 +27,14 @@ type resources struct { } type resourcesModel struct { - ID types.String `tfsdk:"id"` - Name types.String `tfsdk:"name"` - Resources []resourceModel `tfsdk:"resources"` + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + NameRegexp types.String `tfsdk:"name_regexp"` + NameContains types.String `tfsdk:"name_contains"` + NameExclude types.String `tfsdk:"name_exclude"` + NamePrefix types.String `tfsdk:"name_prefix"` + NameSuffix types.String `tfsdk:"name_suffix"` + Resources []resourceModel `tfsdk:"resources"` } func (d *resources) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { @@ -69,6 +76,7 @@ func protocolSchema() schema.SingleNestedAttribute { } } +//nolint:funlen func (d *resources) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { resp.Schema = schema.Schema{ Description: "Resources in Twingate represent servers on the private network that clients can connect to. Resources can be defined by IP, CIDR range, FQDN, or DNS zone. For more information, see the Twingate [documentation](https://docs.twingate.com/docs/resources-and-access-nodes).", @@ -78,10 +86,29 @@ func (d *resources) Schema(ctx context.Context, req datasource.SchemaRequest, re Description: computedDatasourceIDDescription, }, attr.Name: schema.StringAttribute{ - Required: true, - Description: "The name of the Resource", + Optional: true, + Description: "Returns only resources that exactly match this name. If no options are passed it will return all resources. Only one option can be used at a time.", + }, + attr.Name + attr.FilterByRegexp: schema.StringAttribute{ + Optional: true, + Description: "The regular expression match of the name of the resource.", + }, + attr.Name + attr.FilterByContains: schema.StringAttribute{ + Optional: true, + Description: "Match when the value exist in the name of the resource.", + }, + attr.Name + attr.FilterByExclude: schema.StringAttribute{ + Optional: true, + Description: "Match when the value does not exist in the name of the resource.", + }, + attr.Name + attr.FilterByPrefix: schema.StringAttribute{ + Optional: true, + Description: "The name of the resource must start with the value.", + }, + attr.Name + attr.FilterBySuffix: schema.StringAttribute{ + Optional: true, + Description: "The name of the resource must end with the value.", }, - // computed attr.Resources: schema.ListNestedAttribute{ Computed: true, @@ -123,6 +150,7 @@ func (d *resources) Schema(ctx context.Context, req datasource.SchemaRequest, re } } +//nolint:cyclop func (d *resources) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { var data resourcesModel @@ -133,14 +161,51 @@ func (d *resources) Read(ctx context.Context, req datasource.ReadRequest, resp * return } - resources, err := d.client.ReadResourcesByName(ctx, data.Name.ValueString()) + var name, filter string + + if data.Name.ValueString() != "" { + name = data.Name.ValueString() + } + + if data.NameRegexp.ValueString() != "" { + name = data.NameRegexp.ValueString() + filter = attr.FilterByRegexp + } + + if data.NameContains.ValueString() != "" { + name = data.NameContains.ValueString() + filter = attr.FilterByContains + } + + if data.NameExclude.ValueString() != "" { + name = data.NameExclude.ValueString() + filter = attr.FilterByExclude + } + + if data.NamePrefix.ValueString() != "" { + name = data.NamePrefix.ValueString() + filter = attr.FilterByPrefix + } + + if data.NameSuffix.ValueString() != "" { + name = data.NameSuffix.ValueString() + filter = attr.FilterBySuffix + } + + if countOptionalAttributes(data.Name, data.NameRegexp, data.NameContains, data.NameExclude, data.NamePrefix, data.NameSuffix) > 1 { + addErr(&resp.Diagnostics, ErrResourcesDatasourceShouldSetOneOptionalNameAttribute, TwingateResources) + + return + } + + resources, err := d.client.ReadResourcesByName(ctx, name, filter) if err != nil && !errors.Is(err, client.ErrGraphqlResultIsEmpty) { addErr(&resp.Diagnostics, err, TwingateResources) return } - data.ID = types.StringValue("query resources by name: " + data.Name.ValueString()) + data.ID = types.StringValue("query resources by name: " + name) data.Resources = convertResourcesToTerraform(resources) // Save data into Terraform state diff --git a/twingate/internal/test/acctests/datasource/resources_test.go b/twingate/internal/test/acctests/datasource/resources_test.go index a3aa5c0a..5f29f3f1 100644 --- a/twingate/internal/test/acctests/datasource/resources_test.go +++ b/twingate/internal/test/acctests/datasource/resources_test.go @@ -2,6 +2,7 @@ package datasource import ( "fmt" + "regexp" "testing" "github.com/Twingate/terraform-provider-twingate/twingate/internal/attr" @@ -119,3 +120,191 @@ func testTwingateResourcesDoesNotExists(name string) string { } `, name) } + +func TestAccDatasourceTwingateResourcesWithMultipleFilters(t *testing.T) { + t.Parallel() + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: acctests.ProviderFactories, + PreCheck: func() { acctests.PreCheck(t) }, + CheckDestroy: acctests.CheckTwingateResourceDestroy, + Steps: []resource.TestStep{ + { + Config: testDatasourceResourcesWithMultipleFilters(test.RandomResourceName()), + ExpectError: regexp.MustCompile("Only one of name.*"), + }, + }, + }) +} + +func testDatasourceResourcesWithMultipleFilters(name string) string { + return fmt.Sprintf(` + data "twingate_resources" "with-multiple-filters" { + name_regexp = "%[1]s" + name_contains = "%[1]s" + } + `, name) +} + +func TestAccDatasourceTwingateResourcesFilterByPrefix(t *testing.T) { + t.Parallel() + + prefix := test.Prefix() + resourceName := test.RandomResourceName() + networkName := test.RandomName() + theDatasource := "data.twingate_resources." + resourceName + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: acctests.ProviderFactories, + PreCheck: func() { acctests.PreCheck(t) }, + CheckDestroy: acctests.CheckTwingateResourceDestroy, + Steps: []resource.TestStep{ + { + Config: testDatasourceTwingateResourcesFilter(resourceName, networkName, prefix+"_test_app", prefix+"_one", prefix+"_on", attr.FilterByPrefix), + Check: acctests.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(theDatasource, resourcesLen, "1"), + resource.TestCheckResourceAttr(theDatasource, resourceNamePath, prefix+"_one"), + ), + }, + }, + }) +} + +func TestAccDatasourceTwingateResourcesFilterBySuffix(t *testing.T) { + t.Parallel() + + prefix := test.Prefix() + resourceName := test.RandomResourceName() + networkName := test.RandomName() + theDatasource := "data.twingate_resources." + resourceName + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: acctests.ProviderFactories, + PreCheck: func() { acctests.PreCheck(t) }, + CheckDestroy: acctests.CheckTwingateResourceDestroy, + Steps: []resource.TestStep{ + { + Config: testDatasourceTwingateResourcesFilter(resourceName, networkName, "test_app_"+prefix, "one_"+prefix, "e_"+prefix, attr.FilterBySuffix), + Check: acctests.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(theDatasource, resourcesLen, "1"), + resource.TestCheckResourceAttr(theDatasource, resourceNamePath, "one_"+prefix), + ), + }, + }, + }) +} + +func TestAccDatasourceTwingateResourcesFilterByContains(t *testing.T) { + t.Parallel() + + prefix := test.Prefix() + resourceName := test.RandomResourceName() + networkName := test.RandomName() + theDatasource := "data.twingate_resources." + resourceName + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: acctests.ProviderFactories, + PreCheck: func() { acctests.PreCheck(t) }, + CheckDestroy: acctests.CheckTwingateResourceDestroy, + Steps: []resource.TestStep{ + { + Config: testDatasourceTwingateResourcesFilter(resourceName, networkName, prefix+"_test_app", prefix+"_one", prefix+"_on", attr.FilterByContains), + Check: acctests.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(theDatasource, resourcesLen, "1"), + resource.TestCheckResourceAttr(theDatasource, resourceNamePath, prefix+"_one"), + ), + }, + }, + }) +} + +func TestAccDatasourceTwingateResourcesFilterByRegexp(t *testing.T) { + t.Parallel() + + prefix := test.Prefix() + resourceName := test.RandomResourceName() + networkName := test.RandomName() + theDatasource := "data.twingate_resources." + resourceName + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: acctests.ProviderFactories, + PreCheck: func() { acctests.PreCheck(t) }, + CheckDestroy: acctests.CheckTwingateResourceDestroy, + Steps: []resource.TestStep{ + { + Config: testDatasourceTwingateResourcesFilter(resourceName, networkName, prefix+"_test_app", prefix+"_one", prefix+".*app", attr.FilterByRegexp), + Check: acctests.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(theDatasource, resourcesLen, "1"), + resource.TestCheckResourceAttr(theDatasource, resourceNamePath, prefix+"_test_app"), + ), + }, + }, + }) +} + +func testDatasourceTwingateResourcesFilter(resourceName, networkName, name1, name2, name, filter string) string { + return fmt.Sprintf(` + resource "twingate_remote_network" "%[2]s" { + name = "%[2]s" + } + + resource "twingate_resource" "%[1]s_1" { + name = "%[3]s" + address = "acc-test.com" + remote_network_id = twingate_remote_network.%[2]s.id + } + + resource "twingate_resource" "%[1]s_2" { + name = "%[4]s" + address = "acc-test.com" + remote_network_id = twingate_remote_network.%[2]s.id + } + + data "twingate_resources" "%[1]s" { + name%[6]s = "%[5]s" + + depends_on = [twingate_resource.%[1]s_1, twingate_resource.%[1]s_2] + } + `, resourceName, networkName, name1, name2, name, filter) +} + +func TestAccDatasourceTwingateResourcesWithoutFilters(t *testing.T) { + t.Parallel() + + prefix := test.Prefix() + resourceName := test.RandomResourceName() + networkName := test.RandomName() + theDatasource := "data.twingate_resources." + resourceName + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: acctests.ProviderFactories, + PreCheck: func() { acctests.PreCheck(t) }, + CheckDestroy: acctests.CheckTwingateResourceDestroy, + Steps: []resource.TestStep{ + { + Config: testDatasourceTwingateResourcesAll(resourceName, networkName, prefix+"_test_app"), + Check: acctests.ComposeTestCheckFunc( + testCheckResourceAttrNotEqual(theDatasource, resourcesLen, "0"), + ), + }, + }, + }) +} + +func testDatasourceTwingateResourcesAll(resourceName, networkName, name string) string { + return fmt.Sprintf(` + resource "twingate_remote_network" "%[2]s" { + name = "%[2]s" + } + + resource "twingate_resource" "%[1]s" { + name = "%[3]s" + address = "acc-test.com" + remote_network_id = twingate_remote_network.%[2]s.id + } + + data "twingate_resources" "%[1]s" { + depends_on = [twingate_resource.%[1]s] + } + `, resourceName, networkName, name) +} diff --git a/twingate/internal/test/client/resource_test.go b/twingate/internal/test/client/resource_test.go index 0b533749..e1e597cd 100644 --- a/twingate/internal/test/client/resource_test.go +++ b/twingate/internal/test/client/resource_test.go @@ -1184,7 +1184,7 @@ func TestClientResourcesReadByNameOk(t *testing.T) { }), ) - resources, err := client.ReadResourcesByName(context.Background(), "resource-test") + resources, err := client.ReadResourcesByName(context.Background(), "resource-test", "") assert.Nil(t, err) assert.Equal(t, expected, resources) @@ -1204,7 +1204,7 @@ func TestClientResourcesReadByNameEmptyResult(t *testing.T) { httpmock.RegisterResponder("POST", client.GraphqlServerURL, httpmock.NewStringResponder(200, jsonResponse)) - resources, err := client.ReadResourcesByName(context.Background(), "resource-name") + resources, err := client.ReadResourcesByName(context.Background(), "resource-name", "") assert.Nil(t, resources) assert.EqualError(t, err, "failed to read resource with id All: query result is empty") @@ -1218,7 +1218,7 @@ func TestClientResourcesReadByNameRequestError(t *testing.T) { httpmock.RegisterResponder("POST", client.GraphqlServerURL, httpmock.NewErrorResponder(errBadRequest)) - groups, err := client.ReadResourcesByName(context.Background(), "resource-name") + groups, err := client.ReadResourcesByName(context.Background(), "resource-name", "") assert.Nil(t, groups) assert.EqualError(t, err, graphqlErr(client, "failed to read resource with id All", errBadRequest)) @@ -1238,7 +1238,7 @@ func TestClientResourcesReadByNameErrorEmptyName(t *testing.T) { httpmock.RegisterResponder("POST", client.GraphqlServerURL, httpmock.NewStringResponder(200, jsonResponse)) - groups, err := client.ReadResourcesByName(context.Background(), "") + groups, err := client.ReadResourcesByName(context.Background(), "", "") assert.Nil(t, groups) assert.EqualError(t, err, "failed to read resource with id All: query result is empty") @@ -1274,7 +1274,7 @@ func TestClientResourcesReadByNameAllEmptyResult(t *testing.T) { ), ) - resources, err := client.ReadResourcesByName(context.Background(), "resource-name") + resources, err := client.ReadResourcesByName(context.Background(), "resource-name", "") assert.Nil(t, resources) assert.EqualError(t, err, "failed to read resource with id All: query result is empty") @@ -1304,7 +1304,7 @@ func TestClientResourcesReadByNameAllRequestError(t *testing.T) { ), ) - resources, err := client.ReadResourcesByName(context.Background(), "resource-name") + resources, err := client.ReadResourcesByName(context.Background(), "resource-name", "") assert.Nil(t, resources) assert.EqualError(t, err, graphqlErr(client, "failed to read resource with id All", errBadRequest))