From 365cb1ae422cd68320e1389b94ec48605915f0ba Mon Sep 17 00:00:00 2001 From: Douglas Parsons Date: Fri, 26 Aug 2022 17:12:25 +0100 Subject: [PATCH 1/3] Add a data source for prebuilt projects --- {glob => file}/glob.go | 2 +- {glob => file}/ignores.go | 4 +- file/prebuilt.go | 38 +++++ vercel/data_source_prebuilt_project.go | 216 ++++++++++++++++++++++++ vercel/data_source_project_directory.go | 6 +- vercel/provider.go | 5 +- vercel/resource_deployment.go | 65 ++++++- 7 files changed, 326 insertions(+), 10 deletions(-) rename {glob => file}/glob.go (98%) rename {glob => file}/ignores.go (91%) create mode 100644 file/prebuilt.go create mode 100644 vercel/data_source_prebuilt_project.go 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 91% rename from glob/ignores.go rename to file/ignores.go index 3d76e599..7b442508 100644 --- a/glob/ignores.go +++ b/file/ignores.go @@ -1,4 +1,4 @@ -package glob +package file import ( "bufio" @@ -39,6 +39,8 @@ var defaultIgnores = []string{ ".terraform*", "*.tfstate", "*.tfstate.backup", + // Make sure it's impossible to upload build-output as part of a deployment + ".vercel/output", } // GetIgnores is used to parse a .vercelignore file from a given directory, and diff --git a/file/prebuilt.go b/file/prebuilt.go new file mode 100644 index 00000000..6543be92 --- /dev/null +++ b/file/prebuilt.go @@ -0,0 +1,38 @@ +package file + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +type Builds struct { + Target string `json:"target"` + Error *struct{} `json:"error"` + Builds []struct { + Error *struct{} `json:"error"` + } `json:"builds"` +} + +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 +} + +func OutputDir(path string) string { + return filepath.Join(path, ".vercel", "output") +} + +func BuildsPath(path string) string { + return filepath.Join(path, ".vercel", "output", "builds.json") +} diff --git a/vercel/data_source_prebuilt_project.go b/vercel/data_source_prebuilt_project.go new file mode 100644 index 00000000..22aa6d13 --- /dev/null +++ b/vercel/data_source_prebuilt_project.go @@ -0,0 +1,216 @@ +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`", + 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.", + 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) +} + +type AddErrorer interface { + AddError(summary string, detail string) +} + +func isPrebuilt(path string) (bool, error) { + _, err := os.Stat(file.OutputDir(path)) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("unable to read .vercel/output: %w", err) + } + return true, nil +} + +func validatePrebuiltOutput(diags AddErrorer, path string) { + outputDir := file.OutputDir(path) + isPrebuilt, err := isPrebuilt(path) + if err != nil { + diags.AddError( + "Error reading prebuilt project", + fmt.Sprintf( + "An unexpected error occurred when reading the prebuilt directory: %s", + err, + ), + ) + return + } + if !isPrebuilt { + 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", + outputDir, + ), + ) + 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(file.BuildsPath(path)) + 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 + } + + validatePrebuiltOutput(&resp.Diagnostics, config.Path.Value) + if resp.Diagnostics.HasError() { + return + } + + config.Output = map[string]string{} + err := filepath.WalkDir( + filepath.Join(config.Path.Value, ".vercel", "output"), + 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_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/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, From 60f3e3b7d50b75fd74104869e52b0fb580541862 Mon Sep 17 00:00:00 2001 From: Douglas Parsons Date: Wed, 31 Aug 2022 11:49:16 +0100 Subject: [PATCH 2/3] Flesh out tests and docs for Prebuilt deployments --- docs/data-sources/prebuilt_project.md | 69 ++++++++++++++++++ docs/resources/deployment.md | 15 ++++ .../vercel_prebuilt_project/data-source.tf | 31 ++++++++ .../resources/vercel_deployment/resource.tf | 15 ++++ file/ignores.go | 2 - file/prebuilt.go | 9 --- vercel/data_source_file_test.go | 8 +- vercel/data_source_prebuilt_project.go | 46 ++++++------ vercel/data_source_prebuilt_project_test.go | 68 +++++++++++++++++ vercel/data_source_project_directory_test.go | 10 ++- .../examples/one/.vercel/output/builds.json | 6 ++ .../{example => examples/one}/.vercelignore | 0 vercel/{example => examples/one}/file2.html | 0 vercel/{example => examples/one}/index.html | 0 .../one}/windows_line_ending.png | Bin .../examples/two/.vercel/output/config.json | 3 + .../two/.vercel/output/static/index.html | 5 ++ vercel/resource_deployment_test.go | 43 ++++++++++- 18 files changed, 283 insertions(+), 47 deletions(-) create mode 100644 docs/data-sources/prebuilt_project.md create mode 100644 examples/data-sources/vercel_prebuilt_project/data-source.tf create mode 100644 vercel/data_source_prebuilt_project_test.go create mode 100644 vercel/examples/one/.vercel/output/builds.json rename vercel/{example => examples/one}/.vercelignore (100%) rename vercel/{example => examples/one}/file2.html (100%) rename vercel/{example => examples/one}/index.html (100%) rename vercel/{example => examples/one}/windows_line_ending.png (100%) create mode 100644 vercel/examples/two/.vercel/output/config.json create mode 100644 vercel/examples/two/.vercel/output/static/index.html 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/file/ignores.go b/file/ignores.go index 7b442508..301e6e21 100644 --- a/file/ignores.go +++ b/file/ignores.go @@ -39,8 +39,6 @@ var defaultIgnores = []string{ ".terraform*", "*.tfstate", "*.tfstate.backup", - // Make sure it's impossible to upload build-output as part of a deployment - ".vercel/output", } // GetIgnores is used to parse a .vercelignore file from a given directory, and diff --git a/file/prebuilt.go b/file/prebuilt.go index 6543be92..ad9e8334 100644 --- a/file/prebuilt.go +++ b/file/prebuilt.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "os" - "path/filepath" ) type Builds struct { @@ -28,11 +27,3 @@ func ReadBuildsJSON(path string) (builds Builds, err error) { return builds, err } - -func OutputDir(path string) string { - return filepath.Join(path, ".vercel", "output") -} - -func BuildsPath(path string) string { - return filepath.Join(path, ".vercel", "output", "builds.json") -} 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 index 22aa6d13..99b800ac 100644 --- a/vercel/data_source_prebuilt_project.go +++ b/vercel/data_source_prebuilt_project.go @@ -23,10 +23,16 @@ type dataSourcePrebuiltProjectType struct{} 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`", +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.", + 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, }, @@ -85,36 +91,25 @@ type AddErrorer interface { AddError(summary string, detail string) } -func isPrebuilt(path string) (bool, error) { - _, err := os.Stat(file.OutputDir(path)) - if os.IsNotExist(err) { - return false, nil - } - if err != nil { - return false, fmt.Errorf("unable to read .vercel/output: %w", err) - } - return true, nil -} - func validatePrebuiltOutput(diags AddErrorer, path string) { - outputDir := file.OutputDir(path) - isPrebuilt, err := isPrebuilt(path) - if err != nil { + outputDir := filepath.Join(path, ".vercel", "output") + _, err := os.Stat(outputDir) + if os.IsNotExist(err) { diags.AddError( - "Error reading prebuilt project", + "Error reading prebuilt output", fmt.Sprintf( - "An unexpected error occurred when reading the prebuilt directory: %s", - err, + "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 !isPrebuilt { + if err != nil { diags.AddError( - "Error reading prebuilt output", + "Error reading prebuilt project", 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", - outputDir, + "An unexpected error occurred when reading the prebuilt directory: %s", + err, ), ) return @@ -122,7 +117,7 @@ func validatePrebuiltOutput(diags AddErrorer, path string) { // 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(file.BuildsPath(path)) + 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 @@ -168,6 +163,7 @@ func (r dataSourcePrebuiltProject) Read(ctx context.Context, req datasource.Read return } + outputDir := filepath.Join(config.Path.Value, ".vercel", "output") validatePrebuiltOutput(&resp.Diagnostics, config.Path.Value) if resp.Diagnostics.HasError() { return @@ -175,7 +171,7 @@ func (r dataSourcePrebuiltProject) Read(ctx context.Context, req datasource.Read config.Output = map[string]string{} err := filepath.WalkDir( - filepath.Join(config.Path.Value, ".vercel", "output"), + outputDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err diff --git a/vercel/data_source_prebuilt_project_test.go b/vercel/data_source_prebuilt_project_test.go new file mode 100644 index 00000000..d2e75a95 --- /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: "19~e963e8b508fbae85b362afd1cd388c251fa24eee", + }, + ), + ), + }, + }, + }) +} + +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_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/resource_deployment_test.go b/vercel/resource_deployment_test.go index add4e781..2936c0af 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", ""), + ), + }, }, }) } @@ -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" { From 335e35eef6f62e0be5b325d038fa377317c21a34 Mon Sep 17 00:00:00 2001 From: Douglas Parsons Date: Wed, 31 Aug 2022 16:57:31 +0100 Subject: [PATCH 3/3] Some stylistic improvements --- client/dns_record_create.go | 2 +- client/dns_record_update.go | 3 +++ client/environment_variables_get.go | 1 + client/file_create.go | 1 + file/prebuilt.go | 3 +++ vercel/data_source_prebuilt_project.go | 1 + vercel/data_source_prebuilt_project_test.go | 2 +- vercel/resource_deployment_test.go | 4 ++-- 8 files changed, 13 insertions(+), 4 deletions(-) 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/file/prebuilt.go b/file/prebuilt.go index ad9e8334..632df11d 100644 --- a/file/prebuilt.go +++ b/file/prebuilt.go @@ -6,6 +6,8 @@ import ( "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"` @@ -14,6 +16,7 @@ type Builds struct { } `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 { diff --git a/vercel/data_source_prebuilt_project.go b/vercel/data_source_prebuilt_project.go index 99b800ac..04c205c0 100644 --- a/vercel/data_source_prebuilt_project.go +++ b/vercel/data_source_prebuilt_project.go @@ -87,6 +87,7 @@ func (r dataSourcePrebuiltProject) ValidateConfig(ctx context.Context, req datas 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) } diff --git a/vercel/data_source_prebuilt_project_test.go b/vercel/data_source_prebuilt_project_test.go index d2e75a95..c5722c16 100644 --- a/vercel/data_source_prebuilt_project_test.go +++ b/vercel/data_source_prebuilt_project_test.go @@ -35,7 +35,7 @@ func TestAcc_DataSourcePrebuiltProject(t *testing.T) { filepath.Join("output.examples", "two", ".vercel", "output", "config.json"), Checksums{ unix: "19~e963e8b508fbae85b362afd1cd388c251fa24eee", - windows: "19~e963e8b508fbae85b362afd1cd388c251fa24eee", + windows: "22~e18f9a96e9911f5cc7f9d0aa3948fd1e82cdd700", }, ), ), diff --git a/vercel/resource_deployment_test.go b/vercel/resource_deployment_test.go index 2936c0af..437fad5c 100644 --- a/vercel/resource_deployment_test.go +++ b/vercel/resource_deployment_test.go @@ -183,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 { @@ -217,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), ), }, {