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 1 month ago
parent d374e823b2
commit 1df209e084

@ -23,6 +23,7 @@ import (
"os"
"path/filepath"
"syscall"
"time"
"github.com/Masterminds/semver/v3"
"golang.org/x/term"
@ -49,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
@ -121,6 +128,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)
@ -254,3 +265,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/require"
@ -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")
}

@ -22,6 +22,8 @@ import (
"io"
"os"
"path/filepath"
"strconv"
"time"
"github.com/spf13/cobra"
@ -59,6 +61,14 @@ func newPackageCmd(out io.Writer) *cobra.Command {
if len(args) == 0 {
return errors.New("need at least one argument, the path to the chart")
}
if epochStr, ok := os.LookupEnv("SOURCE_DATE_EPOCH"); ok {
sec, err := strconv.ParseInt(epochStr, 10, 64)
if err != nil || sec < 0 {
return fmt.Errorf("invalid SOURCE_DATE_EPOCH %q: must be a non-negative Unix timestamp", epochStr)
}
t := time.Unix(sec, 0)
client.SourceDateEpoch = &t
}
if client.Sign {
if client.Key == "" {
return errors.New("--key is required for signing a package")

Loading…
Cancel
Save