From a77369e8e3b980fa787adf38c05e7c3d34f89077 Mon Sep 17 00:00:00 2001 From: Douglas Harcourt Parsons Date: Thu, 6 Mar 2025 16:27:02 +0000 Subject: [PATCH] Fix prebuilt deployment bugs There were three bugs with prebuilt deployments: - The path_prefix was incorrectly trimmed before attempting to read `.vercel/output/build.json`, which led to issues reading the file. - The logic for walking the output directory did not account for the `filePathMap` field in the `vc-config.json` files for functions, which indicate which additional files should be included in the deployment. - We resolved symlinks when walking the output directory, but to prevent uploading too many of the same files over and over, we should just upload these as a symlink directly. This is doubly relevant as there are many symlinks in the `filePathMap`. --- vercel/data_source_prebuilt_project.go | 152 ++++++++++++++++--------- vercel/resource_deployment.go | 79 +++++++++++-- 2 files changed, 170 insertions(+), 61 deletions(-) diff --git a/vercel/data_source_prebuilt_project.go b/vercel/data_source_prebuilt_project.go index 6d8f07a4..fda22682 100644 --- a/vercel/data_source_prebuilt_project.go +++ b/vercel/data_source_prebuilt_project.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha1" "encoding/hex" + "encoding/json" "fmt" "io/fs" "os" @@ -181,8 +182,9 @@ func (d *prebuiltProjectDataSource) Read(ctx context.Context, req datasource.Rea return } - outputDir := filepath.Join(config.Path.ValueString(), ".vercel", "output") - validatePrebuiltOutput(&resp.Diagnostics, config.Path.ValueString()) + projectPath := config.Path.ValueString() + outputDir := filepath.Join(projectPath, ".vercel", "output") + validatePrebuiltOutput(&resp.Diagnostics, projectPath) if resp.Diagnostics.HasError() { return } @@ -200,73 +202,48 @@ func (d *prebuiltProjectDataSource) Read(ctx context.Context, req datasource.Rea return fmt.Errorf("could not get file info for %s: %w", path, err) } - // If it's a symlink, resolve it - if info.Mode()&os.ModeSymlink != 0 { - dest, err := os.Readlink(path) - if err != nil { - return fmt.Errorf("could not read symlink %s: %w", path, err) - } + // Handle directories + if info.IsDir() { + return nil + } - // If the symlink is relative, make it absolute - if !filepath.IsAbs(dest) { - dest = filepath.Join(filepath.Dir(path), dest) - } + // Check if it's a symlink using Lstat + fileInfo, err := os.Lstat(path) + if err != nil { + return fmt.Errorf("could not lstat file %s: %w", path, err) + } - destInfo, err := os.Stat(dest) + if fileInfo.Mode()&os.ModeSymlink != 0 { + // It's a symlink - read the target path + linkTarget, err := os.Readlink(path) if err != nil { - return fmt.Errorf("could not stat symlink destination %s: %w", dest, err) - } - - if destInfo.IsDir() { - return filepath.WalkDir(dest, func(subPath string, subD fs.DirEntry, subErr error) error { - if subErr != nil { - return subErr - } - if subD.IsDir() { - return nil - } - content, err := os.ReadFile(subPath) - if err != nil { - return fmt.Errorf("could not read file %s: %w", subPath, err) - } - rawSha := sha1.Sum(content) - sha := hex.EncodeToString(rawSha[:]) - - // Get the subpath relative to the symlink's destination directory - subRelPath, err := filepath.Rel(dest, subPath) - if err != nil { - return fmt.Errorf("could not get relative path: %w", err) - } - - // Join it with the original symlink path - fullPath := filepath.Join(path, subRelPath) - config.Output[fullPath] = fmt.Sprintf("%d~%s", len(content), sha) - return nil - }) + return fmt.Errorf("could not read symlink %s: %w", path, err) } - // If it's a symlink to a file, read that file - content, err := os.ReadFile(dest) - if err != nil { - return fmt.Errorf("could not read symlinked file %s: %w", dest, err) - } - rawSha := sha1.Sum(content) + // Hash the link target string (just like Vercel does) + targetData := []byte(linkTarget) + rawSha := sha1.Sum(targetData) sha := hex.EncodeToString(rawSha[:]) - config.Output[path] = fmt.Sprintf("%d~%s", len(content), sha) - return nil - } - - // Handle regular files (non-symlinks) - if !info.IsDir() { + config.Output[path] = fmt.Sprintf("%d~%s", len(targetData), sha) + } else { + // Regular file - read and hash its content 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) } + // If it's a .vc-config.json file, process it immediately + if filepath.Base(path) == ".vc-config.json" { + if err := processVCConfigFile(path, projectPath, &config); err != nil { + return fmt.Errorf("error processing .vc-config.json at %s: %w", path, err) + } + } + return nil }, ) @@ -288,3 +265,70 @@ func (d *prebuiltProjectDataSource) Read(ctx context.Context, req datasource.Rea return } } + +// processVCConfigFile reads the .vc-config.json file and adds all files from filePathMap to the output +func processVCConfigFile(configPath, projectPath string, config *PrebuiltProjectData) error { + content, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("could not read .vc-config.json file: %w", err) + } + + var vcConfig struct { + FilePathMap map[string]string `json:"filePathMap"` + } + + if err := json.Unmarshal(content, &vcConfig); err != nil { + return fmt.Errorf("could not parse .vc-config.json: %w", err) + } + + // Process each file in the filePathMap + for filePath := range vcConfig.FilePathMap { + // Don't process if we've already added this file + if _, exists := config.Output[filePath]; exists { + continue + } + + // Make sure the path is absolute relative to the project + absPath := filePath + if !filepath.IsAbs(absPath) { + absPath = filepath.Join(projectPath, absPath) + } + + // Check if file exists + fileInfo, err := os.Lstat(absPath) // Use Lstat to not follow symlinks + if err != nil { + return fmt.Errorf("could not stat file %s referenced in filePathMap: %w", absPath, err) + } + + // Skip directories + if fileInfo.IsDir() { + continue + } + + if fileInfo.Mode()&os.ModeSymlink != 0 { + // It's a symlink - read the target path + linkTarget, err := os.Readlink(absPath) + if err != nil { + return fmt.Errorf("could not read symlink %s: %w", absPath, err) + } + + // Hash the link target string (just like Vercel does) + targetData := []byte(linkTarget) + rawSha := sha1.Sum(targetData) + sha := hex.EncodeToString(rawSha[:]) + config.Output[filePath] = fmt.Sprintf("%d~%s", len(targetData), sha) + } else { + // Regular file - read and hash its content + fileContent, err := os.ReadFile(absPath) + if err != nil { + return fmt.Errorf("could not read file %s referenced in filePathMap: %w", absPath, err) + } + + rawSha := sha1.Sum(fileContent) + sha := hex.EncodeToString(rawSha[:]) + config.Output[filePath] = fmt.Sprintf("%d~%s", len(fileContent), sha) + } + } + + return nil +} diff --git a/vercel/resource_deployment.go b/vercel/resource_deployment.go index 4c8bb10e..bb0ac321 100644 --- a/vercel/resource_deployment.go +++ b/vercel/resource_deployment.go @@ -270,6 +270,13 @@ func (p *ProjectSettings) fillNulls() *ProjectSettings { } } +func withSlashAtEndIfNeeded(path string) string { + if strings.HasSuffix(path, "/") { + return path + } + return fmt.Sprintf("%s/", path) +} + /* * The files uploaded to Vercel need to have some minor adjustments: * - Legacy behaviour was that any upward navigation ("../") was stripped from the @@ -291,13 +298,13 @@ func normaliseFilename(filename string, pathPrefix types.String) string { } } - return strings.TrimPrefix(filename, filepath.ToSlash(pathPrefix.ValueString())) + return strings.TrimPrefix(filename, withSlashAtEndIfNeeded(filepath.ToSlash(pathPrefix.ValueString()))) } // getFiles is a helper for turning the terraform deployment state into a set of client.DeploymentFile // structs, ready to hit the API with. It also returns a map of files by sha, which is used to quickly // look up any missing SHAs from the create deployment resposnse. -func getFiles(unparsedFiles map[string]string, pathPrefix types.String) ([]client.DeploymentFile, map[string]client.DeploymentFile, error) { +func getFiles(unparsedFiles map[string]string) ([]client.DeploymentFile, map[string]client.DeploymentFile, error) { var files []client.DeploymentFile filesBySha := map[string]client.DeploymentFile{} @@ -313,7 +320,7 @@ func getFiles(unparsedFiles map[string]string, pathPrefix types.String) ([]clien sha := sizeSha[1] file := client.DeploymentFile{ - File: normaliseFilename(filename, pathPrefix), + File: filename, Sha: sha, Size: size, } @@ -491,7 +498,7 @@ func (r *deploymentResource) Create(ctx context.Context, req resource.CreateRequ if resp.Diagnostics.HasError() { return } - files, filesBySha, err := getFiles(unparsedFiles, plan.PathPrefix) + files, filesBySha, err := getFiles(unparsedFiles) if err != nil { resp.Diagnostics.AddError( "Error creating deployment", @@ -516,6 +523,10 @@ func (r *deploymentResource) Create(ctx context.Context, req resource.CreateRequ if plan.Production.ValueBool() { target = "production" } + // normalise filenames. + for i := 0; i < len(files); i++ { + files[i].File = normaliseFilename(files[i].File, plan.PathPrefix) + } cdr := client.CreateDeploymentRequest{ Files: files, Environment: filterNullFromMap(environment), @@ -540,12 +551,14 @@ func (r *deploymentResource) Create(ctx context.Context, req resource.CreateRequ // Then we need to upload the files, and create the deployment again. for _, sha := range mfErr.Missing { f := filesBySha[sha] - content, err := os.ReadFile(f.File) + + // Get file info to check if it's a symlink + fileInfo, err := os.Lstat(f.File) if err != nil { resp.Diagnostics.AddError( - "Error reading file", + "Error checking file", fmt.Sprintf( - "Could not read file %s, unexpected error: %s", + "Could not get info for file %s, unexpected error: %s", f.File, err, ), @@ -553,11 +566,63 @@ func (r *deploymentResource) Create(ctx context.Context, req resource.CreateRequ return } + var content []byte + + if fileInfo.Mode()&os.ModeSymlink != 0 { + // It's a symlink - read the target path + linkTarget, err := os.Readlink(f.File) + if err != nil { + resp.Diagnostics.AddError( + "Error reading symlink", + fmt.Sprintf( + "Could not read symlink %s, unexpected error: %s", + f.File, + err, + ), + ) + return + } + // For symlinks, the content is the target path as string + err = r.client.CreateFile(ctx, client.CreateFileRequest{ + Filename: normaliseFilename(f.File, plan.PathPrefix), + SHA: f.Sha, + Content: linkTarget, // Just use the target path as content + TeamID: plan.TeamID.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError( + "Error uploading symlink file", + fmt.Sprintf( + "Could not upload symlink %s, unexpected error: %s", + f.File, + err, + ), + ) + return + } + } else { + // Regular file - read its content + content, err = os.ReadFile(f.File) + if err != nil { + resp.Diagnostics.AddError( + "Error reading file", + fmt.Sprintf( + "Could not read file %s, unexpected error: %s", + f.File, + err, + ), + ) + return + } + } + err = r.client.CreateFile(ctx, client.CreateFileRequest{ Filename: normaliseFilename(f.File, plan.PathPrefix), SHA: f.Sha, Content: string(content), TeamID: plan.TeamID.ValueString(), + // If we need to preserve the file mode, add it here + // Mode: uint32(fileInfo.Mode()), }) if err != nil { resp.Diagnostics.AddError(