diff --git a/client/dns_record_create.go b/client/dns_record_create.go index 3843b512..e7c7635c 100644 --- a/client/dns_record_create.go +++ b/client/dns_record_create.go @@ -26,7 +26,7 @@ type CreateDNSRecordRequest struct { Value string `json:"value,omitempty"` } -// CreateProjectDomain creates a DNS record for a specified domain name within Vercel. +// CreateDNSRecord 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 != "" { diff --git a/client/dns_record_update.go b/client/dns_record_update.go index aa8c9d1b..be368587 100644 --- a/client/dns_record_update.go +++ b/client/dns_record_update.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/terraform-plugin-log/tflog" ) +// SRVUpdate defines the updatable fields within an SRV block of a DNS record. type SRVUpdate struct { Port *int64 `json:"port"` Priority *int64 `json:"priority"` @@ -16,6 +17,7 @@ type SRVUpdate struct { Weight *int64 `json:"weight"` } +// UpdateDNSRecordRequest defines the structure of the request body for updating a DNS record. type UpdateDNSRecordRequest struct { MXPriority *int64 `json:"mxPriority,omitempty"` Name *string `json:"name,omitempty"` @@ -24,6 +26,7 @@ type UpdateDNSRecordRequest struct { Value *string `json:"value,omitempty"` } +// UpdateDNSRecord updates a DNS record for a specified domain name within Vercel. 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 != "" { diff --git a/client/environment_variables_get.go b/client/environment_variables_get.go index 858a8cf1..bbee76f9 100644 --- a/client/environment_variables_get.go +++ b/client/environment_variables_get.go @@ -33,6 +33,7 @@ func (c *Client) getEnvironmentVariables(ctx context.Context, projectID, teamID return envResponse.Env, err } +// GetEnvironmentVariable gets a singluar environment variable from Vercel based on its ID. func (c *Client) GetEnvironmentVariable(ctx context.Context, projectID, teamID, envID string) (e EnvironmentVariable, err error) { url := fmt.Sprintf("%s/v1/projects/%s/env/%s", c.baseURL, projectID, envID) if teamID != "" { diff --git a/client/file_create.go b/client/file_create.go index 854d239c..c4dd8f6b 100644 --- a/client/file_create.go +++ b/client/file_create.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/terraform-plugin-log/tflog" ) +// CreateFileRequest defines the information needed to upload a file to Vercel. type CreateFileRequest struct { Filename string SHA string diff --git a/docs/data-sources/prebuilt_project.md b/docs/data-sources/prebuilt_project.md new file mode 100644 index 00000000..d48a8c1b --- /dev/null +++ b/docs/data-sources/prebuilt_project.md @@ -0,0 +1,69 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "vercel_prebuilt_project Data Source - terraform-provider-vercel" +subcategory: "" +description: |- + Provides the output of a project built via vercel build and provides metadata for use with a vercel_deployment + The build command https://vercel.com/docs/cli#commands/build can be used to build a project locally or in your own CI environment. + Build artifacts are placed into the .vercel/output directory according to the Build Output API https://vercel.com/docs/build-output-api/v3. + This allows a Vercel Deployment to be created without sharing the Project's source code with Vercel. +--- + +# vercel_prebuilt_project (Data Source) + +Provides the output of a project built via `vercel build` and provides metadata for use with a `vercel_deployment` + +The [build command](https://vercel.com/docs/cli#commands/build) can be used to build a project locally or in your own CI environment. +Build artifacts are placed into the `.vercel/output` directory according to the [Build Output API](https://vercel.com/docs/build-output-api/v3). + +This allows a Vercel Deployment to be created without sharing the Project's source code with Vercel. + +## Example Usage + +```terraform +# In this example, we are assuming that a nextjs UI exists in a `ui` directory +# and has been prebuilt via `vercel build`. +# We assume any terraform code exists in a separate `terraform` directory. +# E.g. +# ``` +# ui/ +# .vercel/ +# output/ +# ... +# src/ +# index.js +# package.json +# ... +# terraform/ +# main.tf +# ... +# ``` + +data "vercel_project" "example" { + name = "my-awesome-project" +} + +data "vercel_prebuilt_project" "example" { + path = "../ui" +} + +resource "vercel_deployment" "example" { + project_id = data.vercel_project.example.id + files = data.vercel_prebuilt_project.example.output + path_prefix = data.vercel_prebuilt_project.example.path +} +``` + + +## Schema + +### Required + +- `path` (String) The path to the project. Note that this path is relative to the root of your terraform files. This should be the directory that contains the `.vercel/output` directory. + +### Read-Only + +- `id` (String) The ID of this resource. +- `output` (Map of String) A map of output file to metadata about the file. The metadata contains the file size and hash, and allows a deployment to be created if the file changes. + + diff --git a/docs/resources/deployment.md b/docs/resources/deployment.md index 16d14daf..df00f6d0 100644 --- a/docs/resources/deployment.md +++ b/docs/resources/deployment.md @@ -78,6 +78,21 @@ resource "vercel_deployment" "git_example" { project_id = vercel_project.git_example.id ref = "d92f10e" # or a git branch } + +## Or deploying a prebuilt project +data "vercel_project" "prebuilt_example" { + name = "my-prebuilt-project" +} + +data "vercel_prebuilt_project" "prebuilt_example" { + path = "../ui" +} + +resource "vercel_deployment" "prebuilt_example" { + project_id = data.vercel_project.prebuilt_example.id + files = data.vercel_prebuilt_project.prebuilt_example.output + path_prefix = data.vercel_prebuilt_project.prebuilt_example.path +} ``` diff --git a/examples/data-sources/vercel_prebuilt_project/data-source.tf b/examples/data-sources/vercel_prebuilt_project/data-source.tf new file mode 100644 index 00000000..72c2359c --- /dev/null +++ b/examples/data-sources/vercel_prebuilt_project/data-source.tf @@ -0,0 +1,31 @@ +# In this example, we are assuming that a nextjs UI exists in a `ui` directory +# and has been prebuilt via `vercel build`. +# We assume any terraform code exists in a separate `terraform` directory. +# E.g. +# ``` +# ui/ +# .vercel/ +# output/ +# ... +# src/ +# index.js +# package.json +# ... +# terraform/ +# main.tf +# ... +# ``` + +data "vercel_project" "example" { + name = "my-awesome-project" +} + +data "vercel_prebuilt_project" "example" { + path = "../ui" +} + +resource "vercel_deployment" "example" { + project_id = data.vercel_project.example.id + files = data.vercel_prebuilt_project.example.output + path_prefix = data.vercel_prebuilt_project.example.path +} diff --git a/examples/resources/vercel_deployment/resource.tf b/examples/resources/vercel_deployment/resource.tf index 18439dd9..d2cec189 100644 --- a/examples/resources/vercel_deployment/resource.tf +++ b/examples/resources/vercel_deployment/resource.tf @@ -44,3 +44,18 @@ resource "vercel_deployment" "git_example" { project_id = vercel_project.git_example.id ref = "d92f10e" # or a git branch } + +## Or deploying a prebuilt project +data "vercel_project" "prebuilt_example" { + name = "my-prebuilt-project" +} + +data "vercel_prebuilt_project" "prebuilt_example" { + path = "../ui" +} + +resource "vercel_deployment" "prebuilt_example" { + project_id = data.vercel_project.prebuilt_example.id + files = data.vercel_prebuilt_project.prebuilt_example.output + path_prefix = data.vercel_prebuilt_project.prebuilt_example.path +} diff --git a/glob/glob.go b/file/glob.go similarity index 98% rename from glob/glob.go rename to file/glob.go index 26f3b183..8278f975 100644 --- a/glob/glob.go +++ b/file/glob.go @@ -1,4 +1,4 @@ -package glob +package file import ( "fmt" diff --git a/glob/ignores.go b/file/ignores.go similarity index 98% rename from glob/ignores.go rename to file/ignores.go index 3d76e599..301e6e21 100644 --- a/glob/ignores.go +++ b/file/ignores.go @@ -1,4 +1,4 @@ -package glob +package file import ( "bufio" diff --git a/file/prebuilt.go b/file/prebuilt.go new file mode 100644 index 00000000..632df11d --- /dev/null +++ b/file/prebuilt.go @@ -0,0 +1,32 @@ +package file + +import ( + "encoding/json" + "fmt" + "os" +) + +// Builds defines some of the information that can be contained within a builds.json file +// as part of the Build API output. +type Builds struct { + Target string `json:"target"` + Error *struct{} `json:"error"` + Builds []struct { + Error *struct{} `json:"error"` + } `json:"builds"` +} + +// ReadBuildsJSON will read a builds.json file and return the parsed content as a Builds struct. +func ReadBuildsJSON(path string) (builds Builds, err error) { + content, err := os.ReadFile(path) + if err != nil { + return builds, err + } + + err = json.Unmarshal(content, &builds) + if err != nil { + return builds, fmt.Errorf("could not parse file %s: %w", path, err) + } + + return builds, err +} diff --git a/vercel/data_source_file_test.go b/vercel/data_source_file_test.go index dbb56270..e3e4cba5 100644 --- a/vercel/data_source_file_test.go +++ b/vercel/data_source_file_test.go @@ -47,9 +47,9 @@ func TestAcc_DataSourceFile(t *testing.T) { { Config: testAccFileConfig(), Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("data.vercel_file.test", "path", "example/index.html"), - resource.TestCheckResourceAttr("data.vercel_file.test", "id", "example/index.html"), - testChecksum("data.vercel_file.test", "file.example/index.html", Checksums{ + resource.TestCheckResourceAttr("data.vercel_file.test", "path", "examples/one/index.html"), + resource.TestCheckResourceAttr("data.vercel_file.test", "id", "examples/one/index.html"), + testChecksum("data.vercel_file.test", "file.examples/one/index.html", Checksums{ unix: "60~9d3fedcc87ac72f54e75d4be7e06d0a6f8497e68", windows: "65~c0b8b91602dc7a394354cd9a21460ce2070b9a13", }), @@ -62,7 +62,7 @@ func TestAcc_DataSourceFile(t *testing.T) { func testAccFileConfig() string { return ` data "vercel_file" "test" { - path = "example/index.html" + path = "examples/one/index.html" } ` } diff --git a/vercel/data_source_prebuilt_project.go b/vercel/data_source_prebuilt_project.go new file mode 100644 index 00000000..04c205c0 --- /dev/null +++ b/vercel/data_source_prebuilt_project.go @@ -0,0 +1,213 @@ +package vercel + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "fmt" + "io/fs" + "os" + "path/filepath" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/vercel/terraform-provider-vercel/file" +) + +type dataSourcePrebuiltProjectType struct{} + +// GetSchema returns the schema information for a project directory data source +func (r dataSourcePrebuiltProjectType) GetSchema(_ context.Context) (tfsdk.Schema, diag.Diagnostics) { + return tfsdk.Schema{ + Description: ` +Provides the output of a project built via ` + "`vercel build`" + ` and provides metadata for use with a ` + "`vercel_deployment`" + ` + +The [build command](https://vercel.com/docs/cli#commands/build) can be used to build a project locally or in your own CI environment. +Build artifacts are placed into the ` + "`.vercel/output`" + ` directory according to the [Build Output API](https://vercel.com/docs/build-output-api/v3). + +This allows a Vercel Deployment to be created without sharing the Project's source code with Vercel. +`, + Attributes: map[string]tfsdk.Attribute{ + "path": { + Description: "The path to the project. Note that this path is relative to the root of your terraform files. This should be the directory that contains the `.vercel/output` directory.", + Required: true, + Type: types.StringType, + }, + "id": { + Computed: true, + Type: types.StringType, + }, + "output": { + Description: "A map of output file to metadata about the file. The metadata contains the file size and hash, and allows a deployment to be created if the file changes.", + Computed: true, + Type: types.MapType{ + ElemType: types.StringType, + }, + }, + }, + }, nil +} + +// NewDataSource instantiates a new DataSource of this DataSourceType. +func (r dataSourcePrebuiltProjectType) NewDataSource(ctx context.Context, p provider.Provider) (datasource.DataSource, diag.Diagnostics) { + return dataSourcePrebuiltProject{ + p: *(p.(*vercelProvider)), + }, nil +} + +type dataSourcePrebuiltProject struct { + p vercelProvider +} + +// PrebuiltProjectData represents the information terraform knows about a project directory data source +type PrebuiltProjectData struct { + Path types.String `tfsdk:"path"` + ID types.String `tfsdk:"id"` + Output map[string]string `tfsdk:"output"` +} + +func (r dataSourcePrebuiltProject) ValidateConfig(ctx context.Context, req datasource.ValidateConfigRequest, resp *datasource.ValidateConfigResponse) { + var config PrebuiltProjectData + diags := req.Config.Get(ctx, &config) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if config.Path.Unknown || config.Path.Null { + return + } + + // if we know the path, let's do a quick check for prebuilt output valid-ness. i.e. reading the output directory + // and ensuring no build errors. + // We want to validate this both here and in the Read method in case the field is Unknown at plan time. + validatePrebuiltOutput(&resp.Diagnostics, config.Path.Value) +} + +// AddErrorer defines an interface that contains the AddError method. Most commonly used with Diagnostics. +type AddErrorer interface { + AddError(summary string, detail string) +} + +func validatePrebuiltOutput(diags AddErrorer, path string) { + outputDir := filepath.Join(path, ".vercel", "output") + _, err := os.Stat(outputDir) + if os.IsNotExist(err) { + diags.AddError( + "Error reading prebuilt output", + fmt.Sprintf( + "A prebuilt project data source was used, but no prebuilt output was found in `%s`. Run `vercel build` to generate a local build", + path, + ), + ) + return + } + if err != nil { + diags.AddError( + "Error reading prebuilt project", + fmt.Sprintf( + "An unexpected error occurred when reading the prebuilt directory: %s", + err, + ), + ) + return + } + + // The .vercel/output/builds.json file may exist, and can contain information about failed builds. + // But it does not _have_ to exist, so we do not rely on its presence. + builds, err := file.ReadBuildsJSON(filepath.Join(outputDir, "builds.json")) + if os.IsNotExist(err) { + // It's okay to not have a builds.json file. So allow this. + return + } + if err != nil { + diags.AddError( + "Error reading prebuilt output", + fmt.Sprintf( + "An unexpected error occurred reading the prebuilt output builds.json: %s", + err, + ), + ) + return + } + + // The file exists so check if there are any failed builds. + containsError := builds.Error != nil + for _, build := range builds.Builds { + if build.Error != nil { + containsError = true + } + } + + if containsError { + diags.AddError( + "Prebuilt deployment cannot be used", + fmt.Sprintf( + "The prebuilt deployment at `%s` cannot be used because `vercel build` failed with an error", + path, + ), + ) + return + } +} + +// Read will recursively read files from a .vercel/output directory. Metadata about all these files will then be made +// available to terraform. +func (r dataSourcePrebuiltProject) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config PrebuiltProjectData + diags := req.Config.Get(ctx, &config) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + outputDir := filepath.Join(config.Path.Value, ".vercel", "output") + validatePrebuiltOutput(&resp.Diagnostics, config.Path.Value) + if resp.Diagnostics.HasError() { + return + } + + config.Output = map[string]string{} + err := filepath.WalkDir( + outputDir, + func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + + content, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("could not read file %s: %w", path, err) + } + + rawSha := sha1.Sum(content) + sha := hex.EncodeToString(rawSha[:]) + + config.Output[path] = fmt.Sprintf("%d~%s", len(content), sha) + return nil + }, + ) + if err != nil { + resp.Diagnostics.AddError( + "Error reading prebuilt output", + fmt.Sprintf( + "An unexpected error occurred reading files from the .vercel directory: %s", + err, + ), + ) + return + } + + config.ID = config.Path + diags = resp.State.Set(ctx, &config) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} diff --git a/vercel/data_source_prebuilt_project_test.go b/vercel/data_source_prebuilt_project_test.go new file mode 100644 index 00000000..c5722c16 --- /dev/null +++ b/vercel/data_source_prebuilt_project_test.go @@ -0,0 +1,68 @@ +package vercel_test + +import ( + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAcc_DataSourcePrebuiltProject(t *testing.T) { + t.Parallel() + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: prebuiltProjectNoOutput(), + ExpectError: regexp.MustCompile(strings.ReplaceAll(`A prebuilt project data source was used, but no prebuilt output was found in \x60.\x60.`, " ", `\s*`)), + }, + { + Config: prebuiltProjectFailedBuild(), + ExpectError: regexp.MustCompile( + strings.ReplaceAll(`The prebuilt deployment at \x60examples/one\x60 cannot be used because \x60vercel build\x60\s*failed with an error`, " ", `\s*`), + ), + }, + { + Config: prebuiltProjectValid(), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.vercel_prebuilt_project.test", "path", "examples/two"), + resource.TestCheckResourceAttr("data.vercel_prebuilt_project.test", "id", "examples/two"), + testChecksum( + "data.vercel_prebuilt_project.test", + filepath.Join("output.examples", "two", ".vercel", "output", "config.json"), + Checksums{ + unix: "19~e963e8b508fbae85b362afd1cd388c251fa24eee", + windows: "22~e18f9a96e9911f5cc7f9d0aa3948fd1e82cdd700", + }, + ), + ), + }, + }, + }) +} + +func prebuiltProjectNoOutput() string { + return ` +data "vercel_prebuilt_project" "test" { + path = "." +} +` +} + +func prebuiltProjectFailedBuild() string { + return ` +data "vercel_prebuilt_project" "test" { + path = "examples/one" +} +` +} + +func prebuiltProjectValid() string { + return ` +data "vercel_prebuilt_project" "test" { + path = "examples/two" +}` +} diff --git a/vercel/data_source_project_directory.go b/vercel/data_source_project_directory.go index 417767eb..ccae2409 100644 --- a/vercel/data_source_project_directory.go +++ b/vercel/data_source_project_directory.go @@ -12,7 +12,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/vercel/terraform-provider-vercel/glob" + "github.com/vercel/terraform-provider-vercel/file" ) type dataSourceProjectDirectoryType struct{} @@ -77,7 +77,7 @@ func (r dataSourceProjectDirectory) Read(ctx context.Context, req datasource.Rea return } - ignoreRules, err := glob.GetIgnores(config.Path.Value) + ignoreRules, err := file.GetIgnores(config.Path.Value) if err != nil { resp.Diagnostics.AddError( "Error reading .vercelignore file", @@ -88,7 +88,7 @@ func (r dataSourceProjectDirectory) Read(ctx context.Context, req datasource.Rea return } - paths, err := glob.GetPaths(config.Path.Value, ignoreRules) + paths, err := file.GetPaths(config.Path.Value, ignoreRules) if err != nil { resp.Diagnostics.AddError( "Error reading directory", diff --git a/vercel/data_source_project_directory_test.go b/vercel/data_source_project_directory_test.go index 2947de66..4f3cad60 100644 --- a/vercel/data_source_project_directory_test.go +++ b/vercel/data_source_project_directory_test.go @@ -16,8 +16,8 @@ func TestAcc_DataSourceProjectDirectory(t *testing.T) { { Config: testAccProjectDirectoryConfig(), Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("data.vercel_project_directory.test", "path", "example"), - testChecksum("data.vercel_project_directory.test", filepath.Join("files.example", "index.html"), Checksums{ + resource.TestCheckResourceAttr("data.vercel_project_directory.test", "path", "examples/one"), + testChecksum("data.vercel_project_directory.test", filepath.Join("files.examples", "one", "index.html"), Checksums{ unix: "60~9d3fedcc87ac72f54e75d4be7e06d0a6f8497e68", windows: "65~c0b8b91602dc7a394354cd9a21460ce2070b9a13", }), @@ -25,6 +25,10 @@ func TestAcc_DataSourceProjectDirectory(t *testing.T) { "data.vercel_project_directory.test", filepath.Join("files.example", "file2.html"), ), + resource.TestCheckNoResourceAttr( + "data.vercel_project_directory.test", + filepath.Join("files.example", ".vercel", "output", "builds.json"), + ), ), }, }, @@ -34,7 +38,7 @@ func TestAcc_DataSourceProjectDirectory(t *testing.T) { func testAccProjectDirectoryConfig() string { return ` data "vercel_project_directory" "test" { - path = "example" + path = "examples/one" } ` } diff --git a/vercel/examples/one/.vercel/output/builds.json b/vercel/examples/one/.vercel/output/builds.json new file mode 100644 index 00000000..01ca55e7 --- /dev/null +++ b/vercel/examples/one/.vercel/output/builds.json @@ -0,0 +1,6 @@ +{ + "error": { + "code": "BUILD_FAILED", + "message": "Build failed because of webpack errors" + } +} diff --git a/vercel/example/.vercelignore b/vercel/examples/one/.vercelignore similarity index 100% rename from vercel/example/.vercelignore rename to vercel/examples/one/.vercelignore diff --git a/vercel/example/file2.html b/vercel/examples/one/file2.html similarity index 100% rename from vercel/example/file2.html rename to vercel/examples/one/file2.html diff --git a/vercel/example/index.html b/vercel/examples/one/index.html similarity index 100% rename from vercel/example/index.html rename to vercel/examples/one/index.html diff --git a/vercel/example/windows_line_ending.png b/vercel/examples/one/windows_line_ending.png similarity index 100% rename from vercel/example/windows_line_ending.png rename to vercel/examples/one/windows_line_ending.png diff --git a/vercel/examples/two/.vercel/output/config.json b/vercel/examples/two/.vercel/output/config.json new file mode 100644 index 00000000..cd2f236b --- /dev/null +++ b/vercel/examples/two/.vercel/output/config.json @@ -0,0 +1,3 @@ +{ + "version": 3 +} diff --git a/vercel/examples/two/.vercel/output/static/index.html b/vercel/examples/two/.vercel/output/static/index.html new file mode 100644 index 00000000..047795bb --- /dev/null +++ b/vercel/examples/two/.vercel/output/static/index.html @@ -0,0 +1,5 @@ + + +

