Skip to content

Commit eec3cf0

Browse files
committed
Relax checksums for source directory builds, to stabilize source diffs across ephemeral Terraform runtimes
1 parent 7a7617f commit eec3cf0

File tree

2 files changed

+95
-28
lines changed

2 files changed

+95
-28
lines changed

heroku/resource_heroku_build.go

Lines changed: 94 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"net/http"
1111
"net/http/httputil"
1212
"os"
13+
"path/filepath"
1314
"regexp"
1415
"strings"
1516
"time"
@@ -188,6 +189,7 @@ func resourceHerokuBuildCreate(d *schema.ResourceData, meta interface{}) error {
188189
opts.Buildpacks = buildpacks
189190
}
190191

192+
var checksum string
191193
if v, ok := d.GetOk("source"); ok {
192194
vL := v.([]interface{})
193195

@@ -213,24 +215,31 @@ func resourceHerokuBuildCreate(d *schema.ResourceData, meta interface{}) error {
213215
if err != nil {
214216
return fmt.Errorf("Error stating build source path %s: %s", path, err)
215217
}
216-
217-
if fileInfo.IsDir() {
218+
// The checksum is "relaxed" for source directories, and not performed on the tarball, but instead purely filenames & contents.
219+
// This allows empemeral runtimes like Terraform Cloud to have a "stable" checksum for a source directory that will be cloned fresh each time.
220+
// The trade-off, is that the checksum is non-standard, and should not be passed to Heroku as a build parameter.
221+
useRelaxedChecksum := fileInfo.IsDir()
222+
if useRelaxedChecksum {
218223
// Generate tarball from the directory
219224
tarballPath, err = generateSourceTarball(path)
220225
if err != nil {
221226
return fmt.Errorf("Error generating build source tarball %s: %s", path, err)
222227
}
223228
defer cleanupSourceFile(tarballPath)
229+
checksum, err = checksumSourceRelaxed(path)
230+
if err != nil {
231+
return fmt.Errorf("Error calculating relaxed checksum for directory source %s: %s", path, err)
232+
}
224233
} else {
225234
// or simply use the path to the file
226235
tarballPath = path
236+
checksum, err = checksumSource(tarballPath)
237+
if err != nil {
238+
return fmt.Errorf("Error calculating checksum for tarball source %s: %s", tarballPath, err)
239+
}
227240
}
228241

229242
// Checksum, create, & upload source archive
230-
checksum, err := checksumSource(tarballPath)
231-
if err != nil {
232-
return fmt.Errorf("Error calculating checksum for build source %s: %s", tarballPath, err)
233-
}
234243
newSource, err := client.SourceCreate(context.TODO())
235244
if err != nil {
236245
return fmt.Errorf("Error creating source for build: %s", err)
@@ -240,7 +249,9 @@ func resourceHerokuBuildCreate(d *schema.ResourceData, meta interface{}) error {
240249
return fmt.Errorf("Error uploading source for build to %s: %s", newSource.SourceBlob.PutURL, err)
241250
}
242251
opts.SourceBlob.URL = &newSource.SourceBlob.GetURL
243-
opts.SourceBlob.Checksum = &checksum
252+
if !useRelaxedChecksum {
253+
opts.SourceBlob.Checksum = &checksum
254+
}
244255
} else if v, ok = sourceArg["url"]; ok && v != "" {
245256
s := v.(string)
246257
opts.SourceBlob.URL = &s
@@ -271,6 +282,8 @@ func resourceHerokuBuildCreate(d *schema.ResourceData, meta interface{}) error {
271282
}
272283

273284
d.SetId(build.ID)
285+
// Capture the checksum, to diff changes in the local source directory.
286+
d.Set("local_checksum", checksum)
274287

275288
build, err = client.BuildInfo(context.TODO(), app, build.ID)
276289
if err != nil {
@@ -321,31 +334,30 @@ func resourceHerokuBuildCustomizeDiff(ctx context.Context, diff *schema.Resource
321334
if err != nil {
322335
return fmt.Errorf("Error stating build source path %s: %s", path, err)
323336
}
324-
325-
if fileInfo.IsDir() {
326-
// To diff this generates a tarball of the source directory for calculating the current "local_checksum", same function call as in resourceHerokuBuildCreate
327-
tarballPath, err = generateSourceTarball(path)
337+
useRelaxedChecksum := fileInfo.IsDir()
338+
var realChecksum string
339+
if useRelaxedChecksum {
340+
realChecksum, err = checksumSourceRelaxed(path)
328341
if err != nil {
329-
return fmt.Errorf("Error generating build source tarball %s: %s", path, err)
342+
return fmt.Errorf("Error calculating relaxed checksum for directory source %s: %s", path, err)
330343
}
331-
defer cleanupSourceFile(tarballPath)
332344
} else {
333345
// or simply use the path to the file
334346
tarballPath = path
347+
realChecksum, err = checksumSource(tarballPath)
348+
if err != nil {
349+
return fmt.Errorf("Error calculating checksum for tarball source %s: %s", tarballPath, err)
350+
}
335351
}
336352

337-
// Calculate & diff the "local_checksum" SHA256
338-
realChecksum, err := checksumSource(tarballPath)
339-
if err == nil {
340-
oldChecksum, newChecksum := diff.GetChange("local_checksum")
341-
log.Printf("[DEBUG] Diffing source: old '%s', new '%s', real '%s'", oldChecksum, newChecksum, realChecksum)
342-
if newChecksum != realChecksum {
343-
if err := diff.SetNew("local_checksum", realChecksum); err != nil {
344-
return fmt.Errorf("Error updating source archive checksum: %s", err)
345-
}
346-
if err := diff.ForceNew("local_checksum"); err != nil {
347-
return fmt.Errorf("Error forcing new source resource: %s", err)
348-
}
353+
oldChecksum, newChecksum := diff.GetChange("local_checksum")
354+
log.Printf("[DEBUG] Diffing source: old '%s', new '%s', real '%s'", oldChecksum, newChecksum, realChecksum)
355+
if newChecksum != realChecksum {
356+
if err := diff.SetNew("local_checksum", realChecksum); err != nil {
357+
return fmt.Errorf("Error updating source archive checksum: %s", err)
358+
}
359+
if err := diff.ForceNew("local_checksum"); err != nil {
360+
return fmt.Errorf("Error forcing new source resource: %s", err)
349361
}
350362
}
351363
}
@@ -414,6 +426,63 @@ func checksumSource(filePath string) (string, error) {
414426
return checksum, nil
415427
}
416428

429+
func checksumSourceRelaxed(sourcePath string) (string, error) {
430+
hash := sha256.New()
431+
432+
info, err := os.Stat(sourcePath)
433+
if err != nil {
434+
return "", fmt.Errorf("Error stating source path '%s' for checksum %w", sourcePath, err)
435+
}
436+
437+
var baseDir string
438+
if info.IsDir() {
439+
baseDir = filepath.Base(sourcePath)
440+
}
441+
442+
err = filepath.Walk(sourcePath, func(path string, info os.FileInfo, walkErr error) error {
443+
var walkPath string
444+
if baseDir != "" {
445+
walkPath = filepath.ToSlash(filepath.Join(baseDir, strings.TrimPrefix(path, sourcePath)))
446+
} else {
447+
walkPath = info.Name()
448+
}
449+
450+
if walkErr != nil {
451+
return fmt.Errorf("Error walking '%s' for checksum: %w", walkPath, walkErr)
452+
}
453+
454+
// Write each path name to the hash, so that if things are renamed, they're ensured to change checksum.
455+
fmt.Fprint(hash, walkPath+"\n")
456+
log.Printf("[DEBUG] hash ← %s (name)", walkPath)
457+
458+
// Skip checksumming unless the file has data/contents.
459+
if info.IsDir() || !info.Mode().IsRegular() {
460+
return nil
461+
}
462+
463+
// Read the file into the hasher.
464+
file, err := os.Open(path)
465+
if err != nil {
466+
return fmt.Errorf("Error opening file '%s' for checksum: %w", info.Name(), err)
467+
}
468+
defer file.Close()
469+
b, err := io.Copy(hash, file)
470+
if err != nil {
471+
return fmt.Errorf("Error reading file '%s' for checksum: %w", info.Name(), err)
472+
}
473+
log.Printf("[DEBUG] hash ← %v (bytes)", b)
474+
475+
return nil
476+
})
477+
if err != nil {
478+
return "", err
479+
}
480+
481+
checksum := fmt.Sprintf("SHA256:%x", hash.Sum(nil))
482+
log.Printf("[DEBUG] hash sum → %v", checksum)
483+
return checksum, nil
484+
}
485+
417486
func setBuildState(d *schema.ResourceData, build *heroku.Build, appName string) error {
418487
d.Set("app", appName)
419488

@@ -448,8 +517,6 @@ func setBuildState(d *schema.ResourceData, build *heroku.Build, appName string)
448517
if v := build.SourceBlob.URL; v != "" {
449518
source["url"] = v
450519
}
451-
} else {
452-
d.Set("local_checksum", build.SourceBlob.Checksum)
453520
}
454521
if v := build.SourceBlob.Version; v != nil {
455522
source["version"] = *v

heroku/resource_heroku_build_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ func TestAccHerokuBuild_LocalSourceTarball_AllOpts(t *testing.T) {
153153
})
154154
}
155155

156-
func TestAccHerokuBuild_LocalSourceDirectory(t *testing.T) {
156+
func TestAccHerokuBuild_LocalSourceDirectoryDiff(t *testing.T) {
157157
var build, build2 heroku.Build
158158
var originalSourceChecksum string
159159
randString := acctest.RandString(10)

0 commit comments

Comments
 (0)