diff --git a/CHANGELOG.md b/CHANGELOG.md index f9ba46f..c9ec902 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,14 @@ All notable changes to this project will be documented in this file. -## [Unreleased](https://github.com/dbt-labs/terraform-provider-dbtcloud/compare/v0.3.21...HEAD) +## [Unreleased](https://github.com/dbt-labs/terraform-provider-dbtcloud/compare/v0.3.22...HEAD) + +# [0.3.22](https://github.com/dbt-labs/terraform-provider-dbtcloud/compare/v0.3.21...v0.3.22) + +### Changes + +- Add resource `dbtcloud_account_features` to manage account level features like Advanced CI +- Add resource `dbtcloud_ip_restrictions_rule` to manage IP restrictions for customers with access to the feature in dbt Cloud # [0.3.21](https://github.com/dbt-labs/terraform-provider-dbtcloud/compare/v0.3.20...v0.3.21) diff --git a/docs/resources/account_features.md b/docs/resources/account_features.md new file mode 100644 index 0000000..7b4cf6f --- /dev/null +++ b/docs/resources/account_features.md @@ -0,0 +1,29 @@ +--- +page_title: "dbtcloud_account_features Resource - dbtcloud" +subcategory: "" +description: |- + Manages dbt Cloud global features at the account level, like Advanced CI. The same feature should not be configured in different resources to avoid conflicts. + When destroying the resource or removing the value for an attribute, the features status will not be changed. Deactivating features will require applying them wih the value set to false. +--- + +# dbtcloud_account_features (Resource) + + +Manages dbt Cloud global features at the account level, like Advanced CI. The same feature should not be configured in different resources to avoid conflicts. + +When destroying the resource or removing the value for an attribute, the features status will not be changed. Deactivating features will require applying them wih the value set to `false`. + + + + +## Schema + +### Optional + +- `advanced_ci` (Boolean) Whether advanced CI is enabled. +- `partial_parsing` (Boolean) Whether partial parsing is enabled. +- `repo_caching` (Boolean) Whether repository caching is enabled. + +### Read-Only + +- `id` (String) The ID of the account. diff --git a/docs/resources/ip_restrictions_rule.md b/docs/resources/ip_restrictions_rule.md new file mode 100644 index 0000000..dc63a50 --- /dev/null +++ b/docs/resources/ip_restrictions_rule.md @@ -0,0 +1,82 @@ +--- +page_title: "dbtcloud_ip_restrictions_rule Resource - dbtcloud" +subcategory: "" +description: |- + Manages IP restriction rules in dbt Cloud. IP restriction rules allow you to control access to your dbt Cloud instance based on IP address ranges. +--- + +# dbtcloud_ip_restrictions_rule (Resource) + + +Manages IP restriction rules in dbt Cloud. IP restriction rules allow you to control access to your dbt Cloud instance based on IP address ranges. + +## Example Usage + +```terraform +resource "dbtcloud_ip_restrictions_rule" "test" { + name = "My restriction rule" + description = "Important description" + cidrs = [ + { + cidr = "::ffff:106:708" # IPv6 config + }, + { + cidr = "1.6.7.10/24" # /24 for adding a range of addresses via netmask + } + ] + type = "deny" + rule_set_enabled = false +} +``` + + +## Schema + +### Required + +- `cidrs` (Attributes Set) Set of CIDR ranges for this rule (see [below for nested schema](#nestedatt--cidrs)) +- `name` (String) The name of the IP restriction rule +- `rule_set_enabled` (Boolean) Whether the IP restriction rule set is enabled or not. Important!: This value needs to be the same for all rules if multiple rules are defined. All rules must be active or inactive at the same time. +- `type` (String) The type of the IP restriction rule (allow or deny) + +### Optional + +- `description` (String) A description of the IP restriction rule + +### Read-Only + +- `id` (Number) The ID of the IP restriction rule + + +### Nested Schema for `cidrs` + +Optional: + +- `cidr` (String) IP CIDR range (can be IPv4 or IPv6) + +Read-Only: + +- `cidr_ipv6` (String) IPv6 CIDR range (read-only) +- `id` (Number) ID of the CIDR range +- `ip_restriction_rule_id` (Number) ID of the IP restriction rule + +## Import + +Import is supported using the following syntax: + +```shell +# using import blocks (requires Terraform >= 1.5) +import { + to = dbtcloud_ip_restrictions_rule.my_rule + id = "ip_restriction_rule_id" +} + +import { + to = dbtcloud_ip_restrictions_rule.my_rule + id = "12345" +} + +# using the older import command +terraform import dbtcloud_ip_restrictions_rule.my_rule "ip_restriction_rule_id" +terraform import dbtcloud_ip_restrictions_rule.my_rule 12345 +``` diff --git a/examples/resources/dbtcloud_ip_restrictions_rule/import.sh b/examples/resources/dbtcloud_ip_restrictions_rule/import.sh new file mode 100644 index 0000000..bbff974 --- /dev/null +++ b/examples/resources/dbtcloud_ip_restrictions_rule/import.sh @@ -0,0 +1,14 @@ +# using import blocks (requires Terraform >= 1.5) +import { + to = dbtcloud_ip_restrictions_rule.my_rule + id = "ip_restriction_rule_id" +} + +import { + to = dbtcloud_ip_restrictions_rule.my_rule + id = "12345" +} + +# using the older import command +terraform import dbtcloud_ip_restrictions_rule.my_rule "ip_restriction_rule_id" +terraform import dbtcloud_ip_restrictions_rule.my_rule 12345 diff --git a/examples/resources/dbtcloud_ip_restrictions_rule/resource.tf b/examples/resources/dbtcloud_ip_restrictions_rule/resource.tf new file mode 100644 index 0000000..67ebe6c --- /dev/null +++ b/examples/resources/dbtcloud_ip_restrictions_rule/resource.tf @@ -0,0 +1,14 @@ +resource "dbtcloud_ip_restrictions_rule" "test" { + name = "My restriction rule" + description = "Important description" + cidrs = [ + { + cidr = "::ffff:106:708" # IPv6 config + }, + { + cidr = "1.6.7.10/24" # /24 for adding a range of addresses via netmask + } + ] + type = "deny" + rule_set_enabled = false +} \ No newline at end of file diff --git a/pkg/dbt_cloud/account_features.go b/pkg/dbt_cloud/account_features.go new file mode 100644 index 0000000..b6d931f --- /dev/null +++ b/pkg/dbt_cloud/account_features.go @@ -0,0 +1,73 @@ +package dbt_cloud + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" +) + +type AccountFeaturesResponse struct { + Data AccountFeatures `json:"data"` + Status ResponseStatus `json:"status"` + Extra ResponseExtra `json:"extra"` +} + +type AccountFeatures struct { + AdvancedCI bool `json:"advanced-ci"` + PartialParsing bool `json:"partial-parsing"` + RepoCaching bool `json:"repo-caching"` +} + +type AccountFeatureUpdateRequest struct { + Feature string `json:"feature"` + Value bool `json:"value"` +} + +func (c *Client) GetAccountFeatures() (*AccountFeatures, error) { + req, err := http.NewRequest( + "GET", + fmt.Sprintf("%s/private/accounts/%d/features/", c.HostURL, c.AccountID), + nil, + ) + if err != nil { + return nil, err + } + + body, err := c.doRequest(req) + if err != nil { + return nil, err + } + + featuresResponse := AccountFeaturesResponse{} + err = json.Unmarshal(body, &featuresResponse) + if err != nil { + return nil, err + } + + return &featuresResponse.Data, nil +} + +func (c *Client) UpdateAccountFeature(feature string, value bool) error { + updateRequest := AccountFeatureUpdateRequest{ + Feature: feature, + Value: value, + } + + updateData, err := json.Marshal(updateRequest) + if err != nil { + return err + } + + req, err := http.NewRequest( + "POST", + fmt.Sprintf("%s/private/accounts/%d/features/", c.HostURL, c.AccountID), + strings.NewReader(string(updateData)), + ) + if err != nil { + return err + } + + _, err = c.doRequest(req) + return err +} diff --git a/pkg/dbt_cloud/client.go b/pkg/dbt_cloud/client.go index a9eddc6..678adef 100644 --- a/pkg/dbt_cloud/client.go +++ b/pkg/dbt_cloud/client.go @@ -178,7 +178,9 @@ func (c *Client) doRequest(req *http.Request) ([]byte, error) { } } - if (res.StatusCode != http.StatusOK) && (res.StatusCode != 201) { + if (res.StatusCode != http.StatusOK) && + (res.StatusCode != http.StatusCreated) && + (res.StatusCode != http.StatusNoContent) { return nil, fmt.Errorf( "%s url: %s, status: %d, body: %s", req.Method, diff --git a/pkg/dbt_cloud/ip_restrictions.go b/pkg/dbt_cloud/ip_restrictions.go new file mode 100644 index 0000000..1397991 --- /dev/null +++ b/pkg/dbt_cloud/ip_restrictions.go @@ -0,0 +1,200 @@ +package dbt_cloud + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/samber/lo" +) + +type IPRestrictions []IPRestrictionsRule + +type IPRestrictionsRule struct { + ID int64 `json:"id,omitempty"` + IPRestrictionRuleSetID int64 `json:"ip_restriction_rule_set_id,omitempty"` + Name string `json:"name"` + Type int64 `json:"type"` + AccountID int64 `json:"account_id,omitempty"` + Description string `json:"description"` + Cidrs []Cidrs `json:"cidrs,"` + RuleSetEnabled bool `json:"rule_set_enabled"` + // not needed for TF + State int64 `json:"state,omitempty"` + CreatedByID int64 `json:"created_by_id,omitempty"` + EnabledForServiceTokens bool `json:"enabled_for_service_tokens,omitempty"` +} + +type Cidrs struct { + ID int64 `json:"id,omitempty"` + IPRestrictionRuleID int64 `json:"ip_restriction_rule_id,omitempty"` + Cidr string `json:"cidr,omitempty"` + CidrIpv6 string `json:"cidr_ipv6,omitempty"` + // not needed for TF + State int64 `json:"state,omitempty"` + Enabled bool `json:"enabled,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type IPRestrictionsResponse struct { + Data IPRestrictions `json:"data"` + Status ResponseStatus `json:"status"` +} + +type IPRestrictionsRuleResponse struct { + Data IPRestrictionsRule `json:"data"` + Status ResponseStatus `json:"status"` +} + +func (c *Client) GetIPRestrictions() (*IPRestrictions, error) { + req, err := http.NewRequest( + "GET", + fmt.Sprintf( + "%s/v3/accounts/%s/ip-restrictions/", + c.HostURL, + strconv.Itoa(c.AccountID), + ), + nil, + ) + if err != nil { + return nil, err + } + + body, err := c.doRequest(req) + if err != nil { + return nil, err + } + + ipRestrictionsResponse := IPRestrictionsResponse{} + err = json.Unmarshal(body, &ipRestrictionsResponse) + if err != nil { + return nil, err + } + + return &ipRestrictionsResponse.Data, nil +} + +func (c *Client) GetIPRestrictionsRule(ruleID int64) (*IPRestrictionsRule, error) { + allIPRestrictions, err := c.GetIPRestrictions() + if err != nil { + return nil, err + } + + foundIPRestriction := lo.Filter( + *allIPRestrictions, + func(ipRestrictionsRule IPRestrictionsRule, _ int) bool { + return ipRestrictionsRule.ID == ruleID + }, + ) + + if len(foundIPRestriction) == 0 { + return nil, nil + } + return &foundIPRestriction[0], nil + +} + +func (c *Client) CreateIPRestrictionsRule( + ipRestrictionsRule IPRestrictionsRule, +) (*IPRestrictionsRule, error) { + + newIPRestrictionsData, err := json.Marshal(ipRestrictionsRule) + if err != nil { + return nil, err + } + + req, err := http.NewRequest( + "POST", + fmt.Sprintf("%s/v3/accounts/%s/ip-restrictions/", c.HostURL, strconv.Itoa(c.AccountID)), + strings.NewReader(string(newIPRestrictionsData)), + ) + if err != nil { + return nil, err + } + + body, err := c.doRequest(req) + if err != nil { + return nil, err + } + + ipRestrictionsRuleResponse := IPRestrictionsRuleResponse{} + err = json.Unmarshal(body, &ipRestrictionsRuleResponse) + if err != nil { + return nil, err + } + + return &ipRestrictionsRuleResponse.Data, nil +} + +func (c *Client) UpdateIPRestrictionsRule( + ipRestrictionsId string, + ipRestrictions IPRestrictionsRule, +) (*IPRestrictionsRule, error) { + ipRestrictionsData, err := json.Marshal(ipRestrictions) + if err != nil { + return nil, err + } + + req, err := http.NewRequest( + "PUT", + fmt.Sprintf( + "%s/v3/accounts/%s/ip-restrictions/%s", + c.HostURL, + strconv.Itoa(c.AccountID), + ipRestrictionsId, + ), + strings.NewReader(string(ipRestrictionsData)), + ) + if err != nil { + return nil, err + } + + body, err := c.doRequest(req) + if err != nil { + return nil, err + } + + ipRestrictionsRuleResponse := IPRestrictionsRuleResponse{} + err = json.Unmarshal(body, &ipRestrictionsRuleResponse) + if err != nil { + return nil, err + } + + return &ipRestrictionsRuleResponse.Data, nil +} + +func (c *Client) DeleteIPRestrictions(ipRestrictions IPRestrictions) error { + for _, ipRestrictionsRule := range ipRestrictions { + err := c.DeleteIPRestrictionsRule(ipRestrictionsRule.ID) + if err != nil { + return err + } + } + return nil +} + +func (c *Client) DeleteIPRestrictionsRule(ipRestrictionsRuleID int64) error { + req, err := http.NewRequest( + "DELETE", + fmt.Sprintf( + "%s/v3/accounts/%s/ip-restrictions/%d", + c.HostURL, + strconv.Itoa(c.AccountID), + ipRestrictionsRuleID, + ), + nil, + ) + if err != nil { + return err + } + + _, err = c.doRequest(req) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/framework/objects/account_features/model.go b/pkg/framework/objects/account_features/model.go new file mode 100644 index 0000000..750c6e8 --- /dev/null +++ b/pkg/framework/objects/account_features/model.go @@ -0,0 +1,12 @@ +package account_features + +import ( + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type AccountFeaturesResourceModel struct { + ID types.String `tfsdk:"id"` + AdvancedCI types.Bool `tfsdk:"advanced_ci"` + PartialParsing types.Bool `tfsdk:"partial_parsing"` + RepoCaching types.Bool `tfsdk:"repo_caching"` +} diff --git a/pkg/framework/objects/account_features/resource.go b/pkg/framework/objects/account_features/resource.go new file mode 100644 index 0000000..318f9f2 --- /dev/null +++ b/pkg/framework/objects/account_features/resource.go @@ -0,0 +1,183 @@ +package account_features + +import ( + "context" + "fmt" + + "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/dbt_cloud" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &accountFeaturesResource{} + _ resource.ResourceWithConfigure = &accountFeaturesResource{} +) + +type accountFeaturesResource struct { + client *dbt_cloud.Client +} + +func AccountFeaturesResource() resource.Resource { + return &accountFeaturesResource{} +} + +func readFeatures(client *dbt_cloud.Client) (AccountFeaturesResourceModel, error) { + features, err := client.GetAccountFeatures() + if err != nil { + return AccountFeaturesResourceModel{}, err + } + + return AccountFeaturesResourceModel{ + ID: types.StringValue(fmt.Sprintf("%d", client.AccountID)), + AdvancedCI: types.BoolValue(features.AdvancedCI), + PartialParsing: types.BoolValue(features.PartialParsing), + RepoCaching: types.BoolValue(features.RepoCaching), + }, nil +} + +func (r *accountFeaturesResource) Metadata( + _ context.Context, + req resource.MetadataRequest, + resp *resource.MetadataResponse, +) { + resp.TypeName = req.ProviderTypeName + "_account_features" +} + +func (r *accountFeaturesResource) Create( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, +) { + // Read current state + var plan AccountFeaturesResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Update features + if !plan.AdvancedCI.IsUnknown() { + err := r.client.UpdateAccountFeature("advanced-ci", plan.AdvancedCI.ValueBool()) + if err != nil { + resp.Diagnostics.AddError("Error updating advanced-ci feature", err.Error()) + return + } + } + + if !plan.PartialParsing.IsUnknown() { + err := r.client.UpdateAccountFeature("partial-parsing", plan.PartialParsing.ValueBool()) + if err != nil { + resp.Diagnostics.AddError("Error updating partial-parsing feature", err.Error()) + return + } + } + + if !plan.RepoCaching.IsUnknown() { + err := r.client.UpdateAccountFeature("repo-caching", plan.RepoCaching.ValueBool()) + if err != nil { + resp.Diagnostics.AddError("Error updating repo-caching feature", err.Error()) + return + } + } + + features, err := readFeatures(r.client) + if err != nil { + resp.Diagnostics.AddError("Error reading account features", err.Error()) + return + } + + diags = resp.State.Set(ctx, &features) + resp.Diagnostics.Append(diags...) +} + +func (r *accountFeaturesResource) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { + + features, err := readFeatures(r.client) + if err != nil { + resp.Diagnostics.AddError("Error reading account features", err.Error()) + return + } + + diags := resp.State.Set(ctx, &features) + resp.Diagnostics.Append(diags...) +} + +func (r *accountFeaturesResource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + var plan AccountFeaturesResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + var state AccountFeaturesResourceModel + diags = req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Update changed values + if !plan.AdvancedCI.IsUnknown() && !plan.AdvancedCI.Equal(state.AdvancedCI) { + err := r.client.UpdateAccountFeature("advanced-ci", plan.AdvancedCI.ValueBool()) + if err != nil { + resp.Diagnostics.AddError("Error updating advanced-ci feature", err.Error()) + return + } + } + + if !plan.PartialParsing.IsUnknown() && !plan.PartialParsing.Equal(state.PartialParsing) { + err := r.client.UpdateAccountFeature("partial-parsing", plan.PartialParsing.ValueBool()) + if err != nil { + resp.Diagnostics.AddError("Error updating partial-parsing feature", err.Error()) + return + } + } + + if !plan.RepoCaching.IsUnknown() && !plan.RepoCaching.Equal(state.RepoCaching) { + err := r.client.UpdateAccountFeature("repo-caching", plan.RepoCaching.ValueBool()) + if err != nil { + resp.Diagnostics.AddError("Error updating repo-caching feature", err.Error()) + return + } + } + + features, err := readFeatures(r.client) + if err != nil { + resp.Diagnostics.AddError("Error reading account features", err.Error()) + return + } + + diags = resp.State.Set(ctx, &features) + resp.Diagnostics.Append(diags...) +} + +func (r *accountFeaturesResource) Delete( + ctx context.Context, + req resource.DeleteRequest, + resp *resource.DeleteResponse, +) { + // no-op, we keep the existing values as we technically can't "delete" the settings, just turn them on and off +} + +func (r *accountFeaturesResource) Configure( + _ context.Context, + req resource.ConfigureRequest, + _ *resource.ConfigureResponse, +) { + if req.ProviderData == nil { + return + } + + r.client = req.ProviderData.(*dbt_cloud.Client) +} diff --git a/pkg/framework/objects/account_features/resource_acceptance_test.go b/pkg/framework/objects/account_features/resource_acceptance_test.go new file mode 100644 index 0000000..58d3855 --- /dev/null +++ b/pkg/framework/objects/account_features/resource_acceptance_test.go @@ -0,0 +1,73 @@ +package account_features_test + +import ( + "testing" + + "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/framework/acctest_helper" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccDbtCloudAccountFeaturesResource(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest_helper.TestAccPreCheck(t) }, + ProtoV6ProviderFactories: acctest_helper.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create and Read testing + { + Config: testAccDbtCloudAccountFeaturesResourceBasicConfig(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "dbtcloud_account_features.test", + "advanced_ci", + "true", + ), + resource.TestCheckResourceAttr( + "dbtcloud_account_features.test", + "partial_parsing", + "false", + ), + ), + }, + // Update testing + { + Config: testAccDbtCloudAccountFeaturesResourceFullConfig(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "dbtcloud_account_features.test", + "advanced_ci", + "true", + ), + resource.TestCheckResourceAttr( + "dbtcloud_account_features.test", + "partial_parsing", + "true", + ), + resource.TestCheckResourceAttr( + "dbtcloud_account_features.test", + "repo_caching", + "true", + ), + ), + }, + }, + }) +} + +func testAccDbtCloudAccountFeaturesResourceBasicConfig() string { + return ` +resource "dbtcloud_account_features" "test" { + advanced_ci = true + partial_parsing = false +} +` +} + +func testAccDbtCloudAccountFeaturesResourceFullConfig() string { + return ` +resource "dbtcloud_account_features" "test" { + advanced_ci = true + partial_parsing = true + repo_caching = true +} +` +} diff --git a/pkg/framework/objects/account_features/schema.go b/pkg/framework/objects/account_features/schema.go new file mode 100644 index 0000000..614c54d --- /dev/null +++ b/pkg/framework/objects/account_features/schema.go @@ -0,0 +1,49 @@ +package account_features + +import ( + "context" + + "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/helper" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" +) + +func (r *accountFeaturesResource) Schema( + _ context.Context, + _ resource.SchemaRequest, + resp *resource.SchemaResponse, +) { + resp.Schema = schema.Schema{ + Description: helper.DocString( + `Manages dbt Cloud global features at the account level, like Advanced CI. The same feature should not be configured in different resources to avoid conflicts. + + When destroying the resource or removing the value for an attribute, the features status will not be changed. Deactivating features will require applying them wih the value set to ~~~false~~~.`, + ), + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The ID of the account.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "advanced_ci": schema.BoolAttribute{ + Description: "Whether advanced CI is enabled.", + Optional: true, + Computed: true, + }, + "partial_parsing": schema.BoolAttribute{ + Description: "Whether partial parsing is enabled.", + Optional: true, + Computed: true, + }, + "repo_caching": schema.BoolAttribute{ + Description: "Whether repository caching is enabled.", + Optional: true, + Computed: true, + }, + }, + } +} diff --git a/pkg/framework/objects/ip_restrictions_rule/model.go b/pkg/framework/objects/ip_restrictions_rule/model.go new file mode 100644 index 0000000..a189e22 --- /dev/null +++ b/pkg/framework/objects/ip_restrictions_rule/model.go @@ -0,0 +1,29 @@ +package ip_restrictions_rule + +import ( + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/samber/lo" +) + +type IPRestrictionsRuleResourceModel struct { + ID types.Int64 `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Type types.String `tfsdk:"type"` + Description types.String `tfsdk:"description"` + RuleSetEnabled types.Bool `tfsdk:"rule_set_enabled"` + Cidrs []CidrModel `tfsdk:"cidrs"` +} + +type CidrModel struct { + Cidr types.String `tfsdk:"cidr"` + CidrIpv6 types.String `tfsdk:"cidr_ipv6"` + ID types.Int64 `tfsdk:"id"` + IPRestrictionRuleID types.Int64 `tfsdk:"ip_restriction_rule_id"` +} + +var ipRestrictionTypeNameToIDMapping = map[string]int64{ + "allow": 1, + "deny": 2, +} + +var ipRestrictionTypeIDToNameMapping = lo.Invert(ipRestrictionTypeNameToIDMapping) diff --git a/pkg/framework/objects/ip_restrictions_rule/resource.go b/pkg/framework/objects/ip_restrictions_rule/resource.go new file mode 100644 index 0000000..f7d1392 --- /dev/null +++ b/pkg/framework/objects/ip_restrictions_rule/resource.go @@ -0,0 +1,263 @@ +package ip_restrictions_rule + +import ( + "context" + "fmt" + "strconv" + + "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/dbt_cloud" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/samber/lo" +) + +var ( + _ resource.Resource = &ipRestrictionsRuleResource{} + _ resource.ResourceWithConfigure = &ipRestrictionsRuleResource{} + _ resource.ResourceWithImportState = &ipRestrictionsRuleResource{} +) + +func IPRestrictionsRuleResource() resource.Resource { + return &ipRestrictionsRuleResource{} +} + +type ipRestrictionsRuleResource struct { + client *dbt_cloud.Client +} + +func (r *ipRestrictionsRuleResource) Metadata( + _ context.Context, + req resource.MetadataRequest, + resp *resource.MetadataResponse, +) { + resp.TypeName = req.ProviderTypeName + "_ip_restrictions_rule" +} + +func (r *ipRestrictionsRuleResource) Configure( + _ context.Context, + req resource.ConfigureRequest, + _ *resource.ConfigureResponse, +) { + if req.ProviderData == nil { + return + } + r.client = req.ProviderData.(*dbt_cloud.Client) +} + +func (r *ipRestrictionsRuleResource) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { + var state IPRestrictionsRuleResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + rule, err := r.client.GetIPRestrictionsRule(state.ID.ValueInt64()) + if err != nil { + resp.Diagnostics.AddError( + "Error reading IP Restrictions Rule", + err.Error(), + ) + return + } + + if rule == nil { + resp.State.RemoveResource(ctx) + return + } + + state.Name = types.StringValue(rule.Name) + state.Type = types.StringValue(ipRestrictionTypeIDToNameMapping[rule.Type]) + state.Description = types.StringValue(rule.Description) + state.RuleSetEnabled = types.BoolValue(rule.RuleSetEnabled) + + state.Cidrs = make([]CidrModel, 0, len(rule.Cidrs)) + for _, cidr := range rule.Cidrs { + state.Cidrs = append(state.Cidrs, CidrModel{ + Cidr: types.StringValue(cidr.Cidr), + CidrIpv6: types.StringValue(cidr.CidrIpv6), + ID: types.Int64Value(cidr.ID), + IPRestrictionRuleID: types.Int64Value(cidr.IPRestrictionRuleID), + }) + } + + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) +} +func (r *ipRestrictionsRuleResource) Create( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, +) { + var plan IPRestrictionsRuleResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ipRestriction := dbt_cloud.IPRestrictionsRule{ + Name: plan.Name.ValueString(), + Type: ipRestrictionTypeNameToIDMapping[plan.Type.ValueString()], + Description: plan.Description.ValueString(), + RuleSetEnabled: plan.RuleSetEnabled.ValueBool(), + Cidrs: make([]dbt_cloud.Cidrs, 0, len(plan.Cidrs)), + } + + for _, cidr := range plan.Cidrs { + ipRestriction.Cidrs = append(ipRestriction.Cidrs, dbt_cloud.Cidrs{ + Cidr: cidr.Cidr.ValueString(), + CidrIpv6: cidr.CidrIpv6.ValueString(), + ID: cidr.ID.ValueInt64(), + IPRestrictionRuleID: cidr.IPRestrictionRuleID.ValueInt64(), + }) + } + + created, err := r.client.CreateIPRestrictionsRule(ipRestriction) + if err != nil { + resp.Diagnostics.AddError( + "Error creating IP Restrictions Rule", + err.Error(), + ) + return + } + + plan.ID = types.Int64Value(created.ID) + plan.Cidrs = make([]CidrModel, 0, len(created.Cidrs)) + + for _, cidr := range created.Cidrs { + plan.Cidrs = append(plan.Cidrs, CidrModel{ + Cidr: types.StringValue(cidr.Cidr), + CidrIpv6: types.StringValue(cidr.CidrIpv6), + ID: types.Int64Value(cidr.ID), + IPRestrictionRuleID: types.Int64Value(cidr.IPRestrictionRuleID), + }) + } + + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) +} + +func (r *ipRestrictionsRuleResource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + var plan IPRestrictionsRuleResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + var state IPRestrictionsRuleResourceModel + diags = req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ipRestrictionsRule := dbt_cloud.IPRestrictionsRule{ + ID: plan.ID.ValueInt64(), + Name: plan.Name.ValueString(), + Type: ipRestrictionTypeNameToIDMapping[plan.Type.ValueString()], + Description: plan.Description.ValueString(), + RuleSetEnabled: plan.RuleSetEnabled.ValueBool(), + Cidrs: []dbt_cloud.Cidrs{}, + } + + for _, cidr := range plan.Cidrs { + ipRestrictionsRule.Cidrs = append(ipRestrictionsRule.Cidrs, dbt_cloud.Cidrs{ + Cidr: cidr.Cidr.ValueString(), + CidrIpv6: cidr.CidrIpv6.ValueString(), + ID: cidr.ID.ValueInt64(), + IPRestrictionRuleID: cidr.IPRestrictionRuleID.ValueInt64(), + }) + } + + for _, cidr := range state.Cidrs { + foundInPlan := lo.Filter(plan.Cidrs, func(c CidrModel, _ int) bool { + return c.ID.ValueInt64() == cidr.ID.ValueInt64() + }) + if len(foundInPlan) == 0 { + ipRestrictionsRule.Cidrs = append(ipRestrictionsRule.Cidrs, dbt_cloud.Cidrs{ + Cidr: cidr.Cidr.ValueString(), + CidrIpv6: cidr.CidrIpv6.ValueString(), + ID: cidr.ID.ValueInt64(), + IPRestrictionRuleID: cidr.IPRestrictionRuleID.ValueInt64(), + State: dbt_cloud.STATE_DELETED, + }) + + } + + } + + created, err := r.client.UpdateIPRestrictionsRule( + strconv.FormatInt(plan.ID.ValueInt64(), 10), + ipRestrictionsRule, + ) + if err != nil { + resp.Diagnostics.AddError( + "Error updating IP Restrictions Rule", + err.Error(), + ) + return + } + plan.Cidrs = make([]CidrModel, 0, len(created.Cidrs)) + + for _, cidr := range created.Cidrs { + plan.Cidrs = append(plan.Cidrs, CidrModel{ + Cidr: types.StringValue(cidr.Cidr), + CidrIpv6: types.StringValue(cidr.CidrIpv6), + ID: types.Int64Value(cidr.ID), + IPRestrictionRuleID: types.Int64Value(cidr.IPRestrictionRuleID), + }) + } + + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) +} + +func (r *ipRestrictionsRuleResource) Delete( + ctx context.Context, + req resource.DeleteRequest, + resp *resource.DeleteResponse, +) { + var state IPRestrictionsRuleResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + err := r.client.DeleteIPRestrictionsRule(state.ID.ValueInt64()) + if err != nil { + resp.Diagnostics.AddError( + "Error deleting IP Restrictions Rule", + err.Error(), + ) + return + } +} + +func (r *ipRestrictionsRuleResource) ImportState( + ctx context.Context, + req resource.ImportStateRequest, + resp *resource.ImportStateResponse, +) { + id, err := strconv.ParseInt(req.ID, 10, 64) + if err != nil { + resp.Diagnostics.AddError( + "Error importing IP Restrictions Rule", + fmt.Sprintf("Invalid ID format: %s. The ID should be an integer", req.ID), + ) + return + } + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) +} diff --git a/pkg/framework/objects/ip_restrictions_rule/resource_acceptance_test.go b/pkg/framework/objects/ip_restrictions_rule/resource_acceptance_test.go new file mode 100644 index 0000000..fe94ebd --- /dev/null +++ b/pkg/framework/objects/ip_restrictions_rule/resource_acceptance_test.go @@ -0,0 +1,179 @@ +package ip_restrictions_rule_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/framework/acctest_helper" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccDbtCloudIPRestrictionsRuleResource(t *testing.T) { + ruleName := strings.ToUpper(acctest.RandStringFromCharSet(10, acctest.CharSetAlpha)) + ruleName2 := strings.ToUpper(acctest.RandStringFromCharSet(10, acctest.CharSetAlpha)) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest_helper.TestAccPreCheck(t) }, + ProtoV6ProviderFactories: acctest_helper.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create basic IP restrictions rule + { + Config: testAccDbtCloudIPRestrictionsRuleResourceBasicConfig(ruleName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "dbtcloud_ip_restrictions_rule.test", + "name", + ruleName, + ), + resource.TestCheckResourceAttr( + "dbtcloud_ip_restrictions_rule.test", + "type", + "allow", + ), + resource.TestCheckResourceAttr( + "dbtcloud_ip_restrictions_rule.test", + "description", + "Test IP restriction rule", + ), + resource.TestCheckResourceAttr( + "dbtcloud_ip_restrictions_rule.test", + "cidrs.#", + "2", + ), + resource.TestCheckResourceAttr( + "dbtcloud_ip_restrictions_rule.test", + "rule_set_enabled", + "false", + ), + ), + }, + // Update rule name and description + { + Config: testAccDbtCloudIPRestrictionsRuleResourceModifiedConfig(ruleName2), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "dbtcloud_ip_restrictions_rule.test", + "name", + ruleName2, + ), + resource.TestCheckResourceAttr( + "dbtcloud_ip_restrictions_rule.test", + "description", + "Modified test IP restriction rule", + ), + ), + }, + // Add more CIDRs + { + Config: testAccDbtCloudIPRestrictionsRuleResourceMoreCIDRsConfig(ruleName2), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "dbtcloud_ip_restrictions_rule.test", + "cidrs.#", + "4", + ), + ), + }, + // Remove CIDRs and change type to deny + { + Config: testAccDbtCloudIPRestrictionsRuleResourceLessCIDRsConfig(ruleName2), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "dbtcloud_ip_restrictions_rule.test", + "cidrs.#", + "1", + ), + ), + }, + // Import test + { + ResourceName: "dbtcloud_ip_restrictions_rule.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func testAccDbtCloudIPRestrictionsRuleResourceBasicConfig(name string) string { + return fmt.Sprintf(` +resource "dbtcloud_ip_restrictions_rule" "test" { + name = "%s" + type = "allow" + description = "Test IP restriction rule" + rule_set_enabled = false + + cidrs = [ + { + cidr = "10.0.0.0/24" + }, + { + cidr = "192.168.1.0/24" + } + ] +} +`, name) +} + +func testAccDbtCloudIPRestrictionsRuleResourceModifiedConfig(name string) string { + return fmt.Sprintf(` +resource "dbtcloud_ip_restrictions_rule" "test" { + name = "%s" + type = "allow" + description = "Modified test IP restriction rule" + rule_set_enabled = false + + cidrs = [ + { + cidr = "10.0.0.0/24" + }, + { + cidr = "192.168.1.0/24" + } + ] +} +`, name) +} + +func testAccDbtCloudIPRestrictionsRuleResourceMoreCIDRsConfig(name string) string { + return fmt.Sprintf(` +resource "dbtcloud_ip_restrictions_rule" "test" { + name = "%s" + type = "allow" + description = "Modified test IP restriction rule" + rule_set_enabled = false + + cidrs = [ + { + cidr = "10.0.0.0/24" + }, + { + cidr = "192.168.1.0/24" + }, + { + cidr = "72.16.0.0/24" + }, + { + cidr = "192.168.2.0/24" + } + ] +} +`, name) +} + +func testAccDbtCloudIPRestrictionsRuleResourceLessCIDRsConfig(name string) string { + return fmt.Sprintf(` +resource "dbtcloud_ip_restrictions_rule" "test" { + name = "%s" + type = "deny" + description = "Modified test IP restriction rule" + rule_set_enabled = false + + cidrs = [{ + cidr = "10.0.0.0/24" + }] +} +`, name) +} diff --git a/pkg/framework/objects/ip_restrictions_rule/schema.go b/pkg/framework/objects/ip_restrictions_rule/schema.go new file mode 100644 index 0000000..bf60520 --- /dev/null +++ b/pkg/framework/objects/ip_restrictions_rule/schema.go @@ -0,0 +1,81 @@ +package ip_restrictions_rule + +import ( + "context" + + "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/helper" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func (r *ipRestrictionsRuleResource) Schema( + _ context.Context, + _ resource.SchemaRequest, + resp *resource.SchemaResponse, +) { + resp.Schema = schema.Schema{ + Description: helper.DocString(` + Manages IP restriction rules in dbt Cloud. IP restriction rules allow you to control access to your dbt Cloud instance based on IP address ranges. + `), + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{ + Computed: true, + Description: "The ID of the IP restriction rule", + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Required: true, + Description: "The name of the IP restriction rule", + }, + "type": schema.StringAttribute{ + Required: true, + Description: "The type of the IP restriction rule (allow or deny)", + Validators: []validator.String{ + stringvalidator.OneOf( + "allow", + "deny", + ), + }, + }, + "description": schema.StringAttribute{ + Optional: true, + Description: "A description of the IP restriction rule", + }, + "rule_set_enabled": schema.BoolAttribute{ + Required: true, + Description: "Whether the IP restriction rule set is enabled or not. Important!: This value needs to be the same for all rules if multiple rules are defined. All rules must be active or inactive at the same time.", + }, + "cidrs": schema.SetNestedAttribute{ + Required: true, + Description: "Set of CIDR ranges for this rule", + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "cidr": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "IP CIDR range (can be IPv4 or IPv6)", + }, + "cidr_ipv6": schema.StringAttribute{ + Computed: true, + Description: "IPv6 CIDR range (read-only)", + }, + "id": schema.Int64Attribute{ + Computed: true, + Description: "ID of the CIDR range", + }, + "ip_restriction_rule_id": schema.Int64Attribute{ + Computed: true, + Description: "ID of the IP restriction rule", + }, + }, + }, + }, + }, + } +} diff --git a/pkg/provider/framework_provider.go b/pkg/provider/framework_provider.go index ba5f10d..79e08c3 100644 --- a/pkg/provider/framework_provider.go +++ b/pkg/provider/framework_provider.go @@ -6,10 +6,12 @@ import ( "strconv" "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/dbt_cloud" + "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/framework/objects/account_features" "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/framework/objects/environment" "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/framework/objects/global_connection" "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/framework/objects/group" "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/framework/objects/group_partial_permissions" + "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/framework/objects/ip_restrictions_rule" "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/framework/objects/job" "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/framework/objects/lineage_integration" "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/framework/objects/notification" @@ -204,5 +206,7 @@ func (p *dbtCloudProvider) Resources(_ context.Context) []func() resource.Resour global_connection.GlobalConnectionResource, lineage_integration.LineageIntegrationResource, oauth_configuration.OAuthConfigurationResource, + account_features.AccountFeaturesResource, + ip_restrictions_rule.IPRestrictionsRuleResource, } } diff --git a/terraform_resources.d2 b/terraform_resources.d2 index 532b8a0..4ddbaa9 100644 --- a/terraform_resources.d2 +++ b/terraform_resources.d2 @@ -1,16 +1,18 @@ +vars: { + d2-config: { + layout-engine: elk + } +} + *.style.font-size: 22 *.*.style.font-size: 22 title: |md - # Terraform resources (v0.3.20) + # Terraform resources (v0.3.22) | {near: top-center} direction: right - -license_map -partial_license_map - project_connection: { style: { fill: "#C5C6C7" @@ -19,10 +21,9 @@ project_connection: { } privatelink_endpoint: {tooltip: Datasource only} -group: {tooltip: Group permissions as well} +group group_partial_permissions -service_token: {tooltip: Permissions as well} -project_artefacts: {tooltip: For setting the project docs and source freshness} +service_token job: { style: { fill: "#ACE1AF" @@ -33,7 +34,7 @@ job: { conns: Connections (will be removed in the future,\nuse global_connection) { bigquery_connection fabric_connection - connection: {tooltip: Works for Snowflake, Redshift, Postgres and Databricks} + connection bigquery_connection.style.fill: "#C5C6C7" fabric_connection.style.fill: "#C5C6C7" @@ -64,9 +65,7 @@ job -- environment job -- environment_variable_job_override notification -- job partial_notification -- job -project_artefacts -- job -project_artefacts -- project webhook -- job: triggered by { style: { stroke-dash: 3 @@ -75,6 +74,7 @@ webhook -- job: triggered by { environment -- global_connection environment -- conns global_connection -- privatelink_endpoint +global_connection -- oauth_configuration environment -- env_creds conns -- privatelink_endpoint @@ -97,3 +97,11 @@ project_connection -- conns { (job -- *)[*].style.stroke: green (* -- job)[*].style.stroke: green + +account_level_settings: "Account level settings" { + account_features + ip_restrictions_rule + license_map + partial_license_map +} +account_level_settings.style.fill-pattern: dots \ No newline at end of file diff --git a/terraform_resources.png b/terraform_resources.png index 7cb9848..4960511 100644 Binary files a/terraform_resources.png and b/terraform_resources.png differ