feat(package): support SOURCE_DATE_EPOCH for reproducible chart archives

Read SOURCE_DATE_EPOCH in the CLI layer (pkg/cmd/package.go) and pass it
to the action layer as Package.SourceDateEpoch *time.Time. Before calling
Save(), stampModTimes() overwrites every tar-entry ModTime in the chart
(including templates, files, raw values, schema and sub-charts) with the
requested epoch.

writeToTar and Save are not modified: the caller now supplies the epoch as
the modTime argument, which is how mattfarina's review (#32060) asked for
this to be structured.

Closes #31439

Signed-off-by: Ilya Kiselev <kis-ilya-a@yandex.ru>
pull/32173/head
Ilya Kiselev 4 months ago
parent ccb8f595ab
commit 8424d9f21d

@ -50,6 +50,12 @@ type Package struct {
Destination string
DependencyUpdate bool
// SourceDateEpoch, when non-nil, overrides all tar entry modification times
// in the produced chart archive. Set by callers that want reproducible builds.
// The environment variable SOURCE_DATE_EPOCH is the conventional way to supply
// this value from the CLI; reading that variable is the CLI's responsibility.
SourceDateEpoch *time.Time
RepositoryConfig string
RepositoryCache string
PlainHTTP bool
@ -59,8 +65,6 @@ type Package struct {
KeyFile string
CaFile string
InsecureSkipTLSVerify bool
// SourceDateEpoch, when set, normalizes chart timestamps for reproducible archives.
SourceDateEpoch *time.Time
}
const (
@ -128,6 +132,10 @@ func (p *Package) Run(path string, _ map[string]any) (string, error) {
dest = p.Destination
}
if p.SourceDateEpoch != nil {
stampModTimes(ch, *p.SourceDateEpoch)
}
name, err := chartutil.Save(ch, dest)
if err != nil {
return "", fmt.Errorf("failed to save: %w", err)
@ -261,3 +269,22 @@ func openPassphraseFile(passphraseFile string, stdin *os.File) (*os.File, error)
}
return os.Open(passphraseFile)
}
// stampModTimes recursively sets all file modification times in a chart to t.
// This is used to produce reproducible archives when SourceDateEpoch is set.
func stampModTimes(c *chart.Chart, t time.Time) {
c.ModTime = t
c.SchemaModTime = t
for _, f := range c.Raw {
f.ModTime = t
}
for _, f := range c.Templates {
f.ModTime = t
}
for _, f := range c.Files {
f.ModTime = t
}
for _, dep := range c.Dependencies() {
stampModTimes(dep, t)
}
}

@ -17,10 +17,14 @@ limitations under the License.
package action
import (
"archive/tar"
"compress/gzip"
"errors"
"io"
"os"
"path"
"testing"
"time"
"github.com/Masterminds/semver/v3"
"github.com/stretchr/testify/assert"
@ -170,3 +174,56 @@ func TestRun(t *testing.T) {
require.Equal(t, "empty-0.1.0.tgz", filename)
require.NoError(t, os.Remove(filename))
}
func TestRunWithSourceDateEpoch(t *testing.T) {
chartPath := "testdata/charts/chart-with-schema"
epoch := time.Unix(1700000000, 0)
client := NewPackage()
client.SourceDateEpoch = &epoch
filename, err := client.Run(chartPath, nil)
require.NoError(t, err)
t.Cleanup(func() { os.Remove(filename) })
// All tar entry ModTimes must equal the epoch.
f, err := os.Open(filename)
require.NoError(t, err)
defer f.Close()
gr, err := gzip.NewReader(f)
require.NoError(t, err)
defer gr.Close()
tr := tar.NewReader(gr)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
require.NoError(t, err)
require.Equal(t, epoch, hdr.ModTime, "expected epoch ModTime for entry %s", hdr.Name)
}
}
func TestRunWithSourceDateEpochReproducible(t *testing.T) {
chartPath := "testdata/charts/chart-with-schema"
epoch := time.Unix(1700000000, 0)
build := func() []byte {
t.Helper()
dir := t.TempDir()
client := NewPackage()
client.SourceDateEpoch = &epoch
client.Destination = dir
filename, err := client.Run(chartPath, nil)
require.NoError(t, err)
data, err := os.ReadFile(filename)
require.NoError(t, err)
return data
}
first := build()
second := build()
require.Equal(t, first, second, "two builds with the same SOURCE_DATE_EPOCH must be byte-identical")
}

Loading…
Cancel
Save