diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee1bfee8..b96c31dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,7 +124,8 @@ jobs: fail-fast: false matrix: terraform: - - '1.3.*' + - '1.4.*' + - '1.5.*' - 'latest' steps: diff --git a/docs/data-sources/user.md b/docs/data-sources/user.md index 6097a835..4f97f008 100644 --- a/docs/data-sources/user.md +++ b/docs/data-sources/user.md @@ -29,7 +29,6 @@ data "twingate_user" "foo" { - `email` (String) The email address of the User - `first_name` (String) The first name of the User -- `is_admin` (Boolean, Deprecated) Indicates whether the User is an admin - `last_name` (String) The last name of the User - `role` (String) Indicates the User's role. Either ADMIN, DEVOPS, SUPPORT, or MEMBER - `type` (String) Indicates the User's type. Either MANUAL or SYNCED. diff --git a/docs/data-sources/users.md b/docs/data-sources/users.md index b8192533..16c07f10 100644 --- a/docs/data-sources/users.md +++ b/docs/data-sources/users.md @@ -32,7 +32,6 @@ Read-Only: - `email` (String) The email address of the User - `first_name` (String) The first name of the User - `id` (String) The ID of the User -- `is_admin` (Boolean, Deprecated) Indicates whether the User is an admin - `last_name` (String) The last name of the User - `role` (String) Indicates the User's role. Either ADMIN, DEVOPS, SUPPORT, or MEMBER. - `type` (String) Indicates the User's type. Either MANUAL or SYNCED. diff --git a/docs/guides/migration-v1-to-v2-guide.md b/docs/guides/migration-v1-to-v2-guide.md new file mode 100644 index 00000000..fbecac4a --- /dev/null +++ b/docs/guides/migration-v1-to-v2-guide.md @@ -0,0 +1,73 @@ +--- +subcategory: "migration" +page_title: "v1 to v2 Migration Guide" +description: |- +This document covers how to migrate from v1 to v2 of the Twingate Terraform provider. +--- + +# Migration Guide +j +This guide covers how to migrate from v1 to v2 of the Twingate Terraform provider. Migration needs to be done for the following objects: +- Resources + - `twingate_resource` +- Data sources + - `twingate_user` + - `twingate_users` + +## Migrating Resources + +The `protocols` attribute in the `twingate_resource` Resource has been changed from a block to an object. + +In v1, the following was valid: + +```terraform +resource "twingate_resource" "resource" { + name = "resource" + address = "internal.int" + remote_network_id = twingate_remote_network.aws_network.id + + protocols { + allow_icmp = true + tcp { + policy = "RESTRICTED" + ports = ["80", "82-83"] + } + udp { + policy = "ALLOW_ALL" + } + } +} +``` + +The `protocols`, `tcp` and `udp` attributes were blocks and not objects. In v2, these are now objects: + +``` +protocols { -> protocols = { +tcp { -> tcp = { +udp { -> udp = { +``` + +In v2, the above resource needs to be rewritten like this: + +```terraform +resource "twingate_resource" "resource" { + name = "resource" + address = "internal.int" + remote_network_id = twingate_remote_network.aws_network.id + + protocols = { + allow_icmp = true + tcp = { + policy = "RESTRICTED" + ports = ["80", "82-83"] + } + udp = { + policy = "ALLOW_ALL" + } + } +} +``` + +## Migrating data sources + +The attribute `is_admin` has been removed from the `twingate_user` and `twingate_users` data sources. Similar information is now available via the [`role` attribute](https://registry.terraform.io/providers/Twingate/twingate/latest/docs/data-sources/users#role). diff --git a/docs/resources/resource.md b/docs/resources/resource.md index fa065d76..7d85643f 100644 --- a/docs/resources/resource.md +++ b/docs/resources/resource.md @@ -41,13 +41,13 @@ resource "twingate_resource" "resource" { security_policy_id = data.twingate_security_policy.test_policy.id - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "RESTRICTED" ports = ["80", "82-83"] } - udp { + udp = { policy = "ALLOW_ALL" } } @@ -72,14 +72,14 @@ resource "twingate_resource" "resource" { ### Optional -- `access` (Block List, Max: 1) Restrict access to certain groups or service accounts (see [below for nested schema](#nestedblock--access)) +- `access` (Block List) Restrict access to certain groups or service accounts (see [below for nested schema](#nestedblock--access)) - `alias` (String) Set a DNS alias address for the Resource. Must be a DNS-valid name string. - `is_active` (Boolean) Set the resource as active or inactive. Default is `true`. - `is_authoritative` (Boolean) Determines whether assignments in the access block will override any existing assignments. Default is `true`. If set to `false`, assignments made outside of Terraform will be ignored. -- `is_browser_shortcut_enabled` (Boolean) Controls whether an "Open in Browser" shortcut will be shown for this Resource in the Twingate Client. -- `is_visible` (Boolean) Controls whether this Resource will be visible in the main Resource list in the Twingate Client. -- `protocols` (Block List, Max: 1) Restrict access to certain protocols and ports. By default or when this argument is not defined, there is no restriction, and all protocols and ports are allowed. (see [below for nested schema](#nestedblock--protocols)) -- `security_policy_id` (String) The ID of a `twingate_security_policy` to set as this Resource's Security Policy. Default is `Default Policy` +- `is_browser_shortcut_enabled` (Boolean) Controls whether an "Open in Browser" shortcut will be shown for this Resource in the Twingate Client. Default is `false`. +- `is_visible` (Boolean) Controls whether this Resource will be visible in the main Resource list in the Twingate Client. Default is `true`. +- `protocols` (Attributes) Restrict access to certain protocols and ports. By default or when this argument is not defined, there is no restriction, and all protocols and ports are allowed. (see [below for nested schema](#nestedatt--protocols)) +- `security_policy_id` (String) The ID of a `twingate_security_policy` to set as this Resource's Security Policy. Default is `Default Policy`. ### Read-Only @@ -94,40 +94,31 @@ Optional: - `service_account_ids` (Set of String) List of Service Account IDs that will have permission to access the Resource. - + ### Nested Schema for `protocols` -Required: - -- `tcp` (Block List, Min: 1, Max: 1) (see [below for nested schema](#nestedblock--protocols--tcp)) -- `udp` (Block List, Min: 1, Max: 1) (see [below for nested schema](#nestedblock--protocols--udp)) - Optional: - `allow_icmp` (Boolean) Whether to allow ICMP (ping) traffic +- `tcp` (Attributes) (see [below for nested schema](#nestedatt--protocols--tcp)) +- `udp` (Attributes) (see [below for nested schema](#nestedatt--protocols--udp)) - + ### Nested Schema for `protocols.tcp` -Required: - -- `policy` (String) Whether to allow or deny all ports, or restrict protocol access within certain port ranges: Can be `RESTRICTED` (only listed ports are allowed), `ALLOW_ALL`, or `DENY_ALL` - Optional: -- `ports` (List of String) List of port ranges between 1 and 65535 inclusive, in the format `100-200` for a range, or `8080` for a single port +- `policy` (String) Whether to allow or deny all ports, or restrict protocol access within certain port ranges: Can be `RESTRICTED` (only listed ports are allowed), `ALLOW_ALL`, or `DENY_ALL` +- `ports` (Set of String) List of port ranges between 1 and 65535 inclusive, in the format `100-200` for a range, or `8080` for a single port - + ### Nested Schema for `protocols.udp` -Required: - -- `policy` (String) Whether to allow or deny all ports, or restrict protocol access within certain port ranges: Can be `RESTRICTED` (only listed ports are allowed), `ALLOW_ALL`, or `DENY_ALL` - Optional: -- `ports` (List of String) List of port ranges between 1 and 65535 inclusive, in the format `100-200` for a range, or `8080` for a single port +- `policy` (String) Whether to allow or deny all ports, or restrict protocol access within certain port ranges: Can be `RESTRICTED` (only listed ports are allowed), `ALLOW_ALL`, or `DENY_ALL` +- `ports` (Set of String) List of port ranges between 1 and 65535 inclusive, in the format `100-200` for a range, or `8080` for a single port ## Import diff --git a/examples/resources/twingate_resource/resource.tf b/examples/resources/twingate_resource/resource.tf index e5b80dce..d1cbb6f4 100644 --- a/examples/resources/twingate_resource/resource.tf +++ b/examples/resources/twingate_resource/resource.tf @@ -26,13 +26,13 @@ resource "twingate_resource" "resource" { security_policy_id = data.twingate_security_policy.test_policy.id - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "RESTRICTED" ports = ["80", "82-83"] } - udp { + udp = { policy = "ALLOW_ALL" } } diff --git a/go.mod b/go.mod index ee6e9a9f..68cf1e3b 100644 --- a/go.mod +++ b/go.mod @@ -11,8 +11,6 @@ require ( github.com/hashicorp/terraform-plugin-framework v1.4.2 github.com/hashicorp/terraform-plugin-framework-validators v0.12.0 github.com/hashicorp/terraform-plugin-go v0.19.1 - github.com/hashicorp/terraform-plugin-mux v0.12.0 - github.com/hashicorp/terraform-plugin-sdk/v2 v2.30.0 github.com/hashicorp/terraform-plugin-testing v1.5.1 github.com/hasura/go-graphql-client v0.10.1 github.com/iancoleman/strcase v0.3.0 @@ -57,6 +55,7 @@ require ( github.com/hashicorp/terraform-exec v0.19.0 // indirect github.com/hashicorp/terraform-json v0.17.1 // indirect github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect + github.com/hashicorp/terraform-plugin-sdk/v2 v2.30.0 // indirect github.com/hashicorp/terraform-registry-address v0.2.3 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d // indirect diff --git a/go.sum b/go.sum index c91ee6dd..d2eda182 100644 --- a/go.sum +++ b/go.sum @@ -158,8 +158,6 @@ github.com/hashicorp/terraform-plugin-go v0.19.1 h1:lf/jTGTeELcz5IIbn/94mJdmnTjR github.com/hashicorp/terraform-plugin-go v0.19.1/go.mod h1:5NMIS+DXkfacX6o5HCpswda5yjkSYfKzn1Nfl9l+qRs= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= -github.com/hashicorp/terraform-plugin-mux v0.12.0 h1:TJlmeslQ11WlQtIFAfth0vXx+gSNgvMEng2Rn9z3WZY= -github.com/hashicorp/terraform-plugin-mux v0.12.0/go.mod h1:8MR0AgmV+Q03DIjyrAKxXyYlq2EUnYBQP8gxAAA0zeM= github.com/hashicorp/terraform-plugin-sdk/v2 v2.30.0 h1:X7vB6vn5tON2b49ILa4W7mFAsndeqJ7bZFOGbVO+0Cc= github.com/hashicorp/terraform-plugin-sdk/v2 v2.30.0/go.mod h1:ydFcxbdj6klCqYEPkPvdvFKiNGKZLUs+896ODUXCyao= github.com/hashicorp/terraform-plugin-testing v1.5.1 h1:T4aQh9JAhmWo4+t1A7x+rnxAJHCDIYW9kXyo4sVO92c= diff --git a/golangci.yml b/golangci.yml index 384ed4e2..4ac2e03a 100644 --- a/golangci.yml +++ b/golangci.yml @@ -22,6 +22,7 @@ linters-settings: - github.com/hashicorp/terraform-plugin-framework/datasource.DataSource - github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier.Set - github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier.Bool + - github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier.String - github.com/hashicorp/terraform-plugin-testing/plancheck.PlanCheck errcheck: check-type-assertions: false diff --git a/main.go b/main.go index 561a0aaa..e2805d77 100644 --- a/main.go +++ b/main.go @@ -6,52 +6,27 @@ import ( "log" "github.com/Twingate/terraform-provider-twingate/twingate" - twingateV2 "github.com/Twingate/terraform-provider-twingate/twingate/v2" - "github.com/hashicorp/terraform-plugin-go/tfprotov6" - "github.com/hashicorp/terraform-plugin-go/tfprotov6/tf6server" - "github.com/hashicorp/terraform-plugin-framework/providerserver" - "github.com/hashicorp/terraform-plugin-mux/tf5to6server" - "github.com/hashicorp/terraform-plugin-mux/tf6muxserver" ) var ( version = "dev" ) +const registry = "registry.terraform.io/Twingate/twingate" + func main() { var debug bool flag.BoolVar(&debug, "debug", false, "set to true to run the provider with support for debuggers") flag.Parse() - ctx := context.Background() - upgradedSdkProvider, err := tf5to6server.UpgradeServer(ctx, twingate.Provider(version).GRPCProvider) - if err != nil { - log.Fatal(err) - } - providers := []func() tfprotov6.ProviderServer{ - func() tfprotov6.ProviderServer { - return upgradedSdkProvider + err := providerserver.Serve(context.Background(), twingate.New(version), + providerserver.ServeOpts{ + Debug: debug, + Address: registry, + ProtocolVersion: 6, }, - providerserver.NewProtocol6(twingateV2.New(version)()), - } - - muxServer, err := tf6muxserver.NewMuxServer(ctx, providers...) - - if err != nil { - log.Fatal(err) - } - - var serveOpts []tf6server.ServeOpt - if debug { - serveOpts = append(serveOpts, tf6server.WithManagedDebug()) - } - - err = tf6server.Serve( - "registry.terraform.io/Twingate/twingate", - muxServer.ProviderServer, - serveOpts..., ) if err != nil { diff --git a/templates/guides/migration-v1-to-v2-guide.md.tmpl b/templates/guides/migration-v1-to-v2-guide.md.tmpl new file mode 100644 index 00000000..fbecac4a --- /dev/null +++ b/templates/guides/migration-v1-to-v2-guide.md.tmpl @@ -0,0 +1,73 @@ +--- +subcategory: "migration" +page_title: "v1 to v2 Migration Guide" +description: |- +This document covers how to migrate from v1 to v2 of the Twingate Terraform provider. +--- + +# Migration Guide +j +This guide covers how to migrate from v1 to v2 of the Twingate Terraform provider. Migration needs to be done for the following objects: +- Resources + - `twingate_resource` +- Data sources + - `twingate_user` + - `twingate_users` + +## Migrating Resources + +The `protocols` attribute in the `twingate_resource` Resource has been changed from a block to an object. + +In v1, the following was valid: + +```terraform +resource "twingate_resource" "resource" { + name = "resource" + address = "internal.int" + remote_network_id = twingate_remote_network.aws_network.id + + protocols { + allow_icmp = true + tcp { + policy = "RESTRICTED" + ports = ["80", "82-83"] + } + udp { + policy = "ALLOW_ALL" + } + } +} +``` + +The `protocols`, `tcp` and `udp` attributes were blocks and not objects. In v2, these are now objects: + +``` +protocols { -> protocols = { +tcp { -> tcp = { +udp { -> udp = { +``` + +In v2, the above resource needs to be rewritten like this: + +```terraform +resource "twingate_resource" "resource" { + name = "resource" + address = "internal.int" + remote_network_id = twingate_remote_network.aws_network.id + + protocols = { + allow_icmp = true + tcp = { + policy = "RESTRICTED" + ports = ["80", "82-83"] + } + udp = { + policy = "ALLOW_ALL" + } + } +} +``` + +## Migrating data sources + +The attribute `is_admin` has been removed from the `twingate_user` and `twingate_users` data sources. Similar information is now available via the [`role` attribute](https://registry.terraform.io/providers/Twingate/twingate/latest/docs/data-sources/users#role). diff --git a/twingate/internal/attr/helper.go b/twingate/internal/attr/helper.go index 788e7d72..8500f8a5 100644 --- a/twingate/internal/attr/helper.go +++ b/twingate/internal/attr/helper.go @@ -5,6 +5,7 @@ import "strings" const ( attrFirstElement = ".0" attrPathSeparator = ".0." + attrSeparator = "." attrLenSymbol = ".#" ) @@ -18,10 +19,24 @@ func First(attributes ...string) string { return attr + attrFirstElement } +func FirstAttr(attributes ...string) string { + attr := PathAttr(attributes...) + + if attr == "" { + return "" + } + + return attr + attrFirstElement +} + func Path(attributes ...string) string { return strings.Join(attributes, attrPathSeparator) } +func PathAttr(attributes ...string) string { + return strings.Join(attributes, attrSeparator) +} + func Len(attributes ...string) string { attr := Path(attributes...) @@ -31,3 +46,13 @@ func Len(attributes ...string) string { return attr + attrLenSymbol } + +func LenAttr(attributes ...string) string { + attr := PathAttr(attributes...) + + if attr == "" { + return "" + } + + return attr + attrLenSymbol +} diff --git a/twingate/internal/attr/user.go b/twingate/internal/attr/user.go index f9a9804a..53975ca7 100644 --- a/twingate/internal/attr/user.go +++ b/twingate/internal/attr/user.go @@ -4,7 +4,6 @@ const ( FirstName = "first_name" LastName = "last_name" Email = "email" - IsAdmin = "is_admin" Role = "role" Users = "users" SendInvite = "send_invite" diff --git a/twingate/internal/model/user.go b/twingate/internal/model/user.go index 8bbe20ce..02b17c78 100644 --- a/twingate/internal/model/user.go +++ b/twingate/internal/model/user.go @@ -42,17 +42,12 @@ func (u User) GetName() string { return u.Email } -func (u User) IsAdmin() bool { - return u.Role == UserRoleAdmin -} - func (u User) ToTerraform() interface{} { return map[string]interface{}{ attr.ID: u.ID, attr.FirstName: u.FirstName, attr.LastName: u.LastName, attr.Email: u.Email, - attr.IsAdmin: u.IsAdmin(), attr.Role: u.Role, attr.Type: u.Type, } diff --git a/twingate/internal/provider/datasource/converter.go b/twingate/internal/provider/datasource/converter.go index 7a519fa3..9ed03a3e 100644 --- a/twingate/internal/provider/datasource/converter.go +++ b/twingate/internal/provider/datasource/converter.go @@ -47,7 +47,6 @@ func convertUsersToTerraform(users []*model.User) []userModel { FirstName: types.StringValue(user.FirstName), LastName: types.StringValue(user.LastName), Email: types.StringValue(user.Email), - IsAdmin: types.BoolValue(user.IsAdmin()), Role: types.StringValue(user.Role), Type: types.StringValue(user.Type), } diff --git a/twingate/internal/provider/datasource/converter_test.go b/twingate/internal/provider/datasource/converter_test.go index c767268f..72fda413 100644 --- a/twingate/internal/provider/datasource/converter_test.go +++ b/twingate/internal/provider/datasource/converter_test.go @@ -107,7 +107,6 @@ func TestConverterUsersToTerraform(t *testing.T) { FirstName: types.StringValue("Name"), LastName: types.StringValue("Last"), Email: types.StringValue("user@email.com"), - IsAdmin: types.BoolValue(false), Role: types.StringValue("USER"), Type: types.StringValue("SYNCED"), }, @@ -116,7 +115,6 @@ func TestConverterUsersToTerraform(t *testing.T) { FirstName: types.StringValue("Admin"), LastName: types.StringValue("Last"), Email: types.StringValue("admin@email.com"), - IsAdmin: types.BoolValue(true), Role: types.StringValue(model.UserRoleAdmin), Type: types.StringValue("MANUAL"), }, diff --git a/twingate/internal/provider/datasource/user.go b/twingate/internal/provider/datasource/user.go index 6dbdea8f..6e3532a8 100644 --- a/twingate/internal/provider/datasource/user.go +++ b/twingate/internal/provider/datasource/user.go @@ -31,7 +31,6 @@ type userModel struct { FirstName types.String `tfsdk:"first_name"` LastName types.String `tfsdk:"last_name"` Email types.String `tfsdk:"email"` - IsAdmin types.Bool `tfsdk:"is_admin"` Role types.String `tfsdk:"role"` Type types.String `tfsdk:"type"` } @@ -79,11 +78,6 @@ func (d *user) Schema(ctx context.Context, req datasource.SchemaRequest, resp *d Computed: true, Description: "The email address of the User", }, - attr.IsAdmin: schema.BoolAttribute{ - Computed: true, - Description: "Indicates whether the User is an admin", - DeprecationMessage: "This read-only Boolean value will be deprecated in a future release. You may use the `role` value instead.", - }, attr.Role: schema.StringAttribute{ Computed: true, Description: fmt.Sprintf("Indicates the User's role. Either %s, %s, %s, or %s", model.UserRoleAdmin, model.UserRoleDevops, model.UserRoleSupport, model.UserRoleMember), @@ -117,7 +111,6 @@ func (d *user) Read(ctx context.Context, req datasource.ReadRequest, resp *datas data.FirstName = types.StringValue(user.FirstName) data.LastName = types.StringValue(user.LastName) data.Email = types.StringValue(user.Email) - data.IsAdmin = types.BoolValue(user.IsAdmin()) data.Role = types.StringValue(user.Role) data.Type = types.StringValue(user.Type) diff --git a/twingate/internal/provider/datasource/users.go b/twingate/internal/provider/datasource/users.go index 7c1b3705..3b9aa08d 100644 --- a/twingate/internal/provider/datasource/users.go +++ b/twingate/internal/provider/datasource/users.go @@ -81,11 +81,6 @@ func (d *users) Schema(ctx context.Context, req datasource.SchemaRequest, resp * Computed: true, Description: "The email address of the User", }, - attr.IsAdmin: schema.BoolAttribute{ - Computed: true, - Description: "Indicates whether the User is an admin", - DeprecationMessage: "This read-only Boolean value will be deprecated in a future release. You may use the `role` value instead.", - }, attr.Role: schema.StringAttribute{ Computed: true, Description: fmt.Sprintf("Indicates the User's role. Either %s, %s, %s, or %s.", model.UserRoleAdmin, model.UserRoleDevops, model.UserRoleSupport, model.UserRoleMember), diff --git a/twingate/internal/provider/resource/converter_test.go b/twingate/internal/provider/resource/converter_test.go index 1bc533de..065c87dc 100644 --- a/twingate/internal/provider/resource/converter_test.go +++ b/twingate/internal/provider/resource/converter_test.go @@ -7,37 +7,31 @@ import ( "github.com/Twingate/terraform-provider-twingate/twingate/internal/attr" "github.com/Twingate/terraform-provider-twingate/twingate/internal/model" + tfattr "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" "github.com/stretchr/testify/assert" ) func TestConvertProtocol(t *testing.T) { cases := []struct { - input []interface{} + input types.Object expected *model.Protocol expectedErr error }{ {}, { - input: []interface{}{ - map[string]interface{}{ - attr.Policy: model.PolicyAllowAll, - attr.Ports: []interface{}{ - "-", - }, - }, - }, + input: types.ObjectValueMust(protocolAttributeTypes(), map[string]tfattr.Value{ + attr.Policy: types.StringValue(model.PolicyAllowAll), + attr.Ports: makeTestSet("-"), + }), expectedErr: errors.New("failed to parse protocols port range"), }, { - input: []interface{}{ - map[string]interface{}{ - attr.Policy: model.PolicyRestricted, - attr.Ports: []interface{}{ - "80-88", - }, - }, - }, + input: types.ObjectValueMust(protocolAttributeTypes(), map[string]tfattr.Value{ + attr.Policy: types.StringValue(model.PolicyRestricted), + attr.Ports: makeTestSet("80-88"), + }), expected: &model.Protocol{ Policy: model.PolicyRestricted, Ports: []*model.PortRange{ @@ -125,40 +119,49 @@ func TestConvertPortsRangeToMap(t *testing.T) { } } +func makeTestSet(values ...string) types.Set { + elements := make([]tfattr.Value, 0, len(values)) + for _, val := range values { + elements = append(elements, types.StringValue(val)) + } + + return types.SetValueMust(types.StringType, elements) +} + func TestEqualPorts(t *testing.T) { cases := []struct { - inputA []interface{} - inputB []interface{} + inputA types.Set + inputB types.Set expected bool }{ { - inputA: []interface{}{""}, - inputB: []interface{}{""}, + inputA: makeTestSet(""), + inputB: makeTestSet(""), expected: false, }, { - inputA: []interface{}{"80"}, - inputB: []interface{}{""}, + inputA: makeTestSet("80"), + inputB: makeTestSet(""), expected: false, }, { - inputA: []interface{}{"80"}, - inputB: []interface{}{"90"}, + inputA: makeTestSet("80"), + inputB: makeTestSet("90"), expected: false, }, { - inputA: []interface{}{"80"}, - inputB: []interface{}{"80"}, + inputA: makeTestSet("80"), + inputB: makeTestSet("80"), expected: true, }, { - inputA: []interface{}{"80-81"}, - inputB: []interface{}{"80", "81"}, + inputA: makeTestSet("80-81"), + inputB: makeTestSet("80", "81"), expected: true, }, { - inputA: []interface{}{"80-81", "70"}, - inputB: []interface{}{"70", "80", "81"}, + inputA: makeTestSet("80-81", "70"), + inputB: makeTestSet("70", "80", "81"), expected: true, }, } diff --git a/twingate/internal/provider/resource/helper.go b/twingate/internal/provider/resource/helper.go index feb14fe5..1e013c00 100644 --- a/twingate/internal/provider/resource/helper.go +++ b/twingate/internal/provider/resource/helper.go @@ -4,28 +4,9 @@ import ( "fmt" "github.com/Twingate/terraform-provider-twingate/twingate/internal/utils" - tfDiag "github.com/hashicorp/terraform-plugin-framework/diag" - "github.com/hashicorp/terraform-plugin-sdk/v2/diag" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" ) -func ErrAttributeSet(err error, attribute string) diag.Diagnostics { - return diag.FromErr(fmt.Errorf("error setting %s: %w ", attribute, err)) -} - -func convertIDs(data interface{}) []string { - return utils.Map[interface{}, string]( - data.(*schema.Set).List(), - func(elem interface{}) string { - return elem.(string) - }, - ) -} - -func castToStrings(a, b interface{}) (string, string) { - return a.(string), b.(string) -} - // setIntersection - for given two sets A and B, // A ∩ B (read as A intersection B) is the set of common elements that belong to set A and B. // If A = {1, 2, 3, 4} and B = {3, 4, 5, 7}, then the intersection of A and B is given by A ∩ B = {3, 4}. @@ -76,7 +57,7 @@ func withDefaultValue(str, defaultValue string) string { return defaultValue } -func addErr(diagnostics *tfDiag.Diagnostics, err error, operation, resource string) { +func addErr(diagnostics *diag.Diagnostics, err error, operation, resource string) { if err == nil { return } diff --git a/twingate/internal/provider/resource/resource.go b/twingate/internal/provider/resource/resource.go index 36b9466d..d2490869 100644 --- a/twingate/internal/provider/resource/resource.go +++ b/twingate/internal/provider/resource/resource.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "log" "reflect" "regexp" "strings" @@ -12,9 +11,26 @@ import ( "github.com/Twingate/terraform-provider-twingate/twingate/internal/attr" "github.com/Twingate/terraform-provider-twingate/twingate/internal/client" "github.com/Twingate/terraform-provider-twingate/twingate/internal/model" - "github.com/hashicorp/terraform-plugin-sdk/v2/diag" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/Twingate/terraform-provider-twingate/twingate/internal/utils" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + tfattr "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/setdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) const DefaultSecurityPolicyName = "Default Policy" @@ -24,269 +40,961 @@ var ( ErrPortsWithPolicyAllowAll = errors.New(model.PolicyAllowAll + " policy does not allow specifying ports.") ErrPortsWithPolicyDenyAll = errors.New(model.PolicyDenyAll + " policy does not allow specifying ports.") ErrPolicyRestrictedWithoutPorts = errors.New(model.PolicyRestricted + " policy requires specifying ports.") + ErrInvalidAttributeCombination = errors.New("invalid attribute combination") ErrWildcardAddressWithEnabledShortcut = errors.New("Resources with a CIDR range or wildcard can't have the browser shortcut enabled.") + ErrDefaultPolicyNotSet = errors.New("default policy not set") ) -func Resource() *schema.Resource { //nolint:funlen - portsSchema := &schema.Resource{ - Schema: map[string]*schema.Schema{ - attr.Policy: { - Type: schema.TypeString, - Required: true, - ValidateFunc: validation.StringInSlice(model.Policies, false), - Description: fmt.Sprintf("Whether to allow or deny all ports, or restrict protocol access within certain port ranges: Can be `%s` (only listed ports are allowed), `%s`, or `%s`", model.PolicyRestricted, model.PolicyAllowAll, model.PolicyDenyAll), - }, - attr.Ports: { - Type: schema.TypeList, - Optional: true, - Description: "List of port ranges between 1 and 65535 inclusive, in the format `100-200` for a range, or `8080` for a single port", - Elem: &schema.Schema{ - Type: schema.TypeString, - }, - DiffSuppressFunc: portsNotChanged, - }, - }, +// Ensure the implementation satisfies the desired interfaces. +var _ resource.Resource = &twingateResource{} + +func NewResourceResource() resource.Resource { + return &twingateResource{} +} + +type twingateResource struct { + client *client.Client +} + +type resourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Address types.String `tfsdk:"address"` + RemoteNetworkID types.String `tfsdk:"remote_network_id"` + IsAuthoritative types.Bool `tfsdk:"is_authoritative"` + Protocols types.Object `tfsdk:"protocols"` + Access types.List `tfsdk:"access"` + IsActive types.Bool `tfsdk:"is_active"` + IsVisible types.Bool `tfsdk:"is_visible"` + IsBrowserShortcutEnabled types.Bool `tfsdk:"is_browser_shortcut_enabled"` + Alias types.String `tfsdk:"alias"` + SecurityPolicyID types.String `tfsdk:"security_policy_id"` +} + +type resourceModelV0 struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Address types.String `tfsdk:"address"` + RemoteNetworkID types.String `tfsdk:"remote_network_id"` + IsAuthoritative types.Bool `tfsdk:"is_authoritative"` + Protocols types.List `tfsdk:"protocols"` + Access types.List `tfsdk:"access"` + IsActive types.Bool `tfsdk:"is_active"` + IsVisible types.Bool `tfsdk:"is_visible"` + IsBrowserShortcutEnabled types.Bool `tfsdk:"is_browser_shortcut_enabled"` + Alias types.String `tfsdk:"alias"` + SecurityPolicyID types.String `tfsdk:"security_policy_id"` +} + +func (r *twingateResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = TwingateResource +} + +func (r *twingateResource) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) { + if req.ProviderData == nil { + return } - protocolsSchema := &schema.Resource{ - Schema: map[string]*schema.Schema{ - attr.AllowIcmp: { - Type: schema.TypeBool, - Optional: true, - Default: true, - Description: "Whether to allow ICMP (ping) traffic", - }, - attr.TCP: { - Type: schema.TypeList, - Required: true, - MaxItems: 1, - Elem: portsSchema, - }, - attr.UDP: { - Type: schema.TypeList, - Required: true, - MaxItems: 1, - Elem: portsSchema, - }, - }, + r.client = req.ProviderData.(*client.Client) +} + +func (r *twingateResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root(attr.ID), req, resp) + + res, err := r.client.ReadResource(ctx, req.ID) + if err != nil { + resp.Diagnostics.AddError("failed to import state", err.Error()) + + return } - accessSchema := &schema.Resource{ - Schema: map[string]*schema.Schema{ - attr.GroupIDs: { - Type: schema.TypeSet, - Elem: &schema.Schema{Type: schema.TypeString}, - MinItems: 1, - Optional: true, - AtLeastOneOf: []string{attr.Path(attr.Access, attr.ServiceAccountIDs)}, - Description: "List of Group IDs that will have permission to access the Resource.", - }, - attr.ServiceAccountIDs: { - Type: schema.TypeSet, - Elem: &schema.Schema{Type: schema.TypeString}, - MinItems: 1, - Optional: true, - AtLeastOneOf: []string{attr.Path(attr.Access, attr.GroupIDs)}, - Description: "List of Service Account IDs that will have permission to access the Resource.", - }, - }, + if res.Protocols != nil { + protocols, diags := convertProtocolsToTerraform(res.Protocols, nil) + resp.Diagnostics.Append(diags...) + + if resp.Diagnostics.HasError() { + return + } + + resp.State.SetAttribute(ctx, path.Root(attr.Protocols), protocols) } - return &schema.Resource{ - 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).", - CreateContext: resourceCreate, - UpdateContext: resourceUpdate, - ReadContext: resourceRead, - DeleteContext: resourceDelete, + if len(res.Groups) > 0 || len(res.ServiceAccounts) > 0 { + access, diags := convertAccessBlockToTerraform(ctx, res, types.SetNull(types.StringType), types.SetNull(types.StringType)) + + resp.Diagnostics.Append(diags...) + + if resp.Diagnostics.HasError() { + return + } + + resp.State.SetAttribute(ctx, path.Root(attr.Access), access) + } +} - Schema: map[string]*schema.Schema{ - // required - attr.Name: { - Type: schema.TypeString, +//nolint:funlen +func (r *twingateResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Version: 1, + 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).", + Attributes: map[string]schema.Attribute{ + attr.Name: schema.StringAttribute{ Required: true, Description: "The name of the Resource", }, - attr.Address: { - Type: schema.TypeString, + attr.Address: schema.StringAttribute{ Required: true, Description: "The Resource's IP/CIDR or FQDN/DNS zone", }, - attr.RemoteNetworkID: { - Type: schema.TypeString, + attr.RemoteNetworkID: schema.StringAttribute{ Required: true, Description: "Remote Network ID where the Resource lives", }, // optional - attr.IsAuthoritative: { - Type: schema.TypeBool, + attr.IsActive: schema.BoolAttribute{ Optional: true, Computed: true, - Description: "Determines whether assignments in the access block will override any existing assignments. Default is `true`. If set to `false`, assignments made outside of Terraform will be ignored.", + Description: "Set the resource as active or inactive. Default is `true`.", + Default: booldefault.StaticBool(true), }, - attr.Protocols: { - Type: schema.TypeList, - Optional: true, - MaxItems: 1, - Description: "Restrict access to certain protocols and ports. By default or when this argument is not defined, there is no restriction, and all protocols and ports are allowed.", - Elem: protocolsSchema, - DiffSuppressOnRefresh: true, - DiffSuppressFunc: protocolsNotChanged, + attr.IsAuthoritative: schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "Determines whether assignments in the access block will override any existing assignments. Default is `true`. If set to `false`, assignments made outside of Terraform will be ignored.", + PlanModifiers: []planmodifier.Bool{boolplanmodifier.UseStateForUnknown()}, }, - attr.Access: { - Type: schema.TypeList, - Optional: true, - MaxItems: 1, - Description: "Restrict access to certain groups or service accounts", - Elem: accessSchema, + attr.Alias: schema.StringAttribute{ + Optional: true, + Description: "Set a DNS alias address for the Resource. Must be a DNS-valid name string.", + PlanModifiers: []planmodifier.String{CaseInsensitiveDiff()}, }, - attr.SecurityPolicyID: { - Type: schema.TypeString, - Optional: true, - Description: "The ID of a `twingate_security_policy` to set as this Resource's Security Policy. Default is `Default Policy`", - DiffSuppressOnRefresh: true, - DiffSuppressFunc: defaultPolicyNotChanged, + attr.Protocols: protocols(), + // computed + attr.SecurityPolicyID: schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "The ID of a `twingate_security_policy` to set as this Resource's Security Policy. Default is `Default Policy`.", + Default: stringdefault.StaticString(DefaultSecurityPolicyID), + PlanModifiers: []planmodifier.String{UseDefaultPolicyForUnknownModifier()}, }, - attr.IsActive: { - Type: schema.TypeBool, - Optional: true, - Description: "Set the resource as active or inactive. Default is `true`.", - Default: true, + attr.IsVisible: schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "Controls whether this Resource will be visible in the main Resource list in the Twingate Client. Default is `true`.", + Default: booldefault.StaticBool(true), + PlanModifiers: []planmodifier.Bool{boolplanmodifier.UseStateForUnknown()}, }, - // computed - attr.IsVisible: { - Type: schema.TypeBool, + attr.IsBrowserShortcutEnabled: schema.BoolAttribute{ Optional: true, Computed: true, - Description: "Controls whether this Resource will be visible in the main Resource list in the Twingate Client.", + Description: "Controls whether an \"Open in Browser\" shortcut will be shown for this Resource in the Twingate Client. Default is `false`.", + Default: booldefault.StaticBool(false), + }, + attr.ID: schema.StringAttribute{ + Computed: true, + Description: "Autogenerated ID of the Resource, encoded in base64", + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + }, + + Blocks: map[string]schema.Block{attr.Access: accessBlock()}, + } +} + +func (r *twingateResource) UpgradeState(ctx context.Context) map[int64]resource.StateUpgrader { //nolint + return map[int64]resource.StateUpgrader{ + // State upgrade implementation from 0 (prior state version) to 1 (Schema.Version) + 0: { + PriorSchema: &schema.Schema{ + Attributes: map[string]schema.Attribute{ + attr.ID: schema.StringAttribute{ + Computed: true, + }, + attr.Name: schema.StringAttribute{ + Required: true, + }, + attr.Address: schema.StringAttribute{ + Required: true, + }, + attr.RemoteNetworkID: schema.StringAttribute{ + Required: true, + }, + attr.IsActive: schema.BoolAttribute{ + Optional: true, + Computed: true, + }, + attr.IsAuthoritative: schema.BoolAttribute{ + Optional: true, + Computed: true, + }, + attr.Alias: schema.StringAttribute{ + Optional: true, + }, + attr.SecurityPolicyID: schema.StringAttribute{ + Optional: true, + Computed: true, + }, + attr.IsVisible: schema.BoolAttribute{ + Optional: true, + Computed: true, + }, + attr.IsBrowserShortcutEnabled: schema.BoolAttribute{ + Optional: true, + Computed: true, + }, + }, + + Blocks: map[string]schema.Block{ + attr.Access: schema.ListNestedBlock{ + Validators: []validator.List{ + listvalidator.SizeAtMost(1), + }, + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + attr.GroupIDs: schema.SetAttribute{ + Optional: true, + ElementType: types.StringType, + Validators: []validator.Set{ + setvalidator.SizeAtLeast(1), + }, + }, + attr.ServiceAccountIDs: schema.SetAttribute{ + Optional: true, + ElementType: types.StringType, + Validators: []validator.Set{ + setvalidator.SizeAtLeast(1), + }, + }, + }, + }, + }, + attr.Protocols: schema.ListNestedBlock{ + Validators: []validator.List{ + listvalidator.SizeAtMost(1), + }, + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + attr.AllowIcmp: schema.BoolAttribute{ + Optional: true, + Computed: true, + }, + }, + Blocks: map[string]schema.Block{ + attr.UDP: schema.ListNestedBlock{ + Validators: []validator.List{ + listvalidator.SizeAtMost(1), + }, + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + attr.Policy: schema.StringAttribute{ + Optional: true, + Computed: true, + }, + attr.Ports: schema.SetAttribute{ + Optional: true, + Computed: true, + ElementType: types.StringType, + }, + }, + }, + }, + attr.TCP: schema.ListNestedBlock{ + Validators: []validator.List{ + listvalidator.SizeAtMost(1), + }, + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + attr.Policy: schema.StringAttribute{ + Optional: true, + Computed: true, + }, + attr.Ports: schema.SetAttribute{ + Optional: true, + Computed: true, + ElementType: types.StringType, + }, + }, + }, + }, + }, + }, + }, + }, + }, + StateUpgrader: func(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) { + var priorState resourceModelV0 + + resp.Diagnostics.Append(req.State.Get(ctx, &priorState)...) + + if resp.Diagnostics.HasError() { + return + } + + protocols, err := convertProtocolsV0(priorState.Protocols) + if err != nil { + resp.Diagnostics.AddError( + "failed to convert protocols for prior state version 0", + err.Error(), + ) + + return + } + + protocolsState, diags := convertProtocolsToTerraform(protocols, nil) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + upgradedState := resourceModel{ + ID: priorState.ID, + Name: priorState.Name, + Address: priorState.Address, + RemoteNetworkID: priorState.RemoteNetworkID, + Protocols: protocolsState, + Access: priorState.Access, + IsActive: priorState.IsActive, + } + + if !priorState.IsAuthoritative.IsNull() { + upgradedState.IsAuthoritative = priorState.IsAuthoritative + } + + if !priorState.IsVisible.IsNull() { + upgradedState.IsVisible = priorState.IsVisible + } + + if !priorState.IsBrowserShortcutEnabled.IsNull() { + upgradedState.IsBrowserShortcutEnabled = priorState.IsBrowserShortcutEnabled + } + + if !priorState.Alias.IsNull() && priorState.Alias.ValueString() != "" { + upgradedState.Alias = priorState.Alias + } + + if !priorState.SecurityPolicyID.IsNull() && priorState.SecurityPolicyID.ValueString() != "" { + upgradedState.SecurityPolicyID = priorState.SecurityPolicyID + } + + resp.Diagnostics.Append(resp.State.Set(ctx, upgradedState)...) + + resp.Diagnostics.AddWarning("Please update the protocols sections format from a block to an object", + "See the v1 to v2 migration guide in the Twingate Terraform Provider documentation https://registry.terraform.io/providers/Twingate/twingate/latest/docs/guides/migrate-guide-v1-to-v2") }, - attr.IsBrowserShortcutEnabled: { - Type: schema.TypeBool, + }, + } +} + +func protocols() schema.SingleNestedAttribute { + return schema.SingleNestedAttribute{ + Optional: true, + Computed: true, + Default: objectdefault.StaticValue(defaultProtocolsObject()), + Attributes: map[string]schema.Attribute{ + attr.AllowIcmp: schema.BoolAttribute{ Optional: true, Computed: true, - Description: `Controls whether an "Open in Browser" shortcut will be shown for this Resource in the Twingate Client.`, + Default: booldefault.StaticBool(true), + Description: "Whether to allow ICMP (ping) traffic", }, - attr.Alias: { - Type: schema.TypeString, - Optional: true, - Description: "Set a DNS alias address for the Resource. Must be a DNS-valid name string.", - DiffSuppressFunc: aliasDiff, + + attr.UDP: protocol(), + attr.TCP: protocol(), + }, + Description: "Restrict access to certain protocols and ports. By default or when this argument is not defined, there is no restriction, and all protocols and ports are allowed.", + } +} + +func protocol() schema.SingleNestedAttribute { + return schema.SingleNestedAttribute{ + Optional: true, + Computed: true, + Default: objectdefault.StaticValue(defaultProtocolObject()), + Attributes: map[string]schema.Attribute{ + attr.Policy: schema.StringAttribute{ + Optional: true, + Computed: true, + Validators: []validator.String{ + stringvalidator.OneOf(model.Policies...), + }, + Default: stringdefault.StaticString(model.PolicyAllowAll), + Description: fmt.Sprintf("Whether to allow or deny all ports, or restrict protocol access within certain port ranges: Can be `%s` (only listed ports are allowed), `%s`, or `%s`", model.PolicyRestricted, model.PolicyAllowAll, model.PolicyDenyAll), }, - attr.ID: { - Type: schema.TypeString, + attr.Ports: schema.SetAttribute{ + Optional: true, Computed: true, - Description: "Autogenerated ID of the Resource, encoded in base64", + ElementType: types.StringType, + Description: "List of port ranges between 1 and 65535 inclusive, in the format `100-200` for a range, or `8080` for a single port", + PlanModifiers: []planmodifier.Set{ + PortsDiff(), + }, + Default: setdefault.StaticValue(defaultEmptyPorts()), }, }, - Importer: &schema.ResourceImporter{ - StateContext: schema.ImportStatePassthroughContext, + } +} + +func accessBlock() schema.ListNestedBlock { + return schema.ListNestedBlock{ + Validators: []validator.List{ + listvalidator.SizeAtMost(1), + }, + Description: "Restrict access to certain groups or service accounts", + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + attr.GroupIDs: schema.SetAttribute{ + Optional: true, + ElementType: types.StringType, + Description: "List of Group IDs that will have permission to access the Resource.", + Validators: []validator.Set{ + setvalidator.SizeAtLeast(1), + }, + }, + attr.ServiceAccountIDs: schema.SetAttribute{ + Optional: true, + ElementType: types.StringType, + Description: "List of Service Account IDs that will have permission to access the Resource.", + Validators: []validator.Set{ + setvalidator.SizeAtLeast(1), + }, + }, + }, }, } } -func resourceCreate(ctx context.Context, resourceData *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*client.Client) +func PortsDiff() planmodifier.Set { + return portsDiff{} +} + +type portsDiff struct{} + +// Description returns a human-readable description of the plan modifier. +func (m portsDiff) Description(_ context.Context) string { + return "Handles ports difference." +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m portsDiff) MarkdownDescription(_ context.Context) string { + return "Handles ports difference." +} + +// PlanModifySet implements the plan modification logic. +func (m portsDiff) PlanModifySet(_ context.Context, req planmodifier.SetRequest, resp *planmodifier.SetResponse) { + if req.StateValue.IsNull() { + return + } + + if equalPorts(req.StateValue, req.PlanValue) { + resp.PlanValue = req.StateValue + } +} + +func equalPorts(one, another types.Set) bool { + oldPortsRange, err := convertPorts(one) + if err != nil { + return false + } + + newPortsRange, err := convertPorts(another) + if err != nil { + return false + } + + return portRangeEqual(oldPortsRange, newPortsRange) +} + +func portRangeEqual(one, another []*model.PortRange) bool { + oneMap := convertPortsRangeToMap(one) + anotherMap := convertPortsRangeToMap(another) + + return reflect.DeepEqual(oneMap, anotherMap) +} + +func convertPorts(list types.Set) ([]*model.PortRange, error) { + items := list.Elements() + + var ports = make([]*model.PortRange, 0, len(items)) + + for _, port := range items { + portRange, err := model.NewPortRange(port.(types.String).ValueString()) + if err != nil { + return nil, err //nolint:wrapcheck + } + + ports = append(ports, portRange) + } + + return ports, nil +} + +func convertPortsRangeToMap(portsRange []*model.PortRange) map[int]struct{} { + out := make(map[int]struct{}) + + for _, port := range portsRange { + if port.Start == port.End { + out[port.Start] = struct{}{} + + continue + } + + for i := port.Start; i <= port.End; i++ { + out[i] = struct{}{} + } + } + + return out +} + +func (r *twingateResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan resourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + + if resp.Diagnostics.HasError() { + return + } + + input, err := convertResource(&plan) + if err != nil { + addErr(&resp.Diagnostics, err, operationCreate, TwingateResource) + + return + } + + resource, err := r.client.CreateResource(ctx, input) + if err != nil { + addErr(&resp.Diagnostics, err, operationCreate, TwingateResource) + + return + } + + if err = r.client.AddResourceAccess(ctx, resource.ID, resource.ServiceAccounts); err != nil { + addErr(&resp.Diagnostics, err, operationCreate, TwingateResource) + + return + } + + if !input.IsActive { + if err := r.client.UpdateResourceActiveState(ctx, &model.Resource{ + ID: resource.ID, + IsActive: false, + }); err != nil { + addErr(&resp.Diagnostics, err, operationCreate, TwingateResource) + + return + } + + resource.IsActive = false + } + + r.helper(ctx, resource, &plan, &plan, &resp.State, &resp.Diagnostics, err, operationCreate) +} + +func getAccessAttribute(list types.List, attribute string) []string { + if list.IsNull() || list.IsUnknown() || len(list.Elements()) == 0 { + return nil + } + + obj := list.Elements()[0].(types.Object) + if obj.IsNull() || obj.IsUnknown() { + return nil + } + + val := obj.Attributes()[attribute] + if val == nil || val.IsNull() || val.IsUnknown() { + return nil + } + + return convertIDs(val.(types.Set)) +} + +func convertResource(plan *resourceModel) (*model.Resource, error) { + protocols, err := convertProtocols(&plan.Protocols) + if err != nil { + return nil, err + } + + groupIDs := getAccessAttribute(plan.Access, attr.GroupIDs) + serviceAccountIDs := getAccessAttribute(plan.Access, attr.ServiceAccountIDs) + + if !plan.Access.IsNull() && groupIDs == nil && serviceAccountIDs == nil { + return nil, ErrInvalidAttributeCombination + } + + isBrowserShortcutEnabled := getOptionalBool(plan.IsBrowserShortcutEnabled) + + if isBrowserShortcutEnabled != nil && *isBrowserShortcutEnabled && isWildcardAddress(plan.Address.ValueString()) { + return nil, ErrWildcardAddressWithEnabledShortcut + } + + return &model.Resource{ + Name: plan.Name.ValueString(), + RemoteNetworkID: plan.RemoteNetworkID.ValueString(), + Address: plan.Address.ValueString(), + Protocols: protocols, + Groups: groupIDs, + ServiceAccounts: serviceAccountIDs, + IsActive: plan.IsActive.ValueBool(), + IsAuthoritative: convertAuthoritativeFlag(plan.IsAuthoritative), + Alias: getOptionalString(plan.Alias), + IsVisible: getOptionalBool(plan.IsVisible), + IsBrowserShortcutEnabled: isBrowserShortcutEnabled, + SecurityPolicyID: plan.SecurityPolicyID.ValueStringPointer(), + }, nil +} + +func getOptionalBool(val types.Bool) *bool { + if !val.IsUnknown() { + return val.ValueBoolPointer() + } + + return nil +} + +func getOptionalString(val types.String) *string { + if !val.IsUnknown() && !val.IsNull() { + return val.ValueStringPointer() + } + + return nil +} + +func convertIDs(list types.Set) []string { + return utils.Map(list.Elements(), func(item tfattr.Value) string { + return item.(types.String).ValueString() + }) +} + +func equalProtocolsState(objA, objB *types.Object) bool { + if objA.IsNull() != objB.IsNull() || objA.IsUnknown() != objB.IsUnknown() { + return false + } + + protocolsA, err := convertProtocols(objA) + if err != nil { + return false + } + + protocolsB, err := convertProtocols(objB) + if err != nil { + return false + } + + return equalProtocols(protocolsA, protocolsB) +} + +func equalProtocols(one, another *model.Protocols) bool { + return one.AllowIcmp == another.AllowIcmp && equalProtocol(one.TCP, another.TCP) && equalProtocol(one.UDP, another.UDP) +} + +func equalProtocol(one, another *model.Protocol) bool { + return one.Policy == another.Policy && portRangeEqual(one.Ports, another.Ports) +} + +func convertProtocols(protocols *types.Object) (*model.Protocols, error) { + if protocols == nil || protocols.IsNull() || protocols.IsUnknown() { + return model.DefaultProtocols(), nil + } + + udp, err := convertProtocol(protocols.Attributes()[attr.UDP]) + if err != nil { + return nil, err + } + + tcp, err := convertProtocol(protocols.Attributes()[attr.TCP]) + if err != nil { + return nil, err + } + + return &model.Protocols{ + AllowIcmp: protocols.Attributes()[attr.AllowIcmp].(types.Bool).ValueBool(), + UDP: udp, + TCP: tcp, + }, nil +} + +func convertProtocol(protocol tfattr.Value) (*model.Protocol, error) { + obj := convertProtocolObj(protocol) + if obj.IsNull() { + return nil, nil //nolint:nilnil + } + + ports, err := decodePorts(obj) + if err != nil { + return nil, err + } + + policy := obj.Attributes()[attr.Policy].(types.String).ValueString() + if err := isValidPolicy(policy, ports); err != nil { + return nil, err + } + + if policy == model.PolicyDenyAll { + policy = model.PolicyRestricted + } + + return model.NewProtocol(policy, ports), nil +} + +func convertProtocolObj(protocol tfattr.Value) types.Object { + if protocol == nil || protocol.IsNull() { + return types.ObjectNull(nil) + } + + obj, ok := protocol.(types.Object) + if !ok || obj.IsNull() { + return types.ObjectNull(nil) + } + + return obj +} + +func decodePorts(obj types.Object) ([]*model.PortRange, error) { + portsVal := obj.Attributes()[attr.Ports] + if portsVal == nil || portsVal.IsNull() { + return nil, nil + } + + portsList, ok := portsVal.(types.Set) + if !ok { + return nil, nil + } + + return convertPorts(portsList) +} + +func isValidPolicy(policy string, ports []*model.PortRange) error { + switch policy { + case model.PolicyAllowAll: + if len(ports) > 0 { + return ErrPortsWithPolicyAllowAll + } + + case model.PolicyDenyAll: + if len(ports) > 0 { + return ErrPortsWithPolicyDenyAll + } + + case model.PolicyRestricted: + if len(ports) == 0 { + return ErrPolicyRestrictedWithoutPorts + } + } + + return nil +} + +func convertProtocolsV0(protocols types.List) (*model.Protocols, error) { + if protocols.IsNull() || protocols.IsUnknown() || len(protocols.Elements()) == 0 { + return model.DefaultProtocols(), nil + } + + obj := protocols.Elements()[0].(types.Object) + if obj.IsNull() || obj.IsUnknown() { + return model.DefaultProtocols(), nil + } + + udp, err := convertProtocolV0(obj.Attributes()[attr.UDP]) + if err != nil { + return nil, err + } + + tcp, err := convertProtocolV0(obj.Attributes()[attr.TCP]) + if err != nil { + return nil, err + } + + return &model.Protocols{ + AllowIcmp: obj.Attributes()[attr.AllowIcmp].(types.Bool).ValueBool(), + UDP: udp, + TCP: tcp, + }, nil +} + +func convertProtocolV0(protocol tfattr.Value) (*model.Protocol, error) { + obj := convertProtocolObjV0(protocol) + if obj.IsNull() { + return nil, nil //nolint:nilnil + } + + ports, err := decodePortsV0(obj) + if err != nil { + return nil, err + } + + policy := obj.Attributes()[attr.Policy].(types.String).ValueString() + if err := isValidPolicyV0(policy, ports); err != nil { + return nil, err + } + + if policy == model.PolicyDenyAll { + policy = model.PolicyRestricted + } + + return model.NewProtocol(policy, ports), nil +} + +func convertProtocolObjV0(protocol tfattr.Value) types.Object { + if protocol == nil || protocol.IsNull() { + return types.ObjectNull(nil) + } - resource, err := convertResource(resourceData) - if err != nil { - return diag.FromErr(err) + list, ok := protocol.(types.List) + if !ok || list.IsNull() || list.IsUnknown() || len(list.Elements()) == 0 { + return types.ObjectNull(nil) + } + + obj := list.Elements()[0].(types.Object) + if obj.IsNull() || obj.IsUnknown() { + return types.ObjectNull(nil) } - shouldBeDisabled := !resource.IsActive + return obj +} - resource, err = client.CreateResource(ctx, resource) - if err != nil { - return diag.FromErr(err) +func decodePortsV0(obj types.Object) ([]*model.PortRange, error) { + portsVal := obj.Attributes()[attr.Ports] + if portsVal == nil || portsVal.IsNull() { + return nil, nil } - if err = client.AddResourceAccess(ctx, resource.ID, resource.ServiceAccounts); err != nil { - return diag.FromErr(err) + portsList, ok := portsVal.(types.Set) + if !ok { + return nil, nil } - if shouldBeDisabled { - if err := client.UpdateResourceActiveState(ctx, &model.Resource{ - ID: resource.ID, - IsActive: false, - }); err != nil { - return diag.FromErr(err) + return convertPortsV0(portsList) +} + +func convertPortsV0(list types.Set) ([]*model.PortRange, error) { + items := list.Elements() + + var ports = make([]*model.PortRange, 0, len(items)) + + for _, port := range items { + portRange, err := model.NewPortRange(port.(types.String).ValueString()) + if err != nil { + return nil, err //nolint:wrapcheck } - resource.IsActive = false + ports = append(ports, portRange) } - log.Printf("[INFO] Created resource %s", resource.Name) + return ports, nil +} + +func isValidPolicyV0(policy string, ports []*model.PortRange) error { + switch policy { + case model.PolicyAllowAll: + if len(ports) > 0 { + return ErrPortsWithPolicyAllowAll + } + + case model.PolicyDenyAll: + if len(ports) > 0 { + return ErrPortsWithPolicyDenyAll + } + + case model.PolicyRestricted: + if len(ports) == 0 { + return ErrPolicyRestrictedWithoutPorts + } + } - return resourceResourceReadHelper(resourceData, resource, nil) + return nil } -func resourceUpdate(ctx context.Context, resourceData *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*client.Client) +func (r *twingateResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state resourceModel - resource, err := convertResource(resourceData) - if err != nil { - return diag.FromErr(err) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + + if resp.Diagnostics.HasError() { + return } - resource.ID = resourceData.Id() + resource, err := r.client.ReadResource(ctx, state.ID.ValueString()) + if resource != nil { + resource.IsAuthoritative = convertAuthoritativeFlag(state.IsAuthoritative) - if resourceData.HasChange(attr.Access) { - idsToDelete, idsToAdd, err := getChangedAccessIDs(ctx, resourceData, resource, client) - if err != nil { - return diag.FromErr(err) + if state.SecurityPolicyID.ValueString() == "" { + s := "" + resource.SecurityPolicyID = &s } + } - if err := client.RemoveResourceAccess(ctx, resource.ID, idsToDelete); err != nil { - return diag.FromErr(err) - } + r.helper(ctx, resource, &state, &state, &resp.State, &resp.Diagnostics, err, operationRead) +} + +func (r *twingateResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state resourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + + if resp.Diagnostics.HasError() { + return + } + + input, err := convertResource(&plan) + if err != nil { + addErr(&resp.Diagnostics, err, operationUpdate, TwingateResource) + + return + } + + planSecurityPolicy := input.SecurityPolicyID + input.ID = state.ID.ValueString() - if err = client.AddResourceAccess(ctx, resource.ID, idsToAdd); err != nil { - return diag.FromErr(err) + if !plan.Access.Equal(state.Access) { + if err := r.updateResourceAccess(ctx, &plan, &state, input); err != nil { + addErr(&resp.Diagnostics, err, operationUpdate, TwingateResource) + + return } } - if resourceData.HasChanges( - attr.RemoteNetworkID, - attr.Name, - attr.Address, - attr.Protocols, - attr.IsVisible, - attr.IsBrowserShortcutEnabled, - attr.Alias, - attr.SecurityPolicyID, - attr.IsActive, - ) { - diagErr := setDefaultSecurityPolicy(ctx, resource, client) - if diagErr.HasError() { - return diagErr + var resource *model.Resource + + if isResourceChanged(&plan, &state) { + if err := r.setDefaultSecurityPolicy(ctx, input); err != nil { + addErr(&resp.Diagnostics, err, operationUpdate, TwingateResource) + + return } - resource, err = client.UpdateResource(ctx, resource) + resource, err = r.client.UpdateResource(ctx, input) } else { - resource, err = client.ReadResource(ctx, resource.ID) + resource, err = r.client.ReadResource(ctx, input.ID) } if resource != nil { - resource.IsAuthoritative = convertAuthoritativeFlagLegacy(resourceData) - log.Printf("[INFO] Updated resource %s", resource.Name) + resource.IsAuthoritative = input.IsAuthoritative + } + + if planSecurityPolicy != nil && *planSecurityPolicy == "" { + resource.SecurityPolicyID = planSecurityPolicy } - return resourceResourceReadHelper(resourceData, resource, err) + r.helper(ctx, resource, &state, &plan, &resp.State, &resp.Diagnostics, err, operationUpdate) } -func setDefaultSecurityPolicy(ctx context.Context, resource *model.Resource, client *client.Client) diag.Diagnostics { +func (r *twingateResource) setDefaultSecurityPolicy(ctx context.Context, resource *model.Resource) error { if DefaultSecurityPolicyID == "" { - policy, _ := client.ReadSecurityPolicy(ctx, "", DefaultSecurityPolicyName) + policy, _ := r.client.ReadSecurityPolicy(ctx, "", DefaultSecurityPolicyName) if policy != nil { DefaultSecurityPolicyID = policy.ID } } if DefaultSecurityPolicyID == "" { - return diag.Errorf("default policy not set") + return ErrDefaultPolicyNotSet } - remoteResource, err := client.ReadResource(ctx, resource.ID) + remoteResource, err := r.client.ReadResource(ctx, resource.ID) if err != nil { - return diag.FromErr(err) + return err //nolint:wrapcheck } if remoteResource.SecurityPolicyID != nil && (resource.SecurityPolicyID == nil || *resource.SecurityPolicyID == "") && @@ -297,47 +1005,93 @@ func setDefaultSecurityPolicy(ctx context.Context, resource *model.Resource, cli return nil } -func resourceRead(ctx context.Context, resourceData *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*client.Client) +func isResourceChanged(plan, state *resourceModel) bool { + return !plan.RemoteNetworkID.Equal(state.RemoteNetworkID) || + !plan.Name.Equal(state.Name) || + !plan.Address.Equal(state.Address) || + !equalProtocolsState(&plan.Protocols, &state.Protocols) || + !plan.IsActive.Equal(state.IsActive) || + !plan.IsVisible.Equal(state.IsVisible) || + !plan.IsBrowserShortcutEnabled.Equal(state.IsBrowserShortcutEnabled) || + !plan.Alias.Equal(state.Alias) || + !plan.SecurityPolicyID.Equal(state.SecurityPolicyID) +} - securityPolicyID := resourceData.Get(attr.SecurityPolicyID) +func (r *twingateResource) updateResourceAccess(ctx context.Context, plan, state *resourceModel, input *model.Resource) error { + idsToDelete, idsToAdd, err := r.getChangedAccessIDs(ctx, plan, state, input) + if err != nil { + return fmt.Errorf("failed to update resource access: %w", err) + } - resource, err := client.ReadResource(ctx, resourceData.Id()) - if resource != nil { - resource.IsAuthoritative = convertAuthoritativeFlagLegacy(resourceData) + if err := r.client.RemoveResourceAccess(ctx, input.ID, idsToDelete); err != nil { + return fmt.Errorf("failed to update resource access: %w", err) + } - if securityPolicyID == "" { - resource.SecurityPolicyID = nil - } + if err := r.client.AddResourceAccess(ctx, input.ID, idsToAdd); err != nil { + return fmt.Errorf("failed to update resource access: %w", err) } - return resourceResourceReadHelper(resourceData, resource, err) + return nil } -func resourceDelete(ctx context.Context, resourceData *schema.ResourceData, meta interface{}) diag.Diagnostics { - c := meta.(*client.Client) - resourceID := resourceData.Id() - - err := c.DeleteResource(ctx, resourceID) +func (r *twingateResource) getChangedAccessIDs(ctx context.Context, plan, state *resourceModel, resource *model.Resource) ([]string, []string, error) { + remote, err := r.client.ReadResource(ctx, resource.ID) if err != nil { - return diag.FromErr(err) + return nil, nil, fmt.Errorf("failed to get changedIDs: %w", err) + } + + var oldGroups, oldServiceAccounts []string + if resource.IsAuthoritative { + oldGroups, oldServiceAccounts = remote.Groups, remote.ServiceAccounts + } else { + oldGroups = getOldIDsNonAuthoritative(plan, state, attr.GroupIDs) + oldServiceAccounts = getOldIDsNonAuthoritative(plan, state, attr.ServiceAccountIDs) } - log.Printf("[INFO] Deleted resource id %s", resourceData.Id()) + // ids to delete + groupsToDelete := setDifference(oldGroups, resource.Groups) + serviceAccountsToDelete := setDifference(oldServiceAccounts, resource.ServiceAccounts) + + // ids to add + groupsToAdd := setDifference(resource.Groups, remote.Groups) + serviceAccountsToAdd := setDifference(resource.ServiceAccounts, remote.ServiceAccounts) + + return append(groupsToDelete, serviceAccountsToDelete...), append(groupsToAdd, serviceAccountsToAdd...), nil +} + +func getOldIDsNonAuthoritative(plan, state *resourceModel, attribute string) []string { + if !plan.Access.Equal(state.Access) { + return getAccessAttribute(state.Access, attribute) + } return nil } -func resourceResourceReadHelper(resourceData *schema.ResourceData, resource *model.Resource, err error) diag.Diagnostics { +func (r *twingateResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state resourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + + if resp.Diagnostics.HasError() { + return + } + + err := r.client.DeleteResource(ctx, state.ID.ValueString()) + addErr(&resp.Diagnostics, err, operationDelete, TwingateResource) +} + +func (r *twingateResource) helper(ctx context.Context, resource *model.Resource, state, reference *resourceModel, respState *tfsdk.State, diagnostics *diag.Diagnostics, err error, operation string) { if err != nil { if errors.Is(err, client.ErrGraphqlResultIsEmpty) { // clear state - resourceData.SetId("") + respState.RemoveResource(ctx) - return nil + return } - return diag.FromErr(err) + addErr(diagnostics, err, operation, TwingateResource) + + return } if resource.Protocols == nil { @@ -345,382 +1099,407 @@ func resourceResourceReadHelper(resourceData *schema.ResourceData, resource *mod } if !resource.IsAuthoritative { - groups, serviceAccounts := convertAccess(resourceData) - resource.ServiceAccounts = setIntersection(serviceAccounts, resource.ServiceAccounts) - resource.Groups = setIntersection(groups, resource.Groups) + resource.Groups = setIntersection(getAccessAttribute(reference.Access, attr.GroupIDs), resource.Groups) + resource.ServiceAccounts = setIntersection(getAccessAttribute(reference.Access, attr.ServiceAccountIDs), resource.ServiceAccounts) } - resourceData.SetId(resource.ID) - - return readDiagnostics(resourceData, resource) -} + setState(ctx, state, reference, resource, diagnostics) -func readDiagnostics(resourceData *schema.ResourceData, resource *model.Resource) diag.Diagnostics { //nolint:cyclop - if err := resourceData.Set(attr.Name, resource.Name); err != nil { - return ErrAttributeSet(err, attr.Name) + if diagnostics.HasError() { + return } - if err := resourceData.Set(attr.RemoteNetworkID, resource.RemoteNetworkID); err != nil { - return ErrAttributeSet(err, attr.RemoteNetworkID) - } + // Set refreshed state + diagnostics.Append(respState.Set(ctx, state)...) +} - if err := resourceData.Set(attr.Address, resource.Address); err != nil { - return ErrAttributeSet(err, attr.Address) +func setState(ctx context.Context, state, reference *resourceModel, resource *model.Resource, diagnostics *diag.Diagnostics) { //nolint:cyclop + state.ID = types.StringValue(resource.ID) + state.Name = types.StringValue(resource.Name) + state.RemoteNetworkID = types.StringValue(resource.RemoteNetworkID) + state.Address = types.StringValue(resource.Address) + state.IsActive = types.BoolValue(resource.IsActive) + state.IsAuthoritative = types.BoolValue(resource.IsAuthoritative) + state.SecurityPolicyID = types.StringPointerValue(resource.SecurityPolicyID) + + if !state.IsVisible.IsNull() || !reference.IsVisible.IsUnknown() { + state.IsVisible = types.BoolPointerValue(resource.IsVisible) } - if err := resourceData.Set(attr.IsAuthoritative, resource.IsAuthoritative); err != nil { - return ErrAttributeSet(err, attr.IsAuthoritative) + if !state.IsBrowserShortcutEnabled.IsNull() || !reference.IsBrowserShortcutEnabled.IsUnknown() { + state.IsBrowserShortcutEnabled = types.BoolPointerValue(resource.IsBrowserShortcutEnabled) } - if err := resourceData.Set(attr.Access, resource.AccessToTerraform()); err != nil { - return ErrAttributeSet(err, attr.Access) + if !state.Alias.IsNull() || !reference.Alias.IsUnknown() { + state.Alias = reference.Alias } - protocols, err := convertProtocols(resourceData) - if err == nil && protocols != nil && protocols.TCP != nil && protocols.UDP != nil { - if portRangeEqual(protocols.TCP.Ports, resource.Protocols.TCP.Ports) { - resource.Protocols.TCP.Ports = protocols.TCP.Ports + if !state.Protocols.IsNull() || !reference.Protocols.IsUnknown() { + protocols, diags := convertProtocolsToTerraform(resource.Protocols, &reference.Protocols) + diagnostics.Append(diags...) + + if diagnostics.HasError() { + return } - if portRangeEqual(protocols.UDP.Ports, resource.Protocols.UDP.Ports) { - resource.Protocols.UDP.Ports = protocols.UDP.Ports + if !equalProtocolsState(&state.Protocols, &protocols) { + state.Protocols = protocols } } - if err := resourceData.Set(attr.Protocols, resource.Protocols.ToTerraform()); err != nil { - return ErrAttributeSet(err, attr.Protocols) - } + if !state.Access.IsNull() { + access, diags := convertAccessBlockToTerraform(ctx, resource, + state.Access.Elements()[0].(types.Object).Attributes()[attr.GroupIDs], + state.Access.Elements()[0].(types.Object).Attributes()[attr.ServiceAccountIDs]) - if resource.IsVisible != nil { - if err := resourceData.Set(attr.IsVisible, *resource.IsVisible); err != nil { - return ErrAttributeSet(err, attr.IsVisible) - } - } + diagnostics.Append(diags...) - if resource.IsBrowserShortcutEnabled != nil { - if err := resourceData.Set(attr.IsBrowserShortcutEnabled, *resource.IsBrowserShortcutEnabled); err != nil { - return ErrAttributeSet(err, attr.IsBrowserShortcutEnabled) + if diagnostics.HasError() { + return } - } - if err := resourceData.Set(attr.Alias, resource.Alias); err != nil { - return ErrAttributeSet(err, attr.Alias) + state.Access = access } +} - if err := resourceData.Set(attr.SecurityPolicyID, resource.SecurityPolicyID); err != nil { - return ErrAttributeSet(err, attr.SecurityPolicyID) - } +func convertProtocolsToTerraform(protocols *model.Protocols, reference *types.Object) (types.Object, diag.Diagnostics) { + var diagnostics diag.Diagnostics - if err := resourceData.Set(attr.IsActive, resource.IsActive); err != nil { - return ErrAttributeSet(err, attr.IsActive) + if protocols == nil || reference != nil && (reference.IsUnknown() || reference.IsNull()) { + return defaultProtocolsModelToTerraform() } - return nil -} - -func aliasDiff(key, _, _ string, resourceData *schema.ResourceData) bool { - oldVal, newVal := castToStrings(resourceData.GetChange(key)) + var referenceTCP, referenceUDP tfattr.Value + if reference != nil { + referenceTCP = reference.Attributes()[attr.TCP] + referenceUDP = reference.Attributes()[attr.UDP] + } - return oldVal == newVal -} + tcp, diags := convertProtocolModelToTerraform(protocols.TCP, referenceTCP) + diagnostics.Append(diags...) -func equalPorts(a, b interface{}) bool { - oldPorts, newPorts := a.([]interface{}), b.([]interface{}) + udp, diags := convertProtocolModelToTerraform(protocols.UDP, referenceUDP) + diagnostics.Append(diags...) - oldPortsRange, err := convertPorts(oldPorts) - if err != nil { - return false + if diagnostics.HasError() { + return types.ObjectNull(protocolsAttributeTypes()), diagnostics } - newPortsRange, err := convertPorts(newPorts) - if err != nil { - return false + attributes := map[string]tfattr.Value{ + attr.AllowIcmp: types.BoolValue(protocols.AllowIcmp), + attr.TCP: tcp, + attr.UDP: udp, } - return portRangeEqual(oldPortsRange, newPortsRange) -} - -func portRangeEqual(portsA, portsB []*model.PortRange) bool { - mapA := convertPortsRangeToMap(portsA) - mapB := convertPortsRangeToMap(portsB) + obj := types.ObjectValueMust(protocolsAttributeTypes(), attributes) - return reflect.DeepEqual(mapA, mapB) + return obj, diagnostics } -func convertPortsRangeToMap(portsRange []*model.PortRange) map[int]struct{} { - out := make(map[int]struct{}) - - for _, port := range portsRange { - if port.Start == port.End { - out[port.Start] = struct{}{} - - continue - } +func convertPortsToTerraform(ports []*model.PortRange) types.Set { + if len(ports) == 0 { + return defaultEmptyPorts() + } - for i := port.Start; i <= port.End; i++ { - out[i] = struct{}{} - } + elements := make([]tfattr.Value, 0, len(ports)) + for _, port := range ports { + elements = append(elements, types.StringValue(port.String())) } - return out + return types.SetValueMust(types.StringType, elements) } -func portsNotChanged(attribute, oldValue, newValue string, data *schema.ResourceData) bool { - keys := []string{ - attr.Path(attr.Protocols, attr.TCP, attr.Ports), - attr.Path(attr.Protocols, attr.UDP, attr.Ports), +func convertProtocolModelToTerraform(protocol *model.Protocol, _ tfattr.Value) (types.Object, diag.Diagnostics) { + if protocol == nil { + return types.ObjectNull(protocolAttributeTypes()), nil } - if strings.HasSuffix(attribute, "#") && newValue == "0" { - return newValue == oldValue + ports := convertPortsToTerraform(protocol.Ports) + + policy := protocol.Policy + if policy == model.PolicyRestricted && len(ports.Elements()) == 0 { + policy = model.PolicyDenyAll } - for _, key := range keys { - if strings.HasPrefix(attribute, key) { - return equalPorts(data.GetChange(key)) - } + attributes := map[string]tfattr.Value{ + attr.Policy: types.StringValue(policy), + attr.Ports: ports, } - return false + return types.ObjectValue(protocolAttributeTypes(), attributes) } -// protocolsNotChanged - suppress protocols change when uses default value. -func protocolsNotChanged(attribute, oldValue, newValue string, data *schema.ResourceData) bool { - switch attribute { - case attr.Len(attr.Protocols): - return newValue == "0" - case attr.Len(attr.Protocols, attr.TCP), attr.Len(attr.Protocols, attr.UDP): - return newValue == "0" - case attr.Path(attr.Protocols, attr.TCP, attr.Policy), attr.Path(attr.Protocols, attr.UDP, attr.Policy): - return oldValue == model.PolicyAllowAll && newValue == "" - } +func defaultProtocolsModelToTerraform() (types.Object, diag.Diagnostics) { + attributeTypes := protocolsAttributeTypes() - return false -} + var diagnostics diag.Diagnostics -func defaultPolicyNotChanged(attribute, oldValue, newValue string, data *schema.ResourceData) bool { - return oldValue == DefaultSecurityPolicyID && (newValue == "" || newValue == DefaultSecurityPolicyID) -} + defaultPorts, diags := defaultProtocolModelToTerraform() + diagnostics.Append(diags...) -func getChangedAccessIDs(ctx context.Context, resourceData *schema.ResourceData, resource *model.Resource, client *client.Client) ([]string, []string, error) { - remote, err := client.ReadResource(ctx, resource.ID) - if err != nil { - return nil, nil, fmt.Errorf("failed to get changedIDs: %w", err) + if diagnostics.HasError() { + return makeNullObject(attributeTypes), diagnostics } - var oldGroups, oldServiceAccounts []string - if resource.IsAuthoritative { - oldGroups, oldServiceAccounts = remote.Groups, remote.ServiceAccounts - } else { - oldGroups = getOldIDsNonAuthoritative(resourceData, attr.GroupIDs) - oldServiceAccounts = getOldIDsNonAuthoritative(resourceData, attr.ServiceAccountIDs) + attributes := map[string]tfattr.Value{ + attr.AllowIcmp: types.BoolValue(true), + attr.TCP: defaultPorts, + attr.UDP: defaultPorts, } - // ids to delete - groupsToDelete := setDifference(oldGroups, resource.Groups) - serviceAccountsToDelete := setDifference(oldServiceAccounts, resource.ServiceAccounts) + obj, diags := types.ObjectValue(attributeTypes, attributes) + diagnostics.Append(diags...) - // ids to add - groupsToAdd := setDifference(resource.Groups, remote.Groups) - serviceAccountsToAdd := setDifference(resource.ServiceAccounts, remote.ServiceAccounts) + if diagnostics.HasError() { + return makeNullObject(attributeTypes), diagnostics + } - return append(groupsToDelete, serviceAccountsToDelete...), append(groupsToAdd, serviceAccountsToAdd...), nil + return obj, diagnostics } -func getOldIDsNonAuthoritative(resourceData *schema.ResourceData, attribute string) []string { - if resourceData.HasChange(attr.Path(attr.Access, attribute)) { - old, _ := resourceData.GetChange(attr.Path(attr.Access, attribute)) +func defaultProtocolsObject() types.Object { + attributeTypes := protocolsAttributeTypes() - return convertIDs(old) - } + var diagnostics diag.Diagnostics - return nil -} + defaultPorts, diags := defaultProtocolModelToTerraform() + diagnostics.Append(diags...) -func convertResource(data *schema.ResourceData) (*model.Resource, error) { - protocols, err := convertProtocols(data) - if err != nil { - return nil, err + if diagnostics.HasError() { + return makeNullObject(attributeTypes) } - groups, serviceAccounts := convertAccess(data) - res := &model.Resource{ - Name: data.Get(attr.Name).(string), - RemoteNetworkID: data.Get(attr.RemoteNetworkID).(string), - Address: data.Get(attr.Address).(string), - Protocols: protocols, - Groups: groups, - ServiceAccounts: serviceAccounts, - IsAuthoritative: convertAuthoritativeFlagLegacy(data), - Alias: getOptionalString(data, attr.Alias), - SecurityPolicyID: getOptionalString(data, attr.SecurityPolicyID), - IsActive: data.Get(attr.IsActive).(bool), + attributes := map[string]tfattr.Value{ + attr.AllowIcmp: types.BoolValue(true), + attr.TCP: defaultPorts, + attr.UDP: defaultPorts, } - isVisible, ok := data.GetOkExists(attr.IsVisible) //nolint - if val := isVisible.(bool); ok { - res.IsVisible = &val - } + obj, diags := types.ObjectValue(attributeTypes, attributes) + diagnostics.Append(diags...) - isBrowserShortcutEnabled, ok := data.GetOkExists(attr.IsBrowserShortcutEnabled) //nolint - if val := isBrowserShortcutEnabled.(bool); ok && isAttrKnown(data, attr.IsBrowserShortcutEnabled) { - res.IsBrowserShortcutEnabled = &val + if diagnostics.HasError() { + return makeNullObject(attributeTypes) } - if res.IsBrowserShortcutEnabled != nil && *res.IsBrowserShortcutEnabled && isWildcardAddress(res.Address) { - return nil, ErrWildcardAddressWithEnabledShortcut + return obj +} + +func defaultEmptyPorts() types.Set { + return types.SetValueMust(types.StringType, []tfattr.Value{}) +} + +func defaultProtocolModelToTerraform() (basetypes.ObjectValue, diag.Diagnostics) { + attributes := map[string]tfattr.Value{ + attr.Policy: types.StringValue(model.PolicyAllowAll), + attr.Ports: defaultEmptyPorts(), } - return res, nil + return types.ObjectValue(protocolAttributeTypes(), attributes) } -var cidrRgxp = regexp.MustCompile(`(\d{1,3}\.){3}\d{1,3}(/\d+)?`) +func defaultProtocolObject() basetypes.ObjectValue { + obj, _ := defaultProtocolModelToTerraform() -func isWildcardAddress(address string) bool { - return strings.ContainsAny(address, "*?") || cidrRgxp.MatchString(address) + return obj } -func isAttrKnown(data *schema.ResourceData, attr string) bool { - cfg := data.GetRawConfig() - val := cfg.GetAttr(attr) +func protocolsAttributeTypes() map[string]tfattr.Type { + return map[string]tfattr.Type{ + attr.AllowIcmp: types.BoolType, + attr.TCP: types.ObjectType{ + AttrTypes: protocolAttributeTypes(), + }, + attr.UDP: types.ObjectType{ + AttrTypes: protocolAttributeTypes(), + }, + } +} - return !val.IsNull() && val.IsKnown() +func protocolAttributeTypes() map[string]tfattr.Type { + return map[string]tfattr.Type{ + attr.Policy: types.StringType, + attr.Ports: types.SetType{ + ElemType: types.StringType, + }, + } } -func getOptionalString(data *schema.ResourceData, attr string) *string { - if data == nil { - return nil +func convertAccessBlockToTerraform(ctx context.Context, resource *model.Resource, stateGroupIDs, stateServiceAccounts tfattr.Value) (types.List, diag.Diagnostics) { + var diagnostics, diags diag.Diagnostics + + groupIDs, serviceAccountIDs := types.SetNull(types.StringType), types.SetNull(types.StringType) + + if len(resource.Groups) > 0 { + groupIDs, diags = makeSet(resource.Groups) + diagnostics.Append(diags...) } - var result *string + if len(resource.ServiceAccounts) > 0 { + serviceAccountIDs, diags = makeSet(resource.ServiceAccounts) + diagnostics.Append(diags...) + } - cfg := data.GetRawConfig() - if cfg.IsNull() { - return nil + if diagnostics.HasError() { + return makeObjectsListNull(ctx, accessAttributeTypes()), diagnostics } - val := cfg.GetAttr(attr) + attributes := map[string]tfattr.Value{ + attr.GroupIDs: stateGroupIDs, + attr.ServiceAccountIDs: stateServiceAccounts, + } + + if !groupIDs.IsNull() { + attributes[attr.GroupIDs] = groupIDs + } + + if !serviceAccountIDs.IsNull() { + attributes[attr.ServiceAccountIDs] = serviceAccountIDs + } + + obj, diags := types.ObjectValue(accessAttributeTypes(), attributes) + diagnostics.Append(diags...) - if !val.IsNull() { - str := val.AsString() - result = &str + if diagnostics.HasError() { + return makeObjectsListNull(ctx, accessAttributeTypes()), diagnostics } - return result + return makeObjectsList(ctx, obj) } -func convertAccess(data *schema.ResourceData) ([]string, []string) { - rawList := data.Get(attr.Access).([]interface{}) - if len(rawList) == 0 || rawList[0] == nil { - return nil, nil +func accessAttributeTypes() map[string]tfattr.Type { + return map[string]tfattr.Type{ + attr.GroupIDs: types.SetType{ + ElemType: types.StringType, + }, + attr.ServiceAccountIDs: types.SetType{ + ElemType: types.StringType, + }, } +} - rawMap := rawList[0].(map[string]interface{}) +func makeNullObject(attributeTypes map[string]tfattr.Type) types.Object { + return types.ObjectNull(attributeTypes) +} - return convertIDs(rawMap[attr.GroupIDs]), convertIDs(rawMap[attr.ServiceAccountIDs]) +func makeObjectsListNull(ctx context.Context, attributeTypes map[string]tfattr.Type) types.List { + return types.ListNull(types.ObjectNull(attributeTypes).Type(ctx)) } -func convertAuthoritativeFlagLegacy(data *schema.ResourceData) bool { - flag, hasFlag := data.GetOkExists(attr.IsAuthoritative) //nolint +func makeObjectsList(ctx context.Context, objects ...types.Object) (types.List, diag.Diagnostics) { + obj := objects[0] - if hasFlag { - return flag.(bool) - } + items := utils.Map(objects, func(item types.Object) tfattr.Value { + return tfattr.Value(item) + }) - // default value - return true + return types.ListValue(obj.Type(ctx), items) } -func convertProtocols(data *schema.ResourceData) (*model.Protocols, error) { - rawList := data.Get(attr.Protocols).([]interface{}) - if len(rawList) == 0 { - return model.DefaultProtocols(), nil - } - - rawMap := rawList[0].(map[string]interface{}) +func makeSet(list []string) (types.Set, diag.Diagnostics) { + return types.SetValue(types.StringType, stringsToTerraformValue(list)) +} - udp, err := convertProtocol(rawMap[attr.UDP].([]interface{})) - if err != nil { - return nil, err +func stringsToTerraformValue(list []string) []tfattr.Value { + if len(list) == 0 { + return nil } - tcp, err := convertProtocol(rawMap[attr.TCP].([]interface{})) - if err != nil { - return nil, err + out := make([]tfattr.Value, 0, len(list)) + for _, item := range list { + out = append(out, types.StringValue(item)) } - return &model.Protocols{ - UDP: udp, - TCP: tcp, - AllowIcmp: rawMap[attr.AllowIcmp].(bool), - }, nil + return out } -func convertProtocol(rawList []interface{}) (*model.Protocol, error) { - if len(rawList) == 0 { - return nil, nil //nolint:nilnil +func CaseInsensitiveDiff() planmodifier.String { + return caseInsensitiveDiffModifier{ + description: "Handles case insensitive strings", } +} + +type caseInsensitiveDiffModifier struct { + description string +} + +func (m caseInsensitiveDiffModifier) Description(_ context.Context) string { + return m.description +} - rawMap := rawList[0].(map[string]interface{}) - policy := rawMap[attr.Policy].(string) +func (m caseInsensitiveDiffModifier) MarkdownDescription(_ context.Context) string { + return m.description +} - if policy == "" { - policy = model.PolicyAllowAll +func (m caseInsensitiveDiffModifier) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { + // Do not replace on resource creation. + if req.State.Raw.IsNull() { + return } - ports, err := convertPorts(rawMap[attr.Ports].([]interface{})) - if err != nil { - return nil, err + // Do not replace on resource destroy. + if req.Plan.Raw.IsNull() { + return } - if err := validateProtocol(policy, ports); err != nil { - return nil, err + if !req.PlanValue.IsUnknown() && req.StateValue.IsNull() { + return } - if policy == model.PolicyDenyAll { - policy = model.PolicyRestricted + if strings.EqualFold(strings.ToLower(req.PlanValue.ValueString()), strings.ToLower(req.StateValue.ValueString())) { + resp.PlanValue = req.StateValue } +} - return model.NewProtocol(policy, ports), nil +var cidrRgxp = regexp.MustCompile(`(\d{1,3}\.){3}\d{1,3}(/\d+)?`) + +func isWildcardAddress(address string) bool { + return strings.ContainsAny(address, "*?") || cidrRgxp.MatchString(address) } -func validateProtocol(policy string, ports []*model.PortRange) error { - switch policy { - case model.PolicyAllowAll: - if len(ports) > 0 { - return ErrPortsWithPolicyAllowAll - } +func UseDefaultPolicyForUnknownModifier() planmodifier.String { + return useDefaultPolicyForUnknownModifier{} +} - case model.PolicyDenyAll: - if len(ports) > 0 { - return ErrPortsWithPolicyDenyAll - } +// useDefaultPolicyForUnknownModifier implements the plan modifier. +type useDefaultPolicyForUnknownModifier struct{} - case model.PolicyRestricted: - if len(ports) == 0 { - return ErrPolicyRestrictedWithoutPorts - } - } +// Description returns a human-readable description of the plan modifier. +func (m useDefaultPolicyForUnknownModifier) Description(_ context.Context) string { + return "Once set, the value of this attribute will fallback to Default Policy on unset." +} - return nil +// MarkdownDescription returns a markdown description of the plan modifier. +func (m useDefaultPolicyForUnknownModifier) MarkdownDescription(_ context.Context) string { + return "Once set, the value of this attribute will fallback to Default Policy on unset." } -func convertPorts(rawList []interface{}) ([]*model.PortRange, error) { - var ports = make([]*model.PortRange, 0, len(rawList)) +// PlanModifyString implements the plan modification logic. +func (m useDefaultPolicyForUnknownModifier) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { + if req.StateValue.IsNull() && req.ConfigValue.IsNull() { + resp.PlanValue = types.StringPointerValue(nil) - for _, port := range rawList { - var str string - if port != nil { - str = port.(string) - } + return + } - portRange, err := model.NewPortRange(str) - if err != nil { - return nil, err //nolint:wrapcheck - } + // Do nothing if there is no state value. + if req.StateValue.IsNull() { + return + } - ports = append(ports, portRange) + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return } - return ports, nil + // Do nothing if there is a known planned value. + if req.ConfigValue.ValueString() != "" { + return + } + + if req.StateValue.ValueString() == "" && req.PlanValue.ValueString() == DefaultSecurityPolicyID { + resp.PlanValue = types.StringValue("") + } else if req.StateValue.ValueString() == DefaultSecurityPolicyID && req.PlanValue.ValueString() == "" { + resp.PlanValue = types.StringValue(DefaultSecurityPolicyID) + } } diff --git a/twingate/internal/test/acctests/datasource/resource_test.go b/twingate/internal/test/acctests/datasource/resource_test.go index 2dd67132..0b961385 100644 --- a/twingate/internal/test/acctests/datasource/resource_test.go +++ b/twingate/internal/test/acctests/datasource/resource_test.go @@ -44,13 +44,13 @@ func testDatasourceTwingateResource(networkName, resourceName string) string { name = "%s" address = "acc-test.com" remote_network_id = twingate_remote_network.test_dr1.id - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "RESTRICTED" ports = ["80-83", "85"] } - udp { + udp = { policy = "ALLOW_ALL" ports = [] } diff --git a/twingate/internal/test/acctests/datasource/resources_test.go b/twingate/internal/test/acctests/datasource/resources_test.go index ea031429..a3aa5c0a 100644 --- a/twingate/internal/test/acctests/datasource/resources_test.go +++ b/twingate/internal/test/acctests/datasource/resources_test.go @@ -49,13 +49,13 @@ func testDatasourceTwingateResources(networkName, resourceName string) string { name = "%s" address = "acc-test.com" remote_network_id = twingate_remote_network.test_drs1.id - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "RESTRICTED" ports = ["80-83", "85"] } - udp { + udp = { policy = "ALLOW_ALL" ports = [] } @@ -66,13 +66,13 @@ func testDatasourceTwingateResources(networkName, resourceName string) string { name = "%s" address = "acc-test.com" remote_network_id = twingate_remote_network.test_drs1.id - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "ALLOW_ALL" ports = [] } - udp { + udp = { policy = "ALLOW_ALL" ports = [] } diff --git a/twingate/internal/test/acctests/helper.go b/twingate/internal/test/acctests/helper.go index 9948cf94..22d1c9d8 100644 --- a/twingate/internal/test/acctests/helper.go +++ b/twingate/internal/test/acctests/helper.go @@ -17,11 +17,8 @@ import ( "github.com/Twingate/terraform-provider-twingate/twingate/internal/model" "github.com/Twingate/terraform-provider-twingate/twingate/internal/provider/resource" "github.com/Twingate/terraform-provider-twingate/twingate/internal/test" - twingateV2 "github.com/Twingate/terraform-provider-twingate/twingate/v2" "github.com/hashicorp/terraform-plugin-framework/providerserver" "github.com/hashicorp/terraform-plugin-go/tfprotov6" - "github.com/hashicorp/terraform-plugin-mux/tf5to6server" - "github.com/hashicorp/terraform-plugin-mux/tf6muxserver" sdk "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/plancheck" "github.com/hashicorp/terraform-plugin-testing/terraform" @@ -60,26 +57,7 @@ var providerClient = func() *client.Client { //nolint }() var ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){ //nolint - "twingate": func() (tfprotov6.ProviderServer, error) { - upgradedSdkProvider, err := tf5to6server.UpgradeServer(context.Background(), twingate.Provider("test").GRPCProvider) - if err != nil { - log.Fatal(err) - } - - providers := []func() tfprotov6.ProviderServer{ - func() tfprotov6.ProviderServer { - return upgradedSdkProvider - }, - providerserver.NewProtocol6(twingateV2.New("test")()), - } - - provider, err := tf6muxserver.NewMuxServer(context.Background(), providers...) - if err != nil { - return nil, fmt.Errorf("failed to run mux server: %w", err) - } - - return provider, nil - }, + "twingate": providerserver.NewProtocol6WithError(twingate.New("test")()), } func SetPageLimit(limit int) { diff --git a/twingate/internal/test/acctests/resource/resource_test.go b/twingate/internal/test/acctests/resource/resource_test.go index f7b2c51d..37f7d11f 100644 --- a/twingate/internal/test/acctests/resource/resource_test.go +++ b/twingate/internal/test/acctests/resource/resource_test.go @@ -17,12 +17,12 @@ import ( ) var ( - tcpPolicy = attr.Path(attr.Protocols, attr.TCP, attr.Policy) - udpPolicy = attr.Path(attr.Protocols, attr.UDP, attr.Policy) - firstTCPPort = attr.First(attr.Protocols, attr.TCP, attr.Ports) - firstUDPPort = attr.First(attr.Protocols, attr.UDP, attr.Ports) - tcpPortsLen = attr.Len(attr.Protocols, attr.TCP, attr.Ports) - udpPortsLen = attr.Len(attr.Protocols, attr.UDP, attr.Ports) + tcpPolicy = attr.PathAttr(attr.Protocols, attr.TCP, attr.Policy) + udpPolicy = attr.PathAttr(attr.Protocols, attr.UDP, attr.Policy) + firstTCPPort = attr.FirstAttr(attr.Protocols, attr.TCP, attr.Ports) + firstUDPPort = attr.FirstAttr(attr.Protocols, attr.UDP, attr.Ports) + tcpPortsLen = attr.LenAttr(attr.Protocols, attr.TCP, attr.Ports) + udpPortsLen = attr.LenAttr(attr.Protocols, attr.UDP, attr.Ports) accessGroupIdsLen = attr.Len(attr.Access, attr.GroupIDs) accessServiceAccountIdsLen = attr.Len(attr.Access, attr.ServiceAccountIDs) ) @@ -108,12 +108,12 @@ func createResourceWithSimpleProtocols(terraformResourceName, networkName, resou address = "acc-test.com" remote_network_id = twingate_remote_network.%s.id - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "DENY_ALL" } - udp { + udp = { policy = "DENY_ALL" } } @@ -166,13 +166,13 @@ func createResourceWithProtocolsAndGroups(networkName, groupName1, groupName2, r address = "new-acc-test.com" remote_network_id = twingate_remote_network.test2.id - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "%s" ports = ["80", "82-83"] } - udp { + udp = { policy = "%s" } } @@ -238,13 +238,13 @@ func resourceFullCreationFlow(networkName, groupName, resourceName string) strin address = "acc-test.com" remote_network_id = twingate_remote_network.test3.id - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "%s" ports = ["3306"] } - udp { + udp = { policy = "%s" } } @@ -333,12 +333,12 @@ func createResourceWithTcpDenyAllPolicy(networkName, groupName, resourceName str access { group_ids = [twingate_group.g5.id] } - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "%s" } - udp { + udp = { policy = "%s" } } @@ -390,12 +390,12 @@ func createResourceWithUdpDenyAllPolicy(networkName, groupName, resourceName str access { group_ids = [twingate_group.g6.id] } - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "%s" } - udp { + udp = { policy = "%s" } } @@ -424,6 +424,10 @@ func TestAccTwingateResourceWithDenyAllPolicyAndEmptyPortsList(t *testing.T) { sdk.TestCheckNoResourceAttr(theResource, udpPortsLen), ), }, + { + Config: createResourceWithDenyAllPolicyAndEmptyPortsList(remoteNetworkName, groupName, resourceName), + PlanOnly: true, + }, }, }) } @@ -445,13 +449,13 @@ func createResourceWithDenyAllPolicyAndEmptyPortsList(networkName, groupName, re access { group_ids = [twingate_group.test7.id] } - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "%s" ports = [] } - udp { + udp = { policy = "%s" } } @@ -518,13 +522,13 @@ func createResourceWithRestrictedPolicyAndPortRange(networkName, resourceName, p name = "%s" address = "new-acc-test.com" remote_network_id = twingate_remote_network.test8.id - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "%s" ports = [%s] } - udp { + udp = { policy = "%s" } } @@ -583,13 +587,13 @@ func createResourceWithPortRange(networkName, resourceName, portRange string) st name = "%s" address = "acc-test.com" remote_network_id = twingate_remote_network.test9.id - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "%s" ports = [%s] } - udp { + udp = { policy = "%s" ports = [%s] } @@ -662,8 +666,8 @@ func TestAccTwingateResourcePortReorderingNoChanges(t *testing.T) { Config: createResourceWithPortRange(remoteNetworkName, resourceName, `"82", "83", "80"`), Check: acctests.ComposeTestCheckFunc( acctests.CheckTwingateResourceExists(theResource), - sdk.TestCheckResourceAttr(theResource, firstTCPPort, "82"), - sdk.TestCheckResourceAttr(theResource, firstUDPPort, "82"), + sdk.TestCheckResourceAttr(theResource, firstTCPPort, "80"), + sdk.TestCheckResourceAttr(theResource, firstUDPPort, "80"), ), }, // no changes @@ -775,12 +779,13 @@ func TestAccTwingateResourceImport(t *testing.T) { ImportState: true, ResourceName: theResource, ImportStateCheck: acctests.CheckImportState(map[string]string{ - attr.Address: "acc-test.com.12", - tcpPolicy: model.PolicyRestricted, - tcpPortsLen: "2", - firstTCPPort: "80", - udpPolicy: model.PolicyAllowAll, - udpPortsLen: "0", + attr.Address: "acc-test.com.12", + tcpPolicy: model.PolicyRestricted, + tcpPortsLen: "2", + firstTCPPort: "80", + udpPolicy: model.PolicyAllowAll, + udpPortsLen: "0", + accessGroupIdsLen: "2", }), }, }, @@ -808,13 +813,13 @@ func createResource12(networkName, groupName1, groupName2, resourceName string) access { group_ids = [twingate_group.g121.id, twingate_group.g122.id] } - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "%s" ports = ["80", "82-83"] } - udp { + udp = { policy = "%s" } } @@ -900,13 +905,13 @@ func createResource15(networkName, resourceName string, terraformServiceAccount address = "acc-test.com.15" remote_network_id = twingate_remote_network.test15.id - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "%s" ports = ["80", "82-83"] } - udp { + udp = { policy = "%s" } } @@ -958,13 +963,13 @@ func createResource16(networkName, resourceName string, groups, groupsID []strin address = "acc-test.com.16" remote_network_id = twingate_remote_network.test16.id - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "%s" ports = ["80", "82-83"] } - udp { + udp = { policy = "%s" } } @@ -1068,13 +1073,13 @@ func createResource17(networkName, resourceName string, serviceAccounts, service address = "acc-test.com.17" remote_network_id = twingate_remote_network.test17.id - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "%s" ports = ["80", "82-83"] } - udp { + udp = { policy = "%s" } } @@ -1175,13 +1180,13 @@ func createResource13(networkName, resourceName string, serviceAccounts, service address = "acc-test.com.13" remote_network_id = twingate_remote_network.test13.id - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "%s" ports = ["80", "82-83"] } - udp { + udp = { policy = "%s" } } @@ -1206,7 +1211,7 @@ func TestAccTwingateResourceAccessWithEmptyGroups(t *testing.T) { Steps: []sdk.TestStep{ { Config: createResource18(remoteNetworkName, resourceName), - ExpectError: regexp.MustCompile("Error: Not enough list items"), + ExpectError: regexp.MustCompile("Error: Invalid Attribute Value"), }, }, }) @@ -1223,13 +1228,13 @@ func createResource18(networkName, resourceName string) string { address = "acc-test.com.18" remote_network_id = twingate_remote_network.test18.id - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "%s" ports = ["80", "82-83"] } - udp { + udp = { policy = "%s" } } @@ -1253,7 +1258,7 @@ func TestAccTwingateResourceAccessWithEmptyServiceAccounts(t *testing.T) { Steps: []sdk.TestStep{ { Config: createResource19(remoteNetworkName, resourceName), - ExpectError: regexp.MustCompile("Error: Not enough list items"), + ExpectError: regexp.MustCompile("Error: Invalid Attribute Value"), }, }, }) @@ -1270,13 +1275,13 @@ func createResource19(networkName, resourceName string) string { address = "acc-test.com.19" remote_network_id = twingate_remote_network.test19.id - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "%s" ports = ["80", "82-83"] } - udp { + udp = { policy = "%s" } } @@ -1300,7 +1305,7 @@ func TestAccTwingateResourceAccessWithEmptyBlock(t *testing.T) { Steps: []sdk.TestStep{ { Config: createResource20(remoteNetworkName, resourceName), - ExpectError: regexp.MustCompile("Missing required argument"), + ExpectError: regexp.MustCompile("invalid attribute combination"), }, }, }) @@ -1317,13 +1322,13 @@ func createResource20(networkName, resourceName string) string { address = "acc-test.com.20" remote_network_id = twingate_remote_network.test20.id - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "%s" ports = ["80", "82-83"] } - udp { + udp = { policy = "%s" } } @@ -1425,13 +1430,13 @@ func createResource22(networkName, resourceName string, groups, groupsID []strin address = "acc-test.com.22" remote_network_id = twingate_remote_network.test22.id - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "%s" ports = ["80", "82-83"] } - udp { + udp = { policy = "%s" } } @@ -1532,13 +1537,13 @@ func createResource23(networkName, resourceName string, groups, groupsID []strin address = "acc-test.com.23" remote_network_id = twingate_remote_network.test23.id - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "%s" ports = ["80", "82-83"] } - udp { + udp = { policy = "%s" } } @@ -1592,16 +1597,13 @@ func TestAccTwingateCreateResourceWithFlagIsVisible(t *testing.T) { Config: createSimpleResource(terraformResourceName, remoteNetworkName, resourceName), Check: acctests.ComposeTestCheckFunc( acctests.CheckTwingateResourceExists(theResource), - sdk.TestCheckNoResourceAttr(theResource, attr.IsVisible), + sdk.TestCheckResourceAttr(theResource, attr.IsVisible, "true"), ), }, { - // expecting no changes - default value on the backend side is `true` + // expecting no changes - default value is `true` PlanOnly: true, Config: createResourceWithFlagIsVisible(terraformResourceName, remoteNetworkName, resourceName, true), - Check: acctests.ComposeTestCheckFunc( - sdk.TestCheckResourceAttr(theResource, attr.IsVisible, "true"), - ), }, { Config: createResourceWithFlagIsVisible(terraformResourceName, remoteNetworkName, resourceName, false), @@ -1613,16 +1615,11 @@ func TestAccTwingateCreateResourceWithFlagIsVisible(t *testing.T) { // expecting no changes - no drift after re-applying changes PlanOnly: true, Config: createResourceWithFlagIsVisible(terraformResourceName, remoteNetworkName, resourceName, false), - Check: acctests.ComposeTestCheckFunc( - sdk.TestCheckResourceAttr(theResource, attr.IsVisible, "false"), - ), }, { - // expecting no changes - flag not set - PlanOnly: true, - Config: createSimpleResource(terraformResourceName, remoteNetworkName, resourceName), + Config: createSimpleResource(terraformResourceName, remoteNetworkName, resourceName), Check: acctests.ComposeTestCheckFunc( - sdk.TestCheckNoResourceAttr(theResource, attr.IsVisible), + sdk.TestCheckResourceAttr(theResource, attr.IsVisible, "true"), ), }, }, @@ -1671,27 +1668,32 @@ func TestAccTwingateCreateResourceWithFlagIsBrowserShortcutEnabled(t *testing.T) Config: createSimpleResource(terraformResourceName, remoteNetworkName, resourceName), Check: acctests.ComposeTestCheckFunc( acctests.CheckTwingateResourceExists(theResource), - sdk.TestCheckNoResourceAttr(theResource, attr.IsBrowserShortcutEnabled), ), }, { - // expecting no changes - default value on the backend side is `true` + // expecting no changes - default value is `false` PlanOnly: true, - Config: createResourceWithFlagIsBrowserShortcutEnabled(terraformResourceName, remoteNetworkName, resourceName, true), + Config: createResourceWithFlagIsBrowserShortcutEnabled(terraformResourceName, remoteNetworkName, resourceName, false), Check: acctests.ComposeTestCheckFunc( - sdk.TestCheckResourceAttr(theResource, attr.IsBrowserShortcutEnabled, "true"), + sdk.TestCheckResourceAttr(theResource, attr.IsBrowserShortcutEnabled, "false"), ), }, { - Config: createResourceWithFlagIsBrowserShortcutEnabled(terraformResourceName, remoteNetworkName, resourceName, false), + Config: createResourceWithFlagIsBrowserShortcutEnabled(terraformResourceName, remoteNetworkName, resourceName, true), Check: acctests.ComposeTestCheckFunc( - sdk.TestCheckResourceAttr(theResource, attr.IsBrowserShortcutEnabled, "false"), + sdk.TestCheckResourceAttr(theResource, attr.IsBrowserShortcutEnabled, "true"), ), }, { // expecting no changes - no drift after re-applying changes PlanOnly: true, - Config: createResourceWithFlagIsBrowserShortcutEnabled(terraformResourceName, remoteNetworkName, resourceName, false), + Config: createResourceWithFlagIsBrowserShortcutEnabled(terraformResourceName, remoteNetworkName, resourceName, true), + Check: acctests.ComposeTestCheckFunc( + sdk.TestCheckResourceAttr(theResource, attr.IsBrowserShortcutEnabled, "true"), + ), + }, + { + Config: createResourceWithFlagIsBrowserShortcutEnabled(terraformResourceName, remoteNetworkName, resourceName, false), Check: acctests.ComposeTestCheckFunc( sdk.TestCheckResourceAttr(theResource, attr.IsBrowserShortcutEnabled, "false"), ), @@ -1809,13 +1811,13 @@ func createResource26(networkName, resourceName string, groups, groupsID []strin address = "acc-test.com.26" remote_network_id = twingate_remote_network.test26.id - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "%s" ports = ["80", "82-83"] } - udp { + udp = { policy = "%s" } } @@ -1862,13 +1864,13 @@ func createResource28(networkName, resourceName string, groups, groupsID []strin group_ids = [%s] - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "%s" ports = ["80", "82-83"] } - udp { + udp = { policy = "%s" } } @@ -1898,10 +1900,10 @@ func TestAccTwingateResourceCreateWithAlias(t *testing.T) { ), }, { - // alias attr commented out, means state keeps the same value without changes + // alias attr commented out, means it has nil state Config: createResource29WithoutAlias(terraformResourceName, remoteNetworkName, resourceName), Check: acctests.ComposeTestCheckFunc( - sdk.TestCheckResourceAttr(theResource, attr.Alias, aliasName), + sdk.TestCheckNoResourceAttr(theResource, attr.Alias), ), }, { @@ -1993,13 +1995,13 @@ func createResourceWithGroupsAndServiceAccounts(name, networkName, resourceName address = "acc-test.com.26" remote_network_id = twingate_remote_network.%s.id - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "%s" ports = ["80", "82-83"] } - udp { + udp = { policy = "%s" } } @@ -2051,13 +2053,13 @@ func createResourceWithPort(networkName, resourceName, port string) string { name = "%s" address = "new-acc-test.com" remote_network_id = twingate_remote_network.test30.id - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "%s" ports = ["%s"] } - udp { + udp = { policy = "%s" } } @@ -2090,8 +2092,7 @@ func TestAccTwingateResourceUpdateWithPort(t *testing.T) { } func TestAccTwingateResourceWithPortsFailsForAllowAllAndDenyAllPolicy(t *testing.T) { - t.Parallel() - + const terraformResourceName = "test28" remoteNetworkName := test.RandomName() resourceName := test.RandomResourceName() @@ -2101,11 +2102,11 @@ func TestAccTwingateResourceWithPortsFailsForAllowAllAndDenyAllPolicy(t *testing CheckDestroy: acctests.CheckTwingateResourceDestroy, Steps: []sdk.TestStep{ { - Config: createResourceWithPorts(resourceName, remoteNetworkName, resourceName, model.PolicyAllowAll), + Config: createResourceWithPorts(terraformResourceName, remoteNetworkName, resourceName, model.PolicyAllowAll), ExpectError: regexp.MustCompile(resource.ErrPortsWithPolicyAllowAll.Error()), }, { - Config: createResourceWithPorts(resourceName, remoteNetworkName, resourceName, model.PolicyDenyAll), + Config: createResourceWithPorts(terraformResourceName, remoteNetworkName, resourceName, model.PolicyDenyAll), ExpectError: regexp.MustCompile(resource.ErrPortsWithPolicyDenyAll.Error()), }, }, @@ -2122,13 +2123,13 @@ func createResourceWithPorts(name, networkName, resourceName, policy string) str address = "acc-test-%[1]s.com" remote_network_id = twingate_remote_network.%[1]s.id - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "%[4]s" ports = ["80", "82-83"] } - udp { + udp = { policy = "%[5]s" } } @@ -2137,11 +2138,10 @@ func createResourceWithPorts(name, networkName, resourceName, policy string) str } func TestAccTwingateResourceWithoutPortsOkForAllowAllAndDenyAllPolicy(t *testing.T) { - t.Parallel() - - resourceName := test.RandomResourceName() - theResource := acctests.TerraformResource(resourceName) + const terraformResourceName = "test29" remoteNetworkName := test.RandomName() + resourceName := test.RandomResourceName() + theResource := acctests.TerraformResource(terraformResourceName) sdk.Test(t, sdk.TestCase{ ProtoV6ProviderFactories: acctests.ProviderFactories, @@ -2149,7 +2149,7 @@ func TestAccTwingateResourceWithoutPortsOkForAllowAllAndDenyAllPolicy(t *testing CheckDestroy: acctests.CheckTwingateResourceDestroy, Steps: []sdk.TestStep{ { - Config: createResourceWithoutPorts(resourceName, remoteNetworkName, resourceName, model.PolicyAllowAll), + Config: createResourceWithoutPorts(terraformResourceName, remoteNetworkName, resourceName, model.PolicyAllowAll), Check: acctests.ComposeTestCheckFunc( acctests.CheckTwingateResourceExists(theResource), sdk.TestCheckResourceAttr(theResource, tcpPolicy, model.PolicyAllowAll), @@ -2157,7 +2157,7 @@ func TestAccTwingateResourceWithoutPortsOkForAllowAllAndDenyAllPolicy(t *testing ), }, { - Config: createResourceWithoutPorts(resourceName, remoteNetworkName, resourceName, model.PolicyDenyAll), + Config: createResourceWithoutPorts(terraformResourceName, remoteNetworkName, resourceName, model.PolicyDenyAll), Check: acctests.ComposeTestCheckFunc( acctests.CheckTwingateResourceExists(theResource), sdk.TestCheckResourceAttr(theResource, tcpPolicy, model.PolicyDenyAll), @@ -2178,12 +2178,12 @@ func createResourceWithoutPorts(name, networkName, resourceName, policy string) address = "acc-test-%[1]s.com" remote_network_id = twingate_remote_network.%[1]s.id - protocols { + protocols = { allow_icmp = true - tcp { + tcp = { policy = "%[4]s" } - udp { + udp = { policy = "%[5]s" } } @@ -2192,11 +2192,10 @@ func createResourceWithoutPorts(name, networkName, resourceName, policy string) } func TestAccTwingateResourceWithRestrictedPolicy(t *testing.T) { - t.Parallel() - - resourceName := test.RandomResourceName() - theResource := acctests.TerraformResource(resourceName) + const terraformResourceName = "test30" remoteNetworkName := test.RandomName() + resourceName := test.RandomResourceName() + theResource := acctests.TerraformResource(terraformResourceName) sdk.Test(t, sdk.TestCase{ ProtoV6ProviderFactories: acctests.ProviderFactories, @@ -2204,7 +2203,7 @@ func TestAccTwingateResourceWithRestrictedPolicy(t *testing.T) { CheckDestroy: acctests.CheckTwingateResourceDestroy, Steps: []sdk.TestStep{ { - Config: createResourceWithPorts(resourceName, remoteNetworkName, resourceName, model.PolicyRestricted), + Config: createResourceWithPorts(terraformResourceName, remoteNetworkName, resourceName, model.PolicyRestricted), Check: acctests.ComposeTestCheckFunc( acctests.CheckTwingateResourceExists(theResource), sdk.TestCheckResourceAttr(theResource, tcpPolicy, model.PolicyRestricted), @@ -2216,11 +2215,10 @@ func TestAccTwingateResourceWithRestrictedPolicy(t *testing.T) { } func TestAccTwingateResourcePolicyTransitionDenyAllToRestricted(t *testing.T) { - t.Parallel() - - resourceName := test.RandomResourceName() - theResource := acctests.TerraformResource(resourceName) + const terraformResourceName = "test31" + theResource := acctests.TerraformResource(terraformResourceName) remoteNetworkName := test.RandomName() + resourceName := test.RandomResourceName() sdk.Test(t, sdk.TestCase{ ProtoV6ProviderFactories: acctests.ProviderFactories, @@ -2228,7 +2226,7 @@ func TestAccTwingateResourcePolicyTransitionDenyAllToRestricted(t *testing.T) { CheckDestroy: acctests.CheckTwingateResourceDestroy, Steps: []sdk.TestStep{ { - Config: createResourceWithoutPorts(resourceName, remoteNetworkName, resourceName, model.PolicyDenyAll), + Config: createResourceWithoutPorts(terraformResourceName, remoteNetworkName, resourceName, model.PolicyDenyAll), Check: acctests.ComposeTestCheckFunc( acctests.CheckTwingateResourceExists(theResource), sdk.TestCheckResourceAttr(theResource, tcpPolicy, model.PolicyDenyAll), @@ -2236,7 +2234,7 @@ func TestAccTwingateResourcePolicyTransitionDenyAllToRestricted(t *testing.T) { ), }, { - Config: createResourceWithPorts(resourceName, remoteNetworkName, resourceName, model.PolicyRestricted), + Config: createResourceWithPorts(terraformResourceName, remoteNetworkName, resourceName, model.PolicyRestricted), Check: acctests.ComposeTestCheckFunc( acctests.CheckTwingateResourceExists(theResource), sdk.TestCheckResourceAttr(theResource, tcpPolicy, model.PolicyRestricted), @@ -2248,11 +2246,10 @@ func TestAccTwingateResourcePolicyTransitionDenyAllToRestricted(t *testing.T) { } func TestAccTwingateResourcePolicyTransitionDenyAllToAllowAll(t *testing.T) { - t.Parallel() - - resourceName := test.RandomResourceName() - theResource := acctests.TerraformResource(resourceName) + const terraformResourceName = "test32" + theResource := acctests.TerraformResource(terraformResourceName) remoteNetworkName := test.RandomName() + resourceName := test.RandomResourceName() sdk.Test(t, sdk.TestCase{ ProtoV6ProviderFactories: acctests.ProviderFactories, @@ -2260,7 +2257,7 @@ func TestAccTwingateResourcePolicyTransitionDenyAllToAllowAll(t *testing.T) { CheckDestroy: acctests.CheckTwingateResourceDestroy, Steps: []sdk.TestStep{ { - Config: createResourceWithoutPorts(resourceName, remoteNetworkName, resourceName, model.PolicyDenyAll), + Config: createResourceWithoutPorts(terraformResourceName, remoteNetworkName, resourceName, model.PolicyDenyAll), Check: acctests.ComposeTestCheckFunc( acctests.CheckTwingateResourceExists(theResource), sdk.TestCheckResourceAttr(theResource, tcpPolicy, model.PolicyDenyAll), @@ -2268,7 +2265,7 @@ func TestAccTwingateResourcePolicyTransitionDenyAllToAllowAll(t *testing.T) { ), }, { - Config: createResourceWithoutPorts(resourceName, remoteNetworkName, resourceName, model.PolicyAllowAll), + Config: createResourceWithoutPorts(terraformResourceName, remoteNetworkName, resourceName, model.PolicyAllowAll), Check: acctests.ComposeTestCheckFunc( acctests.CheckTwingateResourceExists(theResource), sdk.TestCheckResourceAttr(theResource, tcpPolicy, model.PolicyAllowAll), @@ -2280,11 +2277,10 @@ func TestAccTwingateResourcePolicyTransitionDenyAllToAllowAll(t *testing.T) { } func TestAccTwingateResourcePolicyTransitionRestrictedToDenyAll(t *testing.T) { - t.Parallel() - - resourceName := test.RandomResourceName() - theResource := acctests.TerraformResource(resourceName) + const terraformResourceName = "test33" + theResource := acctests.TerraformResource(terraformResourceName) remoteNetworkName := test.RandomName() + resourceName := test.RandomResourceName() sdk.Test(t, sdk.TestCase{ ProtoV6ProviderFactories: acctests.ProviderFactories, @@ -2292,7 +2288,7 @@ func TestAccTwingateResourcePolicyTransitionRestrictedToDenyAll(t *testing.T) { CheckDestroy: acctests.CheckTwingateResourceDestroy, Steps: []sdk.TestStep{ { - Config: createResourceWithPorts(resourceName, remoteNetworkName, resourceName, model.PolicyRestricted), + Config: createResourceWithPorts(terraformResourceName, remoteNetworkName, resourceName, model.PolicyRestricted), Check: acctests.ComposeTestCheckFunc( acctests.CheckTwingateResourceExists(theResource), sdk.TestCheckResourceAttr(theResource, tcpPolicy, model.PolicyRestricted), @@ -2300,7 +2296,7 @@ func TestAccTwingateResourcePolicyTransitionRestrictedToDenyAll(t *testing.T) { ), }, { - Config: createResourceWithoutPorts(resourceName, remoteNetworkName, resourceName, model.PolicyDenyAll), + Config: createResourceWithoutPorts(terraformResourceName, remoteNetworkName, resourceName, model.PolicyDenyAll), Check: acctests.ComposeTestCheckFunc( acctests.CheckTwingateResourceExists(theResource), sdk.TestCheckResourceAttr(theResource, tcpPolicy, model.PolicyDenyAll), @@ -2312,11 +2308,10 @@ func TestAccTwingateResourcePolicyTransitionRestrictedToDenyAll(t *testing.T) { } func TestAccTwingateResourcePolicyTransitionRestrictedToAllowAll(t *testing.T) { - t.Parallel() - - resourceName := test.RandomResourceName() - theResource := acctests.TerraformResource(resourceName) + const terraformResourceName = "test34" + theResource := acctests.TerraformResource(terraformResourceName) remoteNetworkName := test.RandomName() + resourceName := test.RandomResourceName() sdk.Test(t, sdk.TestCase{ ProtoV6ProviderFactories: acctests.ProviderFactories, @@ -2324,7 +2319,7 @@ func TestAccTwingateResourcePolicyTransitionRestrictedToAllowAll(t *testing.T) { CheckDestroy: acctests.CheckTwingateResourceDestroy, Steps: []sdk.TestStep{ { - Config: createResourceWithPorts(resourceName, remoteNetworkName, resourceName, model.PolicyRestricted), + Config: createResourceWithPorts(terraformResourceName, remoteNetworkName, resourceName, model.PolicyRestricted), Check: acctests.ComposeTestCheckFunc( acctests.CheckTwingateResourceExists(theResource), sdk.TestCheckResourceAttr(theResource, tcpPolicy, model.PolicyRestricted), @@ -2332,7 +2327,7 @@ func TestAccTwingateResourcePolicyTransitionRestrictedToAllowAll(t *testing.T) { ), }, { - Config: createResourceWithoutPorts(resourceName, remoteNetworkName, resourceName, model.PolicyAllowAll), + Config: createResourceWithoutPorts(terraformResourceName, remoteNetworkName, resourceName, model.PolicyAllowAll), Check: acctests.ComposeTestCheckFunc( acctests.CheckTwingateResourceExists(theResource), sdk.TestCheckResourceAttr(theResource, tcpPolicy, model.PolicyAllowAll), @@ -2344,11 +2339,10 @@ func TestAccTwingateResourcePolicyTransitionRestrictedToAllowAll(t *testing.T) { } func TestAccTwingateResourcePolicyTransitionRestrictedToAllowAllWithPortsShouldFail(t *testing.T) { - t.Parallel() - - resourceName := test.RandomResourceName() - theResource := acctests.TerraformResource(resourceName) + const terraformResourceName = "test35" + theResource := acctests.TerraformResource(terraformResourceName) remoteNetworkName := test.RandomName() + resourceName := test.RandomResourceName() sdk.Test(t, sdk.TestCase{ ProtoV6ProviderFactories: acctests.ProviderFactories, @@ -2356,7 +2350,7 @@ func TestAccTwingateResourcePolicyTransitionRestrictedToAllowAllWithPortsShouldF CheckDestroy: acctests.CheckTwingateResourceDestroy, Steps: []sdk.TestStep{ { - Config: createResourceWithPorts(resourceName, remoteNetworkName, resourceName, model.PolicyRestricted), + Config: createResourceWithPorts(terraformResourceName, remoteNetworkName, resourceName, model.PolicyRestricted), Check: acctests.ComposeTestCheckFunc( acctests.CheckTwingateResourceExists(theResource), sdk.TestCheckResourceAttr(theResource, tcpPolicy, model.PolicyRestricted), @@ -2364,7 +2358,7 @@ func TestAccTwingateResourcePolicyTransitionRestrictedToAllowAllWithPortsShouldF ), }, { - Config: createResourceWithPorts(resourceName, remoteNetworkName, resourceName, model.PolicyAllowAll), + Config: createResourceWithPorts(terraformResourceName, remoteNetworkName, resourceName, model.PolicyAllowAll), ExpectError: regexp.MustCompile(resource.ErrPortsWithPolicyAllowAll.Error()), }, }, @@ -2372,11 +2366,10 @@ func TestAccTwingateResourcePolicyTransitionRestrictedToAllowAllWithPortsShouldF } func TestAccTwingateResourcePolicyTransitionAllowAllToRestricted(t *testing.T) { - t.Parallel() - - resourceName := test.RandomResourceName() - theResource := acctests.TerraformResource(resourceName) + const terraformResourceName = "test36" + theResource := acctests.TerraformResource(terraformResourceName) remoteNetworkName := test.RandomName() + resourceName := test.RandomResourceName() sdk.Test(t, sdk.TestCase{ ProtoV6ProviderFactories: acctests.ProviderFactories, @@ -2384,7 +2377,7 @@ func TestAccTwingateResourcePolicyTransitionAllowAllToRestricted(t *testing.T) { CheckDestroy: acctests.CheckTwingateResourceDestroy, Steps: []sdk.TestStep{ { - Config: createResourceWithoutPorts(resourceName, remoteNetworkName, resourceName, model.PolicyAllowAll), + Config: createResourceWithoutPorts(terraformResourceName, remoteNetworkName, resourceName, model.PolicyAllowAll), Check: acctests.ComposeTestCheckFunc( acctests.CheckTwingateResourceExists(theResource), sdk.TestCheckResourceAttr(theResource, tcpPolicy, model.PolicyAllowAll), @@ -2392,7 +2385,7 @@ func TestAccTwingateResourcePolicyTransitionAllowAllToRestricted(t *testing.T) { ), }, { - Config: createResourceWithPorts(resourceName, remoteNetworkName, resourceName, model.PolicyRestricted), + Config: createResourceWithPorts(terraformResourceName, remoteNetworkName, resourceName, model.PolicyRestricted), Check: acctests.ComposeTestCheckFunc( acctests.CheckTwingateResourceExists(theResource), sdk.TestCheckResourceAttr(theResource, tcpPolicy, model.PolicyRestricted), @@ -2404,11 +2397,10 @@ func TestAccTwingateResourcePolicyTransitionAllowAllToRestricted(t *testing.T) { } func TestAccTwingateResourcePolicyTransitionAllowAllToDenyAll(t *testing.T) { - t.Parallel() - - resourceName := test.RandomResourceName() - theResource := acctests.TerraformResource(resourceName) + const terraformResourceName = "test37" + theResource := acctests.TerraformResource(terraformResourceName) remoteNetworkName := test.RandomName() + resourceName := test.RandomResourceName() sdk.Test(t, sdk.TestCase{ ProtoV6ProviderFactories: acctests.ProviderFactories, @@ -2416,7 +2408,7 @@ func TestAccTwingateResourcePolicyTransitionAllowAllToDenyAll(t *testing.T) { CheckDestroy: acctests.CheckTwingateResourceDestroy, Steps: []sdk.TestStep{ { - Config: createResourceWithoutPorts(resourceName, remoteNetworkName, resourceName, model.PolicyAllowAll), + Config: createResourceWithoutPorts(terraformResourceName, remoteNetworkName, resourceName, model.PolicyAllowAll), Check: acctests.ComposeTestCheckFunc( acctests.CheckTwingateResourceExists(theResource), sdk.TestCheckResourceAttr(theResource, tcpPolicy, model.PolicyAllowAll), @@ -2424,7 +2416,7 @@ func TestAccTwingateResourcePolicyTransitionAllowAllToDenyAll(t *testing.T) { ), }, { - Config: createResourceWithoutPorts(resourceName, remoteNetworkName, resourceName, model.PolicyDenyAll), + Config: createResourceWithoutPorts(terraformResourceName, remoteNetworkName, resourceName, model.PolicyDenyAll), Check: acctests.ComposeTestCheckFunc( acctests.CheckTwingateResourceExists(theResource), sdk.TestCheckResourceAttr(theResource, tcpPolicy, model.PolicyDenyAll), @@ -2435,12 +2427,43 @@ func TestAccTwingateResourcePolicyTransitionAllowAllToDenyAll(t *testing.T) { }) } -func TestAccTwingateResourceWithBrowserOption(t *testing.T) { - t.Parallel() - +func TestAccTwingateResourceTestCaseInsensitiveAlias(t *testing.T) { + const terraformResourceName = "test38" + theResource := acctests.TerraformResource(terraformResourceName) + remoteNetworkName := test.RandomName() resourceName := test.RandomResourceName() - theResource := acctests.TerraformResource(resourceName) + const aliasName = "test.com" + + sdk.Test(t, sdk.TestCase{ + ProtoV6ProviderFactories: acctests.ProviderFactories, + PreCheck: func() { acctests.PreCheck(t) }, + CheckDestroy: acctests.CheckTwingateResourceDestroy, + Steps: []sdk.TestStep{ + { + Config: createResource29(terraformResourceName, remoteNetworkName, resourceName, aliasName), + Check: acctests.ComposeTestCheckFunc( + acctests.CheckTwingateResourceExists(theResource), + sdk.TestCheckResourceAttr(theResource, attr.Name, resourceName), + sdk.TestCheckResourceAttr(theResource, attr.Alias, aliasName), + ), + }, + { + // expecting no changes + PlanOnly: true, + Config: createResource29(terraformResourceName, remoteNetworkName, resourceName, strings.ToUpper(aliasName)), + Check: acctests.ComposeTestCheckFunc( + sdk.TestCheckResourceAttr(theResource, attr.Alias, aliasName), + ), + }, + }, + }) +} + +func TestAccTwingateResourceWithBrowserOption(t *testing.T) { + const terraformResourceName = "test40" + theResource := acctests.TerraformResource(terraformResourceName) remoteNetworkName := test.RandomName() + resourceName := test.RandomResourceName() wildcardAddress := "*.acc-test.com" sdk.Test(t, sdk.TestCase{ @@ -2449,31 +2472,30 @@ func TestAccTwingateResourceWithBrowserOption(t *testing.T) { CheckDestroy: acctests.CheckTwingateResourceDestroy, Steps: []sdk.TestStep{ { - Config: createResourceWithoutBrowserOption(resourceName, remoteNetworkName, resourceName, wildcardAddress), + Config: createResourceWithoutBrowserOption(terraformResourceName, remoteNetworkName, resourceName, wildcardAddress), Check: acctests.ComposeTestCheckFunc( acctests.CheckTwingateResourceExists(theResource), ), }, { - Config: createResourceWithBrowserOption(resourceName, remoteNetworkName, resourceName, wildcardAddress, false), + Config: createResourceWithBrowserOption(terraformResourceName, remoteNetworkName, resourceName, wildcardAddress, false), Check: acctests.ComposeTestCheckFunc( acctests.CheckTwingateResourceExists(theResource), ), }, { - Config: createResourceWithBrowserOption(resourceName, remoteNetworkName, resourceName, wildcardAddress, true), - ExpectError: regexp.MustCompile(resource.ErrWildcardAddressWithEnabledShortcut.Error()), + Config: createResourceWithBrowserOption(terraformResourceName, remoteNetworkName, resourceName, wildcardAddress, true), + ExpectError: regexp.MustCompile("Resources with a CIDR range or wildcard"), }, }, }) } func TestAccTwingateResourceWithBrowserOptionFailOnUpdate(t *testing.T) { - t.Parallel() - - resourceName := test.RandomResourceName() - theResource := acctests.TerraformResource(resourceName) + const terraformResourceName = "test41" + theResource := acctests.TerraformResource(terraformResourceName) remoteNetworkName := test.RandomName() + resourceName := test.RandomResourceName() wildcardAddress := "*.acc-test.com" simpleAddress := "acc-test.com" @@ -2483,31 +2505,30 @@ func TestAccTwingateResourceWithBrowserOptionFailOnUpdate(t *testing.T) { CheckDestroy: acctests.CheckTwingateResourceDestroy, Steps: []sdk.TestStep{ { - Config: createResourceWithoutBrowserOption(resourceName, remoteNetworkName, resourceName, simpleAddress), + Config: createResourceWithoutBrowserOption(terraformResourceName, remoteNetworkName, resourceName, simpleAddress), Check: acctests.ComposeTestCheckFunc( acctests.CheckTwingateResourceExists(theResource), ), }, { - Config: createResourceWithBrowserOption(resourceName, remoteNetworkName, resourceName, simpleAddress, true), + Config: createResourceWithBrowserOption(terraformResourceName, remoteNetworkName, resourceName, simpleAddress, true), Check: acctests.ComposeTestCheckFunc( acctests.CheckTwingateResourceExists(theResource), ), }, { - Config: createResourceWithBrowserOption(resourceName, remoteNetworkName, resourceName, wildcardAddress, true), - ExpectError: regexp.MustCompile(resource.ErrWildcardAddressWithEnabledShortcut.Error()), + Config: createResourceWithBrowserOption(terraformResourceName, remoteNetworkName, resourceName, wildcardAddress, true), + ExpectError: regexp.MustCompile("Resources with a CIDR range or wildcard"), }, }, }) } func TestAccTwingateResourceWithBrowserOptionRecovered(t *testing.T) { - t.Parallel() - - resourceName := test.RandomResourceName() - theResource := acctests.TerraformResource(resourceName) + const terraformResourceName = "test42" + theResource := acctests.TerraformResource(terraformResourceName) remoteNetworkName := test.RandomName() + resourceName := test.RandomResourceName() wildcardAddress := "*.acc-test.com" simpleAddress := "acc-test.com" @@ -2517,13 +2538,13 @@ func TestAccTwingateResourceWithBrowserOptionRecovered(t *testing.T) { CheckDestroy: acctests.CheckTwingateResourceDestroy, Steps: []sdk.TestStep{ { - Config: createResourceWithBrowserOption(resourceName, remoteNetworkName, resourceName, simpleAddress, true), + Config: createResourceWithBrowserOption(terraformResourceName, remoteNetworkName, resourceName, simpleAddress, true), Check: acctests.ComposeTestCheckFunc( acctests.CheckTwingateResourceExists(theResource), ), }, { - Config: createResourceWithoutBrowserOption(resourceName, remoteNetworkName, resourceName, wildcardAddress), + Config: createResourceWithoutBrowserOption(terraformResourceName, remoteNetworkName, resourceName, wildcardAddress), Check: acctests.ComposeTestCheckFunc( acctests.CheckTwingateResourceExists(theResource), ), @@ -2559,14 +2580,37 @@ func createResourceWithBrowserOption(name, networkName, resourceName, address st `, name, networkName, resourceName, address, browserOption) } -func TestAccTwingateResourceUpdateSecurityPolicy(t *testing.T) { - t.Parallel() +func createResourceWithSecurityPolicy(remoteNetwork, resource, policyID string) string { + return fmt.Sprintf(` + resource "twingate_remote_network" "%[1]s" { + name = "%[1]s" + } + resource "twingate_resource" "%[2]s" { + name = "%[2]s" + address = "acc-test-address.com" + remote_network_id = twingate_remote_network.%[1]s.id + security_policy_id = "%[3]s" + } + `, remoteNetwork, resource, policyID) +} + +func createResourceWithoutSecurityPolicy(remoteNetwork, resource string) string { + return fmt.Sprintf(` + resource "twingate_remote_network" "%[1]s" { + name = "%[1]s" + } + resource "twingate_resource" "%[2]s" { + name = "%[2]s" + address = "acc-test-address.com" + remote_network_id = twingate_remote_network.%[1]s.id + } + `, remoteNetwork, resource) +} +func TestAccTwingateResourceUpdateWithDefaultProtocols(t *testing.T) { + remoteNetworkName := test.RandomName() resourceName := test.RandomResourceName() theResource := acctests.TerraformResource(resourceName) - remoteNetworkName := test.RandomName() - - defaultPolicy, testPolicy := preparePolicies(t) sdk.Test(t, sdk.TestCase{ ProtoV6ProviderFactories: acctests.ProviderFactories, @@ -2574,36 +2618,109 @@ func TestAccTwingateResourceUpdateSecurityPolicy(t *testing.T) { CheckDestroy: acctests.CheckTwingateResourceDestroy, Steps: []sdk.TestStep{ { - Config: createResourceWithSecurityPolicy(remoteNetworkName, resourceName, defaultPolicy), + Config: createResourceWithProtocols(remoteNetworkName, resourceName), Check: acctests.ComposeTestCheckFunc( acctests.CheckTwingateResourceExists(theResource), - sdk.TestCheckResourceAttr(theResource, attr.SecurityPolicyID, defaultPolicy), ), }, { - Config: createResourceWithSecurityPolicy(remoteNetworkName, resourceName, testPolicy), + Config: createResourceWithoutProtocols(remoteNetworkName, resourceName), + Check: acctests.ComposeTestCheckFunc( + acctests.CheckTwingateResourceExists(theResource), + ), + }, + }, + }) +} + +func createResourceWithProtocols(remoteNetwork, resource string) string { + return fmt.Sprintf(` + resource "twingate_remote_network" "%[1]s" { + name = "%[1]s" + } + resource "twingate_resource" "%[2]s" { + name = "%[2]s" + address = "acc-test-address.com" + remote_network_id = twingate_remote_network.%[1]s.id + protocols = { + allow_icmp = true + tcp = { + policy = "RESTRICTED" + ports = ["80-83"] + } + udp = { + policy = "RESTRICTED" + ports = ["80"] + } + } + } + `, remoteNetwork, resource) +} + +func createResourceWithoutProtocols(remoteNetwork, resource string) string { + return fmt.Sprintf(` + resource "twingate_remote_network" "%[1]s" { + name = "%[1]s" + } + resource "twingate_resource" "%[2]s" { + name = "%[2]s" + address = "acc-test-address.com" + remote_network_id = twingate_remote_network.%[1]s.id + } + `, remoteNetwork, resource) +} + +func TestAccTwingateResourceUpdatePortsFromEmptyListToNull(t *testing.T) { + remoteNetworkName := test.RandomName() + resourceName := test.RandomResourceName() + theResource := acctests.TerraformResource(resourceName) + + sdk.Test(t, sdk.TestCase{ + ProtoV6ProviderFactories: acctests.ProviderFactories, + PreCheck: func() { acctests.PreCheck(t) }, + CheckDestroy: acctests.CheckTwingateResourceDestroy, + Steps: []sdk.TestStep{ + { + Config: createResourceWithEmptyArrayPorts(remoteNetworkName, resourceName), Check: acctests.ComposeTestCheckFunc( acctests.CheckTwingateResourceExists(theResource), - sdk.TestCheckResourceAttr(theResource, attr.SecurityPolicyID, testPolicy), ), }, { - Config: createResourceWithoutSecurityPolicy(remoteNetworkName, resourceName), + // expect no changes + PlanOnly: true, + Config: createResourceWithDefaultPorts(remoteNetworkName, resourceName), + }, + }, + }) +} + +func TestAccTwingateResourceUpdatePortsFromNullToEmptyList(t *testing.T) { + remoteNetworkName := test.RandomName() + resourceName := test.RandomResourceName() + theResource := acctests.TerraformResource(resourceName) + + sdk.Test(t, sdk.TestCase{ + ProtoV6ProviderFactories: acctests.ProviderFactories, + PreCheck: func() { acctests.PreCheck(t) }, + CheckDestroy: acctests.CheckTwingateResourceDestroy, + Steps: []sdk.TestStep{ + { + Config: createResourceWithDefaultPorts(remoteNetworkName, resourceName), Check: acctests.ComposeTestCheckFunc( acctests.CheckTwingateResourceExists(theResource), - sdk.TestCheckResourceAttr(theResource, attr.SecurityPolicyID, defaultPolicy), ), }, { - Config: createResourceWithSecurityPolicy(remoteNetworkName, resourceName, ""), - // no changes + // expect no changes PlanOnly: true, + Config: createResourceWithEmptyArrayPorts(remoteNetworkName, resourceName), }, }, }) } -func createResourceWithSecurityPolicy(remoteNetwork, resource, policyID string) string { +func createResourceWithDefaultPorts(remoteNetwork, resource string) string { return fmt.Sprintf(` resource "twingate_remote_network" "%[1]s" { name = "%[1]s" @@ -2612,12 +2729,20 @@ func createResourceWithSecurityPolicy(remoteNetwork, resource, policyID string) name = "%[2]s" address = "acc-test-address.com" remote_network_id = twingate_remote_network.%[1]s.id - security_policy_id = "%[3]s" + protocols = { + allow_icmp = true + tcp = { + policy = "ALLOW_ALL" + } + udp = { + policy = "ALLOW_ALL" + } + } } - `, remoteNetwork, resource, policyID) + `, remoteNetwork, resource) } -func createResourceWithoutSecurityPolicy(remoteNetwork, resource string) string { +func createResourceWithEmptyArrayPorts(remoteNetwork, resource string) string { return fmt.Sprintf(` resource "twingate_remote_network" "%[1]s" { name = "%[1]s" @@ -2626,10 +2751,65 @@ func createResourceWithoutSecurityPolicy(remoteNetwork, resource string) string name = "%[2]s" address = "acc-test-address.com" remote_network_id = twingate_remote_network.%[1]s.id + protocols = { + allow_icmp = true + tcp = { + policy = "ALLOW_ALL" + ports = [] + } + udp = { + policy = "ALLOW_ALL" + ports = [] + } + } } `, remoteNetwork, resource) } +func TestAccTwingateResourceUpdateSecurityPolicy(t *testing.T) { + t.Parallel() + + resourceName := test.RandomResourceName() + theResource := acctests.TerraformResource(resourceName) + remoteNetworkName := test.RandomName() + + defaultPolicy, testPolicy := preparePolicies(t) + + sdk.Test(t, sdk.TestCase{ + ProtoV6ProviderFactories: acctests.ProviderFactories, + PreCheck: func() { acctests.PreCheck(t) }, + CheckDestroy: acctests.CheckTwingateResourceDestroy, + Steps: []sdk.TestStep{ + { + Config: createResourceWithSecurityPolicy(remoteNetworkName, resourceName, defaultPolicy), + Check: acctests.ComposeTestCheckFunc( + acctests.CheckTwingateResourceExists(theResource), + sdk.TestCheckResourceAttr(theResource, attr.SecurityPolicyID, defaultPolicy), + ), + }, + { + Config: createResourceWithSecurityPolicy(remoteNetworkName, resourceName, testPolicy), + Check: acctests.ComposeTestCheckFunc( + acctests.CheckTwingateResourceExists(theResource), + sdk.TestCheckResourceAttr(theResource, attr.SecurityPolicyID, testPolicy), + ), + }, + { + Config: createResourceWithoutSecurityPolicy(remoteNetworkName, resourceName), + Check: acctests.ComposeTestCheckFunc( + acctests.CheckTwingateResourceExists(theResource), + sdk.TestCheckResourceAttr(theResource, attr.SecurityPolicyID, defaultPolicy), + ), + }, + { + Config: createResourceWithSecurityPolicy(remoteNetworkName, resourceName, ""), + // no changes + PlanOnly: true, + }, + }, + }) +} + func preparePolicies(t *testing.T) (string, string) { policies, err := acctests.ListSecurityPolicies() if err != nil { @@ -2687,7 +2867,6 @@ func TestAccTwingateResourceSetDefaultSecurityPolicyByDefault(t *testing.T) { { Config: createResourceWithSecurityPolicy(remoteNetworkName, resourceName, ""), Check: acctests.ComposeTestCheckFunc( - sdk.TestCheckResourceAttr(theResource, attr.SecurityPolicyID, defaultPolicy), acctests.CheckResourceSecurityPolicy(theResource, defaultPolicy), ), }, @@ -2700,6 +2879,38 @@ func TestAccTwingateResourceSetDefaultSecurityPolicyByDefault(t *testing.T) { }) } +func TestAccTwingateResourceSecurityPolicy(t *testing.T) { + t.Parallel() + + resourceName := test.RandomResourceName() + theResource := acctests.TerraformResource(resourceName) + remoteNetworkName := test.RandomName() + + _, testPolicy := preparePolicies(t) + + sdk.Test(t, sdk.TestCase{ + ProtoV6ProviderFactories: acctests.ProviderFactories, + PreCheck: func() { acctests.PreCheck(t) }, + CheckDestroy: acctests.CheckTwingateResourceDestroy, + Steps: []sdk.TestStep{ + { + Config: createResourceWithoutSecurityPolicy(remoteNetworkName, resourceName), + Check: acctests.ComposeTestCheckFunc( + acctests.CheckTwingateResourceExists(theResource), + sdk.TestCheckNoResourceAttr(theResource, attr.SecurityPolicyID), + ), + }, + { + Config: createResourceWithSecurityPolicy(remoteNetworkName, resourceName, testPolicy), + Check: acctests.ComposeTestCheckFunc( + acctests.CheckTwingateResourceExists(theResource), + sdk.TestCheckResourceAttr(theResource, attr.SecurityPolicyID, testPolicy), + ), + }, + }, + }) +} + func TestAccTwingateResourceCreateInactive(t *testing.T) { t.Parallel() diff --git a/twingate/internal/test/client/user_test.go b/twingate/internal/test/client/user_test.go index 12534dd4..f7837859 100644 --- a/twingate/internal/test/client/user_test.go +++ b/twingate/internal/test/client/user_test.go @@ -12,11 +12,10 @@ import ( func TestClientUserReadOk(t *testing.T) { testData := []struct { - role string - isAdmin bool + role string }{ - {role: "ADMIN", isAdmin: true}, - {role: "DEVOPS", isAdmin: false}, + {role: "ADMIN"}, + {role: "DEVOPS"}, } for _, td := range testData { @@ -49,7 +48,6 @@ func TestClientUserReadOk(t *testing.T) { assert.EqualValues(t, userID, user.ID) assert.EqualValues(t, email, user.Email) assert.EqualValues(t, td.role, user.Role) - assert.EqualValues(t, td.isAdmin, user.IsAdmin()) }) } } diff --git a/twingate/internal/test/models/user_test.go b/twingate/internal/test/models/user_test.go index 4cd96cec..d1b72681 100644 --- a/twingate/internal/test/models/user_test.go +++ b/twingate/internal/test/models/user_test.go @@ -21,7 +21,6 @@ func TestUserModel(t *testing.T) { attr.FirstName: "", attr.LastName: "", attr.Email: "", - attr.IsAdmin: false, attr.Role: "", attr.Type: "", }, @@ -40,7 +39,6 @@ func TestUserModel(t *testing.T) { attr.FirstName: "John", attr.LastName: "White", attr.Email: "john@white.com", - attr.IsAdmin: true, attr.Role: "ADMIN", attr.Type: "MANUAL", }, @@ -59,7 +57,6 @@ func TestUserModel(t *testing.T) { attr.FirstName: "Hue", attr.LastName: "Black", attr.Email: "hue@black.com", - attr.IsAdmin: false, attr.Role: "USER", attr.Type: "SYNCED", }, diff --git a/twingate/provider.go b/twingate/provider.go index 40cfcd38..b8478ce3 100644 --- a/twingate/provider.go +++ b/twingate/provider.go @@ -9,9 +9,14 @@ import ( "github.com/Twingate/terraform-provider-twingate/twingate/internal/attr" "github.com/Twingate/terraform-provider-twingate/twingate/internal/client" - "github.com/Twingate/terraform-provider-twingate/twingate/internal/provider/resource" - "github.com/hashicorp/terraform-plugin-sdk/v2/diag" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + twingateDatasource "github.com/Twingate/terraform-provider-twingate/twingate/internal/provider/datasource" + twingateResource "github.com/Twingate/terraform-provider-twingate/twingate/internal/provider/resource" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" ) const ( @@ -27,98 +32,143 @@ const ( EnvHTTPMaxRetry = "TWINGATE_HTTP_MAX_RETRY" ) -func Provider(version string) *schema.Provider { - provider := &schema.Provider{ - Schema: providerOptions(), - ResourcesMap: map[string]*schema.Resource{ - resource.TwingateResource: resource.Resource(), - }, - DataSourcesMap: map[string]*schema.Resource{}, +var _ provider.Provider = &Twingate{} + +type Twingate struct { + version string +} + +type twingateProviderModel struct { + APIToken types.String `tfsdk:"api_token"` + Network types.String `tfsdk:"network"` + URL types.String `tfsdk:"url"` + HTTPTimeout types.Int64 `tfsdk:"http_timeout"` + HTTPMaxRetry types.Int64 `tfsdk:"http_max_retry"` +} + +func New(version string) func() provider.Provider { + return func() provider.Provider { + return &Twingate{ + version: version, + } } - provider.ConfigureContextFunc = configure(version, provider) +} - return provider +func (t Twingate) Metadata(ctx context.Context, request provider.MetadataRequest, response *provider.MetadataResponse) { + response.TypeName = "twingate" + response.Version = t.version } -func providerOptions() map[string]*schema.Schema { - return map[string]*schema.Schema{ - attr.APIToken: { - Type: schema.TypeString, - Optional: true, - Sensitive: true, - Description: fmt.Sprintf("The access key for API operations. You can retrieve this\n"+ - "from the Twingate Admin Console ([documentation](https://docs.twingate.com/docs/api-overview)).\n"+ - "Alternatively, this can be specified using the %s environment variable.", EnvAPIToken), - }, - attr.Network: { - Type: schema.TypeString, - Optional: true, - Sensitive: false, - Description: fmt.Sprintf("Your Twingate network ID for API operations.\n"+ - "You can find it in the Admin Console URL, for example:\n"+ - "`autoco.twingate.com`, where `autoco` is your network ID\n"+ - "Alternatively, this can be specified using the %s environment variable.", EnvNetwork), - }, - attr.URL: { - Type: schema.TypeString, - Optional: true, - Sensitive: false, - Description: fmt.Sprintf("The default is '%s'\n"+ - "This is optional and shouldn't be changed under normal circumstances.", DefaultURL), - }, - attr.HTTPTimeout: { - Type: schema.TypeInt, - Optional: true, - Description: fmt.Sprintf("Specifies a time limit in seconds for the http requests made. The default value is %s seconds.\n"+ - "Alternatively, this can be specified using the %s environment variable", DefaultHTTPTimeout, EnvHTTPTimeout), - }, - attr.HTTPMaxRetry: { - Type: schema.TypeInt, - Optional: true, - Description: fmt.Sprintf("Specifies a retry limit for the http requests made. The default value is %s.\n"+ - "Alternatively, this can be specified using the %s environment variable", DefaultHTTPMaxRetry, EnvHTTPMaxRetry), +func (t Twingate) Schema(ctx context.Context, request provider.SchemaRequest, response *provider.SchemaResponse) { + response.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + attr.APIToken: schema.StringAttribute{ + Optional: true, + Sensitive: true, + Description: fmt.Sprintf("The access key for API operations. You can retrieve this\n"+ + "from the Twingate Admin Console ([documentation](https://docs.twingate.com/docs/api-overview)).\n"+ + "Alternatively, this can be specified using the %s environment variable.", EnvAPIToken), + }, + attr.Network: schema.StringAttribute{ + Optional: true, + Description: fmt.Sprintf("Your Twingate network ID for API operations.\n"+ + "You can find it in the Admin Console URL, for example:\n"+ + "`autoco.twingate.com`, where `autoco` is your network ID\n"+ + "Alternatively, this can be specified using the %s environment variable.", EnvNetwork), + }, + attr.URL: schema.StringAttribute{ + Optional: true, + Description: fmt.Sprintf("The default is '%s'\n"+ + "This is optional and shouldn't be changed under normal circumstances.", DefaultURL), + }, + attr.HTTPTimeout: schema.Int64Attribute{ + Optional: true, + Description: fmt.Sprintf("Specifies a time limit in seconds for the http requests made. The default value is %s seconds.\n"+ + "Alternatively, this can be specified using the %s environment variable", DefaultHTTPTimeout, EnvHTTPTimeout), + }, + attr.HTTPMaxRetry: schema.Int64Attribute{ + Optional: true, + Description: fmt.Sprintf("Specifies a retry limit for the http requests made. The default value is %s.\n"+ + "Alternatively, this can be specified using the %s environment variable", DefaultHTTPMaxRetry, EnvHTTPMaxRetry), + }, }, } } -func configure(version string, _ *schema.Provider) func(context.Context, *schema.ResourceData) (interface{}, diag.Diagnostics) { - return func(ctx context.Context, data *schema.ResourceData) (interface{}, diag.Diagnostics) { - apiToken := os.Getenv(EnvAPIToken) - network := os.Getenv(EnvNetwork) - url := withDefault(os.Getenv(EnvURL), DefaultURL) - httpTimeout := mustGetInt(withDefault(os.Getenv(EnvHTTPTimeout), DefaultHTTPTimeout)) - httpMaxRetry := mustGetInt(withDefault(os.Getenv(EnvHTTPMaxRetry), DefaultHTTPMaxRetry)) - - apiToken = withDefault(data.Get(attr.APIToken).(string), apiToken) - network = withDefault(data.Get(attr.Network).(string), network) - url = withDefault(data.Get(attr.URL).(string), url) - httpTimeout = withDefault(data.Get(attr.HTTPTimeout).(int), httpTimeout) - httpMaxRetry = withDefault(data.Get(attr.HTTPMaxRetry).(int), httpMaxRetry) - - if network != "" { - client := client.NewClient(url, - apiToken, - network, - time.Duration(httpTimeout)*time.Second, - httpMaxRetry, - version) - - policy, _ := client.ReadSecurityPolicy(ctx, "", resource.DefaultSecurityPolicyName) - if policy != nil { - resource.DefaultSecurityPolicyID = policy.ID - } - - return client, nil - } +func (t Twingate) Configure(ctx context.Context, request provider.ConfigureRequest, response *provider.ConfigureResponse) { + var config twingateProviderModel - return nil, diag.Diagnostics{ - diag.Diagnostic{ - Severity: diag.Error, - Summary: "Unable to create Twingate client", - Detail: "Unable to create anonymous Twingate client, network has to be provided", - }, - } + response.Diagnostics.Append(request.Config.Get(ctx, &config)...) + + if response.Diagnostics.HasError() { + return + } + + // Default values to environment variables, but override + // with Terraform configuration value if set. + + apiToken := os.Getenv(EnvAPIToken) + network := os.Getenv(EnvNetwork) + url := withDefault(os.Getenv(EnvURL), DefaultURL) + httpTimeout := mustGetInt(withDefault(os.Getenv(EnvHTTPTimeout), DefaultHTTPTimeout)) + httpMaxRetry := mustGetInt(withDefault(os.Getenv(EnvHTTPMaxRetry), DefaultHTTPMaxRetry)) + + apiToken = overrideStrWithConfig(config.APIToken, apiToken) + network = overrideStrWithConfig(config.Network, network) + url = overrideStrWithConfig(config.URL, url) + httpTimeout = overrideIntWithConfig(config.HTTPTimeout, httpTimeout) + httpMaxRetry = overrideIntWithConfig(config.HTTPMaxRetry, httpMaxRetry) + + if network == "" { + response.Diagnostics.AddAttributeError( + path.Root(attr.Network), + fmt.Sprintf("Missing Twingate %s", attr.Network), + fmt.Sprintf("The provider cannot create the Twingate API client as there is a missing or empty value for the Twingate %s. "+ + "Set the %s value in the configuration or use the %s environment variable. "+ + "If either is already set, ensure the value is not empty.", attr.Network, attr.Network, EnvNetwork), + ) + + return } + + client := client.NewClient(url, + apiToken, + network, + time.Duration(httpTimeout)*time.Second, + httpMaxRetry, + t.version) + + response.DataSourceData = client + response.ResourceData = client + + policy, _ := client.ReadSecurityPolicy(ctx, "", twingateResource.DefaultSecurityPolicyName) + if policy != nil { + twingateResource.DefaultSecurityPolicyID = policy.ID + } +} + +func mustGetInt(str string) int { + if val, err := strconv.Atoi(str); err == nil { + return val + } + + return 0 +} + +func overrideStrWithConfig(cfg types.String, defaultValue string) string { + if !cfg.IsNull() { + return cfg.ValueString() + } + + return defaultValue +} + +func overrideIntWithConfig(cfg types.Int64, defaultValue int) int { + if !cfg.IsNull() { + return int(cfg.ValueInt64()) + } + + return defaultValue } func withDefault[T comparable](val, defaultVal T) T { @@ -130,10 +180,33 @@ func withDefault[T comparable](val, defaultVal T) T { return val } -func mustGetInt(str string) int { - if val, err := strconv.Atoi(str); err == nil { - return val +func (t Twingate) DataSources(ctx context.Context) []func() datasource.DataSource { + return []func() datasource.DataSource{ + twingateDatasource.NewConnectorDatasource, + twingateDatasource.NewConnectorsDatasource, + twingateDatasource.NewGroupDatasource, + twingateDatasource.NewGroupsDatasource, + twingateDatasource.NewRemoteNetworkDatasource, + twingateDatasource.NewRemoteNetworksDatasource, + twingateDatasource.NewServiceAccountsDatasource, + twingateDatasource.NewUserDatasource, + twingateDatasource.NewUsersDatasource, + twingateDatasource.NewSecurityPolicyDatasource, + twingateDatasource.NewSecurityPoliciesDatasource, + twingateDatasource.NewResourceDatasource, + twingateDatasource.NewResourcesDatasource, } +} - return 0 +func (t Twingate) Resources(ctx context.Context) []func() resource.Resource { + return []func() resource.Resource{ + twingateResource.NewConnectorTokensResource, + twingateResource.NewConnectorResource, + twingateResource.NewGroupResource, + twingateResource.NewRemoteNetworkResource, + twingateResource.NewServiceAccountResource, + twingateResource.NewServiceKeyResource, + twingateResource.NewUserResource, + twingateResource.NewResourceResource, + } } diff --git a/twingate/v2/provider.go b/twingate/v2/provider.go deleted file mode 100644 index ed9e9546..00000000 --- a/twingate/v2/provider.go +++ /dev/null @@ -1,206 +0,0 @@ -package v2 - -import ( - "context" - "fmt" - "os" - "strconv" - "time" - - "github.com/Twingate/terraform-provider-twingate/twingate/internal/attr" - "github.com/Twingate/terraform-provider-twingate/twingate/internal/client" - twingateDatasource "github.com/Twingate/terraform-provider-twingate/twingate/internal/provider/datasource" - twingateResource "github.com/Twingate/terraform-provider-twingate/twingate/internal/provider/resource" - "github.com/hashicorp/terraform-plugin-framework/datasource" - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/provider" - "github.com/hashicorp/terraform-plugin-framework/provider/schema" - "github.com/hashicorp/terraform-plugin-framework/resource" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -const ( - DefaultHTTPTimeout = "35" - DefaultHTTPMaxRetry = "10" - DefaultURL = "twingate.com" - - // EnvAPIToken env var for Token. - EnvAPIToken = "TWINGATE_API_TOKEN" // #nosec G101 - EnvNetwork = "TWINGATE_NETWORK" - EnvURL = "TWINGATE_URL" - EnvHTTPTimeout = "TWINGATE_HTTP_TIMEOUT" - EnvHTTPMaxRetry = "TWINGATE_HTTP_MAX_RETRY" -) - -var _ provider.Provider = &Twingate{} - -type Twingate struct { - version string -} - -type twingateProviderModel struct { - APIToken types.String `tfsdk:"api_token"` - Network types.String `tfsdk:"network"` - URL types.String `tfsdk:"url"` - HTTPTimeout types.Int64 `tfsdk:"http_timeout"` - HTTPMaxRetry types.Int64 `tfsdk:"http_max_retry"` -} - -func New(version string) func() provider.Provider { - return func() provider.Provider { - return &Twingate{ - version: version, - } - } -} - -func (t Twingate) Metadata(ctx context.Context, request provider.MetadataRequest, response *provider.MetadataResponse) { - response.TypeName = "twingate" - response.Version = t.version -} - -func (t Twingate) Schema(ctx context.Context, request provider.SchemaRequest, response *provider.SchemaResponse) { - response.Schema = schema.Schema{ - Attributes: map[string]schema.Attribute{ - attr.APIToken: schema.StringAttribute{ - Optional: true, - Sensitive: true, - Description: fmt.Sprintf("The access key for API operations. You can retrieve this\n"+ - "from the Twingate Admin Console ([documentation](https://docs.twingate.com/docs/api-overview)).\n"+ - "Alternatively, this can be specified using the %s environment variable.", EnvAPIToken), - }, - attr.Network: schema.StringAttribute{ - Optional: true, - Description: fmt.Sprintf("Your Twingate network ID for API operations.\n"+ - "You can find it in the Admin Console URL, for example:\n"+ - "`autoco.twingate.com`, where `autoco` is your network ID\n"+ - "Alternatively, this can be specified using the %s environment variable.", EnvNetwork), - }, - attr.URL: schema.StringAttribute{ - Optional: true, - Description: fmt.Sprintf("The default is '%s'\n"+ - "This is optional and shouldn't be changed under normal circumstances.", DefaultURL), - }, - attr.HTTPTimeout: schema.Int64Attribute{ - Optional: true, - Description: fmt.Sprintf("Specifies a time limit in seconds for the http requests made. The default value is %s seconds.\n"+ - "Alternatively, this can be specified using the %s environment variable", DefaultHTTPTimeout, EnvHTTPTimeout), - }, - attr.HTTPMaxRetry: schema.Int64Attribute{ - Optional: true, - Description: fmt.Sprintf("Specifies a retry limit for the http requests made. The default value is %s.\n"+ - "Alternatively, this can be specified using the %s environment variable", DefaultHTTPMaxRetry, EnvHTTPMaxRetry), - }, - }, - } -} - -func (t Twingate) Configure(ctx context.Context, request provider.ConfigureRequest, response *provider.ConfigureResponse) { - var config twingateProviderModel - - response.Diagnostics.Append(request.Config.Get(ctx, &config)...) - - if response.Diagnostics.HasError() { - return - } - - // Default values to environment variables, but override - // with Terraform configuration value if set. - - apiToken := os.Getenv(EnvAPIToken) - network := os.Getenv(EnvNetwork) - url := withDefault(os.Getenv(EnvURL), DefaultURL) - httpTimeout := mustGetInt(withDefault(os.Getenv(EnvHTTPTimeout), DefaultHTTPTimeout)) - httpMaxRetry := mustGetInt(withDefault(os.Getenv(EnvHTTPMaxRetry), DefaultHTTPMaxRetry)) - - apiToken = overrideStrWithConfig(config.APIToken, apiToken) - network = overrideStrWithConfig(config.Network, network) - url = overrideStrWithConfig(config.URL, url) - httpTimeout = overrideIntWithConfig(config.HTTPTimeout, httpTimeout) - httpMaxRetry = overrideIntWithConfig(config.HTTPMaxRetry, httpMaxRetry) - - if network == "" { - response.Diagnostics.AddAttributeError( - path.Root(attr.Network), - fmt.Sprintf("Missing Twingate %s", attr.Network), - fmt.Sprintf("The provider cannot create the Twingate API client as there is a missing or empty value for the Twingate %s. "+ - "Set the %s value in the configuration or use the %s environment variable. "+ - "If either is already set, ensure the value is not empty.", attr.Network, attr.Network, EnvNetwork), - ) - - return - } - - client := client.NewClient(url, - apiToken, - network, - time.Duration(httpTimeout)*time.Second, - httpMaxRetry, - t.version) - - response.DataSourceData = client - response.ResourceData = client -} - -func mustGetInt(str string) int { - if val, err := strconv.Atoi(str); err == nil { - return val - } - - return 0 -} - -func overrideStrWithConfig(cfg types.String, defaultValue string) string { - if !cfg.IsNull() { - return cfg.ValueString() - } - - return defaultValue -} - -func overrideIntWithConfig(cfg types.Int64, defaultValue int) int { - if !cfg.IsNull() { - return int(cfg.ValueInt64()) - } - - return defaultValue -} - -func withDefault[T comparable](val, defaultVal T) T { - var zeroValue T - if val == zeroValue { - return defaultVal - } - - return val -} - -func (t Twingate) DataSources(ctx context.Context) []func() datasource.DataSource { - return []func() datasource.DataSource{ - twingateDatasource.NewConnectorDatasource, - twingateDatasource.NewConnectorsDatasource, - twingateDatasource.NewGroupDatasource, - twingateDatasource.NewGroupsDatasource, - twingateDatasource.NewRemoteNetworkDatasource, - twingateDatasource.NewRemoteNetworksDatasource, - twingateDatasource.NewServiceAccountsDatasource, - twingateDatasource.NewUserDatasource, - twingateDatasource.NewUsersDatasource, - twingateDatasource.NewSecurityPolicyDatasource, - twingateDatasource.NewSecurityPoliciesDatasource, - twingateDatasource.NewResourceDatasource, - twingateDatasource.NewResourcesDatasource, - } -} - -func (t Twingate) Resources(ctx context.Context) []func() resource.Resource { - return []func() resource.Resource{ - twingateResource.NewConnectorTokensResource, - twingateResource.NewConnectorResource, - twingateResource.NewGroupResource, - twingateResource.NewRemoteNetworkResource, - twingateResource.NewServiceAccountResource, - twingateResource.NewServiceKeyResource, - twingateResource.NewUserResource, - } -}