feat: honor SOURCE_DATE_EPOCH for chart archives

Parse SOURCE_DATE_EPOCH in CLI commands and pass it through action.Package
and downloader.Manager so library code stays free of environment reads.
Invalid values are rejected at the CLI instead of being silently ignored.

Signed-off-by: Lohit Kolluri <lohitkolluri@gmail.com>
pull/32162/head
Lohit Kolluri 6 days ago
parent 4dec37abd2
commit 3e3c7a4ca7
No known key found for this signature in database

@ -182,6 +182,55 @@ func TestSavePreservesTimestamps(t *testing.T) {
}
}
func TestSaveWithSourceDateEpoch(t *testing.T) {
epoch, err := ParseSourceDateEpochValue("1609459200")
if err != nil {
t.Fatalf("ParseSourceDateEpochValue() error: %v", err)
}
tmp := t.TempDir()
c := &chart.Chart{
Metadata: &chart.Metadata{
APIVersion: chart.APIVersionV3,
Name: "ahab",
Version: "1.2.3",
},
Files: []*common.File{
{Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")},
},
Schema: []byte("{\n \"title\": \"Values\"\n}"),
}
ApplySourceDateEpoch(c, epoch)
where, err := Save(c, tmp)
if err != nil {
t.Fatalf("Failed to save: %s", err)
}
allHeaders, err := retrieveAllHeadersFromTar(where)
if err != nil {
t.Fatalf("Failed to parse tar: %v", err)
}
expected := epoch.Round(time.Second)
for _, header := range allHeaders {
if !header.ModTime.Equal(expected) {
t.Fatalf("Expected SOURCE_DATE_EPOCH timestamp %v, got %v for %q", expected, header.ModTime, header.Name)
}
}
}
func findHeader(t *testing.T, headers []*tar.Header, name string) *tar.Header {
t.Helper()
for _, h := range headers {
if h.Name == name {
return h
}
}
t.Fatalf("Could not find tar header %q", name)
return nil
}
// We could refactor `load.go` to use this `retrieveAllHeadersFromTar` function
// as well, so we are not duplicating components of the code which iterate
// through the tar.

@ -0,0 +1,54 @@
package util
import (
"strconv"
"time"
chart "helm.sh/helm/v4/internal/chart/v3"
)
// ParseSourceDateEpochValue parses SOURCE_DATE_EPOCH per https://reproducible-builds.org/docs/source-date-epoch/.
func ParseSourceDateEpochValue(epochStr string) (time.Time, error) {
epoch, err := strconv.ParseInt(epochStr, 10, 64)
if err != nil {
return time.Time{}, err
}
if epoch < 0 {
return time.Time{}, strconv.ErrRange
}
return time.Unix(epoch, 0).UTC(), nil
}
// ApplySourceDateEpoch sets timestamps on the chart (and dependencies) to epoch.
func ApplySourceDateEpoch(c *chart.Chart, epoch time.Time) {
applySourceDateEpoch(c, epoch)
}
func applySourceDateEpoch(c *chart.Chart, epoch time.Time) {
c.ModTime = epoch
if len(c.Schema) > 0 {
c.SchemaModTime = epoch
}
if c.Lock != nil {
c.Lock.Generated = epoch
}
for _, f := range c.Raw {
if f != nil {
f.ModTime = epoch
}
}
for _, f := range c.Templates {
if f != nil {
f.ModTime = epoch
}
}
for _, f := range c.Files {
if f != nil {
f.ModTime = epoch
}
}
for _, dep := range c.Dependencies() {
applySourceDateEpoch(dep, epoch)
}
}

@ -23,6 +23,7 @@ import (
"os"
"path/filepath"
"syscall"
"time"
"github.com/Masterminds/semver/v3"
"golang.org/x/term"
@ -58,6 +59,8 @@ type Package struct {
KeyFile string
CaFile string
InsecureSkipTLSVerify bool
// SourceDateEpoch, when set, normalizes chart timestamps for reproducible archives.
SourceDateEpoch *time.Time
}
const (
@ -103,6 +106,10 @@ func (p *Package) Run(path string, _ map[string]any) (string, error) {
ch.Metadata.AppVersion = p.AppVersion
}
if p.SourceDateEpoch != nil {
chartutil.ApplySourceDateEpoch(ch, *p.SourceDateEpoch)
}
if reqs := ac.MetaDependencies(); len(reqs) > 0 {
if err := CheckDependencies(ch, reqs); err != nil {
return "", err

@ -186,6 +186,55 @@ func TestSavePreservesTimestamps(t *testing.T) {
}
}
func TestSaveWithSourceDateEpoch(t *testing.T) {
epoch, err := ParseSourceDateEpochValue("1609459200")
if err != nil {
t.Fatalf("ParseSourceDateEpochValue() error: %v", err)
}
tmp := t.TempDir()
c := &chart.Chart{
Metadata: &chart.Metadata{
APIVersion: chart.APIVersionV2,
Name: "ahab",
Version: "1.2.3",
},
Files: []*common.File{
{Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")},
},
Schema: []byte("{\n \"title\": \"Values\"\n}"),
}
ApplySourceDateEpoch(c, epoch)
where, err := Save(c, tmp)
if err != nil {
t.Fatalf("Failed to save: %s", err)
}
allHeaders, err := retrieveAllHeadersFromTar(where)
if err != nil {
t.Fatalf("Failed to parse tar: %v", err)
}
expected := epoch.Round(time.Second)
for _, header := range allHeaders {
if !header.ModTime.Equal(expected) {
t.Fatalf("Expected SOURCE_DATE_EPOCH timestamp %v, got %v for %q", expected, header.ModTime, header.Name)
}
}
}
func findHeader(t *testing.T, headers []*tar.Header, name string) *tar.Header {
t.Helper()
for _, h := range headers {
if h.Name == name {
return h
}
}
t.Fatalf("Could not find tar header %q", name)
return nil
}
// We could refactor `load.go` to use this `retrieveAllHeadersFromTar` function
// as well, so we are not duplicating components of the code which iterate
// through the tar.

@ -0,0 +1,54 @@
package util
import (
"strconv"
"time"
chart "helm.sh/helm/v4/pkg/chart/v2"
)
// ParseSourceDateEpochValue parses SOURCE_DATE_EPOCH per https://reproducible-builds.org/docs/source-date-epoch/.
func ParseSourceDateEpochValue(epochStr string) (time.Time, error) {
epoch, err := strconv.ParseInt(epochStr, 10, 64)
if err != nil {
return time.Time{}, err
}
if epoch < 0 {
return time.Time{}, strconv.ErrRange
}
return time.Unix(epoch, 0).UTC(), nil
}
// ApplySourceDateEpoch sets timestamps on the chart (and dependencies) to epoch.
func ApplySourceDateEpoch(c *chart.Chart, epoch time.Time) {
applySourceDateEpoch(c, epoch)
}
func applySourceDateEpoch(c *chart.Chart, epoch time.Time) {
c.ModTime = epoch
if len(c.Schema) > 0 {
c.SchemaModTime = epoch
}
if c.Lock != nil {
c.Lock.Generated = epoch
}
for _, f := range c.Raw {
if f != nil {
f.ModTime = epoch
}
}
for _, f := range c.Templates {
if f != nil {
f.ModTime = epoch
}
}
for _, f := range c.Files {
if f != nil {
f.ModTime = epoch
}
}
for _, dep := range c.Dependencies() {
applySourceDateEpoch(dep, epoch)
}
}

@ -55,6 +55,10 @@ func newDependencyBuildCmd(out io.Writer) *cobra.Command {
if len(args) > 0 {
chartpath = filepath.Clean(args[0])
}
sourceDateEpoch, err := sourceDateEpochFromEnv()
if err != nil {
return err
}
registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile,
client.InsecureSkipTLSVerify, client.PlainHTTP, client.Username, client.Password)
if err != nil {
@ -72,6 +76,7 @@ func newDependencyBuildCmd(out io.Writer) *cobra.Command {
RepositoryCache: settings.RepositoryCache,
ContentCache: settings.ContentCache,
Debug: settings.Debug,
SourceDateEpoch: sourceDateEpoch,
}
if client.Verify {
man.Verify = downloader.VerifyIfPossible

@ -58,6 +58,10 @@ func newDependencyUpdateCmd(_ *action.Configuration, out io.Writer) *cobra.Comma
if len(args) > 0 {
chartpath = filepath.Clean(args[0])
}
sourceDateEpoch, err := sourceDateEpochFromEnv()
if err != nil {
return err
}
registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile,
client.InsecureSkipTLSVerify, client.PlainHTTP, client.Username, client.Password)
if err != nil {
@ -75,6 +79,7 @@ func newDependencyUpdateCmd(_ *action.Configuration, out io.Writer) *cobra.Comma
RepositoryCache: settings.RepositoryCache,
ContentCache: settings.ContentCache,
Debug: settings.Debug,
SourceDateEpoch: sourceDateEpoch,
}
if client.Verify {
man.Verify = downloader.VerifyAlways

@ -296,6 +296,11 @@ func runInstall(args []string, client *action.Install, valueOpts *values.Options
slog.Warn("this chart is deprecated")
}
sourceDateEpoch, err := sourceDateEpochFromEnv()
if err != nil {
return nil, err
}
if req := ac.MetaDependencies(); len(req) > 0 {
// If CheckDependencies returns an error, we have unfulfilled dependencies.
// As of Helm 2.4.0, this is treated as a stopping condition:
@ -313,6 +318,7 @@ func runInstall(args []string, client *action.Install, valueOpts *values.Options
ContentCache: settings.ContentCache,
Debug: settings.Debug,
RegistryClient: client.GetRegistryClient(),
SourceDateEpoch: sourceDateEpoch,
}
if err := man.Update(); err != nil {
return nil, err

@ -59,6 +59,11 @@ func newPackageCmd(out io.Writer) *cobra.Command {
if len(args) == 0 {
return errors.New("need at least one argument, the path to the chart")
}
sourceDateEpoch, err := sourceDateEpochFromEnv()
if err != nil {
return err
}
client.SourceDateEpoch = sourceDateEpoch
if client.Sign {
if client.Key == "" {
return errors.New("--key is required for signing a package")
@ -101,6 +106,7 @@ func newPackageCmd(out io.Writer) *cobra.Command {
RepositoryConfig: settings.RepositoryConfig,
RepositoryCache: settings.RepositoryCache,
ContentCache: settings.ContentCache,
SourceDateEpoch: sourceDateEpoch,
}
if err := downloadManager.Update(); err != nil {

@ -0,0 +1,38 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package cmd
import (
"fmt"
"os"
"time"
chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
)
// sourceDateEpochFromEnv returns SOURCE_DATE_EPOCH when set, or nil when unset.
func sourceDateEpochFromEnv() (*time.Time, error) {
epochStr, ok := os.LookupEnv("SOURCE_DATE_EPOCH")
if !ok || epochStr == "" {
return nil, nil
}
epoch, err := chartutil.ParseSourceDateEpochValue(epochStr)
if err != nil {
return nil, fmt.Errorf("invalid SOURCE_DATE_EPOCH: %w", err)
}
return &epoch, nil
}

@ -0,0 +1,58 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package cmd
import (
"testing"
"time"
)
func TestSourceDateEpochFromEnv(t *testing.T) {
t.Setenv("SOURCE_DATE_EPOCH", "1609459200")
got, err := sourceDateEpochFromEnv()
if err != nil {
t.Fatalf("sourceDateEpochFromEnv() error: %v", err)
}
if got == nil {
t.Fatal("expected non-nil epoch")
}
want := time.Unix(1609459200, 0).UTC()
if !got.Equal(want) {
t.Fatalf("expected %v, got %v", want, *got)
}
}
func TestSourceDateEpochFromEnvUnset(t *testing.T) {
t.Setenv("SOURCE_DATE_EPOCH", "")
got, err := sourceDateEpochFromEnv()
if err != nil {
t.Fatalf("sourceDateEpochFromEnv() error: %v", err)
}
if got != nil {
t.Fatalf("expected nil epoch, got %v", *got)
}
}
func TestSourceDateEpochFromEnvInvalid(t *testing.T) {
t.Setenv("SOURCE_DATE_EPOCH", "not-a-number")
if _, err := sourceDateEpochFromEnv(); err == nil {
t.Fatal("expected error for invalid SOURCE_DATE_EPOCH")
}
}

@ -193,6 +193,11 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
return err
}
sourceDateEpoch, err := sourceDateEpochFromEnv()
if err != nil {
return err
}
// Check chart dependencies to make sure all are present in /charts
ch, err := loader.Load(chartPath)
if err != nil {
@ -217,6 +222,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
RepositoryCache: settings.RepositoryCache,
ContentCache: settings.ContentCache,
Debug: settings.Debug,
SourceDateEpoch: sourceDateEpoch,
}
if err := man.Update(); err != nil {
return err

@ -29,6 +29,7 @@ import (
"regexp"
"strings"
"sync"
"time"
"github.com/Masterminds/semver/v3"
"sigs.k8s.io/yaml"
@ -78,6 +79,8 @@ type Manager struct {
// ContentCache is a location where a cache of charts can be stored
ContentCache string
// SourceDateEpoch, when set, normalizes chart timestamps for reproducible archives.
SourceDateEpoch *time.Time
}
// Build rebuilds a local charts directory from a lockfile.
@ -304,7 +307,7 @@ func (m *Manager) downloadAll(deps []*chart.Dependency) error {
if m.Debug {
fmt.Fprintf(m.Out, "Archiving %s from repo %s\n", dep.Name, dep.Repository)
}
ver, err := tarFromLocalDir(m.ChartPath, dep.Name, dep.Repository, dep.Version, tmpPath)
ver, err := tarFromLocalDir(m.ChartPath, dep.Name, dep.Repository, dep.Version, tmpPath, m.SourceDateEpoch)
if err != nil {
saveError = err
break
@ -873,7 +876,7 @@ func writeLock(chartpath string, lock *chart.Lock, legacyLockfile bool) error {
}
// archive a dep chart from local directory and save it into destPath
func tarFromLocalDir(chartpath, name, repo, version, destPath string) (string, error) {
func tarFromLocalDir(chartpath, name, repo, version, destPath string, sourceDateEpoch *time.Time) (string, error) {
if !strings.HasPrefix(repo, "file://") {
return "", fmt.Errorf("wrong format: chart %s repository %s", name, repo)
}
@ -888,6 +891,10 @@ func tarFromLocalDir(chartpath, name, repo, version, destPath string) (string, e
return "", err
}
if sourceDateEpoch != nil {
chartutil.ApplySourceDateEpoch(ch, *sourceDateEpoch)
}
constraint, err := semver.NewConstraint(version)
if err != nil {
return "", fmt.Errorf("dependency %s has an invalid version/constraint format: %w", name, err)

Loading…
Cancel
Save