Skip to content

Commit 9db6cc8

Browse files
committed
feat(carvel): generate unique bosh release names
Appends a SHA-256 content hash of .boshrelease inputs to the release version (e.g., 10.4.0+a1b2c3d4e5f6) so that different builds produce distinct versions preventing Artifactory collisions and BOSH Director upload errors feat(carvel bake): uses the release version for the destination tarball path
1 parent f6930c9 commit 9db6cc8

7 files changed

Lines changed: 213 additions & 26 deletions

File tree

internal/acceptance/carvel/carvel_workflow_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,10 +288,11 @@ var _ = Describe("carvel full workflow", Ordered, func() {
288288
Expect(lock.Releases).To(HaveLen(1))
289289
rel := lock.Releases[0]
290290
Expect(rel.Name).To(Equal("k8s-tile-test"))
291-
Expect(rel.Version).To(Equal("0.1.1"))
291+
Expect(rel.Version).To(HavePrefix("0.1.1+"))
292+
Expect(rel.Version).To(MatchRegexp(`^0\.1\.1\+[0-9a-f]{12}$`))
292293
Expect(rel.SHA1).NotTo(BeEmpty(), "lock must contain SHA1 of uploaded tarball")
293294
Expect(rel.RemoteSource).To(Equal("artifactory"))
294-
Expect(rel.RemotePath).To(Equal("bosh-releases/k8s-tile-test/k8s-tile-test-0.1.1.tgz"))
295+
Expect(rel.RemotePath).To(Equal("bosh-releases/k8s-tile-test/k8s-tile-test-" + rel.Version + ".tgz"))
295296

296297
gitInTile("add", "Kilnfile.lock")
297298
gitInTile("commit", "-m", "add Kilnfile.lock from upload")

internal/carvel/baker.go

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
package carvel
22

33
import (
4+
"crypto/sha256"
5+
"encoding/hex"
46
"errors"
57
"fmt"
68
"io"
9+
"io/fs"
710
"os"
811
"os/exec"
912
"path"
1013
"path/filepath"
1114
"regexp"
15+
"sort"
1216
"strings"
1317

1418
"github.com/pivotal-cf/kiln/internal/carvel/models"
@@ -25,7 +29,12 @@ type Baker interface {
2529
BakeFromLockfile(source string, releaseLock cargo.BOSHReleaseTarballLock, localTarball string) error
2630
KilnBake(destination string) error
2731
GetName() string
32+
// GetVersion returns the product version from base.yml or the version file.
2833
GetVersion() (string, error)
34+
// GetReleaseVersion returns the BOSH release version, which includes a
35+
// content fingerprint suffix (e.g., "10.4.0+a1b2c3d4e5f6"). Only valid
36+
// after Bake() or BakeFromLockfile() has been called.
37+
GetReleaseVersion() string
2938
GetReleaseTarball() (string, error)
3039
SetWriter(w io.Writer)
3140
SetProgressWriter(w io.Writer)
@@ -42,6 +51,7 @@ func NewBaker() Baker {
4251
type baker struct {
4352
metadata models.Metadata
4453
source, destination string
54+
releaseVersion string
4555
writer io.Writer
4656
progressWriter io.Writer
4757
}
@@ -138,6 +148,8 @@ func (b *baker) BakeFromLockfile(source string, releaseLock cargo.BOSHReleaseTar
138148
return fmt.Errorf("lockfile release name %q does not match tile name %q", releaseLock.Name, b.metadata.Name)
139149
}
140150

151+
b.releaseVersion = releaseLock.Version
152+
141153
err = os.RemoveAll(b.destination)
142154
if err != nil {
143155
return err
@@ -175,7 +187,7 @@ func (b *baker) BakeFromLockfile(source string, releaseLock cargo.BOSHReleaseTar
175187
return err
176188
}
177189

178-
destTarball := path.Join(releasesDir, b.metadata.Name+"-"+ver+".tgz")
190+
destTarball := path.Join(releasesDir, b.metadata.Name+"-"+releaseLock.Version+".tgz")
179191

180192
b.progress("Copying cached BOSH release from " + localTarball)
181193
b.log("copying cached BOSH release from " + localTarball)
@@ -188,11 +200,10 @@ func (b *baker) BakeFromLockfile(source string, releaseLock cargo.BOSHReleaseTar
188200
}
189201

190202
func (b *baker) GetReleaseTarball() (string, error) {
191-
ver, err := b.GetVersion()
192-
if err != nil {
193-
return "", err
203+
if b.releaseVersion == "" {
204+
return "", fmt.Errorf("release version not set -- call Bake() or BakeFromLockfile() first")
194205
}
195-
tarball := path.Join(b.destination, "releases", b.metadata.Name+"-"+ver+".tgz")
206+
tarball := path.Join(b.destination, "releases", b.metadata.Name+"-"+b.releaseVersion+".tgz")
196207
if _, err := os.Stat(tarball); err != nil {
197208
return "", fmt.Errorf("release tarball not found at %s: %w", tarball, err)
198209
}
@@ -203,6 +214,10 @@ func (b *baker) GetName() string {
203214
return b.metadata.Name
204215
}
205216

217+
func (b *baker) GetReleaseVersion() string {
218+
return b.releaseVersion
219+
}
220+
206221
func (b *baker) GetVersion() (string, error) {
207222
re := regexp.MustCompile(`\s+`)
208223

@@ -666,29 +681,91 @@ func (b *baker) createBoshRelease() error {
666681
return err
667682
}
668683

669-
version, err := b.GetVersion()
684+
productVersion, err := b.GetVersion()
670685
if err != nil {
671686
return err
672687
}
673688

674689
dirName := path.Join(b.source, ".boshrelease")
690+
691+
fingerprint, err := hashBoshReleaseInputs(dirName)
692+
if err != nil {
693+
return err
694+
}
695+
696+
releaseVersion := buildReleaseVersion(productVersion, fingerprint)
697+
b.releaseVersion = releaseVersion
698+
699+
finalTarball := path.Join(b.destination, "releases", b.metadata.Name+"-"+releaseVersion+".tgz")
675700
cmd := exec.Command("bosh",
676701
"create-release",
677702
"--dir="+dirName,
678703
"--force",
679704
"--name", b.metadata.Name,
680-
"--version", version,
681-
"--tarball", path.Join(b.destination, "releases", b.metadata.Name+"-"+version+".tgz"))
705+
"--version", releaseVersion,
706+
"--tarball", finalTarball)
682707
b.log("executing " + cmd.String())
683708
out, err := cmd.CombinedOutput()
684709
b.log("output: " + string(out))
685710
if err != nil {
686711
return err
687712
}
688713

714+
b.progress(fmt.Sprintf(" BOSH release version: %s", releaseVersion))
689715
return nil
690716
}
691717

718+
func hashBoshReleaseInputs(boshReleaseDir string) (string, error) {
719+
h := sha256.New()
720+
721+
var paths []string
722+
err := filepath.WalkDir(boshReleaseDir, func(p string, d fs.DirEntry, err error) error {
723+
if err != nil {
724+
return err
725+
}
726+
if d.IsDir() {
727+
if d.Name() == ".git" {
728+
return filepath.SkipDir
729+
}
730+
return nil
731+
}
732+
rel, err := filepath.Rel(boshReleaseDir, p)
733+
if err != nil {
734+
return err
735+
}
736+
paths = append(paths, rel)
737+
return nil
738+
})
739+
if err != nil {
740+
return "", fmt.Errorf("failed to walk .boshrelease directory: %w", err)
741+
}
742+
743+
sort.Strings(paths)
744+
745+
for _, rel := range paths {
746+
_, _ = fmt.Fprintf(h, "path:%s\n", rel)
747+
748+
f, err := os.Open(filepath.Join(boshReleaseDir, rel))
749+
if err != nil {
750+
return "", err
751+
}
752+
if _, err := io.Copy(h, f); err != nil {
753+
_ = f.Close()
754+
return "", err
755+
}
756+
_ = f.Close()
757+
}
758+
759+
return hex.EncodeToString(h.Sum(nil))[:12], nil
760+
}
761+
762+
func buildReleaseVersion(productVersion, fingerprint string) string {
763+
if strings.Contains(productVersion, "+") {
764+
return productVersion + "." + fingerprint
765+
}
766+
return productVersion + "+" + fingerprint
767+
}
768+
692769
func copyFileContents(src, dst string) (err error) {
693770
in, err := os.Open(src)
694771
if err != nil {

internal/carvel/baker_test.go

Lines changed: 108 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,15 @@ var _ = Describe("Carvel Baker", func() {
194194
Expect(filepath.Join(outputPath, "icon.png")).To(BeAnExistingFile())
195195
Expect(filepath.Join(outputPath, "version")).To(BeAnExistingFile())
196196
})
197-
It("Generates a bosh release tarball", func() {
198-
Expect(filepath.Join(outputPath, "releases", "k8s-tile-test-0.1.1.tgz")).To(BeAnExistingFile())
197+
It("generates a bosh release tarball with fingerprinted version", func() {
198+
releaseVersion := subject.GetReleaseVersion()
199+
Expect(releaseVersion).To(HavePrefix("0.1.1+"))
200+
Expect(releaseVersion).To(MatchRegexp(`^0\.1\.1\+[0-9a-f]{12}$`))
201+
Expect(filepath.Join(outputPath, "releases", "k8s-tile-test-"+releaseVersion+".tgz")).To(BeAnExistingFile())
202+
203+
tarball, err := subject.GetReleaseTarball()
204+
Expect(err).NotTo(HaveOccurred())
205+
Expect(tarball).To(ContainSubstring(releaseVersion))
199206
})
200207
It("does not generate a separate package-install job", func() {
201208
Expect(filepath.Join(boshReleasePath, "jobs", "package-install")).NotTo(BeADirectory())
@@ -347,9 +354,11 @@ var _ = Describe("Carvel Baker", func() {
347354
err = copyTestFile(tarball, cachedTarball)
348355
Expect(err).NotTo(HaveOccurred())
349356

357+
uploadReleaseVersion := subject.GetReleaseVersion()
358+
350359
releaseLock := cargo.BOSHReleaseTarballLock{
351360
Name: "k8s-tile-test",
352-
Version: "0.1.1",
361+
Version: uploadReleaseVersion,
353362
}
354363

355364
subject2 := NewBaker()
@@ -359,8 +368,9 @@ var _ = Describe("Carvel Baker", func() {
359368

360369
outputPath := path.Join(inputPath, ".carvel-tile")
361370
Expect(filepath.Join(outputPath, "base.yml")).To(BeAnExistingFile())
362-
Expect(filepath.Join(outputPath, "releases", "k8s-tile-test-0.1.1.tgz")).To(BeAnExistingFile())
371+
Expect(filepath.Join(outputPath, "releases", "k8s-tile-test-"+uploadReleaseVersion+".tgz")).To(BeAnExistingFile())
363372
Expect(filepath.Join(outputPath, "runtime_configs")).To(BeADirectory())
373+
Expect(subject2.GetReleaseVersion()).To(Equal(uploadReleaseVersion))
364374
})
365375
})
366376

@@ -436,7 +446,7 @@ var _ = Describe("Carvel Baker", func() {
436446

437447
releaseLock := cargo.BOSHReleaseTarballLock{
438448
Name: "k8s-tile-test",
439-
Version: "0.1.1",
449+
Version: uploadBaker.GetReleaseVersion(),
440450
}
441451

442452
publishBaker := NewBaker()
@@ -466,6 +476,99 @@ var _ = Describe("Carvel Baker", func() {
466476
})
467477
})
468478

479+
Context("hashBoshReleaseInputs", func() {
480+
It("is deterministic", func() {
481+
dir, err := os.MkdirTemp("", "hash-test-*")
482+
Expect(err).NotTo(HaveOccurred())
483+
defer func() { _ = os.RemoveAll(dir) }()
484+
485+
Expect(os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello"), 0644)).To(Succeed())
486+
Expect(os.MkdirAll(filepath.Join(dir, "sub"), 0755)).To(Succeed())
487+
Expect(os.WriteFile(filepath.Join(dir, "sub", "b.txt"), []byte("world"), 0644)).To(Succeed())
488+
489+
h1, err := hashBoshReleaseInputs(dir)
490+
Expect(err).NotTo(HaveOccurred())
491+
h2, err := hashBoshReleaseInputs(dir)
492+
Expect(err).NotTo(HaveOccurred())
493+
494+
Expect(h1).To(Equal(h2))
495+
Expect(h1).To(HaveLen(12))
496+
})
497+
498+
It("changes when file contents change", func() {
499+
dir, err := os.MkdirTemp("", "hash-test-*")
500+
Expect(err).NotTo(HaveOccurred())
501+
defer func() { _ = os.RemoveAll(dir) }()
502+
503+
Expect(os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello"), 0644)).To(Succeed())
504+
505+
h1, err := hashBoshReleaseInputs(dir)
506+
Expect(err).NotTo(HaveOccurred())
507+
508+
Expect(os.WriteFile(filepath.Join(dir, "a.txt"), []byte("changed"), 0644)).To(Succeed())
509+
510+
h2, err := hashBoshReleaseInputs(dir)
511+
Expect(err).NotTo(HaveOccurred())
512+
513+
Expect(h1).NotTo(Equal(h2))
514+
})
515+
516+
It("changes when a file is renamed", func() {
517+
dir, err := os.MkdirTemp("", "hash-test-*")
518+
Expect(err).NotTo(HaveOccurred())
519+
defer func() { _ = os.RemoveAll(dir) }()
520+
521+
Expect(os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello"), 0644)).To(Succeed())
522+
523+
h1, err := hashBoshReleaseInputs(dir)
524+
Expect(err).NotTo(HaveOccurred())
525+
526+
Expect(os.Rename(filepath.Join(dir, "a.txt"), filepath.Join(dir, "b.txt"))).To(Succeed())
527+
528+
h2, err := hashBoshReleaseInputs(dir)
529+
Expect(err).NotTo(HaveOccurred())
530+
531+
Expect(h1).NotTo(Equal(h2))
532+
})
533+
534+
It("excludes .git directory", func() {
535+
dir, err := os.MkdirTemp("", "hash-test-*")
536+
Expect(err).NotTo(HaveOccurred())
537+
defer func() { _ = os.RemoveAll(dir) }()
538+
539+
Expect(os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello"), 0644)).To(Succeed())
540+
Expect(os.MkdirAll(filepath.Join(dir, ".git", "objects"), 0755)).To(Succeed())
541+
Expect(os.WriteFile(filepath.Join(dir, ".git", "HEAD"), []byte("ref: refs/heads/main"), 0644)).To(Succeed())
542+
543+
h1, err := hashBoshReleaseInputs(dir)
544+
Expect(err).NotTo(HaveOccurred())
545+
546+
Expect(os.WriteFile(filepath.Join(dir, ".git", "HEAD"), []byte("ref: refs/heads/other"), 0644)).To(Succeed())
547+
548+
h2, err := hashBoshReleaseInputs(dir)
549+
Expect(err).NotTo(HaveOccurred())
550+
551+
Expect(h1).To(Equal(h2))
552+
})
553+
})
554+
555+
Context("buildReleaseVersion", func() {
556+
It("appends fingerprint with + separator", func() {
557+
Expect(buildReleaseVersion("10.4.0", "a1b2c3d4e5f6")).To(Equal("10.4.0+a1b2c3d4e5f6"))
558+
})
559+
560+
It("appends fingerprint with . separator when version already contains +", func() {
561+
Expect(buildReleaseVersion("10.4.0+beta.1", "a1b2c3d4e5f6")).To(Equal("10.4.0+beta.1.a1b2c3d4e5f6"))
562+
})
563+
})
564+
565+
Context("GetReleaseVersion", func() {
566+
It("returns empty string before Bake is called", func() {
567+
subject := NewBaker()
568+
Expect(subject.GetReleaseVersion()).To(BeEmpty())
569+
})
570+
})
571+
469572
Context("generateManifestTemplate with different entry names", func() {
470573
It("parameterizes the entry name throughout the template", func() {
471574
template := generateManifestTemplate("my-custom-pkg")

internal/commands/carvel_publish_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ var _ = Describe("CarvelPublish", func() {
113113
Expect(err).NotTo(HaveOccurred())
114114
tarballData, err := os.ReadFile(tarball)
115115
Expect(err).NotTo(HaveOccurred())
116+
releaseVersion := b.GetReleaseVersion()
116117

117118
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
118119
key := strings.TrimPrefix(r.URL.Path, "/artifactory")
@@ -138,7 +139,7 @@ var _ = Describe("CarvelPublish", func() {
138139
}))
139140

140141
// Pre-load mock with the tarball (simulating a prior upload)
141-
remotePath := "/test-repo/bosh-releases/k8s-tile-test/k8s-tile-test-0.1.1.tgz"
142+
remotePath := "/test-repo/bosh-releases/k8s-tile-test/k8s-tile-test-" + releaseVersion + ".tgz"
142143
blobs[remotePath] = tarballData
143144

144145
kf := cargo.Kilnfile{
@@ -158,8 +159,8 @@ var _ = Describe("CarvelPublish", func() {
158159
lock := cargo.KilnfileLock{
159160
Releases: []cargo.BOSHReleaseTarballLock{{
160161
Name: "k8s-tile-test",
161-
Version: "0.1.1",
162-
RemotePath: "bosh-releases/k8s-tile-test/k8s-tile-test-0.1.1.tgz",
162+
Version: releaseVersion,
163+
RemotePath: "bosh-releases/k8s-tile-test/k8s-tile-test-" + releaseVersion + ".tgz",
163164
RemoteSource: "artifactory",
164165
}},
165166
Stemcell: cargo.Stemcell{OS: "ubuntu-jammy", Version: "1.446"},

internal/commands/carvel_rebake_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ var _ = Describe("CarvelReBake", func() {
165165
Expect(err).NotTo(HaveOccurred())
166166
tarballData, err := os.ReadFile(tarball)
167167
Expect(err).NotTo(HaveOccurred())
168+
releaseVersion := b.GetReleaseVersion()
168169

169170
var (
170171
mu sync.Mutex
@@ -193,7 +194,7 @@ var _ = Describe("CarvelReBake", func() {
193194
}))
194195

195196
// Pre-load the mock with the tarball at the expected path
196-
remotePath := "/test-repo/bosh-releases/k8s-tile-test/k8s-tile-test-0.1.1.tgz"
197+
remotePath := "/test-repo/bosh-releases/k8s-tile-test/k8s-tile-test-" + releaseVersion + ".tgz"
197198
blobs[remotePath] = tarballData
198199

199200
kf := cargo.Kilnfile{
@@ -213,8 +214,8 @@ var _ = Describe("CarvelReBake", func() {
213214
lock := cargo.KilnfileLock{
214215
Releases: []cargo.BOSHReleaseTarballLock{{
215216
Name: "k8s-tile-test",
216-
Version: "0.1.1",
217-
RemotePath: "bosh-releases/k8s-tile-test/k8s-tile-test-0.1.1.tgz",
217+
Version: releaseVersion,
218+
RemotePath: "bosh-releases/k8s-tile-test/k8s-tile-test-" + releaseVersion + ".tgz",
218219
RemoteSource: "artifactory",
219220
}},
220221
Stemcell: cargo.Stemcell{OS: "ubuntu-jammy", Version: "1.446"},

0 commit comments

Comments
 (0)