diff --git a/pkg/action/package_test.go b/pkg/action/package_test.go index 9984b508a..aac2eebd4 100644 --- a/pkg/action/package_test.go +++ b/pkg/action/package_test.go @@ -17,10 +17,18 @@ limitations under the License. package action import ( + "archive/tar" + "bytes" + "compress/gzip" "errors" + "io" "os" "path" + "strings" "testing" + "time" + + "sigs.k8s.io/yaml" "github.com/Masterminds/semver/v3" "github.com/stretchr/testify/assert" @@ -170,3 +178,69 @@ func TestRun(t *testing.T) { require.Equal(t, "empty-0.1.0.tgz", filename) require.NoError(t, os.Remove(filename)) } + +func TestRunWithSourceDateEpochAndLock(t *testing.T) { + tmp := t.TempDir() + epoch := time.Unix(1609459200, 0).UTC() + + // Build twice with the same SourceDateEpoch and assert byte-identical output. + var first []byte + for i := range 2 { + client := NewPackage() + client.Destination = tmp + client.SourceDateEpoch = &epoch + + dest, err := client.Run("testdata/charts/chart-with-lock", nil) + require.NoError(t, err) + + data, err := os.ReadFile(dest) + require.NoError(t, err) + + if i == 0 { + first = data + } else { + require.Equal(t, first, data, "two builds with the same SourceDateEpoch must be byte-identical") + } + require.NoError(t, os.Remove(dest)) + } + + // Open the archive and inspect Chart.lock content. + gz, err := gzip.NewReader(bytes.NewReader(first)) + require.NoError(t, err) + defer gz.Close() + + tr := tar.NewReader(gz) + var lockData []byte + var lockHdr *tar.Header + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + require.NoError(t, err) + if strings.HasSuffix(hdr.Name, "Chart.lock") { + lockHdr = hdr + var buf bytes.Buffer + _, err := io.Copy(&buf, tr) + require.NoError(t, err) + lockData = buf.Bytes() + break + } + } + require.NotNil(t, lockHdr, "Chart.lock not found in archive") + require.NotNil(t, lockData) + + // Validate the generated field in the YAML is the epoch (not the fixture's original timestamp). + var lock struct { + Generated string `yaml:"generated"` + } + err = yaml.Unmarshal(lockData, &lock) + require.NoError(t, err) + require.Equal(t, "2021-01-01T00:00:00Z", lock.Generated, + "Chart.lock generated field must match SourceDateEpoch") + + // Tar header ModTime must also match. + require.True(t, lockHdr.ModTime.Equal(epoch), + "Chart.lock tar header ModTime must match SourceDateEpoch: got %v, want %v", + lockHdr.ModTime, epoch) +} diff --git a/pkg/action/testdata/charts/chart-with-lock/Chart.lock b/pkg/action/testdata/charts/chart-with-lock/Chart.lock new file mode 100644 index 000000000..308e579a6 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-lock/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: nginx + repository: https://charts.bitnami.com/bitnami + version: 1.0.0 +digest: sha256:abc123def456 +generated: "2024-01-01T12:00:00Z" diff --git a/pkg/action/testdata/charts/chart-with-lock/Chart.yaml b/pkg/action/testdata/charts/chart-with-lock/Chart.yaml new file mode 100644 index 000000000..0f8129514 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-lock/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v2 +name: chart-with-lock +description: Test chart with Chart.lock for reproducibility testing +version: 0.1.0 diff --git a/pkg/action/testdata/charts/chart-with-lock/templates/empty.yaml b/pkg/action/testdata/charts/chart-with-lock/templates/empty.yaml new file mode 100644 index 000000000..c80812f6e --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-lock/templates/empty.yaml @@ -0,0 +1 @@ +# This file is intentionally blank diff --git a/pkg/chart/v2/chart.go b/pkg/chart/v2/chart.go index 9772754ce..65c594603 100644 --- a/pkg/chart/v2/chart.go +++ b/pkg/chart/v2/chart.go @@ -179,32 +179,33 @@ func (ch *Chart) CRDObjects() []CRD { // StampModTimes sets timestamps on the chart (and dependencies) to epoch. // This is used for reproducible builds via SOURCE_DATE_EPOCH. -func (ch *Chart) StampModTimes(epoch time.Time) { - ch.ModTime = epoch +func (ch *Chart) StampModTimes(t time.Time) { + t = t.UTC().Truncate(time.Second) + ch.ModTime = t if len(ch.Schema) > 0 { - ch.SchemaModTime = epoch + ch.SchemaModTime = t } if ch.Lock != nil { - ch.Lock.Generated = epoch + ch.Lock.Generated = t } for _, f := range ch.Raw { if f != nil { - f.ModTime = epoch + f.ModTime = t } } for _, f := range ch.Templates { if f != nil { - f.ModTime = epoch + f.ModTime = t } } for _, f := range ch.Files { if f != nil { - f.ModTime = epoch + f.ModTime = t } } for _, dep := range ch.Dependencies() { - dep.StampModTimes(epoch) + dep.StampModTimes(t) } }