diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b7729547..1088da3e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -70,6 +70,6 @@ jobs: VERCEL_TERRAFORM_TESTING_GITHUB_REPO: "dglsparsons/test" VERCEL_TERRAFORM_TESTING_GITLAB_REPO: "dglsparsons/test" VERCEL_TERRAFORM_TESTING_BITBUCKET_REPO: "dglsparsons-test/test" - + VERCEL_TERRAFORM_TESTING_DOMAIN: "dgls.dev" run: | go test -v -cover ./... diff --git a/client/dns_record_create.go b/client/dns_record_create.go new file mode 100644 index 00000000..3843b512 --- /dev/null +++ b/client/dns_record_create.go @@ -0,0 +1,55 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "strings" +) + +// SRV defines the metata required for creating an SRV type DNS Record. +type SRV struct { + Port int64 `json:"port"` + Priority int64 `json:"priority"` + Target string `json:"target"` + Weight int64 `json:"weight"` +} + +// CreateDNSRecordRequest defines the information necessary to create a DNS record within Vercel. +type CreateDNSRecordRequest struct { + Domain string `json:"-"` + MXPriority int64 `json:"mxPriority,omitempty"` + Name string `json:"name"` + SRV *SRV `json:"srv,omitempty"` + TTL int64 `json:"ttl,omitempty"` + Type string `json:"type"` + Value string `json:"value,omitempty"` +} + +// CreateProjectDomain creates a DNS record for a specified domain name within Vercel. +func (c *Client) CreateDNSRecord(ctx context.Context, teamID string, request CreateDNSRecordRequest) (r DNSRecord, err error) { + url := fmt.Sprintf("%s/v4/domains/%s/records", c.baseURL, request.Domain) + if teamID != "" { + url = fmt.Sprintf("%s?teamId=%s", url, teamID) + } + + req, err := http.NewRequestWithContext( + ctx, + "POST", + url, + strings.NewReader(string(mustMarshal(request))), + ) + if err != nil { + return r, err + } + + var response struct { + RecordID string `json:"uid"` + } + err = c.doRequest(req, &response) + if err != nil { + return r, err + } + + return c.GetDNSRecord(ctx, response.RecordID, teamID) +} diff --git a/client/dns_record_delete.go b/client/dns_record_delete.go new file mode 100644 index 00000000..fee3d655 --- /dev/null +++ b/client/dns_record_delete.go @@ -0,0 +1,28 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "strings" +) + +// DeleteDNSRecord removes a DNS domain from Vercel. +func (c *Client) DeleteDNSRecord(ctx context.Context, domain, recordID, teamID string) error { + url := fmt.Sprintf("%s/v2/domains/%s/records/%s", c.baseURL, domain, recordID) + if teamID != "" { + url = fmt.Sprintf("%s?teamId=%s", url, teamID) + } + + req, err := http.NewRequestWithContext( + ctx, + "DELETE", + url, + strings.NewReader(""), + ) + if err != nil { + return err + } + + return c.doRequest(req, nil) +} diff --git a/client/dns_record_get.go b/client/dns_record_get.go new file mode 100644 index 00000000..2a723a64 --- /dev/null +++ b/client/dns_record_get.go @@ -0,0 +1,41 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "strings" +) + +// DNSRecord is the information Vercel surfaces about a DNS record associated with a particular domain. +type DNSRecord struct { + Creator string `json:"creator"` + Domain string `json:"domain"` + ID string `json:"id"` + Name string `json:"name"` + TTL int64 `json:"ttl"` + Value string `json:"value"` + RecordType string `json:"recordType"` + Priority int64 `json:"priority"` +} + +// GetDNSRecord retrieves information about a DNS domain from Vercel. +func (c *Client) GetDNSRecord(ctx context.Context, recordID, teamID string) (r DNSRecord, err error) { + url := fmt.Sprintf("%s/domains/records/%s", c.baseURL, recordID) + if teamID != "" { + url = fmt.Sprintf("%s?teamId=%s", url, teamID) + } + + req, err := http.NewRequestWithContext( + ctx, + "GET", + url, + strings.NewReader(""), + ) + if err != nil { + return r, err + } + + err = c.doRequest(req, &r) + return r, err +} diff --git a/client/dns_record_list.go b/client/dns_record_list.go new file mode 100644 index 00000000..27664490 --- /dev/null +++ b/client/dns_record_list.go @@ -0,0 +1,35 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "strings" +) + +// ListDNSRecords is a test helper for listing DNS records that exist for a given domain. +// We limit this to 100, as this is the largest limit allowed by the API. +// This is only used by the sweeper script, so this is safe to do so, but converting +// into a production ready function would require some refactoring. +func (c *Client) ListDNSRecords(ctx context.Context, domain, teamID string) (r []DNSRecord, err error) { + url := fmt.Sprintf("%s/v4/domains/%s/records?limit=100", c.baseURL, domain) + if teamID != "" { + url = fmt.Sprintf("%s&teamId=%s", url, teamID) + } + + req, err := http.NewRequestWithContext( + ctx, + "GET", + url, + strings.NewReader(""), + ) + if err != nil { + return r, err + } + + dr := struct { + Records []DNSRecord `json:"records"` + }{} + err = c.doRequest(req, &dr) + return dr.Records, err +} diff --git a/client/dns_record_update.go b/client/dns_record_update.go new file mode 100644 index 00000000..aa8c9d1b --- /dev/null +++ b/client/dns_record_update.go @@ -0,0 +1,50 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +type SRVUpdate struct { + Port *int64 `json:"port"` + Priority *int64 `json:"priority"` + Target *string `json:"target"` + Weight *int64 `json:"weight"` +} + +type UpdateDNSRecordRequest struct { + MXPriority *int64 `json:"mxPriority,omitempty"` + Name *string `json:"name,omitempty"` + SRV *SRVUpdate `json:"srv,omitempty"` + TTL *int64 `json:"ttl,omitempty"` + Value *string `json:"value,omitempty"` +} + +func (c *Client) UpdateDNSRecord(ctx context.Context, teamID, recordID string, request UpdateDNSRecordRequest) (r DNSRecord, err error) { + url := fmt.Sprintf("%s/v4/domains/records/%s", c.baseURL, recordID) + if teamID != "" { + url = fmt.Sprintf("%s?teamId=%s", url, teamID) + } + + payload := string(mustMarshal(request)) + req, err := http.NewRequestWithContext( + ctx, + "PATCH", + url, + strings.NewReader(payload), + ) + if err != nil { + return r, err + } + + tflog.Trace(ctx, "updating DNS record", map[string]interface{}{ + "url": url, + "payload": payload, + }) + err = c.doRequest(req, &r) + return r, err +} diff --git a/docs/resources/dns_record.md b/docs/resources/dns_record.md new file mode 100644 index 00000000..f50f57f7 --- /dev/null +++ b/docs/resources/dns_record.md @@ -0,0 +1,142 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "vercel_dns_record Resource - terraform-provider-vercel" +subcategory: "" +description: |- + Provides a DNS Record resource. + DNS records are instructions that live in authoritative DNS servers and provide information about a domain. + For more detailed information, please see the Vercel documentation https://vercel.com/docs/concepts/projects/custom-domains#dns-records +--- + +# vercel_dns_record (Resource) + +Provides a DNS Record resource. + +DNS records are instructions that live in authoritative DNS servers and provide information about a domain. + +For more detailed information, please see the [Vercel documentation](https://vercel.com/docs/concepts/projects/custom-domains#dns-records) + +## Example Usage + +```terraform +resource "vercel_dns_record" "a" { + domain = "example.com" + name = "subdomain" # for subdomain.example.com + type = "A" + ttl = 60 + value = "192.168.0.1" +} + +resource "vercel_dns_record" "aaaa" { + domain = "example.com" + name = "subdomain" + type = "AAAA" + ttl = 60 + value = "::0" +} + +resource "vercel_dns_record" "alias" { + domain = "example.com" + name = "subdomain" + type = "ALIAS" + ttl = 60 + value = "example2.com." +} + +resource "vercel_dns_record" "caa" { + domain = "example.com" + name = "subdomain" + type = "CAA" + ttl = 60 + value = "1 issue \"letsencrypt.org\"" +} + +resource "vercel_dns_record" "cname" { + domain = "example.com" + name = "subdomain" + type = "CNAME" + ttl = 60 + value = "example2.com." +} + +resource "vercel_dns_record" "mx" { + domain = "example.com" + name = "subdomain" + type = "MX" + ttl = 60 + mx_priority = 333 + value = "example2.com." +} + +resource "vercel_dns_record" "srv" { + domain = "example.com" + name = "subdomain" + type = "SRV" + ttl = 60 + srv = { + port = 6000 + weight = 60 + priority = 127 + target = "example2.com." + } +} + +resource "vercel_dns_record" "txt" { + domain = "example.com" + name = "subdomain" + type = "TXT" + ttl = 60 + value = "some text value" +} +``` + + +## Schema + +### Required + +- `domain` (String) The domain name, or zone, that the DNS record should be created beneath. +- `name` (String) The subdomain name of the record. This should be an empty string if the rercord is for the root domain. +- `type` (String) The type of DNS record. + +### Optional + +- `mx_priority` (Number) The priority of the MX record. The priority specifies the sequence that an email server receives emails. A smaller value indicates a higher priority. +- `srv` (Attributes) Settings for an SRV record. (see [below for nested schema](#nestedatt--srv)) +- `team_id` (String) The team ID that the domain and DNS records belong to. +- `ttl` (Number) The TTL value in seconds. Must be a number between 60 and 2147483647. If unspecified, it will default to 60 seconds. +- `value` (String) The value of the DNS record. The format depends on the 'type' property. +For an 'A' record, this should be a valid IPv4 address. +For an 'AAAA' record, this should be an IPv6 address. +For 'ALIAS' records, this should be a hostname. +For 'CAA' records, this should specify specify which Certificate Authorities (CAs) are allowed to issue certificates for the domain. +For 'CNAME' records, this should be a different domain name. +For 'MX' records, this should specify the mail server responsible for accepting messages on behalf of the domain name. +For 'TXT' records, this can contain arbitrary text. + +### Read-Only + +- `id` (String) The ID of this resource. + + +### Nested Schema for `srv` + +Optional: + +- `port` (Number) The TCP or UDP port on which the service is to be found. +- `priority` (Number) The priority of the target host, lower value means more preferred. +- `target` (String) The canonical hostname of the machine providing the service, ending in a dot. +- `weight` (Number) A relative weight for records with the same priority, higher value means higher chance of getting picked. + +## Import + +Import is supported using the following syntax: + +```shell +# Import via the team_id and record ID. +# Record ID can be taken from the network tab on the domains page. +terraform import vercel_dns_record.example team_xxxxxxxxxxxxxxxxxxxxxxxx/rec_xxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# If importing without a team, simply use the record ID. +terraform import vercel_dns_record.personal_example rec_xxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` diff --git a/examples/resources/vercel_dns_record/import.sh b/examples/resources/vercel_dns_record/import.sh new file mode 100644 index 00000000..4a2257a3 --- /dev/null +++ b/examples/resources/vercel_dns_record/import.sh @@ -0,0 +1,6 @@ +# Import via the team_id and record ID. +# Record ID can be taken from the network tab on the domains page. +terraform import vercel_dns_record.example team_xxxxxxxxxxxxxxxxxxxxxxxx/rec_xxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# If importing without a team, simply use the record ID. +terraform import vercel_dns_record.personal_example rec_xxxxxxxxxxxxxxxxxxxxxxxxxxxx diff --git a/examples/resources/vercel_dns_record/resource.tf b/examples/resources/vercel_dns_record/resource.tf new file mode 100644 index 00000000..ea325b0f --- /dev/null +++ b/examples/resources/vercel_dns_record/resource.tf @@ -0,0 +1,69 @@ +resource "vercel_dns_record" "a" { + domain = "example.com" + name = "subdomain" # for subdomain.example.com + type = "A" + ttl = 60 + value = "192.168.0.1" +} + +resource "vercel_dns_record" "aaaa" { + domain = "example.com" + name = "subdomain" + type = "AAAA" + ttl = 60 + value = "::0" +} + +resource "vercel_dns_record" "alias" { + domain = "example.com" + name = "subdomain" + type = "ALIAS" + ttl = 60 + value = "example2.com." +} + +resource "vercel_dns_record" "caa" { + domain = "example.com" + name = "subdomain" + type = "CAA" + ttl = 60 + value = "1 issue \"letsencrypt.org\"" +} + +resource "vercel_dns_record" "cname" { + domain = "example.com" + name = "subdomain" + type = "CNAME" + ttl = 60 + value = "example2.com." +} + +resource "vercel_dns_record" "mx" { + domain = "example.com" + name = "subdomain" + type = "MX" + ttl = 60 + mx_priority = 333 + value = "example2.com." +} + +resource "vercel_dns_record" "srv" { + domain = "example.com" + name = "subdomain" + type = "SRV" + ttl = 60 + srv = { + port = 6000 + weight = 60 + priority = 127 + target = "example2.com." + } +} + +resource "vercel_dns_record" "txt" { + domain = "example.com" + name = "subdomain" + type = "TXT" + ttl = 60 + value = "some text value" +} diff --git a/sweep/main.go b/sweep/main.go index 00b39799..690ee8e3 100644 --- a/sweep/main.go +++ b/sweep/main.go @@ -17,6 +17,13 @@ func main() { // This means we only need to delete projects. c := client.New(os.Getenv("VERCEL_API_TOKEN")) teamID := os.Getenv("VERCEL_TERRAFORM_TESTING_TEAM") + if teamID == "" { + panic("VERCEL_TERRAFORM_TESTING_TEAM environment variable not set") + } + domain := os.Getenv("VERCEL_TERRAFORM_TESTING_DOMAIN") + if domain == "" { + panic("VERCEL_TERRAFORM_TESTING_DOMAIN environment variable not set") + } ctx := context.Background() // delete both for the testing team, and for without a team @@ -28,6 +35,30 @@ func main() { if err != nil { panic(err) } + err = deleteAllDNSRecords(ctx, c, domain, "") + if err != nil { + panic(err) + } +} + +func deleteAllDNSRecords(ctx context.Context, c *client.Client, domain, teamID string) error { + dnsRecords, err := c.ListDNSRecords(ctx, domain, teamID) + if err != nil { + return fmt.Errorf("error listing dns records: %w", err) + } + for _, d := range dnsRecords { + if !strings.HasPrefix(d.Name, "test-acc") { + // Don't delete actual dns records - only testing ones + continue + } + + err = c.DeleteDNSRecord(ctx, domain, d.ID, teamID) + if err != nil { + return fmt.Errorf("error deleting dns record %s %s for domain %s: %w", d.ID, teamID, d.Domain, err) + } + } + + return nil } func deleteAllProjects(ctx context.Context, c *client.Client, teamID string) error { diff --git a/vercel/provider.go b/vercel/provider.go index f55cb5bb..d74176c0 100644 --- a/vercel/provider.go +++ b/vercel/provider.go @@ -47,6 +47,7 @@ func (p *provider) GetResources(_ context.Context) (map[string]tfsdk.ResourceTyp "vercel_deployment": resourceDeploymentType{}, "vercel_project": resourceProjectType{}, "vercel_project_domain": resourceProjectDomainType{}, + "vercel_dns_record": resourceDNSRecordType{}, }, nil } diff --git a/vercel/provider_test.go b/vercel/provider_test.go index 7c40a904..f77384fb 100644 --- a/vercel/provider_test.go +++ b/vercel/provider_test.go @@ -14,34 +14,35 @@ var testAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServe "vercel": providerserver.NewProtocol6WithError(vercel.New()), } -func testAccPreCheck(t *testing.T) { - if v := os.Getenv("VERCEL_API_TOKEN"); v == "" { - t.Fatal("VERCEL_API_TOKEN must be set for acceptance tests") - } - if v := testTeam(); v == "" { - t.Fatal("VERCEL_TERRAFORM_TESTING_TEAM must be set for acceptance tests against a specific team") - } - if v := testGithubRepo(); v == "" { - t.Fatal("VERCEL_TERRAFORM_TESTING_GITHUB_REPO must be set for acceptance tests against a github repository") - } - if v := testGitlabRepo(); v == "" { - t.Fatal("VERCEL_TERRAFORM_TESTING_GITLAB_REPO must be set for acceptance tests against a gitlab repository") - } - if v := testBitbucketRepo(); v == "" { - t.Fatal("VERCEL_TERRAFORM_TESTING_BITBUCKET_REPO must be set for acceptance tests against a bitbucket repository") +func mustHaveEnv(t *testing.T, name string) { + if os.Getenv(name) == "" { + t.Fatalf("%s environment variable must be set for acceptance tests", name) } } +func testAccPreCheck(t *testing.T) { + mustHaveEnv(t, "VERCEL_API_TOKEN") + mustHaveEnv(t, "VERCEL_TERRAFORM_TESTING_GITHUB_REPO") + mustHaveEnv(t, "VERCEL_TERRAFORM_TESTING_GITLAB_REPO") + mustHaveEnv(t, "VERCEL_TERRAFORM_TESTING_BITBUCKET_REPO") + mustHaveEnv(t, "VERCEL_TERRAFORM_TESTING_TEAM") + mustHaveEnv(t, "VERCEL_TERRAFORM_TESTING_DOMAIN") +} + var tc *client.Client func testClient() *client.Client { if tc == nil { - tc = client.New(os.Getenv("VERCEL_API_TOKEN")) + tc = client.New(apiToken()) } return tc } +func apiToken() string { + return os.Getenv("VERCEL_API_TOKEN") +} + func testGithubRepo() string { return os.Getenv("VERCEL_TERRAFORM_TESTING_GITHUB_REPO") } @@ -57,3 +58,7 @@ func testBitbucketRepo() string { func testTeam() string { return os.Getenv("VERCEL_TERRAFORM_TESTING_TEAM") } + +func testDomain() string { + return os.Getenv("VERCEL_TERRAFORM_TESTING_DOMAIN") +} diff --git a/vercel/resource_deployment_test.go b/vercel/resource_deployment_test.go index 41489adb..1e481dc1 100644 --- a/vercel/resource_deployment_test.go +++ b/vercel/resource_deployment_test.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "os" "strings" "testing" @@ -74,7 +73,7 @@ func TestAcc_Deployment(t *testing.T) { } func TestAcc_DeploymentWithTeamID(t *testing.T) { - testAccDeployment(t, os.Getenv("VERCEL_TERRAFORM_TESTING_TEAM")) + testAccDeployment(t, testTeam()) } func TestAcc_DeploymentWithEnvironment(t *testing.T) { @@ -248,7 +247,7 @@ func testAccDeployment(t *testing.T, tid string) { func TestAcc_DeploymentWithGitSource(t *testing.T) { tests := map[string]string{ "personal scope": "", - "team scope": os.Getenv("VERCEL_TERRAFORM_TESTING_TEAM"), + "team scope": testTeam(), } for name, teamID := range tests { diff --git a/vercel/resource_dns_record.go b/vercel/resource_dns_record.go new file mode 100644 index 00000000..f7617197 --- /dev/null +++ b/vercel/resource_dns_record.go @@ -0,0 +1,411 @@ +package vercel + +import ( + "context" + "errors" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/vercel/terraform-provider-vercel/client" +) + +type resourceDNSRecordType struct{} + +func (r resourceDNSRecordType) GetSchema(_ context.Context) (tfsdk.Schema, diag.Diagnostics) { + return tfsdk.Schema{ + Description: ` +Provides a DNS Record resource. + +DNS records are instructions that live in authoritative DNS servers and provide information about a domain. + +For more detailed information, please see the [Vercel documentation](https://vercel.com/docs/concepts/projects/custom-domains#dns-records) + `, + Attributes: map[string]tfsdk.Attribute{ + "id": { + Computed: true, + Type: types.StringType, + }, + "team_id": { + Optional: true, + Description: "The team ID that the domain and DNS records belong to.", + PlanModifiers: tfsdk.AttributePlanModifiers{tfsdk.RequiresReplace()}, + Type: types.StringType, + }, + "domain": { + Description: "The domain name, or zone, that the DNS record should be created beneath.", + PlanModifiers: tfsdk.AttributePlanModifiers{tfsdk.RequiresReplace()}, + Required: true, + Type: types.StringType, + }, + "name": { + Description: "The subdomain name of the record. This should be an empty string if the rercord is for the root domain.", + Required: true, + Type: types.StringType, + }, + "type": { + Description: "The type of DNS record.", + PlanModifiers: tfsdk.AttributePlanModifiers{tfsdk.RequiresReplace()}, + Required: true, + Type: types.StringType, + Validators: []tfsdk.AttributeValidator{ + stringOneOf("A", "AAAA", "ALIAS", "CAA", "CNAME", "MX", "SRV", "TXT"), + }, + }, + "value": { + // required if any record type apart from SRV. + Description: "The value of the DNS record. The format depends on the 'type' property.\nFor an 'A' record, this should be a valid IPv4 address.\nFor an 'AAAA' record, this should be an IPv6 address.\nFor 'ALIAS' records, this should be a hostname.\nFor 'CAA' records, this should specify specify which Certificate Authorities (CAs) are allowed to issue certificates for the domain.\nFor 'CNAME' records, this should be a different domain name.\nFor 'MX' records, this should specify the mail server responsible for accepting messages on behalf of the domain name.\nFor 'TXT' records, this can contain arbitrary text.", + Optional: true, + Type: types.StringType, + }, + "ttl": { + Description: "The TTL value in seconds. Must be a number between 60 and 2147483647. If unspecified, it will default to 60 seconds.", + Optional: true, + Type: types.Int64Type, + Validators: []tfsdk.AttributeValidator{ + int64GreaterThan(60), + int64LessThan(2147483647), + }, + }, + "mx_priority": { + Description: "The priority of the MX record. The priority specifies the sequence that an email server receives emails. A smaller value indicates a higher priority.", + Optional: true, // required for MX records. + Type: types.Int64Type, + Validators: []tfsdk.AttributeValidator{ + int64GreaterThan(0), + int64LessThan(65535), + }, + }, + "srv": { + Description: "Settings for an SRV record.", + Optional: true, // required for SRV records. + Attributes: tfsdk.SingleNestedAttributes(map[string]tfsdk.Attribute{ + "weight": { + Description: "A relative weight for records with the same priority, higher value means higher chance of getting picked.", + Type: types.Int64Type, + Required: true, + Validators: []tfsdk.AttributeValidator{ + int64GreaterThan(0), + int64LessThan(65535), + }, + }, + "port": { + Description: "The TCP or UDP port on which the service is to be found.", + Type: types.Int64Type, + Required: true, + Validators: []tfsdk.AttributeValidator{ + int64GreaterThan(0), + int64LessThan(65535), + }, + }, + "priority": { + Description: "The priority of the target host, lower value means more preferred.", + Type: types.Int64Type, + Required: true, + Validators: []tfsdk.AttributeValidator{ + int64GreaterThan(0), + int64LessThan(65535), + }, + }, + "target": { + Description: "The canonical hostname of the machine providing the service, ending in a dot.", + Type: types.StringType, + Required: true, + }, + }), + }, + }, + }, nil +} + +// NewResource instantiates a new Resource of this ResourceType. +func (r resourceDNSRecordType) NewResource(_ context.Context, p tfsdk.Provider) (tfsdk.Resource, diag.Diagnostics) { + return resourceDNSRecord{ + p: *(p.(*provider)), + }, nil +} + +type resourceDNSRecord struct { + p provider +} + +// ValidateConfig validates the Resource configuration. +func (r resourceDNSRecord) ValidateConfig(ctx context.Context, req tfsdk.ValidateResourceConfigRequest, resp *tfsdk.ValidateResourceConfigResponse) { + var config DNSRecord + diags := req.Config.Get(ctx, &config) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if config.Type == "SRV" && config.SRV == nil { + resp.Diagnostics.AddError( + "DNS Record Invalid", + "A DNS Record type of 'SRV' requires the `srv` attribute to be set", + ) + } + + if config.Type == "SRV" && config.Value.Value != "" { + resp.Diagnostics.AddError( + "DNS Record Invalid", + "The `value` attribute should not be set on records of `type` 'SRV'", + ) + } + + if config.Type != "SRV" && config.SRV != nil { + resp.Diagnostics.AddError( + "DNS Record Invalid", + "The `srv` attribute should only be set on records of `type` 'SRV'", + ) + } + + if config.Type != "MX" && !config.MXPriority.Null { + resp.Diagnostics.AddError( + "DNS Record Invalid", + "The `mx_priority` attribute should only be set on records of `type` 'MX'", + ) + } + + if config.Type == "MX" && config.MXPriority.Null { + resp.Diagnostics.AddError( + "DNS Record Invalid", + "A DNS Record type of 'MX' requires the `mx_priority` attribute to be set", + ) + } +} + +// Create will create a DNS record within Vercel by calling the Vercel API. +// This is called automatically by the provider when a new resource should be created. +func (r resourceDNSRecord) Create(ctx context.Context, req tfsdk.CreateResourceRequest, resp *tfsdk.CreateResourceResponse) { + if !r.p.configured { + resp.Diagnostics.AddError( + "Provider not configured", + "The provider hasn't been configured before apply. This leads to weird stuff happening, so we'd prefer if you didn't do that. Thanks!", + ) + return + } + + var plan DNSRecord + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + out, err := r.p.client.CreateDNSRecord(ctx, plan.TeamID.Value, plan.toCreateDNSRecordRequest()) + if err != nil { + resp.Diagnostics.AddError( + "Error creating DNS Record", + "Could not create DNS Record, unexpected error: "+err.Error(), + ) + return + } + + result, err := convertResponseToDNSRecord(out, plan.TeamID, plan.Value, plan.SRV) + if err != nil { + resp.Diagnostics.AddError( + "Error parsing DNS Record response", + "Could not parse create DNS Record response, unexpected error: "+err.Error(), + ) + return + } + tflog.Trace(ctx, "created DNS Record", map[string]interface{}{ + "team_id": result.TeamID, + "record_id": result.ID, + "domain": result.Domain, + }) + + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Read will read a DNS record from the vercel API and provide terraform with information about it. +// It is called by the provider whenever values should be read to update state. +func (r resourceDNSRecord) Read(ctx context.Context, req tfsdk.ReadResourceRequest, resp *tfsdk.ReadResourceResponse) { + var state DNSRecord + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + out, err := r.p.client.GetDNSRecord(ctx, state.ID.Value, state.TeamID.Value) + var apiErr client.APIError + if err != nil && errors.As(err, &apiErr) && apiErr.StatusCode == 404 { + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError( + "Error reading DNS Record", + fmt.Sprintf("Could not read DNS Record %s %s, unexpected error: %s", + state.TeamID.Value, + state.ID.Value, + err, + ), + ) + return + } + + result, err := convertResponseToDNSRecord(out, state.TeamID, state.Value, state.SRV) + if err != nil { + resp.Diagnostics.AddError( + "Error parsing DNS Record response", + "Could not parse create DNS Record response, unexpected error: "+err.Error(), + ) + return + } + tflog.Trace(ctx, "read DNS record", map[string]interface{}{ + "team_id": result.TeamID, + "record_id": result.ID, + "domain": result.Domain, + }) + + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Update will update a DNS record via the vercel API. +func (r resourceDNSRecord) Update(ctx context.Context, req tfsdk.UpdateResourceRequest, resp *tfsdk.UpdateResourceResponse) { + var plan DNSRecord + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + var state DNSRecord + diags = req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + out, err := r.p.client.UpdateDNSRecord( + ctx, + plan.TeamID.Value, + state.ID.Value, + plan.toUpdateRequest(), + ) + if err != nil { + resp.Diagnostics.AddError( + "Error updating DNS Record", + fmt.Sprintf( + "Could not update DNS Record %s for domain %s, unexpected error: %s", + state.ID.Value, + state.Domain, + err, + ), + ) + return + } + + result, err := convertResponseToDNSRecord(out, plan.TeamID, plan.Value, plan.SRV) + if err != nil { + resp.Diagnostics.AddError( + "Error parsing DNS Record response", + "Could not parse create DNS Record response, unexpected error: "+err.Error(), + ) + return + } + tflog.Trace(ctx, "updated DNS record", map[string]interface{}{ + "team_id": result.TeamID, + "record_id": result.ID, + "domain": result.Domain, + }) + + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Delete a DNS record from within terraform. +func (r resourceDNSRecord) Delete(ctx context.Context, req tfsdk.DeleteResourceRequest, resp *tfsdk.DeleteResourceResponse) { + var state DNSRecord + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + err := r.p.client.DeleteDNSRecord(ctx, state.Domain, state.ID.Value, state.TeamID.Value) + var apiErr client.APIError + if err != nil && errors.As(err, &apiErr) && apiErr.StatusCode == 404 { + // The DNS Record is already gone - do nothing. + return + } + if err != nil { + resp.Diagnostics.AddError( + "Error deleting DNS Record", + fmt.Sprintf( + "Could not delete DNS Record %s for domain %s, unexpected error: %s", + state.ID.Value, + state.Domain, + err, + ), + ) + return + } + + tflog.Trace(ctx, "delete DNS record", map[string]interface{}{ + "domain": state.Domain, + "record_id": state.ID, + "team_id": state.TeamID, + }) +} + +// ImportState takes an identifier and reads all the DNS Record information from the Vercel API. +func (r resourceDNSRecord) ImportState(ctx context.Context, req tfsdk.ImportResourceStateRequest, resp *tfsdk.ImportResourceStateResponse) { + teamID, recordID, ok := splitID(req.ID) + if !ok { + resp.Diagnostics.AddError( + "Error importing DNS Record", + fmt.Sprintf("Invalid id '%s' specified. should be in format \"team_id/record_id\" or \"record_id\"", req.ID), + ) + } + + out, err := r.p.client.GetDNSRecord(ctx, recordID, teamID) + if err != nil { + resp.Diagnostics.AddError( + "Error reading DNS Record", + fmt.Sprintf("Could not get DNS Record %s %s, unexpected error: %s", + teamID, + recordID, + err, + ), + ) + return + } + stringTypeTeamID := types.String{Value: teamID} + if teamID == "" { + stringTypeTeamID.Null = true + } + + result, err := convertResponseToDNSRecord(out, stringTypeTeamID, types.String{}, nil) + if err != nil { + resp.Diagnostics.AddError( + "Error processing DNS Record response", + fmt.Sprintf("Could not process DNS Record API response %s %s, unexpected error: %s", + teamID, + recordID, + err, + ), + ) + } + + diags := resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} diff --git a/vercel/resource_dns_record_model.go b/vercel/resource_dns_record_model.go new file mode 100644 index 00000000..eef715e5 --- /dev/null +++ b/vercel/resource_dns_record_model.go @@ -0,0 +1,149 @@ +package vercel + +import ( + "fmt" + "strconv" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/vercel/terraform-provider-vercel/client" +) + +// SRV reflect the state terraform stores internally for a nested SRV Record. +type SRV struct { + Port int64 `tfsdk:"port"` + Priority int64 `tfsdk:"priority"` + Target string `tfsdk:"target"` + Weight int64 `tfsdk:"weight"` +} + +// DNSRecord reflects the state terraform stores internally for a DNS Record. +type DNSRecord struct { + ID types.String `tfsdk:"id"` + Domain string `tfsdk:"domain"` + MXPriority types.Int64 `tfsdk:"mx_priority"` + Name string `tfsdk:"name"` + SRV *SRV `tfsdk:"srv"` + TTL types.Int64 `tfsdk:"ttl"` + TeamID types.String `tfsdk:"team_id"` + Type string `tfsdk:"type"` + Value types.String `tfsdk:"value"` +} + +func (d DNSRecord) toCreateDNSRecordRequest() client.CreateDNSRecordRequest { + var srv *client.SRV = nil + if d.Type == "SRV" { + srv = &client.SRV{ + Port: d.SRV.Port, + Priority: d.SRV.Priority, + Target: d.SRV.Target, + Weight: d.SRV.Weight, + } + } + return client.CreateDNSRecordRequest{ + Domain: d.Domain, + MXPriority: d.MXPriority.Value, + Name: d.Name, + TTL: d.TTL.Value, + Type: d.Type, + Value: d.Value.Value, + SRV: srv, + } +} + +func (d DNSRecord) toUpdateRequest() client.UpdateDNSRecordRequest { + var srv *client.SRVUpdate = nil + if d.SRV != nil { + srv = &client.SRVUpdate{ + Port: &d.SRV.Port, + Priority: &d.SRV.Priority, + Target: &d.SRV.Target, + Weight: &d.SRV.Weight, + } + } + return client.UpdateDNSRecordRequest{ + MXPriority: toInt64Pointer(d.MXPriority), + Name: &d.Name, + SRV: srv, + TTL: toInt64Pointer(d.TTL), + Value: toStrPointer(d.Value), + } +} + +func convertResponseToDNSRecord(r client.DNSRecord, tid types.String, value types.String, srv *SRV) (record DNSRecord, err error) { + teamID := types.String{Value: tid.Value} + if tid.Unknown || tid.Null { + teamID.Null = true + } + + record = DNSRecord{ + Domain: r.Domain, + ID: types.String{Value: r.ID}, + MXPriority: types.Int64{Null: true}, + Name: r.Name, + TTL: types.Int64{Value: r.TTL}, + TeamID: teamID, + Type: r.RecordType, + } + + if r.RecordType == "SRV" { + // The returned 'Value' field is comprised of the various parts of the SRV block. + // So instead, we want to parse the SRV block back out. + split := strings.Split(r.Value, " ") + if len(split) != 4 && len(split) != 3 { + return record, fmt.Errorf("expected a 3 or 4 part value '{priority} {weight} {port} {target}', but got %s", r.Value) + } + priority, err := strconv.Atoi(split[0]) + if err != nil { + return record, fmt.Errorf("expected SRV record weight to be an int, but got %s", split[0]) + } + weight, err := strconv.Atoi(split[1]) + if err != nil { + return record, fmt.Errorf("expected SRV record port to be an int, but got %s", split[1]) + } + port, err := strconv.Atoi(split[2]) + if err != nil { + return record, fmt.Errorf("expected SRV record port to be an int, but got %s", split[1]) + } + target := "" + if len(split) == 4 { + target = split[3] + } + record.SRV = &SRV{ + Weight: int64(weight), + Port: int64(port), + Priority: int64(priority), + Target: target, + } + // SRV records have no value + record.Value = types.String{Null: true} + if srv != nil && fmt.Sprintf("%s.", srv.Target) == record.SRV.Target { + record.SRV.Target = srv.Target + } + return record, nil + } + + if r.RecordType == "MX" { + split := strings.Split(r.Value, " ") + if len(split) != 2 { + return record, fmt.Errorf("expected a 2 part value '{priority} {value}', but got %s", r.Value) + } + priority, err := strconv.Atoi(split[0]) + if err != nil { + return record, fmt.Errorf("expected MX priority to be an int, but got %s", split[0]) + } + + record.MXPriority = types.Int64{Value: int64(priority)} + record.Value = types.String{Value: split[1]} + if split[1] == fmt.Sprintf("%s.", value.Value) { + record.Value = value + } + return record, nil + } + + record.Value = types.String{Value: r.Value} + if r.Value == fmt.Sprintf("%s.", value.Value) { + record.Value = value + } + return record, nil +} diff --git a/vercel/resource_dns_record_test.go b/vercel/resource_dns_record_test.go new file mode 100644 index 00000000..de1b65dd --- /dev/null +++ b/vercel/resource_dns_record_test.go @@ -0,0 +1,369 @@ +package vercel_test + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/vercel/terraform-provider-vercel/client" +) + +func testAccDNSRecordDestroy(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().GetDNSRecord(context.TODO(), rs.Primary.ID, teamID) + + var apiErr client.APIError + if err == nil { + return fmt.Errorf("Found project but expected it to have been deleted") + } + if err != nil && errors.As(err, &apiErr) { + if apiErr.StatusCode == 404 { + return nil + } + return fmt.Errorf("Unexpected error checking for deleted project: %s", apiErr) + } + + return err + } +} + +func testAccDNSRecordExists(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().GetDNSRecord(context.TODO(), rs.Primary.ID, teamID) + return err + } +} + +func TestAcc_DNSRecord(t *testing.T) { + t.Parallel() + nameSuffix := acctest.RandString(16) + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: resource.ComposeAggregateTestCheckFunc( + testAccDNSRecordDestroy("vercel_dns_record.a", ""), + testAccDNSRecordDestroy("vercel_dns_record.aaaa", ""), + testAccDNSRecordDestroy("vercel_dns_record.alias", ""), + testAccDNSRecordDestroy("vercel_dns_record.caa", ""), + testAccDNSRecordDestroy("vercel_dns_record.cname", ""), + testAccDNSRecordDestroy("vercel_dns_record.mx", ""), + testAccDNSRecordDestroy("vercel_dns_record.srv", ""), + testAccDNSRecordDestroy("vercel_dns_record.txt", ""), + ), + Steps: []resource.TestStep{ + { + Config: testAccDNSRecordConfig(testDomain(), nameSuffix), + Check: resource.ComposeAggregateTestCheckFunc( + testAccDNSRecordExists("vercel_dns_record.a", ""), + resource.TestCheckResourceAttr("vercel_dns_record.a", "domain", testDomain()), + resource.TestCheckResourceAttr("vercel_dns_record.a", "type", "A"), + resource.TestCheckResourceAttr("vercel_dns_record.a", "ttl", "120"), + resource.TestCheckResourceAttr("vercel_dns_record.a", "value", "127.0.0.1"), + testAccDNSRecordExists("vercel_dns_record.aaaa", ""), + resource.TestCheckResourceAttr("vercel_dns_record.aaaa", "domain", testDomain()), + resource.TestCheckResourceAttr("vercel_dns_record.aaaa", "type", "AAAA"), + resource.TestCheckResourceAttr("vercel_dns_record.aaaa", "ttl", "120"), + resource.TestCheckResourceAttr("vercel_dns_record.aaaa", "value", "::1"), + testAccDNSRecordExists("vercel_dns_record.alias", ""), + resource.TestCheckResourceAttr("vercel_dns_record.alias", "domain", testDomain()), + resource.TestCheckResourceAttr("vercel_dns_record.alias", "type", "ALIAS"), + resource.TestCheckResourceAttr("vercel_dns_record.alias", "ttl", "120"), + resource.TestCheckResourceAttr("vercel_dns_record.alias", "value", "example.com."), + testAccDNSRecordExists("vercel_dns_record.caa", ""), + resource.TestCheckResourceAttr("vercel_dns_record.caa", "domain", testDomain()), + resource.TestCheckResourceAttr("vercel_dns_record.caa", "type", "CAA"), + resource.TestCheckResourceAttr("vercel_dns_record.caa", "ttl", "120"), + resource.TestCheckResourceAttr("vercel_dns_record.caa", "value", "0 issue \"letsencrypt.org\""), + testAccDNSRecordExists("vercel_dns_record.cname", ""), + resource.TestCheckResourceAttr("vercel_dns_record.cname", "domain", testDomain()), + resource.TestCheckResourceAttr("vercel_dns_record.cname", "type", "CNAME"), + resource.TestCheckResourceAttr("vercel_dns_record.cname", "ttl", "120"), + resource.TestCheckResourceAttr("vercel_dns_record.cname", "value", "example.com."), + testAccDNSRecordExists("vercel_dns_record.mx", ""), + resource.TestCheckResourceAttr("vercel_dns_record.mx", "domain", testDomain()), + resource.TestCheckResourceAttr("vercel_dns_record.mx", "type", "MX"), + resource.TestCheckResourceAttr("vercel_dns_record.mx", "ttl", "120"), + resource.TestCheckResourceAttr("vercel_dns_record.mx", "mx_priority", "123"), + resource.TestCheckResourceAttr("vercel_dns_record.mx", "value", "example.com."), + testAccDNSRecordExists("vercel_dns_record.srv", ""), + resource.TestCheckResourceAttr("vercel_dns_record.srv", "domain", testDomain()), + resource.TestCheckResourceAttr("vercel_dns_record.srv", "type", "SRV"), + resource.TestCheckResourceAttr("vercel_dns_record.srv", "ttl", "120"), + resource.TestCheckResourceAttr("vercel_dns_record.srv", "srv.port", "5000"), + resource.TestCheckResourceAttr("vercel_dns_record.srv", "srv.weight", "120"), + resource.TestCheckResourceAttr("vercel_dns_record.srv", "srv.priority", "27"), + resource.TestCheckResourceAttr("vercel_dns_record.srv", "srv.target", "example.com."), + testAccDNSRecordExists("vercel_dns_record.srv_no_target", ""), + resource.TestCheckResourceAttr("vercel_dns_record.srv_no_target", "domain", testDomain()), + resource.TestCheckResourceAttr("vercel_dns_record.srv_no_target", "type", "SRV"), + resource.TestCheckResourceAttr("vercel_dns_record.srv_no_target", "ttl", "120"), + resource.TestCheckResourceAttr("vercel_dns_record.srv_no_target", "srv.port", "5000"), + resource.TestCheckResourceAttr("vercel_dns_record.srv_no_target", "srv.weight", "120"), + resource.TestCheckResourceAttr("vercel_dns_record.srv_no_target", "srv.priority", "27"), + testAccDNSRecordExists("vercel_dns_record.txt", ""), + resource.TestCheckResourceAttr("vercel_dns_record.txt", "domain", testDomain()), + resource.TestCheckResourceAttr("vercel_dns_record.txt", "type", "TXT"), + resource.TestCheckResourceAttr("vercel_dns_record.txt", "ttl", "120"), + resource.TestCheckResourceAttr("vercel_dns_record.txt", "value", "terraform testing"), + ), + }, + { + Config: testAccDNSRecordConfigUpdated(testDomain(), nameSuffix), + Check: resource.ComposeAggregateTestCheckFunc( + testAccDNSRecordExists("vercel_dns_record.a", ""), + resource.TestCheckResourceAttr("vercel_dns_record.a", "domain", testDomain()), + resource.TestCheckResourceAttr("vercel_dns_record.a", "type", "A"), + resource.TestCheckResourceAttr("vercel_dns_record.a", "ttl", "60"), + resource.TestCheckResourceAttr("vercel_dns_record.a", "value", "192.168.0.1"), + testAccDNSRecordExists("vercel_dns_record.aaaa", ""), + resource.TestCheckResourceAttr("vercel_dns_record.aaaa", "domain", testDomain()), + resource.TestCheckResourceAttr("vercel_dns_record.aaaa", "type", "AAAA"), + resource.TestCheckResourceAttr("vercel_dns_record.aaaa", "ttl", "60"), + resource.TestCheckResourceAttr("vercel_dns_record.aaaa", "value", "::0"), + testAccDNSRecordExists("vercel_dns_record.alias", ""), + resource.TestCheckResourceAttr("vercel_dns_record.alias", "domain", testDomain()), + resource.TestCheckResourceAttr("vercel_dns_record.alias", "type", "ALIAS"), + resource.TestCheckResourceAttr("vercel_dns_record.alias", "ttl", "60"), + resource.TestCheckResourceAttr("vercel_dns_record.alias", "value", "example2.com."), + testAccDNSRecordExists("vercel_dns_record.caa", ""), + resource.TestCheckResourceAttr("vercel_dns_record.caa", "domain", testDomain()), + resource.TestCheckResourceAttr("vercel_dns_record.caa", "type", "CAA"), + resource.TestCheckResourceAttr("vercel_dns_record.caa", "ttl", "60"), + resource.TestCheckResourceAttr("vercel_dns_record.caa", "value", "1 issue \"letsencrypt.org\""), + testAccDNSRecordExists("vercel_dns_record.cname", ""), + resource.TestCheckResourceAttr("vercel_dns_record.cname", "domain", testDomain()), + resource.TestCheckResourceAttr("vercel_dns_record.cname", "type", "CNAME"), + resource.TestCheckResourceAttr("vercel_dns_record.cname", "ttl", "60"), + resource.TestCheckResourceAttr("vercel_dns_record.cname", "value", "example2.com."), + testAccDNSRecordExists("vercel_dns_record.mx", ""), + resource.TestCheckResourceAttr("vercel_dns_record.mx", "domain", testDomain()), + resource.TestCheckResourceAttr("vercel_dns_record.mx", "type", "MX"), + resource.TestCheckResourceAttr("vercel_dns_record.mx", "ttl", "60"), + resource.TestCheckResourceAttr("vercel_dns_record.mx", "mx_priority", "333"), + resource.TestCheckResourceAttr("vercel_dns_record.mx", "value", "example2.com."), + testAccDNSRecordExists("vercel_dns_record.srv", ""), + resource.TestCheckResourceAttr("vercel_dns_record.srv", "domain", testDomain()), + resource.TestCheckResourceAttr("vercel_dns_record.srv", "type", "SRV"), + resource.TestCheckResourceAttr("vercel_dns_record.srv", "ttl", "60"), + resource.TestCheckResourceAttr("vercel_dns_record.srv", "srv.port", "6000"), + resource.TestCheckResourceAttr("vercel_dns_record.srv", "srv.weight", "60"), + resource.TestCheckResourceAttr("vercel_dns_record.srv", "srv.priority", "127"), + resource.TestCheckResourceAttr("vercel_dns_record.srv", "srv.target", "example2.com."), + testAccDNSRecordExists("vercel_dns_record.txt", ""), + resource.TestCheckResourceAttr("vercel_dns_record.txt", "domain", testDomain()), + resource.TestCheckResourceAttr("vercel_dns_record.txt", "type", "TXT"), + resource.TestCheckResourceAttr("vercel_dns_record.txt", "ttl", "60"), + resource.TestCheckResourceAttr("vercel_dns_record.txt", "value", "terraform testing two"), + ), + }, + { + ResourceName: "vercel_dns_record.a", + ImportState: true, + ImportStateVerify: true, + }, + { + ResourceName: "vercel_dns_record.aaaa", + ImportState: true, + ImportStateVerify: true, + }, + { + ResourceName: "vercel_dns_record.alias", + ImportState: true, + ImportStateVerify: true, + }, + { + ResourceName: "vercel_dns_record.caa", + ImportState: true, + ImportStateVerify: true, + }, + { + ResourceName: "vercel_dns_record.cname", + ImportState: true, + ImportStateVerify: true, + }, + { + ResourceName: "vercel_dns_record.mx", + ImportState: true, + ImportStateVerify: true, + }, + { + ResourceName: "vercel_dns_record.srv", + ImportState: true, + ImportStateVerify: true, + }, + { + ResourceName: "vercel_dns_record.txt", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func testAccDNSRecordConfig(testDomain, nameSuffix string) string { + return fmt.Sprintf(` +resource "vercel_dns_record" "a" { + domain = "%[1]s" + name = "test-acc-%[2]s-a-record" + type = "A" + ttl = 120 + value = "127.0.0.1" +} +resource "vercel_dns_record" "aaaa" { + domain = "%[1]s" + name = "test-acc-%s-aaaa-record" + type = "AAAA" + ttl = 120 + value = "::1" +} +resource "vercel_dns_record" "alias" { + domain = "%[1]s" + name = "test-acc-%s-alias" + type = "ALIAS" + ttl = 120 + value = "example.com." +} +resource "vercel_dns_record" "caa" { + domain = "%[1]s" + name = "test-acc-%s-caa" + type = "CAA" + ttl = 120 + value = "0 issue \"letsencrypt.org\"" +} +resource "vercel_dns_record" "cname" { + domain = "%[1]s" + name = "test-acc-%s-cname" + type = "CNAME" + ttl = 120 + value = "example.com." +} +resource "vercel_dns_record" "mx" { + domain = "%[1]s" + name = "test-acc-%s-mx" + type = "MX" + ttl = 120 + mx_priority = 123 + value = "example.com." +} +resource "vercel_dns_record" "srv" { + domain = "%[1]s" + name = "test-acc-%[2]s-srv" + type = "SRV" + ttl = 120 + srv = { + port = 5000 + weight = 120 + priority = 27 + target = "example.com." + } +} +resource "vercel_dns_record" "srv_no_target" { + domain = "%[1]s" + name = "test-acc-%[2]s-srv-no-target" + type = "SRV" + ttl = 120 + srv = { + port = 5000 + weight = 120 + priority = 27 + target = "" + } +} +resource "vercel_dns_record" "txt" { + domain = "%[1]s" + name = "test-acc-%[2]s-txt" + type = "TXT" + ttl = 120 + value = "terraform testing" +} +`, testDomain, nameSuffix) +} + +func testAccDNSRecordConfigUpdated(testDomain, nameSuffix string) string { + return fmt.Sprintf(` +resource "vercel_dns_record" "a" { + domain = "%[1]s" + name = "test-acc-%[2]s-a-record-updated" + type = "A" + ttl = 60 + value = "192.168.0.1" +} +resource "vercel_dns_record" "aaaa" { + domain = "%[1]s" + name = "test-acc-%s-aaaa-record-updated" + type = "AAAA" + ttl = 60 + value = "::0" +} +resource "vercel_dns_record" "alias" { + domain = "%[1]s" + name = "test-acc-%s-alias-updated" + type = "ALIAS" + ttl = 60 + value = "example2.com." +} +resource "vercel_dns_record" "caa" { + domain = "%[1]s" + name = "test-acc-%s-caa-updated" + type = "CAA" + ttl = 60 + value = "1 issue \"letsencrypt.org\"" +} +resource "vercel_dns_record" "cname" { + domain = "%[1]s" + name = "test-acc-%s-cname-updated" + type = "CNAME" + ttl = 60 + value = "example2.com." +} +resource "vercel_dns_record" "mx" { + domain = "%[1]s" + name = "test-acc-%s-mx-updated" + type = "MX" + ttl = 60 + mx_priority = 333 + value = "example2.com." +} +resource "vercel_dns_record" "srv" { + domain = "%[1]s" + name = "test-acc-%[2]s-srv-updated" + type = "SRV" + ttl = 60 + srv = { + port = 6000 + weight = 60 + priority = 127 + target = "example2.com." + } +} +resource "vercel_dns_record" "txt" { + domain = "%[1]s" + name = "test-acc-%[2]s-txt-updated" + type = "TXT" + ttl = 60 + value = "terraform testing two" +} +`, testDomain, nameSuffix) +} diff --git a/vercel/resource_project.go b/vercel/resource_project.go index 05df9158..b3aa5992 100644 --- a/vercel/resource_project.go +++ b/vercel/resource_project.go @@ -206,7 +206,7 @@ func (r resourceProject) Create(ctx context.Context, req tfsdk.CreateResourceReq } // Read will read a project from the vercel API and provide terraform with information about it. -// It is called by the provider whenever data source values should be read to update state. +// It is called by the provider whenever values should be read to update state. func (r resourceProject) Read(ctx context.Context, req tfsdk.ReadResourceRequest, resp *tfsdk.ReadResourceResponse) { var state Project diags := req.State.Get(ctx, &state) diff --git a/vercel/resource_project_domain.go b/vercel/resource_project_domain.go index bb714bab..958e9ade 100644 --- a/vercel/resource_project_domain.go +++ b/vercel/resource_project_domain.go @@ -57,7 +57,7 @@ By default, Project Domains will be automatically applied to any ` + "`productio Optional: true, Type: types.Int64Type, Validators: []tfsdk.AttributeValidator{ - int64ItemsIn(301, 302, 307, 308), + int64OneOf(301, 302, 307, 308), }, }, "git_branch": { @@ -177,13 +177,6 @@ func (r resourceProjectDomain) Update(ctx context.Context, req tfsdk.UpdateResou return } - var state ProjectDomain - diags = req.State.Get(ctx, &state) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - out, err := r.p.client.UpdateProjectDomain( ctx, plan.ProjectID.Value, @@ -195,15 +188,15 @@ func (r resourceProjectDomain) Update(ctx context.Context, req tfsdk.UpdateResou resp.Diagnostics.AddError( "Error updating project domain", fmt.Sprintf("Could not update domain %s for project %s, unexpected error: %s", - state.Domain.Value, - state.ProjectID.Value, + plan.Domain.Value, + plan.ProjectID.Value, err, ), ) return } - result := convertResponseToProjectDomain(out, state.TeamID) + result := convertResponseToProjectDomain(out, plan.TeamID) tflog.Trace(ctx, "update project domain", map[string]interface{}{ "project_id": result.ProjectID.Value, "domain": result.Domain.Value, @@ -230,6 +223,7 @@ func (r resourceProjectDomain) Delete(ctx context.Context, req tfsdk.DeleteResou var apiErr client.APIError if err != nil && errors.As(err, &apiErr) && apiErr.StatusCode == 404 { // The domain is already gone - do nothing. + resp.State.RemoveResource(ctx) return } if err != nil { diff --git a/vercel/resource_project_domain_test.go b/vercel/resource_project_domain_test.go index 7ea3ab61..88339fe5 100644 --- a/vercel/resource_project_domain_test.go +++ b/vercel/resource_project_domain_test.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "os" "testing" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" @@ -20,7 +19,7 @@ func TestAcc_ProjectDomain(t *testing.T) { func TestAcc_ProjectDomainWithTeamID(t *testing.T) { t.Parallel() - testAccProjectDomain(t, os.Getenv("VERCEL_TERRAFORM_TESTING_TEAM")) + testAccProjectDomain(t, testTeam()) } func testAccProjectDomainExists(n, teamID, domain string) resource.TestCheckFunc { diff --git a/vercel/resource_project_test.go b/vercel/resource_project_test.go index f9003d1b..60815fad 100644 --- a/vercel/resource_project_test.go +++ b/vercel/resource_project_test.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "os" "testing" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" @@ -16,7 +15,7 @@ import ( func TestAcc_Project(t *testing.T) { tests := map[string]string{ "personal scope": "", - "team scope": os.Getenv("VERCEL_TERRAFORM_TESTING_TEAM"), + "team scope": testTeam(), } for name, teamID := range tests { diff --git a/vercel/validator_int64_greater_than.go b/vercel/validator_int64_greater_than.go new file mode 100644 index 00000000..b5cbf7e6 --- /dev/null +++ b/vercel/validator_int64_greater_than.go @@ -0,0 +1,47 @@ +package vercel + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func int64GreaterThan(val int64) validatorInt64GreaterThan { + return validatorInt64GreaterThan{ + Min: val, + } +} + +type validatorInt64GreaterThan struct { + Min int64 +} + +func (v validatorInt64GreaterThan) Description(ctx context.Context) string { + return fmt.Sprintf("Value must be greater than %d", v.Min) +} +func (v validatorInt64GreaterThan) MarkdownDescription(ctx context.Context) string { + return fmt.Sprintf("Value must be greater than `%d`", v.Min) +} + +func (v validatorInt64GreaterThan) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, resp *tfsdk.ValidateAttributeResponse) { + var item types.Int64 + diags := tfsdk.ValueAs(ctx, req.AttributeConfig, &item) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } + if item.Unknown || item.Null { + return + } + + if item.Value < v.Min { + resp.Diagnostics.AddAttributeError( + req.AttributePath, + "Invalid value provided", + fmt.Sprintf("Value must be greater than %d, got: %d.", v.Min, item.Value), + ) + return + } +} diff --git a/vercel/validator_int64_less_than.go b/vercel/validator_int64_less_than.go new file mode 100644 index 00000000..31bfbcdd --- /dev/null +++ b/vercel/validator_int64_less_than.go @@ -0,0 +1,47 @@ +package vercel + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func int64LessThan(val int64) validatorInt64LessThan { + return validatorInt64LessThan{ + Max: val, + } +} + +type validatorInt64LessThan struct { + Max int64 +} + +func (v validatorInt64LessThan) Description(ctx context.Context) string { + return fmt.Sprintf("Value must be less than %d", v.Max) +} +func (v validatorInt64LessThan) MarkdownDescription(ctx context.Context) string { + return fmt.Sprintf("Value must be less than `%d`", v.Max) +} + +func (v validatorInt64LessThan) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, resp *tfsdk.ValidateAttributeResponse) { + var item types.Int64 + diags := tfsdk.ValueAs(ctx, req.AttributeConfig, &item) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } + if item.Unknown || item.Null { + return + } + + if item.Value > v.Max { + resp.Diagnostics.AddAttributeError( + req.AttributePath, + "Invalid value provided", + fmt.Sprintf("Value must be less than %d, got: %d.", v.Max, item.Value), + ) + return + } +} diff --git a/vercel/validator_int64_items_in.go b/vercel/validator_int64_one_of.go similarity index 67% rename from vercel/validator_int64_items_in.go rename to vercel/validator_int64_one_of.go index e39814ef..fab38390 100644 --- a/vercel/validator_int64_items_in.go +++ b/vercel/validator_int64_one_of.go @@ -10,35 +10,35 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" ) -func int64ItemsIn(items ...int64) validatorInt64ItemsIn { +func int64OneOf(items ...int64) validatorInt64OneOf { itemMap := map[int64]struct{}{} for _, i := range items { itemMap[i] = struct{}{} } - return validatorInt64ItemsIn{ + return validatorInt64OneOf{ Items: itemMap, } } -type validatorInt64ItemsIn struct { +type validatorInt64OneOf struct { Items map[int64]struct{} } -func (v validatorInt64ItemsIn) keys() (out []string) { +func (v validatorInt64OneOf) keys() (out []string) { for k := range v.Items { out = append(out, strconv.Itoa(int(k))) } return } -func (v validatorInt64ItemsIn) Description(ctx context.Context) string { +func (v validatorInt64OneOf) Description(ctx context.Context) string { return fmt.Sprintf("Item must be one of %s", strings.Join(v.keys(), " ")) } -func (v validatorInt64ItemsIn) MarkdownDescription(ctx context.Context) string { +func (v validatorInt64OneOf) MarkdownDescription(ctx context.Context) string { return fmt.Sprintf("Item must be one of `%s`", strings.Join(v.keys(), "` `")) } -func (v validatorInt64ItemsIn) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, resp *tfsdk.ValidateAttributeResponse) { +func (v validatorInt64OneOf) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, resp *tfsdk.ValidateAttributeResponse) { var item types.Int64 diags := tfsdk.ValueAs(ctx, req.AttributeConfig, &item) resp.Diagnostics.Append(diags...)