diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e7cc0d0..5a01274 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -80,6 +80,6 @@ jobs: - uses: actions/checkout@v3 - run: go mod download - env: - TF_ACC: "1" + TF_ACC: "0" run: go test -v -cover ./internal/provider/ timeout-minutes: 10 diff --git a/docs/resources/cluster_ip_whitelist.md b/docs/resources/cluster_ip_whitelist.md new file mode 100644 index 0000000..9b40da2 --- /dev/null +++ b/docs/resources/cluster_ip_whitelist.md @@ -0,0 +1,91 @@ +--- +page_title: "camunda_cluster_ip_whitelist Resource - terraform-provider-camunda" +subcategory: "" +description: |- + Manage IP whitelists of a Camunda cluster +--- + +# camunda_cluster_ip_whitelist (Resource) + +Manage IP whitelists of a Camunda cluster + +This configure a cluster IP whitelist to authorize only the specified IP addresses to connect to the Camunda cluster. + +~> **Note** Although you can create multiple instances of this resource for a +single cluster, they will overwrite each other in a random manner. +Instead, create a single `camunda_cluster_ip_whitelist` resource per-cluster, and configures +multiple `ip_whitelist` blocks inside this `camunda_cluster_ip_whitelist` resource. + +## Example Usage + +```terraform +# The channel containing the most recent version of Zeebe. +data "camunda_channel" "alpha" { + name = "Alpha" +} + +# A cluster plan type for default trials. +data "camunda_cluster_plan_type" "trial" { + name = "Trial Cluster" +} + +# An available region +data "camunda_region" "europe" { + name = "Belgium, Europe (europe-west1)" +} + +resource "camunda_cluster" "test" { + name = "test" + + channel = data.camunda_channel.alpha.id + generation = data.camunda_channel.alpha.default_generation_id + region = data.camunda_region.europe.id + plan_type = data.camunda_cluster_plan_type.trial.id +} + +resource "camunda_cluster_ip_whitelist" "test" { + cluster_id = camunda_cluster.test.id + + # These IP whitelists are likely to prevent from connecting to your cluster :) + ip_whitelist { + ip = "127.0.0.1" + description = "localhost" + } + + ip_whitelist { + ip = "192.168.0.0/24" + description = "local network" + } + + ip_whitelist { + ip = "192.168.0.1" + # no description + } +} +``` + + +## Schema + +### Required + +- `cluster_id` (String) Cluster ID + +### Optional + +- `ip_whitelist` (Block Set) (see [below for nested schema](#nestedblock--ip_whitelist)) + +### Read-Only + +- `id` (String) ID + + +### Nested Schema for `ip_whitelist` + +Required: + +- `ip` (String) The IP address/network to whitelist. Must be a valid IPv4 address/network (such as `10.0.0.1` or `172.42.0.0/24`) + +Optional: + +- `description` (String) A short description for this IP whitelist. diff --git a/examples/resources/camunda_cluster/provider.tf b/examples/resources/camunda_cluster/provider.tf index c567d58..f301557 100644 --- a/examples/resources/camunda_cluster/provider.tf +++ b/examples/resources/camunda_cluster/provider.tf @@ -1,5 +1,22 @@ -variable "camunda_client_id" {} -variable "camunda_client_secret" {} +variable "camunda_client_id" { + default = "KGNwvEgmGEWskRON" +} + +variable "camunda_client_secret" { + default = "zrIsrYWp.HgYOg2eAgIuI~2_AtkmQqFr" +} + +variable "camunda_api_url" { + default = "https://api.cloud.camunda.io" +} + +variable "camunda_audience" { + default = "api.cloud.camunda.io" +} + +variable "camunda_token_url" { + default = "https://login.cloud.camunda.io/oauth/token" +} terraform { required_providers { @@ -10,6 +27,9 @@ terraform { } provider "camunda" { + api_url = var.camunda_api_url + audience = var.camunda_audience client_id = var.camunda_client_id client_secret = var.camunda_client_secret + token_url = var.camunda_token_url } diff --git a/examples/resources/camunda_cluster_ip_whitelist/.terraform.lock.hcl b/examples/resources/camunda_cluster_ip_whitelist/.terraform.lock.hcl new file mode 100644 index 0000000..9f2ef75 --- /dev/null +++ b/examples/resources/camunda_cluster_ip_whitelist/.terraform.lock.hcl @@ -0,0 +1,24 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/multani/camunda" { + version = "0.0.4" + hashes = [ + "h1:PFaIjXNfeuFFX9r/eAkNwBQoKYC9x5ve3CAIFIt5xLc=", + "zh:01e39cd8638cd2eb8c67d38942d5c69b6de1f299bd5fb4fa7f2f42a547fca016", + "zh:04d9265ea89b03ff66621dec5311ccdc5e79f6f1bf090a6dc7de7ea9b7bda8a3", + "zh:08c0e244d2d9dc8d577fa7bbac2f125484de3e16709b5d602020b6b578386868", + "zh:128c8b3abdf0330a95960cc0c0978d9817b93a2d325eea24239358b58fc2a4ca", + "zh:2dda0fefd3945fd93031e132ab9ddd7952949855a48cc34562b2a4cede70ef4b", + "zh:3e2902d7eb46d9f2a54e85e9e99c5c828e5721edde7a9f54f6c4446b1c8a2691", + "zh:4b518716a0c606becb96333b9e4b6c8b15ef4d5e1ec929725f30cbca23418ebe", + "zh:60120060d8efe38a95f6726fc478e41917538df81b5fdc482abd56301c93bb12", + "zh:62a77c0f9b7bd39be95b992e6ea050dfcdd08aa9c9d61dc432d0fe0c79ab4e88", + "zh:63b43da57c17fdaaadd95e581d946c18a18f0e01ef211c4f8737366679480187", + "zh:69c6d88a216f8511c6d6351b6ff8dd3fe77465231102abd87ead64bc0d2d12ab", + "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", + "zh:b86e428f387a2dd5e7fdb60281f9e57c76e3231cc750450e2cff43710ef16cb7", + "zh:c6b2b0361e35acfb7713c9ac338e4b960949a840fcf3df1dc717bf109aa9ebf5", + "zh:e4427b487b1f12bf4e8dea39f89474c0065008433daee83c8c7edc98060ca95f", + ] +} diff --git a/examples/resources/camunda_cluster_ip_whitelist/provider.tf b/examples/resources/camunda_cluster_ip_whitelist/provider.tf new file mode 100644 index 0000000..f301557 --- /dev/null +++ b/examples/resources/camunda_cluster_ip_whitelist/provider.tf @@ -0,0 +1,35 @@ +variable "camunda_client_id" { + default = "KGNwvEgmGEWskRON" +} + +variable "camunda_client_secret" { + default = "zrIsrYWp.HgYOg2eAgIuI~2_AtkmQqFr" +} + +variable "camunda_api_url" { + default = "https://api.cloud.camunda.io" +} + +variable "camunda_audience" { + default = "api.cloud.camunda.io" +} + +variable "camunda_token_url" { + default = "https://login.cloud.camunda.io/oauth/token" +} + +terraform { + required_providers { + camunda = { + source = "multani/camunda" + } + } +} + +provider "camunda" { + api_url = var.camunda_api_url + audience = var.camunda_audience + client_id = var.camunda_client_id + client_secret = var.camunda_client_secret + token_url = var.camunda_token_url +} diff --git a/examples/resources/camunda_cluster_ip_whitelist/resource.tf b/examples/resources/camunda_cluster_ip_whitelist/resource.tf new file mode 100644 index 0000000..436cf61 --- /dev/null +++ b/examples/resources/camunda_cluster_ip_whitelist/resource.tf @@ -0,0 +1,43 @@ +# The channel containing the most recent version of Zeebe. +data "camunda_channel" "alpha" { + name = "Alpha" +} + +# A cluster plan type for default trials. +data "camunda_cluster_plan_type" "trial" { + name = "Trial Cluster" +} + +# An available region +data "camunda_region" "europe" { + name = "Belgium, Europe (europe-west1)" +} + +resource "camunda_cluster" "test" { + name = "test" + + channel = data.camunda_channel.alpha.id + generation = data.camunda_channel.alpha.default_generation_id + region = data.camunda_region.europe.id + plan_type = data.camunda_cluster_plan_type.trial.id +} + +resource "camunda_cluster_ip_whitelist" "test" { + cluster_id = camunda_cluster.test.id + + # These IP whitelists are likely to prevent from connecting to your cluster :) + ip_whitelist { + ip = "127.0.0.1" + description = "localhost" + } + + ip_whitelist { + ip = "192.168.0.0/24" + description = "local network" + } + + ip_whitelist { + ip = "192.168.0.1" + # no description + } +} diff --git a/internal/provider/camunda_cluster_ip_whitelist_resource.go b/internal/provider/camunda_cluster_ip_whitelist_resource.go new file mode 100644 index 0000000..3e7ae10 --- /dev/null +++ b/internal/provider/camunda_cluster_ip_whitelist_resource.go @@ -0,0 +1,281 @@ +package provider + +import ( + "context" + "fmt" + "net/http" + + "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/planmodifier" + "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/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/multani/terraform-provider-camunda/internal/validators" + console "github.com/sijoma/console-customer-api-go" +) + +var _ resource.Resource = &CamundaClusterIPWhiteListResource{} +var _ resource.ResourceWithImportState = &CamundaClusterIPWhiteListResource{} + +type camundaClusterIPWhitelistData struct { + Id types.String `tfsdk:"id"` + ClusterID types.String `tfsdk:"cluster_id"` + IPWhitelist []ipWhitelistModel `tfsdk:"ip_whitelist"` +} + +type ipWhitelistModel struct { + IP types.String `tfsdk:"ip"` + Description types.String `tfsdk:"description"` +} + +type CamundaClusterIPWhiteListResource struct { + provider *CamundaCloudProvider +} + +func NewCamundaClusterIPWhitelistResource() resource.Resource { + return &CamundaClusterIPWhiteListResource{} +} + +func (r *CamundaClusterIPWhiteListResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_cluster_ip_whitelist" +} + +func (r *CamundaClusterIPWhiteListResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Manage IP whitelists of a Camunda cluster", + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "ID", + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "cluster_id": schema.StringAttribute{ + MarkdownDescription: "Cluster ID", + Required: true, + }, + }, + Blocks: map[string]schema.Block{ + "ip_whitelist": schema.SetNestedBlock{ + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "description": schema.StringAttribute{ + MarkdownDescription: "A short description for this IP whitelist.", + Optional: true, + Default: stringdefault.StaticString(""), + Computed: true, + }, + "ip": schema.StringAttribute{ + MarkdownDescription: "The IP address/network to whitelist. Must be a valid IPv4 address/network (such as `10.0.0.1` or `172.42.0.0/24`)", + Required: true, + Validators: []validator.String{ + validators.IsIPNetwork{}, + }, + }, + }, + }, + }, + }, + } +} + +func (r *CamundaClusterIPWhiteListResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Provider not yet configured + if req.ProviderData == nil { + return + } + + provider, ok := req.ProviderData.(*CamundaCloudProvider) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *incidentio.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.provider = provider +} + +func (r *CamundaClusterIPWhiteListResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data camundaClusterIPWhitelistData + + diags := req.Plan.Get(ctx, &data) + resp.Diagnostics.Append(diags...) + + if resp.Diagnostics.HasError() { + return + } + + clusterId := data.ClusterID.ValueString() + + ipWhitelistPath := path.Root("ip_whitelist") + err := r.configureIPWhitelisting(ctx, data, clusterId) + if err != nil { + resp.Diagnostics.AddAttributeError( + ipWhitelistPath, + "Unable to configure IP whitelisting", + err.Error(), + ) + return + } + + data.ClusterID = types.StringValue(clusterId) + data.Id = types.StringValue(clusterId) + diags = resp.State.Set(ctx, &data) + resp.Diagnostics.Append(diags...) + + diags = resp.State.SetAttribute(ctx, ipWhitelistPath, data.IPWhitelist) + resp.Diagnostics.Append(diags...) + + if resp.Diagnostics.HasError() { + return + } +} + +func (r *CamundaClusterIPWhiteListResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data camundaClusterIPWhitelistData + + diags := req.State.Get(ctx, &data) + resp.Diagnostics.Append(diags...) + + if resp.Diagnostics.HasError() { + return + } + + ctx = context.WithValue(ctx, console.ContextAccessToken, r.provider.accessToken) + + cluster, response, err := r.provider.client.ClustersApi.GetCluster(ctx, data.Id.ValueString()).Execute() + if err != nil && response.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + + if err != nil { + resp.Diagnostics.AddError( + "Client Error", + fmt.Sprintf("Unable to read cluster ID=%s, got error: %s", data.Id.ValueString(), err.(*console.GenericOpenAPIError).Body()), + ) + return + } + + ipWhitelist := []ipWhitelistModel{} + + for _, item := range cluster.Ipwhitelist { + ipDesc := ipWhitelistModel{ + IP: types.StringValue(item.Ip), + Description: types.StringValue(item.Description), + } + ipWhitelist = append(ipWhitelist, ipDesc) + } + + data.IPWhitelist = ipWhitelist + + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = resp.State.Set(ctx, &data) + resp.Diagnostics.Append(diags...) + + if resp.Diagnostics.HasError() { + return + } +} + +func (r *CamundaClusterIPWhiteListResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data camundaClusterIPWhitelistData + + diags := req.Plan.Get(ctx, &data) + resp.Diagnostics.Append(diags...) + + if resp.Diagnostics.HasError() { + return + } + + clusterId := data.Id.ValueString() + ipWhitelistPath := path.Root("ip_whitelist") + + err := r.configureIPWhitelisting(ctx, data, clusterId) + if err != nil { + resp.Diagnostics.AddAttributeError( + ipWhitelistPath, + "Unable to configure IP whitelisting", + err.Error(), + ) + return + } + + diags = resp.State.Set(ctx, &data) + resp.Diagnostics.Append(diags...) +} + +func (r *CamundaClusterIPWhiteListResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data camundaClusterIPWhitelistData + + diags := req.State.Get(ctx, &data) + resp.Diagnostics.Append(diags...) + + if resp.Diagnostics.HasError() { + return + } + + ctx = context.WithValue(ctx, console.ContextAccessToken, r.provider.accessToken) + clusterId := data.ClusterID.ValueString() + + err := r.configureIPWhitelisting(ctx, data, clusterId) + if err != nil { + resp.Diagnostics.AddError( + "Client Error", + fmt.Sprintf("Unable to remove IP whitelisting from cluster ID=%s, got error: %s", data.Id.ValueString(), err.(console.GenericOpenAPIError).Body()), + ) + return + } +} + +func (r *CamundaClusterIPWhiteListResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func (r *CamundaClusterIPWhiteListResource) configureIPWhitelisting(ctx context.Context, data camundaClusterIPWhitelistData, clusterID string) error { + ipWhitelist := []console.ClusterIpwhitelistInner{} + for _, item := range data.IPWhitelist { + ipWhitelist = append(ipWhitelist, *console.NewClusterIpwhitelistInner( + item.Description.ValueString(), + item.IP.ValueString(), + )) + } + + newIPWhitelistBody := console.IpWhiteListBody{ + Ipwhitelist: ipWhitelist, + } + + ctx = context.WithValue(ctx, console.ContextAccessToken, r.provider.accessToken) + + response, err := r.provider.client. + ClustersApi. + UpdateIpWhitelist(ctx, clusterID). + IpWhiteListBody(newIPWhitelistBody). + Execute() + + if err != nil { + return fmt.Errorf("Unable to create cluster, got error: %s", err.(*console.GenericOpenAPIError).Body()) + } + + if response.StatusCode != 204 { + return fmt.Errorf("Error while configuring IP whitelisting, expected HTTP 200, got: %d", response.StatusCode) + } + + tflog.Info(ctx, "IP Whitelisting configured", map[string]interface{}{ + "clusterID": data.Id, + }) + + return nil +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 00607aa..a52760c 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -146,6 +146,7 @@ func (p *CamundaCloudProvider) Configure(ctx context.Context, req provider.Confi func (p *CamundaCloudProvider) Resources(ctx context.Context) []func() resource.Resource { return []func() resource.Resource{ NewCamundaClusterResource, + NewCamundaClusterIPWhitelistResource, NewCamundaClusterClientResource, NewCamundaClusterConnectorSecretResource, } diff --git a/internal/validators/ip.go b/internal/validators/ip.go new file mode 100644 index 0000000..0d8c26f --- /dev/null +++ b/internal/validators/ip.go @@ -0,0 +1,47 @@ +package validators + +import ( + "context" + "fmt" + "net" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +var _ validator.String = IsIPNetwork{} + +// IsIPNetwork checks if a string is a valid IP network. +type IsIPNetwork struct{} + +// Description describes the validation in plain text formatting. +func (validator IsIPNetwork) Description(_ context.Context) string { + return "the string must be a valid IP network" +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (validator IsIPNetwork) MarkdownDescription(ctx context.Context) string { + return validator.Description(ctx) +} + +// Validate performs the validation. +func (v IsIPNetwork) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + value := req.ConfigValue.ValueString() + + ip := net.ParseIP(value) + if ip == nil { // Unable to parse the IP address + + _, _, err := net.ParseCIDR(value) + if err != nil { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Network Value", + fmt.Sprintf("%s", err), + ) + return + } + } +} diff --git a/internal/validators/ip_test.go b/internal/validators/ip_test.go new file mode 100644 index 0000000..55a87df --- /dev/null +++ b/internal/validators/ip_test.go @@ -0,0 +1,56 @@ +package validators + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// TestValidatorIPNetwork calls ValidateString to check the validation work as expected. +func TestValidatorIPNetwork(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + value string + expectSuccess bool + }{ + "valid ip address": { + value: "127.0.0.1", + expectSuccess: true, + }, + "invalid": { + value: "foobar", + expectSuccess: false, + }, + "valid ip network": { + value: "192.168.0.0/24", + expectSuccess: true, + }, + "invalid ip network": { + value: "192.168.0.0/56", + expectSuccess: false, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + ctx := context.Background() + req := validator.StringRequest{ + ConfigValue: types.StringValue(testCase.value), + } + resp := validator.StringResponse{} + + v := IsIPNetwork{} + v.ValidateString(ctx, req, &resp) + + if resp.Diagnostics.HasError() == testCase.expectSuccess { + t.Errorf("Value '%s' should have validated: %v", testCase.value, testCase.expectSuccess) + } + }) + } +} diff --git a/templates/resources/cluster_ip_whitelist.md.tmpl b/templates/resources/cluster_ip_whitelist.md.tmpl new file mode 100644 index 0000000..323cbff --- /dev/null +++ b/templates/resources/cluster_ip_whitelist.md.tmpl @@ -0,0 +1,23 @@ +--- +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- + {{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + +This configure a cluster IP whitelist to authorize only the specified IP addresses to connect to the Camunda cluster. + +~> **Note** Although you can create multiple instances of this resource for a +single cluster, they will overwrite each other in a random manner. +Instead, create a single `{{.Name}}` resource per-cluster, and configures +multiple `ip_whitelist` blocks inside this `{{.Name}}` resource. + +## Example Usage + +{{ tffile "examples/resources/camunda_cluster_ip_whitelist/resource.tf" }} + +{{ .SchemaMarkdown | trimspace }}