feat(util): respect SOURCE_DATE_EPOCH for reproducible chart archives

Add ParseSourceDateEpoch and ApplySourceDateEpoch helpers to the v2
and v3 chart util packages. The caller (helm package, dependency
build) now reads the environment variable, stamps every zero-valued
ModTime field on the chart tree, and lets the existing tar writer
pick up those times.

This keeps Save() free from environment side effects: the epoch is
parsed and applied at the call site. Entries that already carry a
non-zero ModTime are left untouched.

Signed-off-by: Maxime Wojtczak <maxime@cluster2600.com>
Signed-off-by: Maxime Grenu <maxime.grenu@gmail.com>
pull/31845/head
Maxime Grenu 7 months ago
parent 2f51ffe93c
commit dad0b59d43
No known key found for this signature in database
GPG Key ID: 532A7B7866CFDC51

@ -0,0 +1,84 @@
/*
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 util
import (
"fmt"
"os"
"strconv"
"time"
chart "helm.sh/helm/v4/internal/chart/v3"
)
// ParseSourceDateEpoch reads the SOURCE_DATE_EPOCH environment variable and
// returns the corresponding time. It returns the zero time when the variable
// is not set or is set to the empty string. An error is returned when the
// value cannot be parsed or is negative.
//
// SOURCE_DATE_EPOCH is a standardised environment variable for reproducible
// builds; see https://reproducible-builds.org/docs/source-date-epoch/
func ParseSourceDateEpoch() (time.Time, error) {
v, ok := os.LookupEnv("SOURCE_DATE_EPOCH")
if !ok || v == "" {
return time.Time{}, nil
}
epoch, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return time.Time{}, fmt.Errorf("invalid SOURCE_DATE_EPOCH %q: %w", v, err)
}
if epoch < 0 {
return time.Time{}, fmt.Errorf("invalid SOURCE_DATE_EPOCH %q: negative value", v)
}
return time.Unix(epoch, 0), nil
}
// ApplySourceDateEpoch sets the ModTime on the chart and all of its entries
// that currently have a zero ModTime to t. It recurses into dependencies.
// When t is the zero time this is a no-op.
func ApplySourceDateEpoch(c *chart.Chart, t time.Time) {
if t.IsZero() {
return
}
if c.ModTime.IsZero() {
c.ModTime = t
}
if c.Lock != nil && c.Lock.Generated.IsZero() {
c.Lock.Generated = t
}
if c.Schema != nil && c.SchemaModTime.IsZero() {
c.SchemaModTime = t
}
for _, f := range c.Raw {
if f.ModTime.IsZero() {
f.ModTime = t
}
}
for _, f := range c.Templates {
if f.ModTime.IsZero() {
f.ModTime = t
}
}
for _, f := range c.Files {
if f.ModTime.IsZero() {
f.ModTime = t
}
}
for _, dep := range c.Dependencies() {
ApplySourceDateEpoch(dep, t)
}
}

@ -0,0 +1,158 @@
/*
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 util
import (
"os"
"testing"
"time"
"helm.sh/helm/v4/pkg/chart/common"
chart "helm.sh/helm/v4/internal/chart/v3"
)
func TestParseSourceDateEpoch(t *testing.T) {
tests := []struct {
name string
value string
set bool
want time.Time
wantErr bool
}{
{
name: "not set",
set: false,
want: time.Time{},
},
{
name: "valid epoch",
value: "1700000000",
set: true,
want: time.Unix(1700000000, 0),
},
{
name: "invalid string",
value: "not-a-number",
set: true,
wantErr: true,
},
{
name: "negative value",
value: "-1",
set: true,
wantErr: true,
},
{
name: "zero",
value: "0",
set: true,
want: time.Unix(0, 0),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.set {
t.Setenv("SOURCE_DATE_EPOCH", tt.value)
} else {
prevVal, wasSet := os.LookupEnv("SOURCE_DATE_EPOCH")
os.Unsetenv("SOURCE_DATE_EPOCH")
t.Cleanup(func() {
if wasSet {
os.Setenv("SOURCE_DATE_EPOCH", prevVal)
}
})
}
got, err := ParseSourceDateEpoch()
if (err != nil) != tt.wantErr {
t.Errorf("ParseSourceDateEpoch() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !got.Equal(tt.want) {
t.Errorf("ParseSourceDateEpoch() = %v, want %v", got, tt.want)
}
})
}
}
func TestApplySourceDateEpoch(t *testing.T) {
epoch := time.Unix(1700000000, 0)
c := &chart.Chart{
Metadata: &chart.Metadata{
Name: "test",
Version: "0.1.0",
},
Templates: []*common.File{
{Name: "templates/test.yaml"},
},
Files: []*common.File{
{Name: "README.md"},
},
}
ApplySourceDateEpoch(c, epoch)
if !c.ModTime.Equal(epoch) {
t.Errorf("Chart.ModTime = %v, want %v", c.ModTime, epoch)
}
for _, f := range c.Templates {
if !f.ModTime.Equal(epoch) {
t.Errorf("Template %s ModTime = %v, want %v", f.Name, f.ModTime, epoch)
}
}
for _, f := range c.Files {
if !f.ModTime.Equal(epoch) {
t.Errorf("File %s ModTime = %v, want %v", f.Name, f.ModTime, epoch)
}
}
}
func TestApplySourceDateEpochPreservesExisting(t *testing.T) {
epoch := time.Unix(1700000000, 0)
existing := time.Unix(1600000000, 0)
c := &chart.Chart{
Metadata: &chart.Metadata{
Name: "test",
Version: "0.1.0",
},
ModTime: existing,
}
ApplySourceDateEpoch(c, epoch)
if !c.ModTime.Equal(existing) {
t.Errorf("Chart.ModTime = %v, want existing %v", c.ModTime, existing)
}
}
func TestApplySourceDateEpochZeroNoop(t *testing.T) {
c := &chart.Chart{
Metadata: &chart.Metadata{
Name: "test",
Version: "0.1.0",
},
}
ApplySourceDateEpoch(c, time.Time{})
if !c.ModTime.IsZero() {
t.Errorf("Chart.ModTime = %v, want zero", c.ModTime)
}
}

@ -109,6 +109,13 @@ func (p *Package) Run(path string, _ map[string]any) (string, error) {
}
}
// Apply SOURCE_DATE_EPOCH for reproducible builds if set.
epoch, err := chartutil.ParseSourceDateEpoch()
if err != nil {
fmt.Fprintf(os.Stderr, "WARNING: %v\n", err)
}
chartutil.ApplySourceDateEpoch(ch, epoch)
var dest string
if p.Destination == "." {
// Save to the current working directory.

@ -0,0 +1,84 @@
/*
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 util
import (
"fmt"
"os"
"strconv"
"time"
chart "helm.sh/helm/v4/pkg/chart/v2"
)
// ParseSourceDateEpoch reads the SOURCE_DATE_EPOCH environment variable and
// returns the corresponding time. It returns the zero time when the variable
// is not set or is set to the empty string. An error is returned when the
// value cannot be parsed or is negative.
//
// SOURCE_DATE_EPOCH is a standardised environment variable for reproducible
// builds; see https://reproducible-builds.org/docs/source-date-epoch/
func ParseSourceDateEpoch() (time.Time, error) {
v, ok := os.LookupEnv("SOURCE_DATE_EPOCH")
if !ok || v == "" {
return time.Time{}, nil
}
epoch, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return time.Time{}, fmt.Errorf("invalid SOURCE_DATE_EPOCH %q: %w", v, err)
}
if epoch < 0 {
return time.Time{}, fmt.Errorf("invalid SOURCE_DATE_EPOCH %q: negative value", v)
}
return time.Unix(epoch, 0), nil
}
// ApplySourceDateEpoch sets the ModTime on the chart and all of its entries
// that currently have a zero ModTime to t. It recurses into dependencies.
// When t is the zero time this is a no-op.
func ApplySourceDateEpoch(c *chart.Chart, t time.Time) {
if t.IsZero() {
return
}
if c.ModTime.IsZero() {
c.ModTime = t
}
if c.Lock != nil && c.Lock.Generated.IsZero() {
c.Lock.Generated = t
}
if c.Schema != nil && c.SchemaModTime.IsZero() {
c.SchemaModTime = t
}
for _, f := range c.Raw {
if f.ModTime.IsZero() {
f.ModTime = t
}
}
for _, f := range c.Templates {
if f.ModTime.IsZero() {
f.ModTime = t
}
}
for _, f := range c.Files {
if f.ModTime.IsZero() {
f.ModTime = t
}
}
for _, dep := range c.Dependencies() {
ApplySourceDateEpoch(dep, t)
}
}

@ -0,0 +1,158 @@
/*
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 util
import (
"os"
"testing"
"time"
"helm.sh/helm/v4/pkg/chart/common"
chart "helm.sh/helm/v4/pkg/chart/v2"
)
func TestParseSourceDateEpoch(t *testing.T) {
tests := []struct {
name string
value string
set bool
want time.Time
wantErr bool
}{
{
name: "not set",
set: false,
want: time.Time{},
},
{
name: "valid epoch",
value: "1700000000",
set: true,
want: time.Unix(1700000000, 0),
},
{
name: "invalid string",
value: "not-a-number",
set: true,
wantErr: true,
},
{
name: "negative value",
value: "-1",
set: true,
wantErr: true,
},
{
name: "zero",
value: "0",
set: true,
want: time.Unix(0, 0),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.set {
t.Setenv("SOURCE_DATE_EPOCH", tt.value)
} else {
prevVal, wasSet := os.LookupEnv("SOURCE_DATE_EPOCH")
os.Unsetenv("SOURCE_DATE_EPOCH")
t.Cleanup(func() {
if wasSet {
os.Setenv("SOURCE_DATE_EPOCH", prevVal)
}
})
}
got, err := ParseSourceDateEpoch()
if (err != nil) != tt.wantErr {
t.Errorf("ParseSourceDateEpoch() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !got.Equal(tt.want) {
t.Errorf("ParseSourceDateEpoch() = %v, want %v", got, tt.want)
}
})
}
}
func TestApplySourceDateEpoch(t *testing.T) {
epoch := time.Unix(1700000000, 0)
c := &chart.Chart{
Metadata: &chart.Metadata{
Name: "test",
Version: "0.1.0",
},
Templates: []*common.File{
{Name: "templates/test.yaml"},
},
Files: []*common.File{
{Name: "README.md"},
},
}
ApplySourceDateEpoch(c, epoch)
if !c.ModTime.Equal(epoch) {
t.Errorf("Chart.ModTime = %v, want %v", c.ModTime, epoch)
}
for _, f := range c.Templates {
if !f.ModTime.Equal(epoch) {
t.Errorf("Template %s ModTime = %v, want %v", f.Name, f.ModTime, epoch)
}
}
for _, f := range c.Files {
if !f.ModTime.Equal(epoch) {
t.Errorf("File %s ModTime = %v, want %v", f.Name, f.ModTime, epoch)
}
}
}
func TestApplySourceDateEpochPreservesExisting(t *testing.T) {
epoch := time.Unix(1700000000, 0)
existing := time.Unix(1600000000, 0)
c := &chart.Chart{
Metadata: &chart.Metadata{
Name: "test",
Version: "0.1.0",
},
ModTime: existing,
}
ApplySourceDateEpoch(c, epoch)
if !c.ModTime.Equal(existing) {
t.Errorf("Chart.ModTime = %v, want existing %v", c.ModTime, existing)
}
}
func TestApplySourceDateEpochZeroNoop(t *testing.T) {
c := &chart.Chart{
Metadata: &chart.Metadata{
Name: "test",
Version: "0.1.0",
},
}
ApplySourceDateEpoch(c, time.Time{})
if !c.ModTime.IsZero() {
t.Errorf("Chart.ModTime = %v, want zero", c.ModTime)
}
}

@ -899,6 +899,13 @@ func tarFromLocalDir(chartpath, name, repo, version, destPath string) (string, e
}
if constraint.Check(v) {
// Apply SOURCE_DATE_EPOCH for reproducible builds if set.
epoch, epochErr := chartutil.ParseSourceDateEpoch()
if epochErr != nil {
fmt.Fprintf(os.Stderr, "WARNING: %v\n", epochErr)
}
chartutil.ApplySourceDateEpoch(ch, epoch)
_, err = chartutil.Save(ch, destPath)
return ch.Metadata.Version, err
}

Loading…
Cancel
Save