Ilya Kiselev 2 days ago committed by GitHub
commit e726f182cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -23,6 +23,7 @@ import (
"os"
"path/filepath"
"syscall"
"time"
"github.com/Masterminds/semver/v3"
"golang.org/x/term"
@ -49,6 +50,15 @@ type Package struct {
Destination string
DependencyUpdate bool
// SourceDateEpoch, when non-nil, overrides all tar entry modification times
// in the produced chart archive, and also overwrites the "generated"
// timestamp in Chart.lock (if present) since that value is both the lock
// file's own tar entry modtime and part of its marshaled YAML content.
// 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 +131,10 @@ func (p *Package) Run(path string, _ map[string]any) (string, error) {
dest = p.Destination
}
if p.SourceDateEpoch != nil {
ch.StampModTimes(*p.SourceDateEpoch)
}
name, err := chartutil.Save(ch, dest)
if err != nil {
return "", fmt.Errorf("failed to save: %w", err)

@ -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"
@ -169,3 +173,98 @@ 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"
// Use UTC so the comparison with tar header ModTime (always UTC) is straightforward.
epoch := time.Unix(1700000000, 0).UTC()
client := NewPackage()
client.SourceDateEpoch = &epoch
filename, err := client.Run(chartPath, nil)
require.NoError(t, err)
t.Cleanup(func() { os.Remove(filename) })
f, err := os.Open(filename)
require.NoError(t, err)
defer f.Close()
// Check gzip header ModTime: helm leaves it at zero (deterministic by design).
gr, err := gzip.NewReader(f)
require.NoError(t, err)
require.True(t, gr.ModTime.IsZero(), "gzip header ModTime should be zero")
defer gr.Close()
// Use Equal() rather than require.Equal so that timezone differences between
// the stored epoch (UTC) and the value tar.Reader reconstructs do not cause
// a false failure on non-UTC machines.
tr := tar.NewReader(gr)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
require.NoError(t, err)
require.True(t, epoch.Equal(hdr.ModTime), "entry %s: got ModTime %v, want %v", hdr.Name, hdr.ModTime, epoch)
}
}
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")
}
func TestRunWithSourceDateEpochStampsLockGenerated(t *testing.T) {
chartPath := "testdata/charts/chart-with-lock"
epoch := time.Unix(1700000000, 0).UTC()
client := NewPackage()
client.SourceDateEpoch = &epoch
filename, err := client.Run(chartPath, nil)
require.NoError(t, err)
t.Cleanup(func() { os.Remove(filename) })
f, err := os.Open(filename)
require.NoError(t, err)
defer f.Close()
gr, err := gzip.NewReader(f)
require.NoError(t, err)
defer gr.Close()
found := false
tr := tar.NewReader(gr)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
require.NoError(t, err)
if path.Base(hdr.Name) == "Chart.lock" {
found = true
// Without stamping Lock.Generated, this entry would keep the
// fixture's original 2016 timestamp instead of the epoch.
require.True(t, epoch.Equal(hdr.ModTime), "Chart.lock entry: got ModTime %v, want %v", hdr.ModTime, epoch)
}
}
require.True(t, found, "expected archive to contain a Chart.lock entry")
}

@ -0,0 +1,3 @@
dependencies: []
digest: sha256:0000000000000000000000000000000000000000000000000000000000000
generated: "2016-01-01T00:00:00Z"

@ -0,0 +1,4 @@
apiVersion: v2
name: chart-with-lock
version: 0.1.0
description: Test chart with a Chart.lock, used to verify SourceDateEpoch stamping

@ -105,6 +105,37 @@ func (ch *Chart) Root() *Chart {
// Dependencies are the charts that this chart depends on.
func (ch *Chart) Dependencies() []*Chart { return ch.dependencies }
// StampModTimes recursively sets all file modification times in the chart and
// its dependencies to t. t is normalized to UTC and truncated to whole seconds
// because tar headers have second-level granularity and timezone-independent
// storage. This is used to produce reproducible archives when a build process
// supplies a fixed timestamp (e.g. via SOURCE_DATE_EPOCH).
//
// If Lock is set, its Generated field is also overwritten with t. This is not
// just a tar header change: Generated is marshaled into Chart.lock's own YAML
// content, so this stamping is visible to anyone who reads the lock file out
// of the produced archive.
func (ch *Chart) StampModTimes(t time.Time) {
t = t.UTC().Truncate(time.Second)
ch.ModTime = t
ch.SchemaModTime = t
if ch.Lock != nil {
ch.Lock.Generated = t
}
for _, f := range ch.Raw {
f.ModTime = t
}
for _, f := range ch.Templates {
f.ModTime = t
}
for _, f := range ch.Files {
f.ModTime = t
}
for _, dep := range ch.dependencies {
dep.StampModTimes(t)
}
}
// IsRoot determines if the chart is the root chart.
func (ch *Chart) IsRoot() bool { return ch.parent == nil }

@ -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")

@ -16,12 +16,16 @@ limitations under the License.
package cmd
import (
"archive/tar"
"compress/gzip"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"helm.sh/helm/v4/internal/test/ensure"
chart "helm.sh/helm/v4/pkg/chart/v2"
@ -192,6 +196,68 @@ func TestSetAppVersion(t *testing.T) {
}
}
func TestPackageSourceDateEpoch(t *testing.T) {
chartToPackage := "testdata/testcharts/alpine"
for _, tt := range []struct {
name string
envVal string
err string
}{
{name: "non-numeric value", envVal: "not-a-number", err: "invalid SOURCE_DATE_EPOCH"},
{name: "negative value", envVal: "-1", err: "invalid SOURCE_DATE_EPOCH"},
} {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
t.Setenv("SOURCE_DATE_EPOCH", tt.envVal)
cmd := fmt.Sprintf("package %s --destination=%s", chartToPackage, dir)
_, _, err := executeActionCommand(cmd)
if err == nil || !strings.Contains(err.Error(), tt.err) {
t.Fatalf("expected error containing %q, got %v", tt.err, err)
}
})
}
t.Run("valid value stamps archive entries", func(t *testing.T) {
dir := t.TempDir()
t.Setenv("SOURCE_DATE_EPOCH", "1700000000")
cmd := fmt.Sprintf("package %s --destination=%s", chartToPackage, dir)
_, output, err := executeActionCommand(cmd)
if err != nil {
t.Logf("Output: %s", output)
t.Fatal(err)
}
chartPath := filepath.Join(dir, "alpine-0.1.0.tgz")
f, err := os.Open(chartPath)
if err != nil {
t.Fatal(err)
}
defer f.Close()
gr, err := gzip.NewReader(f)
if err != nil {
t.Fatal(err)
}
defer gr.Close()
epoch := time.Unix(1700000000, 0).UTC()
tr := tar.NewReader(gr)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
if !epoch.Equal(hdr.ModTime) {
t.Errorf("entry %s: got ModTime %v, want %v", hdr.Name, hdr.ModTime, epoch)
}
}
})
}
func TestPackageFileCompletion(t *testing.T) {
checkFileCompletion(t, "package", true)
checkFileCompletion(t, "package mypath", true) // Multiple paths can be given

Loading…
Cancel
Save