Hello, World

+ + diff --git a/vercel/provider.go b/vercel/provider.go index 3fe6fc19..f61193ed 100644 --- a/vercel/provider.go +++ b/vercel/provider.go @@ -47,20 +47,21 @@ func (p *vercelProvider) GetResources(_ context.Context) (map[string]provider.Re return map[string]provider.ResourceType{ "vercel_alias": resourceAliasType{}, "vercel_deployment": resourceDeploymentType{}, + "vercel_dns_record": resourceDNSRecordType{}, "vercel_project": resourceProjectType{}, "vercel_project_domain": resourceProjectDomainType{}, "vercel_project_environment_variable": resourceProjectEnvironmentVariableType{}, - "vercel_dns_record": resourceDNSRecordType{}, }, nil } // GetDataSources shows the available data sources for the vercel provider func (p *vercelProvider) GetDataSources(_ context.Context) (map[string]provider.DataSourceType, diag.Diagnostics) { return map[string]provider.DataSourceType{ + "vercel_alias": dataSourceAliasType{}, "vercel_file": dataSourceFileType{}, + "vercel_prebuilt_project": dataSourcePrebuiltProjectType{}, "vercel_project": dataSourceProjectType{}, "vercel_project_directory": dataSourceProjectDirectoryType{}, - "vercel_alias": dataSourceAliasType{}, }, nil } diff --git a/vercel/resource_deployment.go b/vercel/resource_deployment.go index 82a8ecff..d83a5faf 100644 --- a/vercel/resource_deployment.go +++ b/vercel/resource_deployment.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "os" + "path/filepath" + "strings" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/provider" @@ -13,6 +15,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/vercel/terraform-provider-vercel/client" + "github.com/vercel/terraform-provider-vercel/file" ) type resourceDeploymentType struct{} @@ -179,15 +182,66 @@ func (r resourceDeployment) ValidateConfig(ctx context.Context, req resource.Val "Deployment Invalid", "A Deployment cannot have both `ref` and `files` specified", ) + return } if config.Ref.Null && config.Files.Null { resp.Diagnostics.AddError( "Deployment Invalid", "A Deployment must have either `ref` or `files` specified", ) + return } } +func validatePrebuiltBuilds(diags AddErrorer, config Deployment, files []client.DeploymentFile) { + buildsFilePath, ok := getPrebuiltBuildsFile(files) + if !ok { + // It's okay to not have a builds.json file. So allow this. + return + } + + builds, err := file.ReadBuildsJSON(buildsFilePath) + if err != nil { + diags.AddError( + "Error reading prebuilt output", + fmt.Sprintf( + "An unexpected error occurred reading the prebuilt output builds.json: %s", + err, + ), + ) + return + } + + target := "preview" + if config.Production.Value { + target = "production" + } + + // Verify that the target matches what we hope the target is for the deployment. + if (builds.Target != "production" && target == "production") || + (builds.Target == "production" && target != "production") { + diags.AddError( + "Prebuilt deployment cannot be used", + fmt.Sprintf( + "The prebuilt deployment at `%s` was built with the target environment %s, but the deployment targets environment %s", + buildsFilePath, + builds.Target, + target, + ), + ) + return + } +} + +func getPrebuiltBuildsFile(files []client.DeploymentFile) (string, bool) { + for _, f := range files { + if strings.HasSuffix(f.File, filepath.Join(".vercel", "output", "builds.json")) { + return f.File, true + } + } + return "", false +} + // Create will create a deployment within Vercel. This is done by first attempting to trigger a deployment, seeing what // files are required, uploading those files, and then attempting to create a deployment again. // This is called automatically by the provider when a new resource should be created. @@ -226,10 +280,11 @@ func (r resourceDeployment) Create(ctx context.Context, req resource.CreateReque return } - target := "" - if plan.Production.Value { - target = "production" + validatePrebuiltBuilds(&resp.Diagnostics, plan, files) + if resp.Diagnostics.HasError() { + return } + var environment map[string]string diags = plan.Environment.ElementsAs(ctx, &environment, false) resp.Diagnostics.Append(diags...) @@ -237,6 +292,10 @@ func (r resourceDeployment) Create(ctx context.Context, req resource.CreateReque return } + target := "" + if plan.Production.Value { + target = "production" + } cdr := client.CreateDeploymentRequest{ Files: files, Environment: environment, diff --git a/vercel/resource_deployment_test.go b/vercel/resource_deployment_test.go index add4e781..437fad5c 100644 --- a/vercel/resource_deployment_test.go +++ b/vercel/resource_deployment_test.go @@ -88,6 +88,13 @@ func TestAcc_Deployment(t *testing.T) { resource.TestCheckResourceAttr("vercel_deployment.test", "production", "true"), ), }, + { + Config: deploymentWithPrebuiltProject(projectSuffix, teamIDConfig()), + Check: resource.ComposeAggregateTestCheckFunc( + testTeamID, + testAccDeploymentExists("vercel_deployment.test", ""), + ), + }, }, }) } @@ -176,7 +183,7 @@ func TestAcc_DeploymentWithDeleteOnDestroy(t *testing.T) { projectSuffix := acctest.RandString(16) extraConfig := "delete_on_destroy = true" deploymentID := "" - storeDeploymentId := func(n string, did *string) resource.TestCheckFunc { + storeDeploymentID := func(n string, did *string) resource.TestCheckFunc { return func(s *terraform.State) error { rs, ok := s.RootModule().Resources[n] if !ok { @@ -210,7 +217,7 @@ func TestAcc_DeploymentWithDeleteOnDestroy(t *testing.T) { Config: testAccDeploymentConfig(projectSuffix, teamIDConfig(), extraConfig), Check: resource.ComposeAggregateTestCheckFunc( testAccDeploymentExists("vercel_deployment.test", ""), - storeDeploymentId("vercel_deployment.test", &deploymentID), + storeDeploymentID("vercel_deployment.test", &deploymentID), ), }, { @@ -257,6 +264,34 @@ resource "vercel_project" "test" { `, projectSuffix, teamID) } +func deploymentWithPrebuiltProject(projectSuffix, teamID string) string { + return fmt.Sprintf(` +resource "vercel_project" "test" { + name = "test-acc-deployment-%[1]s" + %[2]s + environment = [ + { + key = "bar" + value = "baz" + target = ["preview"] + } + ] +} + +data "vercel_prebuilt_project" "test" { + path = "examples/two" +} + +resource "vercel_deployment" "test" { + project_id = vercel_project.test.id + %[2]s + + files = data.vercel_prebuilt_project.test.output + path_prefix = data.vercel_prebuilt_project.test.path +} +`, projectSuffix, teamID) +} + func testAccDeploymentConfig(projectSuffix, teamID, deploymentExtras string) string { return fmt.Sprintf(` resource "vercel_project" "test" { @@ -272,11 +307,11 @@ resource "vercel_project" "test" { } data "vercel_file" "index" { - path = "example/index.html" + path = "examples/one/index.html" } data "vercel_file" "windows_line_ending" { - path = "example/windows_line_ending.png" + path = "examples/one/windows_line_ending.png" } resource "vercel_deployment" "test" { @@ -302,7 +337,7 @@ resource "vercel_project" "test" { } data "vercel_file" "index" { - path = "../vercel/example/index.html" + path = "../vercel/examples/one/index.html" } resource "vercel_deployment" "test" { @@ -324,7 +359,7 @@ resource "vercel_project" "test" { } data "vercel_file" "index" { - path = "../vercel/example/index.html" + path = "../vercel/examples/one/index.html" } resource "vercel_deployment" "test" {