From 3dba84fa1dba45bc19d3d2d86ba854fe77fbd51e Mon Sep 17 00:00:00 2001 From: Douglas Harcourt Parsons Date: Mon, 15 Apr 2024 11:01:30 +0100 Subject: [PATCH] Add support for Edge Config Tokens --- client/edge_config.go | 4 +- client/edge_config_token.go | 102 ++++++ docs/data-sources/edge_config.md | 10 +- docs/data-sources/edge_config_token.md | 44 +++ docs/resources/edge_config.md | 18 +- docs/resources/edge_config_token.md | 77 +++++ .../vercel_edge_config/data-source.tf | 3 + .../vercel_edge_config_token/data-source.tf | 4 + .../resources/vercel_edge_config/resource.tf | 18 +- .../vercel_edge_config_token/import.sh | 11 + .../vercel_edge_config_token/resource.tf | 19 ++ vercel/data_source_edge_config.go | 2 +- vercel/data_source_edge_config_token.go | 131 ++++++++ vercel/data_source_edge_config_token_test.go | 50 +++ vercel/provider.go | 2 + vercel/resource_dns_record.go | 2 +- vercel/resource_edge_config.go | 10 +- vercel/resource_edge_config_token.go | 290 ++++++++++++++++++ vercel/resource_edge_config_token_test.go | 98 ++++++ vercel/resource_project.go | 16 +- vercel/resource_project_domain.go | 18 +- vercel/split.go | 29 ++ 22 files changed, 913 insertions(+), 45 deletions(-) create mode 100644 client/edge_config_token.go create mode 100644 docs/data-sources/edge_config_token.md create mode 100644 docs/resources/edge_config_token.md create mode 100644 examples/data-sources/vercel_edge_config/data-source.tf create mode 100644 examples/data-sources/vercel_edge_config_token/data-source.tf create mode 100644 examples/resources/vercel_edge_config_token/import.sh create mode 100644 examples/resources/vercel_edge_config_token/resource.tf create mode 100644 vercel/data_source_edge_config_token.go create mode 100644 vercel/data_source_edge_config_token_test.go create mode 100644 vercel/resource_edge_config_token.go create mode 100644 vercel/resource_edge_config_token_test.go create mode 100644 vercel/split.go diff --git a/client/edge_config.go b/client/edge_config.go index a230b513..743d1b94 100644 --- a/client/edge_config.go +++ b/client/edge_config.go @@ -18,7 +18,7 @@ type CreateEdgeConfigRequest struct { TeamID string `json:"-"` } -func (c *Client) CreateEdgeConfig(ctx context.Context, request CreateEdgeConfigRequest) (e *EdgeConfig, err error) { +func (c *Client) CreateEdgeConfig(ctx context.Context, request CreateEdgeConfigRequest) (e EdgeConfig, err error) { url := fmt.Sprintf("%s/v1/edge-config", c.baseURL) if c.teamID(request.TeamID) != "" { url = fmt.Sprintf("%s?teamId=%s", url, c.teamID(request.TeamID)) @@ -37,7 +37,7 @@ func (c *Client) CreateEdgeConfig(ctx context.Context, request CreateEdgeConfigR return e, err } -func (c *Client) GetEdgeConfig(ctx context.Context, id, teamID string) (e *EdgeConfig, err error) { +func (c *Client) GetEdgeConfig(ctx context.Context, id, teamID string) (e EdgeConfig, err error) { url := fmt.Sprintf("%s/v1/edge-config/%s", c.baseURL, id) if c.teamID(teamID) != "" { url = fmt.Sprintf("%s?teamId=%s", url, c.teamID(teamID)) diff --git a/client/edge_config_token.go b/client/edge_config_token.go new file mode 100644 index 00000000..da08339d --- /dev/null +++ b/client/edge_config_token.go @@ -0,0 +1,102 @@ +package client + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +type EdgeConfigToken struct { + TeamID string + Token string `json:"token"` + Label string `json:"label"` + ID string `json:"id"` + EdgeConfigID string `json:"edgeConfigId"` +} + +func (e EdgeConfigToken) ConnectionString() string { + return fmt.Sprintf( + "https://edge-config.vercel.com/%s?token=%s", + e.EdgeConfigID, + e.Token, + ) +} + +type CreateEdgeConfigTokenRequest struct { + Label string `json:"label"` + TeamID string `json:"-"` + EdgeConfigID string `json:"-"` +} + +func (c *Client) CreateEdgeConfigToken(ctx context.Context, request CreateEdgeConfigTokenRequest) (e EdgeConfigToken, err error) { + url := fmt.Sprintf("%s/v1/edge-config/%s/token", c.baseURL, request.EdgeConfigID) + if c.teamID(request.TeamID) != "" { + url = fmt.Sprintf("%s?teamId=%s", url, c.teamID(request.TeamID)) + } + payload := string(mustMarshal(request)) + tflog.Trace(ctx, "creating edge config token", map[string]interface{}{ + "url": url, + "payload": payload, + }) + err = c.doRequest(clientRequest{ + ctx: ctx, + method: "POST", + url: url, + body: payload, + }, &e) + e.Label = request.Label + e.TeamID = request.TeamID + e.EdgeConfigID = request.EdgeConfigID + return e, err +} + +type EdgeConfigTokenRequest struct { + TeamID string + EdgeConfigID string + Token string +} + +func (c *Client) DeleteEdgeConfigToken(ctx context.Context, request EdgeConfigTokenRequest) error { + url := fmt.Sprintf("%s/v1/edge-config/%s/tokens", c.baseURL, request.EdgeConfigID) + if c.teamID(request.TeamID) != "" { + url = fmt.Sprintf("%s?teamId=%s", url, c.teamID(request.TeamID)) + } + payload := string(mustMarshal( + struct { + Tokens []string `json:"tokens"` + }{ + Tokens: []string{request.Token}, + }, + )) + + tflog.Trace(ctx, "deleting edge config token", map[string]interface{}{ + "url": url, + "payload": payload, + }) + return c.doRequest(clientRequest{ + ctx: ctx, + method: "DELETE", + url: url, + body: payload, + }, nil) +} + +func (c *Client) GetEdgeConfigToken(ctx context.Context, request EdgeConfigTokenRequest) (e EdgeConfigToken, err error) { + url := fmt.Sprintf("%s/v1/edge-config/%s/token/%s", c.baseURL, request.EdgeConfigID, request.Token) + if c.teamID(request.TeamID) != "" { + url = fmt.Sprintf("%s?teamId=%s", url, c.teamID(request.TeamID)) + } + + tflog.Trace(ctx, "getting edge config token", map[string]interface{}{ + "url": url, + }) + + err = c.doRequest(clientRequest{ + ctx: ctx, + method: "GET", + url: url, + }, &e) + e.TeamID = request.TeamID + return e, err +} diff --git a/docs/data-sources/edge_config.md b/docs/data-sources/edge_config.md index d362a930..8187c190 100644 --- a/docs/data-sources/edge_config.md +++ b/docs/data-sources/edge_config.md @@ -3,17 +3,23 @@ page_title: "vercel_edge_config Data Source - terraform-provider-vercel" subcategory: "" description: |- - Provides information about an existing Edge Config resource. + Provides information about an existing Edge Config. An Edge Config is a global data store that enables experimentation with feature flags, A/B testing, critical redirects, and more. --- # vercel_edge_config (Data Source) -Provides information about an existing Edge Config resource. +Provides information about an existing Edge Config. An Edge Config is a global data store that enables experimentation with feature flags, A/B testing, critical redirects, and more. +## Example Usage +```terraform +data "vercel_edge_config" "example" { + id = "ecfg_xxxxxxxxxxxxxxxxxxxxxxxxxxxx" +} +``` ## Schema diff --git a/docs/data-sources/edge_config_token.md b/docs/data-sources/edge_config_token.md new file mode 100644 index 00000000..95a70ffb --- /dev/null +++ b/docs/data-sources/edge_config_token.md @@ -0,0 +1,44 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "vercel_edge_config_token Data Source - terraform-provider-vercel" +subcategory: "" +description: |- + Provides information about an existing Edge Config Token. + An Edge Config is a global data store that enables experimentation with feature flags, A/B testing, critical redirects, and more. + An Edge Config token is used to authenticate against an Edge Config's endpoint. +--- + +# vercel_edge_config_token (Data Source) + +Provides information about an existing Edge Config Token. + +An Edge Config is a global data store that enables experimentation with feature flags, A/B testing, critical redirects, and more. + +An Edge Config token is used to authenticate against an Edge Config's endpoint. + +## Example Usage + +```terraform +data "vercel_edge_config_token" "test" { + edge_config_id = "ecfg_xxxxxxxxxxxxxxxxxxxxxxxxxxxx" + token = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `edge_config_id` (String) The label of the Edge Config Token. +- `token` (String) A read access token used for authenticating against the Edge Config's endpoint for high volume, low-latency requests. + +### Optional + +- `team_id` (String) The ID of the team the Edge Config should exist under. Required when configuring a team resource if a default team has not been set in the provider. + +### Read-Only + +- `connection_string` (String) A connection string is a URL that connects a project to an Edge Config. The variable can be called anything, but our Edge Config client SDK will search for process.env.EDGE_CONFIG by default. +- `id` (String) The ID of this resource. +- `label` (String) The label of the Edge Config Token. diff --git a/docs/resources/edge_config.md b/docs/resources/edge_config.md index c3658fcc..542b7226 100644 --- a/docs/resources/edge_config.md +++ b/docs/resources/edge_config.md @@ -17,7 +17,23 @@ An Edge Config is a global data store that enables experimentation with feature ```terraform resource "vercel_edge_config" "example" { - name = "my-edge-config" + name = "example" +} + +resource "vercel_project" "example" { + name = "edge-config-example" +} + +resource "vercel_edge_config_token" "example" { + edge_config_id = vercel_edge_config.example.id + label = "example token" +} + +resource "vercel_project_environment_variable" "example" { + project_id = vercel_project.example.id + target = ["production", "preview", "development"] + key = "EDGE_CONFIG" + value = vercel_edge_config_token.example.connection_string } ``` diff --git a/docs/resources/edge_config_token.md b/docs/resources/edge_config_token.md new file mode 100644 index 00000000..bb0aec37 --- /dev/null +++ b/docs/resources/edge_config_token.md @@ -0,0 +1,77 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "vercel_edge_config_token Resource - terraform-provider-vercel" +subcategory: "" +description: |- + Provides an Edge Config Token resource. + An Edge Config is a global data store that enables experimentation with feature flags, A/B testing, critical redirects, and more. + An Edge Config token is used to authenticate against an Edge Config's endpoint. +--- + +# vercel_edge_config_token (Resource) + +Provides an Edge Config Token resource. + +An Edge Config is a global data store that enables experimentation with feature flags, A/B testing, critical redirects, and more. + +An Edge Config token is used to authenticate against an Edge Config's endpoint. + +## Example Usage + +```terraform +resource "vercel_edge_config" "example" { + name = "example" +} + +resource "vercel_project" "example" { + name = "edge-config-example" +} + +resource "vercel_edge_config_token" "example" { + edge_config_id = vercel_edge_config.example.id + label = "example token" +} + +resource "vercel_project_environment_variable" "example" { + project_id = vercel_project.example.id + target = ["production", "preview", "development"] + key = "EDGE_CONFIG" + value = vercel_edge_config_token.example.connection_string +} +``` + + +## Schema + +### Required + +- `edge_config_id` (String) The label of the Edge Config Token. +- `label` (String) The label of the Edge Config Token. + +### Optional + +- `team_id` (String) The ID of the team the Edge Config should exist under. Required when configuring a team resource if a default team has not been set in the provider. + +### Read-Only + +- `connection_string` (String) A connection string is a URL that connects a project to an Edge Config. The variable can be called anything, but our Edge Config client SDK will search for process.env.EDGE_CONFIG by default. +- `id` (String) The ID of this resource. +- `token` (String) A read access token used for authenticating against the Edge Config's endpoint for high volume, low-latency requests. + +## Import + +Import is supported using the following syntax: + +```shell +# If importing into a personal account, or with a team configured on +# the provider, simply use the edge config id and token value. +# - edge_config_id is hard to find, but can be found by navigating to the Edge Config in the Vercel UI and looking at the URL. It should begin with `ecfg_`. +# - token can be found in the Vercel UI under Storage, Edge Config, the specific Edge Config, Tokens. +terraform import vercel_edge_config.example ecfg_xxxxxxxxxxxxxxxxxxxxxxxxxxxx/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + +# Alternatively, you can import via the team_id and edge_config_id. +# - team_id can be found in the team `settings` tab in the Vercel UI. +# - edge_config_id is hard to find, but can be found by navigating to the Edge Config in the Vercel UI and looking at the URL. It should begin with `ecfg_`. +# - token can be found in the Vercel UI under Storage, Edge Config, the specific Edge Config, Tokens. +terraform import vercel_edge_config.example team_xxxxxxxxxxxxxxxxxxxxxxxx/ecfg_xxxxxxxxxxxxxxxxxxxxxxxxxxxx/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +``` diff --git a/examples/data-sources/vercel_edge_config/data-source.tf b/examples/data-sources/vercel_edge_config/data-source.tf new file mode 100644 index 00000000..b5ca44ad --- /dev/null +++ b/examples/data-sources/vercel_edge_config/data-source.tf @@ -0,0 +1,3 @@ +data "vercel_edge_config" "example" { + id = "ecfg_xxxxxxxxxxxxxxxxxxxxxxxxxxxx" +} diff --git a/examples/data-sources/vercel_edge_config_token/data-source.tf b/examples/data-sources/vercel_edge_config_token/data-source.tf new file mode 100644 index 00000000..39918467 --- /dev/null +++ b/examples/data-sources/vercel_edge_config_token/data-source.tf @@ -0,0 +1,4 @@ +data "vercel_edge_config_token" "test" { + edge_config_id = "ecfg_xxxxxxxxxxxxxxxxxxxxxxxxxxxx" + token = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} diff --git a/examples/resources/vercel_edge_config/resource.tf b/examples/resources/vercel_edge_config/resource.tf index 4bf9cb4d..255884ed 100644 --- a/examples/resources/vercel_edge_config/resource.tf +++ b/examples/resources/vercel_edge_config/resource.tf @@ -1,3 +1,19 @@ resource "vercel_edge_config" "example" { - name = "my-edge-config" + name = "example" +} + +resource "vercel_project" "example" { + name = "edge-config-example" +} + +resource "vercel_edge_config_token" "example" { + edge_config_id = vercel_edge_config.example.id + label = "example token" +} + +resource "vercel_project_environment_variable" "example" { + project_id = vercel_project.example.id + target = ["production", "preview", "development"] + key = "EDGE_CONFIG" + value = vercel_edge_config_token.example.connection_string } diff --git a/examples/resources/vercel_edge_config_token/import.sh b/examples/resources/vercel_edge_config_token/import.sh new file mode 100644 index 00000000..3a464888 --- /dev/null +++ b/examples/resources/vercel_edge_config_token/import.sh @@ -0,0 +1,11 @@ +# If importing into a personal account, or with a team configured on +# the provider, simply use the edge config id and token value. +# - edge_config_id is hard to find, but can be found by navigating to the Edge Config in the Vercel UI and looking at the URL. It should begin with `ecfg_`. +# - token can be found in the Vercel UI under Storage, Edge Config, the specific Edge Config, Tokens. +terraform import vercel_edge_config.example ecfg_xxxxxxxxxxxxxxxxxxxxxxxxxxxx/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + +# Alternatively, you can import via the team_id and edge_config_id. +# - team_id can be found in the team `settings` tab in the Vercel UI. +# - edge_config_id is hard to find, but can be found by navigating to the Edge Config in the Vercel UI and looking at the URL. It should begin with `ecfg_`. +# - token can be found in the Vercel UI under Storage, Edge Config, the specific Edge Config, Tokens. +terraform import vercel_edge_config.example team_xxxxxxxxxxxxxxxxxxxxxxxx/ecfg_xxxxxxxxxxxxxxxxxxxxxxxxxxxx/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx diff --git a/examples/resources/vercel_edge_config_token/resource.tf b/examples/resources/vercel_edge_config_token/resource.tf new file mode 100644 index 00000000..255884ed --- /dev/null +++ b/examples/resources/vercel_edge_config_token/resource.tf @@ -0,0 +1,19 @@ +resource "vercel_edge_config" "example" { + name = "example" +} + +resource "vercel_project" "example" { + name = "edge-config-example" +} + +resource "vercel_edge_config_token" "example" { + edge_config_id = vercel_edge_config.example.id + label = "example token" +} + +resource "vercel_project_environment_variable" "example" { + project_id = vercel_project.example.id + target = ["production", "preview", "development"] + key = "EDGE_CONFIG" + value = vercel_edge_config_token.example.connection_string +} diff --git a/vercel/data_source_edge_config.go b/vercel/data_source_edge_config.go index be9aa39f..e194d695 100644 --- a/vercel/data_source_edge_config.go +++ b/vercel/data_source_edge_config.go @@ -50,7 +50,7 @@ func (d *edgeConfigDataSource) Configure(ctx context.Context, req datasource.Con func (r *edgeConfigDataSource) Schema(_ context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { resp.Schema = schema.Schema{ Description: ` -Provides information about an existing Edge Config resource. +Provides information about an existing Edge Config. An Edge Config is a global data store that enables experimentation with feature flags, A/B testing, critical redirects, and more.`, Attributes: map[string]schema.Attribute{ diff --git a/vercel/data_source_edge_config_token.go b/vercel/data_source_edge_config_token.go new file mode 100644 index 00000000..c80ddf4d --- /dev/null +++ b/vercel/data_source_edge_config_token.go @@ -0,0 +1,131 @@ +package vercel + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/vercel/terraform-provider-vercel/client" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &edgeConfigTokenDataSource{} + _ datasource.DataSourceWithConfigure = &edgeConfigTokenDataSource{} +) + +func newEdgeConfigTokenDataSource() datasource.DataSource { + return &edgeConfigTokenDataSource{} +} + +type edgeConfigTokenDataSource struct { + client *client.Client +} + +func (d *edgeConfigTokenDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_edge_config_token" +} + +func (d *edgeConfigTokenDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *client.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + d.client = client +} + +// Schema returns the schema information for an edgeConfigToken data source +func (r *edgeConfigTokenDataSource) Schema(_ context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: ` +Provides information about an existing Edge Config Token. + +An Edge Config is a global data store that enables experimentation with feature flags, A/B testing, critical redirects, and more. + +An Edge Config token is used to authenticate against an Edge Config's endpoint. +`, + Attributes: map[string]schema.Attribute{ + "label": schema.StringAttribute{ + Description: "The label of the Edge Config Token.", + Computed: true, + }, + "edge_config_id": schema.StringAttribute{ + Description: "The label of the Edge Config Token.", + Required: true, + }, + "team_id": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "The ID of the team the Edge Config should exist under. Required when configuring a team resource if a default team has not been set in the provider.", + }, + "id": schema.StringAttribute{ + Computed: true, + }, + "token": schema.StringAttribute{ + Description: "A read access token used for authenticating against the Edge Config's endpoint for high volume, low-latency requests.", + Required: true, + }, + "connection_string": schema.StringAttribute{ + Description: "A connection string is a URL that connects a project to an Edge Config. The variable can be called anything, but our Edge Config client SDK will search for process.env.EDGE_CONFIG by default.", + Computed: true, + }, + }, + } +} + +// Read will read the edgeConfigToken information by requesting it from the Vercel API, and will update terraform +// with this information. +// It is called by the provider whenever data source values should be read to update state. +func (d *edgeConfigTokenDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config EdgeConfigToken + diags := req.Config.Get(ctx, &config) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + out, err := d.client.GetEdgeConfigToken(ctx, client.EdgeConfigTokenRequest{ + Token: config.Token.ValueString(), + TeamID: config.TeamID.ValueString(), + EdgeConfigID: config.EdgeConfigID.ValueString(), + }) + if client.NotFound(err) { + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError( + "Error reading EdgeConfig Token", + fmt.Sprintf("Could not get Edge Config Token %s %s, unexpected error: %s", + config.TeamID.ValueString(), + config.ID.ValueString(), + err, + ), + ) + return + } + + result := responseToEdgeConfigToken(out) + tflog.Trace(ctx, "read edge config token", map[string]interface{}{ + "team_id": result.TeamID.ValueString(), + "edge_config_id": result.ID.ValueString(), + }) + + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} diff --git a/vercel/data_source_edge_config_token_test.go b/vercel/data_source_edge_config_token_test.go new file mode 100644 index 00000000..84bd5679 --- /dev/null +++ b/vercel/data_source_edge_config_token_test.go @@ -0,0 +1,50 @@ +package vercel_test + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAcc_EdgeConfigTokenDataSource(t *testing.T) { + name := acctest.RandString(16) + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccEdgeConfigTokenDataSourceConfig(name, teamIDConfig()), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.vercel_edge_config_token.test", "label", "test-acc-token"), + resource.TestCheckResourceAttrSet("data.vercel_edge_config_token.test", "edge_config_id"), + resource.TestCheckResourceAttrSet("data.vercel_edge_config_token.test", "connection_string"), + resource.TestCheckResourceAttrSet("data.vercel_edge_config_token.test", "id"), + resource.TestCheckResourceAttrSet("data.vercel_edge_config_token.test", "token"), + ), + }, + }, + }) +} + +func testAccEdgeConfigTokenDataSourceConfig(name, teamID string) string { + return fmt.Sprintf(` +resource "vercel_edge_config" "test" { + name = "%[1]s" + %[2]s +} + +resource "vercel_edge_config_token" "test" { + label = "test-acc-token" + edge_config_id = vercel_edge_config.test.id + %[2]s +} + +data "vercel_edge_config_token" "test" { + edge_config_id = vercel_edge_config.test.id + token = vercel_edge_config_token.test.token + %[2]s +} +`, name, teamID) +} diff --git a/vercel/provider.go b/vercel/provider.go index 06239170..9af9b3f3 100644 --- a/vercel/provider.go +++ b/vercel/provider.go @@ -58,6 +58,7 @@ func (p *vercelProvider) Resources(_ context.Context) []func() resource.Resource newProjectEnvironmentVariableResource, newSharedEnvironmentVariableResource, newEdgeConfigResource, + newEdgeConfigTokenResource, } } @@ -70,6 +71,7 @@ func (p *vercelProvider) DataSources(_ context.Context) []func() datasource.Data newProjectDirectoryDataSource, newSharedEnvironmentVariableDataSource, newEdgeConfigDataSource, + newEdgeConfigTokenDataSource, } } diff --git a/vercel/resource_dns_record.go b/vercel/resource_dns_record.go index 52b3c406..03954500 100644 --- a/vercel/resource_dns_record.go +++ b/vercel/resource_dns_record.go @@ -392,7 +392,7 @@ func (r *dnsRecordResource) Delete(ctx context.Context, req resource.DeleteReque // ImportState takes an identifier and reads all the DNS Record information from the Vercel API. func (r *dnsRecordResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { - teamID, recordID, ok := splitID(req.ID) + teamID, recordID, ok := splitInto1Or2(req.ID) if !ok { resp.Diagnostics.AddError( "Error importing DNS Record", diff --git a/vercel/resource_edge_config.go b/vercel/resource_edge_config.go index 100098b1..e0b9a906 100644 --- a/vercel/resource_edge_config.go +++ b/vercel/resource_edge_config.go @@ -90,7 +90,7 @@ type EdgeConfig struct { TeamID types.String `tfsdk:"team_id"` } -func responseToEdgeConfig(out *client.EdgeConfig) EdgeConfig { +func responseToEdgeConfig(out client.EdgeConfig) EdgeConfig { return EdgeConfig{ Name: types.StringValue(out.Slug), ID: types.StringValue(out.ID), @@ -150,7 +150,7 @@ func (r *edgeConfigResource) Read(ctx context.Context, req resource.ReadRequest, } if err != nil { resp.Diagnostics.AddError( - "Error reading EdgeConfig", + "Error reading Edge Config", fmt.Sprintf("Could not get Edge Config %s %s, unexpected error: %s", state.TeamID.ValueString(), state.ID.ValueString(), @@ -214,10 +214,10 @@ func (r *edgeConfigResource) Delete(ctx context.Context, req resource.DeleteRequ } func (r *edgeConfigResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { - teamID, id, ok := splitID(req.ID) + teamID, id, ok := splitInto1Or2(req.ID) if !ok { resp.Diagnostics.AddError( - "Error importing edge config", + "Error importing Edge Config", fmt.Sprintf("Invalid id '%s' specified. should be in format \"team_id/edge_config_id\" or \"edge_config_id\"", req.ID), ) } @@ -229,7 +229,7 @@ func (r *edgeConfigResource) ImportState(ctx context.Context, req resource.Impor } if err != nil { resp.Diagnostics.AddError( - "Error reading EdgeConfig", + "Error reading Edge Config", fmt.Sprintf("Could not get Edge Config %s %s, unexpected error: %s", teamID, id, diff --git a/vercel/resource_edge_config_token.go b/vercel/resource_edge_config_token.go new file mode 100644 index 00000000..904e8e61 --- /dev/null +++ b/vercel/resource_edge_config_token.go @@ -0,0 +1,290 @@ +package vercel + +import ( + "context" + "fmt" + + "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" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/vercel/terraform-provider-vercel/client" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &edgeConfigTokenResource{} + _ resource.ResourceWithConfigure = &edgeConfigTokenResource{} + _ resource.ResourceWithImportState = &edgeConfigTokenResource{} +) + +func newEdgeConfigTokenResource() resource.Resource { + return &edgeConfigTokenResource{} +} + +type edgeConfigTokenResource struct { + client *client.Client +} + +func (r *edgeConfigTokenResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_edge_config_token" +} + +func (r *edgeConfigTokenResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + r.client = client +} + +// Schema returns the schema information for an edgeConfigToken resource. +func (r *edgeConfigTokenResource) Schema(_ context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: ` +Provides an Edge Config Token resource. + +An Edge Config is a global data store that enables experimentation with feature flags, A/B testing, critical redirects, and more. + +An Edge Config token is used to authenticate against an Edge Config's endpoint. +`, + Attributes: map[string]schema.Attribute{ + "label": schema.StringAttribute{ + Description: "The label of the Edge Config Token.", + Required: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + Validators: []validator.String{ + stringLengthBetween(1, 52), + }, + }, + "edge_config_id": schema.StringAttribute{ + Description: "The label of the Edge Config Token.", + Required: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + "team_id": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "The ID of the team the Edge Config should exist under. Required when configuring a team resource if a default team has not been set in the provider.", + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplaceIfConfigured()}, + }, + "id": schema.StringAttribute{ + Computed: true, + }, + "token": schema.StringAttribute{ + Description: "A read access token used for authenticating against the Edge Config's endpoint for high volume, low-latency requests.", + Computed: true, + }, + "connection_string": schema.StringAttribute{ + Description: "A connection string is a URL that connects a project to an Edge Config. The variable can be called anything, but our Edge Config client SDK will search for process.env.EDGE_CONFIG by default.", + Computed: true, + }, + }, + } +} + +type EdgeConfigToken struct { + Label types.String `tfsdk:"label"` + Token types.String `tfsdk:"token"` + ID types.String `tfsdk:"id"` + TeamID types.String `tfsdk:"team_id"` + EdgeConfigID types.String `tfsdk:"edge_config_id"` + ConnectionString types.String `tfsdk:"connection_string"` +} + +func responseToEdgeConfigToken(out client.EdgeConfigToken) EdgeConfigToken { + return EdgeConfigToken{ + TeamID: toTeamID(out.TeamID), + Token: types.StringValue(out.Token), + Label: types.StringValue(out.Label), + ID: types.StringValue(out.ID), + EdgeConfigID: types.StringValue(out.EdgeConfigID), + ConnectionString: types.StringValue(out.ConnectionString()), + } +} + +// Create will create an edgeConfigToken within Vercel. +// This is called automatically by the provider when a new resource should be created. +func (r *edgeConfigTokenResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan EdgeConfigToken + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + out, err := r.client.CreateEdgeConfigToken(ctx, client.CreateEdgeConfigTokenRequest{ + Label: plan.Label.ValueString(), + TeamID: plan.TeamID.ValueString(), + EdgeConfigID: plan.EdgeConfigID.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError( + "Error creating Edge Config Token", + "Could not create Edge Config Token, unexpected error: "+err.Error(), + ) + return + } + + result := responseToEdgeConfigToken(out) + tflog.Trace(ctx, "created Edge Config Token", map[string]interface{}{ + "team_id": plan.TeamID.ValueString(), + "edge_config_id": result.EdgeConfigID.ValueString(), + "token_id": result.ID.ValueString(), + }) + + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Read will read edgeConfigToken information by requesting it from the Vercel API, and will update terraform +// with this information. +func (r *edgeConfigTokenResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state EdgeConfigToken + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + out, err := r.client.GetEdgeConfigToken(ctx, client.EdgeConfigTokenRequest{ + Token: state.Token.ValueString(), + TeamID: state.TeamID.ValueString(), + EdgeConfigID: state.EdgeConfigID.ValueString(), + }) + if client.NotFound(err) { + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError( + "Error reading Edge Config Token", + fmt.Sprintf("Could not get Edge Config Token %s %s, unexpected error: %s", + state.TeamID.ValueString(), + state.ID.ValueString(), + err, + ), + ) + return + } + + result := responseToEdgeConfigToken(out) + tflog.Trace(ctx, "read edge config token", map[string]interface{}{ + "team_id": result.TeamID.ValueString(), + "edge_config_id": result.EdgeConfigID.ValueString(), + "token_id": result.ID.ValueString(), + }) + + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Update does nothing. +func (r *edgeConfigTokenResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + resp.Diagnostics.AddError( + "Updating an Edge Config Token is not supported", + "Updating an Edge Config Token is not supported", + ) +} + +// Delete deletes an Edge Config. +func (r *edgeConfigTokenResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state EdgeConfigToken + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + err := r.client.DeleteEdgeConfigToken(ctx, client.EdgeConfigTokenRequest{ + Token: state.Token.ValueString(), + TeamID: state.TeamID.ValueString(), + EdgeConfigID: state.EdgeConfigID.ValueString(), + }) + if client.NotFound(err) { + return + } + if err != nil { + resp.Diagnostics.AddError( + "Error deleting Edge Config Token", + fmt.Sprintf( + "Could not delete Edge Config Token %s %s %s, unexpected error: %s", + state.TeamID.ValueString(), + state.EdgeConfigID.ValueString(), + state.ID.ValueString(), + err, + ), + ) + return + } + + tflog.Trace(ctx, "deleted edge config token", map[string]interface{}{ + "team_id": state.TeamID.ValueString(), + "edge_config_id": state.EdgeConfigID.ValueString(), + "token_id": state.ID.ValueString(), + }) +} + +func (r *edgeConfigTokenResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + teamID, edgeConfigID, token, ok := splitInto2Or3(req.ID) + if !ok { + resp.Diagnostics.AddError( + "Error importing edge config token", + fmt.Sprintf("Invalid id '%s' specified. should be in format \"team_id/edge_config_id/token\" or \"edge_config_id/token\"", req.ID), + ) + } + + out, err := r.client.GetEdgeConfigToken(ctx, client.EdgeConfigTokenRequest{ + Token: token, + EdgeConfigID: edgeConfigID, + TeamID: teamID, + }) + if client.NotFound(err) { + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError( + "Error importing Edge Config Token", + fmt.Sprintf("Could not get Edge Config Token %s %s %s, unexpected error: %s", + teamID, + edgeConfigID, + token, + err, + ), + ) + return + } + + result := responseToEdgeConfigToken(out) + tflog.Trace(ctx, "import edge config token", map[string]interface{}{ + "team_id": result.TeamID.ValueString(), + "edge_config_id": result.EdgeConfigID.ValueString(), + "token_id": result.ID.ValueString(), + }) + + diags := resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} diff --git a/vercel/resource_edge_config_token_test.go b/vercel/resource_edge_config_token_test.go new file mode 100644 index 00000000..94b55ec8 --- /dev/null +++ b/vercel/resource_edge_config_token_test.go @@ -0,0 +1,98 @@ +package vercel_test + +import ( + "context" + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/vercel/terraform-provider-vercel/client" +) + +func testCheckEdgeConfigTokenExists(teamID, n string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("no ID is set") + } + + _, err := testClient().GetEdgeConfigToken(context.TODO(), client.EdgeConfigTokenRequest{ + Token: rs.Primary.Attributes["token"], + EdgeConfigID: rs.Primary.Attributes["edge_config_id"], + TeamID: teamID, + }) + if err != nil { + return fmt.Errorf("error getting %s/%s/%s: %w", teamID, rs.Primary.Attributes["edge_config_id"], rs.Primary.ID, err) + } + return err + } +} + +func testCheckEdgeConfigTokenDeleted(n, teamID string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("no ID is set") + } + + _, err := testClient().GetEdgeConfigToken(context.TODO(), client.EdgeConfigTokenRequest{ + Token: rs.Primary.ID, + EdgeConfigID: rs.Primary.Attributes["edge_config_id"], + TeamID: teamID, + }) + if err == nil { + return fmt.Errorf("expected not_found error, but got no error") + } + if !client.NotFound(err) { + return fmt.Errorf("Unexpected error checking for deleted project: %s", err) + } + + return nil + } +} + +func TestAcc_EdgeConfigTokenResource(t *testing.T) { + name := acctest.RandString(16) + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: testCheckEdgeConfigTokenDeleted("vercel_edge_config_token.test", testTeam()), + Steps: []resource.TestStep{ + { + Config: testAccResourceEdgeConfigToken(name, teamIDConfig()), + Check: resource.ComposeAggregateTestCheckFunc( + testCheckEdgeConfigTokenExists(testTeam(), "vercel_edge_config_token.test"), + resource.TestCheckResourceAttr("vercel_edge_config_token.test", "label", "test token"), + resource.TestCheckResourceAttrSet("vercel_edge_config_token.test", "id"), + resource.TestCheckResourceAttrSet("vercel_edge_config_token.test", "edge_config_id"), + resource.TestCheckResourceAttrSet("vercel_edge_config_token.test", "connection_string"), + ), + }, + }, + }) +} + +func testAccResourceEdgeConfigToken(name, team string) string { + return fmt.Sprintf(` +resource "vercel_edge_config" "test" { + name = "%[1]s" + %[2]s +} + +resource "vercel_edge_config_token" "test" { + label = "test token" + edge_config_id = vercel_edge_config.test.id + %[2]s +} +`, name, team) +} diff --git a/vercel/resource_project.go b/vercel/resource_project.go index 84f94558..a9a043c8 100644 --- a/vercel/resource_project.go +++ b/vercel/resource_project.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "regexp" - "strings" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -720,23 +719,10 @@ func (r *projectResource) Delete(ctx context.Context, req resource.DeleteRequest }) } -// splitID is a helper function for splitting an import ID into the corresponding parts. -// It also validates whether the ID is in a correct format. -func splitID(id string) (teamID, _id string, ok bool) { - if strings.Contains(id, "/") { - attributes := strings.Split(id, "/") - if len(attributes) != 2 { - return "", "", false - } - return attributes[0], attributes[1], true - } - return "", id, true -} - // ImportState takes an identifier and reads all the project information from the Vercel API. // Note that environment variables are also read. The results are then stored in terraform state. func (r *projectResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { - teamID, projectID, ok := splitID(req.ID) + teamID, projectID, ok := splitInto1Or2(req.ID) if !ok { resp.Diagnostics.AddError( "Error importing project", diff --git a/vercel/resource_project_domain.go b/vercel/resource_project_domain.go index f91962b5..a16c4df3 100644 --- a/vercel/resource_project_domain.go +++ b/vercel/resource_project_domain.go @@ -3,7 +3,6 @@ package vercel import ( "context" "fmt" - "strings" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" @@ -263,25 +262,10 @@ func (r *projectDomainResource) Delete(ctx context.Context, req resource.DeleteR }) } -// splitProjectDomainID is a helper function for splitting an import ID into the corresponding parts. -// It also validates whether the ID is in a correct format. -func splitProjectDomainID(id string) (teamID, projectID, domain string, ok bool) { - attributes := strings.Split(id, "/") - if len(attributes) == 2 { - // we have project_id/domain - return "", attributes[0], attributes[1], true - } - if len(attributes) == 3 { - // we have team_id/project_id/domain - return attributes[0], attributes[1], attributes[2], true - } - return "", "", "", false -} - // ImportState takes an identifier and reads all the project domain information from the Vercel API. // Note that environment variables are also read. The results are then stored in terraform state. func (r *projectDomainResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { - teamID, projectID, domain, ok := splitProjectDomainID(req.ID) + teamID, projectID, domain, ok := splitInto2Or3(req.ID) if !ok { resp.Diagnostics.AddError( "Error importing project domain", diff --git a/vercel/split.go b/vercel/split.go new file mode 100644 index 00000000..422b7dce --- /dev/null +++ b/vercel/split.go @@ -0,0 +1,29 @@ +package vercel + +import "strings" + +// splitInto2Or3 is a helper function for splitting an import ID into the corresponding parts. +// It also validates whether the ID is in a correct format. +func splitInto2Or3(id string) (teamID, firstID, secondID string, ok bool) { + attributes := strings.Split(id, "/") + if len(attributes) == 2 { + return "", attributes[0], attributes[1], true + } + if len(attributes) == 3 { + return attributes[0], attributes[1], attributes[2], true + } + return "", "", "", false +} + +// splitInto1Or2 is a helper function for splitting an import ID into the corresponding parts. +// It also validates whether the ID is in a correct format. +func splitInto1Or2(id string) (teamID, firstID string, ok bool) { + if strings.Contains(id, "/") { + attributes := strings.Split(id, "/") + if len(attributes) != 2 { + return "", "", false + } + return attributes[0], attributes[1], true + } + return "", id, true +}