From ca8eae936113de27730405d5834295ac452548ca Mon Sep 17 00:00:00 2001 From: Matt Farina Date: Sun, 21 Sep 2025 13:43:53 -0400 Subject: [PATCH 1/2] Reproducible chart archive builds Building the same chart into an archive multiple times will have the same sha256 hash. Perviously, the time in the headers for a file was time.Now() which changed each time. The time is now collected from the operating system when the file is loaded and this time is used. Fixes: #3612 Signed-off-by: Matt Farina --- internal/chart/v3/chart.go | 5 + internal/chart/v3/chart_test.go | 68 ++++--- internal/chart/v3/lint/rules/template_test.go | 22 ++- internal/chart/v3/loader/directory.go | 2 +- internal/chart/v3/loader/load.go | 17 +- internal/chart/v3/loader/load_test.go | 48 +++-- internal/chart/v3/util/create.go | 2 +- internal/chart/v3/util/save.go | 16 +- internal/chart/v3/util/save_test.go | 108 ++++++++++- pkg/action/action_test.go | 36 ++-- pkg/action/hooks_test.go | 10 +- pkg/action/install_test.go | 5 +- pkg/action/show_test.go | 30 ++-- pkg/chart/common/file.go | 4 + pkg/chart/common/util/values_test.go | 3 +- pkg/chart/loader/archive/archive.go | 8 +- pkg/chart/v2/chart.go | 5 + pkg/chart/v2/chart_test.go | 68 ++++--- pkg/chart/v2/lint/rules/template_test.go | 22 ++- pkg/chart/v2/loader/directory.go | 2 +- pkg/chart/v2/loader/load.go | 16 +- pkg/chart/v2/loader/load_test.go | 53 +++--- pkg/chart/v2/util/create.go | 2 +- pkg/chart/v2/util/save.go | 16 +- pkg/chart/v2/util/save_test.go | 107 ++++++++++- pkg/cmd/upgrade_test.go | 6 +- pkg/engine/engine_test.go | 170 ++++++++++-------- pkg/release/v1/mock.go | 2 +- 28 files changed, 586 insertions(+), 267 deletions(-) diff --git a/internal/chart/v3/chart.go b/internal/chart/v3/chart.go index 2edc6c339..48f006e79 100644 --- a/internal/chart/v3/chart.go +++ b/internal/chart/v3/chart.go @@ -19,6 +19,7 @@ import ( "path/filepath" "regexp" "strings" + "time" "helm.sh/helm/v4/pkg/chart/common" ) @@ -47,9 +48,13 @@ type Chart struct { Values map[string]interface{} `json:"values"` // Schema is an optional JSON schema for imposing structure on Values Schema []byte `json:"schema"` + // SchemaModTime the schema was last modified + SchemaModTime time.Time `json:"schemamodtime,omitempty"` // Files are miscellaneous files in a chart archive, // e.g. README, LICENSE, etc. Files []*common.File `json:"files"` + // ModTime the chart metadata was last modified + ModTime time.Time `json:"modtime,omitzero"` parent *Chart dependencies []*Chart diff --git a/internal/chart/v3/chart_test.go b/internal/chart/v3/chart_test.go index b1820ac0a..07cbf4b39 100644 --- a/internal/chart/v3/chart_test.go +++ b/internal/chart/v3/chart_test.go @@ -18,6 +18,7 @@ package v3 import ( "encoding/json" "testing" + "time" "github.com/stretchr/testify/assert" @@ -25,27 +26,33 @@ import ( ) func TestCRDs(t *testing.T) { + modTime := time.Now() chrt := Chart{ Files: []*common.File{ { - Name: "crds/foo.yaml", - Data: []byte("hello"), + Name: "crds/foo.yaml", + ModTime: modTime, + Data: []byte("hello"), }, { - Name: "bar.yaml", - Data: []byte("hello"), + Name: "bar.yaml", + ModTime: modTime, + Data: []byte("hello"), }, { - Name: "crds/foo/bar/baz.yaml", - Data: []byte("hello"), + Name: "crds/foo/bar/baz.yaml", + ModTime: modTime, + Data: []byte("hello"), }, { - Name: "crdsfoo/bar/baz.yaml", - Data: []byte("hello"), + Name: "crdsfoo/bar/baz.yaml", + ModTime: modTime, + Data: []byte("hello"), }, { - Name: "crds/README.md", - Data: []byte("# hello"), + Name: "crds/README.md", + ModTime: modTime, + Data: []byte("# hello"), }, }, } @@ -61,8 +68,9 @@ func TestSaveChartNoRawData(t *testing.T) { chrt := Chart{ Raw: []*common.File{ { - Name: "fhqwhgads.yaml", - Data: []byte("Everybody to the Limit"), + Name: "fhqwhgads.yaml", + ModTime: time.Now(), + Data: []byte("Everybody to the Limit"), }, }, } @@ -163,27 +171,33 @@ func TestChartFullPath(t *testing.T) { } func TestCRDObjects(t *testing.T) { + modTime := time.Now() chrt := Chart{ Files: []*common.File{ { - Name: "crds/foo.yaml", - Data: []byte("hello"), + Name: "crds/foo.yaml", + ModTime: modTime, + Data: []byte("hello"), }, { - Name: "bar.yaml", - Data: []byte("hello"), + Name: "bar.yaml", + ModTime: modTime, + Data: []byte("hello"), }, { - Name: "crds/foo/bar/baz.yaml", - Data: []byte("hello"), + Name: "crds/foo/bar/baz.yaml", + ModTime: modTime, + Data: []byte("hello"), }, { - Name: "crdsfoo/bar/baz.yaml", - Data: []byte("hello"), + Name: "crdsfoo/bar/baz.yaml", + ModTime: modTime, + Data: []byte("hello"), }, { - Name: "crds/README.md", - Data: []byte("# hello"), + Name: "crds/README.md", + ModTime: modTime, + Data: []byte("# hello"), }, }, } @@ -193,16 +207,18 @@ func TestCRDObjects(t *testing.T) { Name: "crds/foo.yaml", Filename: "crds/foo.yaml", File: &common.File{ - Name: "crds/foo.yaml", - Data: []byte("hello"), + Name: "crds/foo.yaml", + ModTime: modTime, + Data: []byte("hello"), }, }, { Name: "crds/foo/bar/baz.yaml", Filename: "crds/foo/bar/baz.yaml", File: &common.File{ - Name: "crds/foo/bar/baz.yaml", - Data: []byte("hello"), + Name: "crds/foo/bar/baz.yaml", + ModTime: modTime, + Data: []byte("hello"), }, }, } diff --git a/internal/chart/v3/lint/rules/template_test.go b/internal/chart/v3/lint/rules/template_test.go index d7665211a..0ffc92002 100644 --- a/internal/chart/v3/lint/rules/template_test.go +++ b/internal/chart/v3/lint/rules/template_test.go @@ -22,6 +22,7 @@ import ( "path/filepath" "strings" "testing" + "time" chart "helm.sh/helm/v4/internal/chart/v3" "helm.sh/helm/v4/internal/chart/v3/lint/support" @@ -183,6 +184,7 @@ func TestValidateMetadataName(t *testing.T) { } func TestDeprecatedAPIFails(t *testing.T) { + modTime := time.Now() mychart := chart.Chart{ Metadata: &chart.Metadata{ APIVersion: "v2", @@ -192,12 +194,14 @@ func TestDeprecatedAPIFails(t *testing.T) { }, Templates: []*common.File{ { - Name: "templates/baddeployment.yaml", - Data: []byte("apiVersion: apps/v1beta1\nkind: Deployment\nmetadata:\n name: baddep\nspec: {selector: {matchLabels: {foo: bar}}}"), + Name: "templates/baddeployment.yaml", + ModTime: modTime, + Data: []byte("apiVersion: apps/v1beta1\nkind: Deployment\nmetadata:\n name: baddep\nspec: {selector: {matchLabels: {foo: bar}}}"), }, { - Name: "templates/goodsecret.yaml", - Data: []byte("apiVersion: v1\nkind: Secret\nmetadata:\n name: goodsecret"), + Name: "templates/goodsecret.yaml", + ModTime: modTime, + Data: []byte("apiVersion: v1\nkind: Secret\nmetadata:\n name: goodsecret"), }, }, } @@ -252,8 +256,9 @@ func TestStrictTemplateParsingMapError(t *testing.T) { }, Templates: []*common.File{ { - Name: "templates/configmap.yaml", - Data: []byte(manifest), + Name: "templates/configmap.yaml", + ModTime: time.Now(), + Data: []byte(manifest), }, }, } @@ -381,8 +386,9 @@ func TestEmptyWithCommentsManifests(t *testing.T) { }, Templates: []*common.File{ { - Name: "templates/empty-with-comments.yaml", - Data: []byte("#@formatter:off\n"), + Name: "templates/empty-with-comments.yaml", + ModTime: time.Now(), + Data: []byte("#@formatter:off\n"), }, }, } diff --git a/internal/chart/v3/loader/directory.go b/internal/chart/v3/loader/directory.go index 8cb7323dc..dfe3af3b2 100644 --- a/internal/chart/v3/loader/directory.go +++ b/internal/chart/v3/loader/directory.go @@ -111,7 +111,7 @@ func LoadDir(dir string) (*chart.Chart, error) { data = bytes.TrimPrefix(data, utf8bom) - files = append(files, &archive.BufferedFile{Name: n, Data: data}) + files = append(files, &archive.BufferedFile{Name: n, ModTime: fi.ModTime(), Data: data}) return nil } if err = sympath.Walk(topdir, walk); err != nil { diff --git a/internal/chart/v3/loader/load.go b/internal/chart/v3/loader/load.go index b1b4bba8f..1c5b4cad1 100644 --- a/internal/chart/v3/loader/load.go +++ b/internal/chart/v3/loader/load.go @@ -25,6 +25,7 @@ import ( "maps" "os" "path/filepath" + "slices" "strings" utilyaml "k8s.io/apimachinery/pkg/util/yaml" @@ -71,11 +72,12 @@ func Load(name string) (*chart.Chart, error) { func LoadFiles(files []*archive.BufferedFile) (*chart.Chart, error) { c := new(chart.Chart) subcharts := make(map[string][]*archive.BufferedFile) + var subChartsKeys []string // do not rely on assumed ordering of files in the chart and crash // if Chart.yaml was not coming early enough to initialize metadata for _, f := range files { - c.Raw = append(c.Raw, &common.File{Name: f.Name, Data: f.Data}) + c.Raw = append(c.Raw, &common.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data}) if f.Name == "Chart.yaml" { if c.Metadata == nil { c.Metadata = new(chart.Metadata) @@ -89,6 +91,7 @@ func LoadFiles(files []*archive.BufferedFile) (*chart.Chart, error) { if c.Metadata.APIVersion == "" { c.Metadata.APIVersion = chart.APIVersionV3 } + c.ModTime = f.ModTime } } for _, f := range files { @@ -109,20 +112,24 @@ func LoadFiles(files []*archive.BufferedFile) (*chart.Chart, error) { c.Values = values case f.Name == "values.schema.json": c.Schema = f.Data + c.SchemaModTime = f.ModTime case strings.HasPrefix(f.Name, "templates/"): - c.Templates = append(c.Templates, &common.File{Name: f.Name, Data: f.Data}) + c.Templates = append(c.Templates, &common.File{Name: f.Name, Data: f.Data, ModTime: f.ModTime}) case strings.HasPrefix(f.Name, "charts/"): if filepath.Ext(f.Name) == ".prov" { - c.Files = append(c.Files, &common.File{Name: f.Name, Data: f.Data}) + c.Files = append(c.Files, &common.File{Name: f.Name, Data: f.Data, ModTime: f.ModTime}) continue } fname := strings.TrimPrefix(f.Name, "charts/") cname := strings.SplitN(fname, "/", 2)[0] - subcharts[cname] = append(subcharts[cname], &archive.BufferedFile{Name: fname, Data: f.Data}) + if slices.Index(subChartsKeys, cname) == -1 { + subChartsKeys = append(subChartsKeys, cname) + } + subcharts[cname] = append(subcharts[cname], &archive.BufferedFile{Name: fname, ModTime: f.ModTime, Data: f.Data}) default: - c.Files = append(c.Files, &common.File{Name: f.Name, Data: f.Data}) + c.Files = append(c.Files, &common.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data}) } } diff --git a/internal/chart/v3/loader/load_test.go b/internal/chart/v3/loader/load_test.go index 9f41429cc..f91005bf6 100644 --- a/internal/chart/v3/loader/load_test.go +++ b/internal/chart/v3/loader/load_test.go @@ -184,9 +184,11 @@ func TestLoadFile(t *testing.T) { } func TestLoadFiles(t *testing.T) { + modTime := time.Now() goodFiles := []*archive.BufferedFile{ { - Name: "Chart.yaml", + Name: "Chart.yaml", + ModTime: modTime, Data: []byte(`apiVersion: v3 name: frobnitz description: This is a frobnitz. @@ -207,20 +209,24 @@ icon: https://example.com/64x64.png `), }, { - Name: "values.yaml", - Data: []byte("var: some values"), + Name: "values.yaml", + ModTime: modTime, + Data: []byte("var: some values"), }, { - Name: "values.schema.json", - Data: []byte("type: Values"), + Name: "values.schema.json", + ModTime: modTime, + Data: []byte("type: Values"), }, { - Name: "templates/deployment.yaml", - Data: []byte("some deployment"), + Name: "templates/deployment.yaml", + ModTime: modTime, + Data: []byte("some deployment"), }, { - Name: "templates/service.yaml", - Data: []byte("some service"), + Name: "templates/service.yaml", + ModTime: modTime, + Data: []byte("some service"), }, } @@ -260,26 +266,32 @@ icon: https://example.com/64x64.png // Test the order of file loading. The Chart.yaml file needs to come first for // later comparison checks. See https://github.com/helm/helm/pull/8948 func TestLoadFilesOrder(t *testing.T) { + modTime := time.Now() goodFiles := []*archive.BufferedFile{ { - Name: "requirements.yaml", - Data: []byte("dependencies:"), + Name: "requirements.yaml", + ModTime: modTime, + Data: []byte("dependencies:"), }, { - Name: "values.yaml", - Data: []byte("var: some values"), + Name: "values.yaml", + ModTime: modTime, + Data: []byte("var: some values"), }, { - Name: "templates/deployment.yaml", - Data: []byte("some deployment"), + Name: "templates/deployment.yaml", + ModTime: modTime, + Data: []byte("some deployment"), }, { - Name: "templates/service.yaml", - Data: []byte("some service"), + Name: "templates/service.yaml", + ModTime: modTime, + Data: []byte("some service"), }, { - Name: "Chart.yaml", + Name: "Chart.yaml", + ModTime: modTime, Data: []byte(`apiVersion: v3 name: frobnitz description: This is a frobnitz. diff --git a/internal/chart/v3/util/create.go b/internal/chart/v3/util/create.go index c5e728721..0dfa30995 100644 --- a/internal/chart/v3/util/create.go +++ b/internal/chart/v3/util/create.go @@ -661,7 +661,7 @@ func CreateFrom(chartfile *chart.Metadata, dest, src string) error { for _, template := range schart.Templates { newData := transform(string(template.Data), schart.Name()) - updatedTemplates = append(updatedTemplates, &common.File{Name: template.Name, Data: newData}) + updatedTemplates = append(updatedTemplates, &common.File{Name: template.Name, ModTime: template.ModTime, Data: newData}) } schart.Templates = updatedTemplates diff --git a/internal/chart/v3/util/save.go b/internal/chart/v3/util/save.go index 49d93bf40..f755300ba 100644 --- a/internal/chart/v3/util/save.go +++ b/internal/chart/v3/util/save.go @@ -166,7 +166,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error { if err != nil { return err } - if err := writeToTar(out, filepath.Join(base, ChartfileName), cdata); err != nil { + if err := writeToTar(out, filepath.Join(base, ChartfileName), cdata, c.ModTime); err != nil { return err } @@ -176,7 +176,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error { if err != nil { return err } - if err := writeToTar(out, filepath.Join(base, "Chart.lock"), ldata); err != nil { + if err := writeToTar(out, filepath.Join(base, "Chart.lock"), ldata, c.Lock.Generated); err != nil { return err } } @@ -184,7 +184,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error { // Save values.yaml for _, f := range c.Raw { if f.Name == ValuesfileName { - if err := writeToTar(out, filepath.Join(base, ValuesfileName), f.Data); err != nil { + if err := writeToTar(out, filepath.Join(base, ValuesfileName), f.Data, f.ModTime); err != nil { return err } } @@ -195,7 +195,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error { if !json.Valid(c.Schema) { return errors.New("invalid JSON in " + SchemafileName) } - if err := writeToTar(out, filepath.Join(base, SchemafileName), c.Schema); err != nil { + if err := writeToTar(out, filepath.Join(base, SchemafileName), c.Schema, c.SchemaModTime); err != nil { return err } } @@ -203,7 +203,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error { // Save templates for _, f := range c.Templates { n := filepath.Join(base, f.Name) - if err := writeToTar(out, n, f.Data); err != nil { + if err := writeToTar(out, n, f.Data, f.ModTime); err != nil { return err } } @@ -211,7 +211,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error { // Save files for _, f := range c.Files { n := filepath.Join(base, f.Name) - if err := writeToTar(out, n, f.Data); err != nil { + if err := writeToTar(out, n, f.Data, f.ModTime); err != nil { return err } } @@ -226,13 +226,13 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error { } // writeToTar writes a single file to a tar archive. -func writeToTar(out *tar.Writer, name string, body []byte) error { +func writeToTar(out *tar.Writer, name string, body []byte, modTime time.Time) error { // TODO: Do we need to create dummy parent directory names if none exist? h := &tar.Header{ Name: filepath.ToSlash(name), Mode: 0644, Size: int64(len(body)), - ModTime: time.Now(), + ModTime: modTime, } if err := out.WriteHeader(h); err != nil { return err diff --git a/internal/chart/v3/util/save_test.go b/internal/chart/v3/util/save_test.go index 9b1b14a4c..93da34470 100644 --- a/internal/chart/v3/util/save_test.go +++ b/internal/chart/v3/util/save_test.go @@ -20,6 +20,8 @@ import ( "archive/tar" "bytes" "compress/gzip" + "crypto/sha256" + "fmt" "io" "os" "path" @@ -49,7 +51,7 @@ func TestSave(t *testing.T) { Digest: "testdigest", }, Files: []*common.File{ - {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, + {Name: "scheherazade/shahryar.txt", ModTime: time.Now(), Data: []byte("1,001 Nights")}, }, Schema: []byte("{\n \"title\": \"Values\"\n}"), } @@ -115,7 +117,7 @@ func TestSave(t *testing.T) { Digest: "testdigest", }, Files: []*common.File{ - {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, + {Name: "scheherazade/shahryar.txt", ModTime: time.Now(), Data: []byte("1,001 Nights")}, }, } _, err := Save(c, tmp) @@ -141,7 +143,6 @@ func TestSavePreservesTimestamps(t *testing.T) { // check will fail because `initialCreateTime` will be identical to the // written timestamp for the files. initialCreateTime := time.Now().Add(-1 * time.Second) - tmp := t.TempDir() c := &chart.Chart{ @@ -150,14 +151,16 @@ func TestSavePreservesTimestamps(t *testing.T) { Name: "ahab", Version: "1.2.3", }, + ModTime: initialCreateTime, Values: map[string]interface{}{ "imageName": "testimage", "imageId": 42, }, Files: []*common.File{ - {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, + {Name: "scheherazade/shahryar.txt", ModTime: initialCreateTime, Data: []byte("1,001 Nights")}, }, - Schema: []byte("{\n \"title\": \"Values\"\n}"), + Schema: []byte("{\n \"title\": \"Values\"\n}"), + SchemaModTime: initialCreateTime, } where, err := Save(c, tmp) @@ -170,8 +173,9 @@ func TestSavePreservesTimestamps(t *testing.T) { t.Fatalf("Failed to parse tar: %v", err) } + roundedTime := initialCreateTime.Round(time.Second) for _, header := range allHeaders { - if header.ModTime.Before(initialCreateTime) { + if !header.ModTime.Equal(roundedTime) { t.Fatalf("File timestamp not preserved: %v", header.ModTime) } } @@ -213,6 +217,7 @@ func retrieveAllHeadersFromTar(path string) ([]*tar.Header, error) { func TestSaveDir(t *testing.T) { tmp := t.TempDir() + modTime := time.Now() c := &chart.Chart{ Metadata: &chart.Metadata{ @@ -221,10 +226,10 @@ func TestSaveDir(t *testing.T) { Version: "1.2.3", }, Files: []*common.File{ - {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, + {Name: "scheherazade/shahryar.txt", ModTime: modTime, Data: []byte("1,001 Nights")}, }, Templates: []*common.File{ - {Name: path.Join(TemplatesDir, "nested", "dir", "thing.yaml"), Data: []byte("abc: {{ .Values.abc }}")}, + {Name: path.Join(TemplatesDir, "nested", "dir", "thing.yaml"), ModTime: modTime, Data: []byte("abc: {{ .Values.abc }}")}, }, } @@ -260,3 +265,90 @@ func TestSaveDir(t *testing.T) { t.Fatalf("Did not get expected error for chart named %q", c.Name()) } } + +func TestRepeatableSave(t *testing.T) { + tmp := t.TempDir() + defer os.RemoveAll(tmp) + modTime := time.Date(2021, 9, 1, 20, 34, 58, 651387237, time.UTC) + tests := []struct { + name string + chart *chart.Chart + want string + }{ + { + name: "Package 1 file", + chart: &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV3, + Name: "ahab", + Version: "1.2.3", + }, + ModTime: modTime, + Lock: &chart.Lock{ + Digest: "testdigest", + }, + Files: []*common.File{ + {Name: "scheherazade/shahryar.txt", ModTime: modTime, Data: []byte("1,001 Nights")}, + }, + Schema: []byte("{\n \"title\": \"Values\"\n}"), + SchemaModTime: modTime, + }, + want: "bcb52ba7b7c2801be84cdc96d395f00749896a4679a7c9deacdfe934d0c49c1b", + }, + { + name: "Package 2 files", + chart: &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV3, + Name: "ahab", + Version: "1.2.3", + }, + ModTime: modTime, + Lock: &chart.Lock{ + Digest: "testdigest", + }, + Files: []*common.File{ + {Name: "scheherazade/shahryar.txt", ModTime: modTime, Data: []byte("1,001 Nights")}, + {Name: "scheherazade/dunyazad.txt", ModTime: modTime, Data: []byte("1,001 Nights again")}, + }, + Schema: []byte("{\n \"title\": \"Values\"\n}"), + SchemaModTime: modTime, + }, + want: "566bb87d0a044828e1e3acc4e9849b2c378eb9156a8662ceb618ea41b279bb10", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // create package + dest := path.Join(tmp, "newdir") + where, err := Save(test.chart, dest) + if err != nil { + t.Fatalf("Failed to save: %s", err) + } + // get shasum for package + result, err := sha256Sum(where) + if err != nil { + t.Fatalf("Failed to check shasum: %s", err) + } + // assert that the package SHA is what we wanted. + if result != test.want { + t.Errorf("FormatName() result = %v, want %v", result, test.want) + } + }) + } +} + +func sha256Sum(filePath string) (string, error) { + f, err := os.Open(filePath) + if err != nil { + return "", err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + + return fmt.Sprintf("%x", h.Sum(nil)), nil +} diff --git a/pkg/action/action_test.go b/pkg/action/action_test.go index 8f9159dd1..b77a462f5 100644 --- a/pkg/action/action_test.go +++ b/pkg/action/action_test.go @@ -123,9 +123,10 @@ type chartOptions struct { type chartOption func(*chartOptions) func buildChart(opts ...chartOption) *chart.Chart { + modTime := time.Now() defaultTemplates := []*common.File{ - {Name: "templates/hello", Data: []byte("hello: world")}, - {Name: "templates/hooks", Data: []byte(manifestWithHook)}, + {Name: "templates/hello", ModTime: modTime, Data: []byte("hello: world")}, + {Name: "templates/hooks", ModTime: modTime, Data: []byte(manifestWithHook)}, } return buildChartWithTemplates(defaultTemplates, opts...) } @@ -181,8 +182,9 @@ func withValues(values map[string]interface{}) chartOption { func withNotes(notes string) chartOption { return func(opts *chartOptions) { opts.Templates = append(opts.Templates, &common.File{ - Name: "templates/NOTES.txt", - Data: []byte(notes), + Name: "templates/NOTES.txt", + ModTime: time.Now(), + Data: []byte(notes), }) } } @@ -201,12 +203,13 @@ func withMetadataDependency(dependency chart.Dependency) chartOption { func withSampleTemplates() chartOption { return func(opts *chartOptions) { + modTime := time.Now() sampleTemplates := []*common.File{ // This adds basic templates and partials. - {Name: "templates/goodbye", Data: []byte("goodbye: world")}, - {Name: "templates/empty", Data: []byte("")}, - {Name: "templates/with-partials", Data: []byte(`hello: {{ template "_planet" . }}`)}, - {Name: "templates/partials/_planet", Data: []byte(`{{define "_planet"}}Earth{{end}}`)}, + {Name: "templates/goodbye", ModTime: modTime, Data: []byte("goodbye: world")}, + {Name: "templates/empty", ModTime: modTime, Data: []byte("")}, + {Name: "templates/with-partials", ModTime: modTime, Data: []byte(`hello: {{ template "_planet" . }}`)}, + {Name: "templates/partials/_planet", ModTime: modTime, Data: []byte(`{{define "_planet"}}Earth{{end}}`)}, } opts.Templates = append(opts.Templates, sampleTemplates...) } @@ -214,20 +217,21 @@ func withSampleTemplates() chartOption { func withSampleSecret() chartOption { return func(opts *chartOptions) { - sampleSecret := &common.File{Name: "templates/secret.yaml", Data: []byte("apiVersion: v1\nkind: Secret\n")} + sampleSecret := &common.File{Name: "templates/secret.yaml", ModTime: time.Now(), Data: []byte("apiVersion: v1\nkind: Secret\n")} opts.Templates = append(opts.Templates, sampleSecret) } } func withSampleIncludingIncorrectTemplates() chartOption { return func(opts *chartOptions) { + modTime := time.Now() sampleTemplates := []*common.File{ // This adds basic templates and partials. - {Name: "templates/goodbye", Data: []byte("goodbye: world")}, - {Name: "templates/empty", Data: []byte("")}, - {Name: "templates/incorrect", Data: []byte("{{ .Values.bad.doh }}")}, - {Name: "templates/with-partials", Data: []byte(`hello: {{ template "_planet" . }}`)}, - {Name: "templates/partials/_planet", Data: []byte(`{{define "_planet"}}Earth{{end}}`)}, + {Name: "templates/goodbye", ModTime: modTime, Data: []byte("goodbye: world")}, + {Name: "templates/empty", ModTime: modTime, Data: []byte("")}, + {Name: "templates/incorrect", ModTime: modTime, Data: []byte("{{ .Values.bad.doh }}")}, + {Name: "templates/with-partials", ModTime: modTime, Data: []byte(`hello: {{ template "_planet" . }}`)}, + {Name: "templates/partials/_planet", ModTime: modTime, Data: []byte(`{{define "_planet"}}Earth{{end}}`)}, } opts.Templates = append(opts.Templates, sampleTemplates...) } @@ -236,7 +240,7 @@ func withSampleIncludingIncorrectTemplates() chartOption { func withMultipleManifestTemplate() chartOption { return func(opts *chartOptions) { sampleTemplates := []*common.File{ - {Name: "templates/rbac", Data: []byte(rbacManifests)}, + {Name: "templates/rbac", ModTime: time.Now(), Data: []byte(rbacManifests)}, } opts.Templates = append(opts.Templates, sampleTemplates...) } @@ -853,7 +857,7 @@ func TestRenderResources_PostRenderer_MergeError(t *testing.T) { Version: "0.1.0", }, Templates: []*common.File{ - {Name: "templates/invalid", Data: []byte("invalid: yaml: content:")}, + {Name: "templates/invalid", ModTime: time.Now(), Data: []byte("invalid: yaml: content:")}, }, } values := map[string]interface{}{} diff --git a/pkg/action/hooks_test.go b/pkg/action/hooks_test.go index 9502737d7..710f6a61a 100644 --- a/pkg/action/hooks_test.go +++ b/pkg/action/hooks_test.go @@ -180,9 +180,10 @@ func runInstallForHooksWithSuccess(t *testing.T, manifest, expectedNamespace str outBuffer := &bytes.Buffer{} instAction.cfg.KubeClient = &kubefake.PrintingKubeClient{Out: io.Discard, LogOutput: outBuffer} + modTime := time.Now() templates := []*common.File{ - {Name: "templates/hello", Data: []byte("hello: world")}, - {Name: "templates/hooks", Data: []byte(manifest)}, + {Name: "templates/hello", ModTime: modTime, Data: []byte("hello: world")}, + {Name: "templates/hooks", ModTime: modTime, Data: []byte(manifest)}, } vals := map[string]interface{}{} @@ -209,9 +210,10 @@ func runInstallForHooksWithFailure(t *testing.T, manifest, expectedNamespace str outBuffer := &bytes.Buffer{} failingClient.PrintingKubeClient = kubefake.PrintingKubeClient{Out: io.Discard, LogOutput: outBuffer} + modTime := time.Now() templates := []*common.File{ - {Name: "templates/hello", Data: []byte("hello: world")}, - {Name: "templates/hooks", Data: []byte(manifest)}, + {Name: "templates/hello", ModTime: modTime, Data: []byte("hello: world")}, + {Name: "templates/hooks", ModTime: modTime, Data: []byte(manifest)}, } vals := map[string]interface{}{} diff --git a/pkg/action/install_test.go b/pkg/action/install_test.go index 3900c0633..9f04f40d4 100644 --- a/pkg/action/install_test.go +++ b/pkg/action/install_test.go @@ -465,8 +465,9 @@ func TestInstallRelease_DryRun_Lookup(t *testing.T) { mockChart := buildChart(withSampleTemplates()) mockChart.Templates = append(mockChart.Templates, &common.File{ - Name: "templates/lookup", - Data: []byte(`goodbye: {{ lookup "v1" "Namespace" "" "___" }}`), + Name: "templates/lookup", + ModTime: time.Now(), + Data: []byte(`goodbye: {{ lookup "v1" "Namespace" "" "___" }}`), }) resi, err := instAction.Run(mockChart, vals) diff --git a/pkg/action/show_test.go b/pkg/action/show_test.go index faf306f2a..f3b767fca 100644 --- a/pkg/action/show_test.go +++ b/pkg/action/show_test.go @@ -18,6 +18,7 @@ package action import ( "testing" + "time" "helm.sh/helm/v4/pkg/chart/common" chart "helm.sh/helm/v4/pkg/chart/v2" @@ -26,17 +27,18 @@ import ( func TestShow(t *testing.T) { config := actionConfigFixture(t) client := NewShow(ShowAll, config) + modTime := time.Now() client.chart = &chart.Chart{ Metadata: &chart.Metadata{Name: "alpine"}, Files: []*common.File{ - {Name: "README.md", Data: []byte("README\n")}, - {Name: "crds/ignoreme.txt", Data: []byte("error")}, - {Name: "crds/foo.yaml", Data: []byte("---\nfoo\n")}, - {Name: "crds/bar.json", Data: []byte("---\nbar\n")}, - {Name: "crds/baz.yaml", Data: []byte("baz\n")}, + {Name: "README.md", ModTime: modTime, Data: []byte("README\n")}, + {Name: "crds/ignoreme.txt", ModTime: modTime, Data: []byte("error")}, + {Name: "crds/foo.yaml", ModTime: modTime, Data: []byte("---\nfoo\n")}, + {Name: "crds/bar.json", ModTime: modTime, Data: []byte("---\nbar\n")}, + {Name: "crds/baz.yaml", ModTime: modTime, Data: []byte("baz\n")}, }, Raw: []*common.File{ - {Name: "values.yaml", Data: []byte("VALUES\n")}, + {Name: "values.yaml", ModTime: modTime, Data: []byte("VALUES\n")}, }, Values: map[string]interface{}{}, } @@ -104,13 +106,14 @@ func TestShowValuesByJsonPathFormat(t *testing.T) { func TestShowCRDs(t *testing.T) { config := actionConfigFixture(t) client := NewShow(ShowCRDs, config) + modTime := time.Now() client.chart = &chart.Chart{ Metadata: &chart.Metadata{Name: "alpine"}, Files: []*common.File{ - {Name: "crds/ignoreme.txt", Data: []byte("error")}, - {Name: "crds/foo.yaml", Data: []byte("---\nfoo\n")}, - {Name: "crds/bar.json", Data: []byte("---\nbar\n")}, - {Name: "crds/baz.yaml", Data: []byte("baz\n")}, + {Name: "crds/ignoreme.txt", ModTime: modTime, Data: []byte("error")}, + {Name: "crds/foo.yaml", ModTime: modTime, Data: []byte("---\nfoo\n")}, + {Name: "crds/bar.json", ModTime: modTime, Data: []byte("---\nbar\n")}, + {Name: "crds/baz.yaml", ModTime: modTime, Data: []byte("baz\n")}, }, } @@ -137,12 +140,13 @@ baz func TestShowNoReadme(t *testing.T) { config := actionConfigFixture(t) client := NewShow(ShowAll, config) + modTime := time.Now() client.chart = &chart.Chart{ Metadata: &chart.Metadata{Name: "alpine"}, Files: []*common.File{ - {Name: "crds/ignoreme.txt", Data: []byte("error")}, - {Name: "crds/foo.yaml", Data: []byte("---\nfoo\n")}, - {Name: "crds/bar.json", Data: []byte("---\nbar\n")}, + {Name: "crds/ignoreme.txt", ModTime: modTime, Data: []byte("error")}, + {Name: "crds/foo.yaml", ModTime: modTime, Data: []byte("---\nfoo\n")}, + {Name: "crds/bar.json", ModTime: modTime, Data: []byte("---\nbar\n")}, }, } diff --git a/pkg/chart/common/file.go b/pkg/chart/common/file.go index 304643f1a..1068bf450 100644 --- a/pkg/chart/common/file.go +++ b/pkg/chart/common/file.go @@ -15,6 +15,8 @@ limitations under the License. package common +import "time" + // File represents a file as a name/value pair. // // By convention, name is a relative path within the scope of the chart's @@ -24,4 +26,6 @@ type File struct { Name string `json:"name"` // Data is the template as byte data. Data []byte `json:"data"` + // ModTime is the file's mod-time + ModTime time.Time `json:"modtime,omitzero"` } diff --git a/pkg/chart/common/util/values_test.go b/pkg/chart/common/util/values_test.go index 5fc030567..706d3cfda 100644 --- a/pkg/chart/common/util/values_test.go +++ b/pkg/chart/common/util/values_test.go @@ -18,6 +18,7 @@ package util import ( "testing" + "time" "helm.sh/helm/v4/pkg/chart/common" chart "helm.sh/helm/v4/pkg/chart/v2" @@ -46,7 +47,7 @@ func TestToRenderValues(t *testing.T) { Templates: []*common.File{}, Values: chartValues, Files: []*common.File{ - {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, + {Name: "scheherazade/shahryar.txt", ModTime: time.Now(), Data: []byte("1,001 Nights")}, }, } c.AddDependency(&chart.Chart{ diff --git a/pkg/chart/loader/archive/archive.go b/pkg/chart/loader/archive/archive.go index 4d4ca4391..c6875db3f 100644 --- a/pkg/chart/loader/archive/archive.go +++ b/pkg/chart/loader/archive/archive.go @@ -29,6 +29,7 @@ import ( "path" "regexp" "strings" + "time" ) // MaxDecompressedChartSize is the maximum size of a chart archive that will be @@ -46,8 +47,9 @@ var utf8bom = []byte{0xEF, 0xBB, 0xBF} // BufferedFile represents an archive file buffered for later processing. type BufferedFile struct { - Name string - Data []byte + Name string + ModTime time.Time + Data []byte } // LoadArchiveFiles reads in files out of an archive into memory. This function @@ -148,7 +150,7 @@ func LoadArchiveFiles(in io.Reader) ([]*BufferedFile, error) { data := bytes.TrimPrefix(b.Bytes(), utf8bom) - files = append(files, &BufferedFile{Name: n, Data: data}) + files = append(files, &BufferedFile{Name: n, ModTime: hd.ModTime, Data: data}) b.Reset() } diff --git a/pkg/chart/v2/chart.go b/pkg/chart/v2/chart.go index f59bcd8b3..d77a53ddc 100644 --- a/pkg/chart/v2/chart.go +++ b/pkg/chart/v2/chart.go @@ -19,6 +19,7 @@ import ( "path/filepath" "regexp" "strings" + "time" "helm.sh/helm/v4/pkg/chart/common" ) @@ -50,9 +51,13 @@ type Chart struct { Values map[string]interface{} `json:"values"` // Schema is an optional JSON schema for imposing structure on Values Schema []byte `json:"schema"` + // SchemaModTime the schema was last modified + SchemaModTime time.Time `json:"schemamodtime,omitempty"` // Files are miscellaneous files in a chart archive, // e.g. README, LICENSE, etc. Files []*common.File `json:"files"` + // ModTime the chart metadata was last modified + ModTime time.Time `json:"modtime,omitzero"` parent *Chart dependencies []*Chart diff --git a/pkg/chart/v2/chart_test.go b/pkg/chart/v2/chart_test.go index a96d8c0c0..d0837eb16 100644 --- a/pkg/chart/v2/chart_test.go +++ b/pkg/chart/v2/chart_test.go @@ -18,6 +18,7 @@ package v2 import ( "encoding/json" "testing" + "time" "github.com/stretchr/testify/assert" @@ -25,27 +26,33 @@ import ( ) func TestCRDs(t *testing.T) { + modTime := time.Now() chrt := Chart{ Files: []*common.File{ { - Name: "crds/foo.yaml", - Data: []byte("hello"), + Name: "crds/foo.yaml", + ModTime: modTime, + Data: []byte("hello"), }, { - Name: "bar.yaml", - Data: []byte("hello"), + Name: "bar.yaml", + ModTime: modTime, + Data: []byte("hello"), }, { - Name: "crds/foo/bar/baz.yaml", - Data: []byte("hello"), + Name: "crds/foo/bar/baz.yaml", + ModTime: modTime, + Data: []byte("hello"), }, { - Name: "crdsfoo/bar/baz.yaml", - Data: []byte("hello"), + Name: "crdsfoo/bar/baz.yaml", + ModTime: modTime, + Data: []byte("hello"), }, { - Name: "crds/README.md", - Data: []byte("# hello"), + Name: "crds/README.md", + ModTime: modTime, + Data: []byte("# hello"), }, }, } @@ -61,8 +68,9 @@ func TestSaveChartNoRawData(t *testing.T) { chrt := Chart{ Raw: []*common.File{ { - Name: "fhqwhgads.yaml", - Data: []byte("Everybody to the Limit"), + Name: "fhqwhgads.yaml", + ModTime: time.Now(), + Data: []byte("Everybody to the Limit"), }, }, } @@ -163,27 +171,33 @@ func TestChartFullPath(t *testing.T) { } func TestCRDObjects(t *testing.T) { + modTime := time.Now() chrt := Chart{ Files: []*common.File{ { - Name: "crds/foo.yaml", - Data: []byte("hello"), + Name: "crds/foo.yaml", + ModTime: modTime, + Data: []byte("hello"), }, { - Name: "bar.yaml", - Data: []byte("hello"), + Name: "bar.yaml", + ModTime: modTime, + Data: []byte("hello"), }, { - Name: "crds/foo/bar/baz.yaml", - Data: []byte("hello"), + Name: "crds/foo/bar/baz.yaml", + ModTime: modTime, + Data: []byte("hello"), }, { - Name: "crdsfoo/bar/baz.yaml", - Data: []byte("hello"), + Name: "crdsfoo/bar/baz.yaml", + ModTime: modTime, + Data: []byte("hello"), }, { - Name: "crds/README.md", - Data: []byte("# hello"), + Name: "crds/README.md", + ModTime: modTime, + Data: []byte("# hello"), }, }, } @@ -193,16 +207,18 @@ func TestCRDObjects(t *testing.T) { Name: "crds/foo.yaml", Filename: "crds/foo.yaml", File: &common.File{ - Name: "crds/foo.yaml", - Data: []byte("hello"), + Name: "crds/foo.yaml", + ModTime: modTime, + Data: []byte("hello"), }, }, { Name: "crds/foo/bar/baz.yaml", Filename: "crds/foo/bar/baz.yaml", File: &common.File{ - Name: "crds/foo/bar/baz.yaml", - Data: []byte("hello"), + Name: "crds/foo/bar/baz.yaml", + ModTime: modTime, + Data: []byte("hello"), }, }, } diff --git a/pkg/chart/v2/lint/rules/template_test.go b/pkg/chart/v2/lint/rules/template_test.go index 7f9899070..c08ba6cc3 100644 --- a/pkg/chart/v2/lint/rules/template_test.go +++ b/pkg/chart/v2/lint/rules/template_test.go @@ -22,6 +22,7 @@ import ( "path/filepath" "strings" "testing" + "time" "helm.sh/helm/v4/pkg/chart/common" chart "helm.sh/helm/v4/pkg/chart/v2" @@ -194,6 +195,7 @@ func TestValidateMetadataName(t *testing.T) { } func TestDeprecatedAPIFails(t *testing.T) { + modTime := time.Now() mychart := chart.Chart{ Metadata: &chart.Metadata{ APIVersion: "v2", @@ -203,12 +205,14 @@ func TestDeprecatedAPIFails(t *testing.T) { }, Templates: []*common.File{ { - Name: "templates/baddeployment.yaml", - Data: []byte("apiVersion: apps/v1beta1\nkind: Deployment\nmetadata:\n name: baddep\nspec: {selector: {matchLabels: {foo: bar}}}"), + Name: "templates/baddeployment.yaml", + ModTime: modTime, + Data: []byte("apiVersion: apps/v1beta1\nkind: Deployment\nmetadata:\n name: baddep\nspec: {selector: {matchLabels: {foo: bar}}}"), }, { - Name: "templates/goodsecret.yaml", - Data: []byte("apiVersion: v1\nkind: Secret\nmetadata:\n name: goodsecret"), + Name: "templates/goodsecret.yaml", + ModTime: modTime, + Data: []byte("apiVersion: v1\nkind: Secret\nmetadata:\n name: goodsecret"), }, }, } @@ -267,8 +271,9 @@ func TestStrictTemplateParsingMapError(t *testing.T) { }, Templates: []*common.File{ { - Name: "templates/configmap.yaml", - Data: []byte(manifest), + Name: "templates/configmap.yaml", + ModTime: time.Now(), + Data: []byte(manifest), }, }, } @@ -400,8 +405,9 @@ func TestEmptyWithCommentsManifests(t *testing.T) { }, Templates: []*common.File{ { - Name: "templates/empty-with-comments.yaml", - Data: []byte("#@formatter:off\n"), + Name: "templates/empty-with-comments.yaml", + ModTime: time.Now(), + Data: []byte("#@formatter:off\n"), }, }, } diff --git a/pkg/chart/v2/loader/directory.go b/pkg/chart/v2/loader/directory.go index c6f31560c..82578d924 100644 --- a/pkg/chart/v2/loader/directory.go +++ b/pkg/chart/v2/loader/directory.go @@ -111,7 +111,7 @@ func LoadDir(dir string) (*chart.Chart, error) { data = bytes.TrimPrefix(data, utf8bom) - files = append(files, &archive.BufferedFile{Name: n, Data: data}) + files = append(files, &archive.BufferedFile{Name: n, ModTime: fi.ModTime(), Data: data}) return nil } if err = sympath.Walk(topdir, walk); err != nil { diff --git a/pkg/chart/v2/loader/load.go b/pkg/chart/v2/loader/load.go index 028d59e82..ba3a9b6bc 100644 --- a/pkg/chart/v2/loader/load.go +++ b/pkg/chart/v2/loader/load.go @@ -76,7 +76,7 @@ func LoadFiles(files []*archive.BufferedFile) (*chart.Chart, error) { // do not rely on assumed ordering of files in the chart and crash // if Chart.yaml was not coming early enough to initialize metadata for _, f := range files { - c.Raw = append(c.Raw, &common.File{Name: f.Name, Data: f.Data}) + c.Raw = append(c.Raw, &common.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data}) if f.Name == "Chart.yaml" { if c.Metadata == nil { c.Metadata = new(chart.Metadata) @@ -90,6 +90,7 @@ func LoadFiles(files []*archive.BufferedFile) (*chart.Chart, error) { if c.Metadata.APIVersion == "" { c.Metadata.APIVersion = chart.APIVersionV1 } + c.ModTime = f.ModTime } } for _, f := range files { @@ -110,6 +111,7 @@ func LoadFiles(files []*archive.BufferedFile) (*chart.Chart, error) { c.Values = values case f.Name == "values.schema.json": c.Schema = f.Data + c.SchemaModTime = f.ModTime // Deprecated: requirements.yaml is deprecated use Chart.yaml. // We will handle it for you because we are nice people @@ -124,7 +126,7 @@ func LoadFiles(files []*archive.BufferedFile) (*chart.Chart, error) { return c, fmt.Errorf("cannot load requirements.yaml: %w", err) } if c.Metadata.APIVersion == chart.APIVersionV1 { - c.Files = append(c.Files, &common.File{Name: f.Name, Data: f.Data}) + c.Files = append(c.Files, &common.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data}) } // Deprecated: requirements.lock is deprecated use Chart.lock. case f.Name == "requirements.lock": @@ -139,22 +141,22 @@ func LoadFiles(files []*archive.BufferedFile) (*chart.Chart, error) { log.Printf("Warning: Dependency locking is handled in Chart.lock since apiVersion \"v2\". We recommend migrating to Chart.lock.") } if c.Metadata.APIVersion == chart.APIVersionV1 { - c.Files = append(c.Files, &common.File{Name: f.Name, Data: f.Data}) + c.Files = append(c.Files, &common.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data}) } case strings.HasPrefix(f.Name, "templates/"): - c.Templates = append(c.Templates, &common.File{Name: f.Name, Data: f.Data}) + c.Templates = append(c.Templates, &common.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data}) case strings.HasPrefix(f.Name, "charts/"): if filepath.Ext(f.Name) == ".prov" { - c.Files = append(c.Files, &common.File{Name: f.Name, Data: f.Data}) + c.Files = append(c.Files, &common.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data}) continue } fname := strings.TrimPrefix(f.Name, "charts/") cname := strings.SplitN(fname, "/", 2)[0] - subcharts[cname] = append(subcharts[cname], &archive.BufferedFile{Name: fname, Data: f.Data}) + subcharts[cname] = append(subcharts[cname], &archive.BufferedFile{Name: fname, ModTime: f.ModTime, Data: f.Data}) default: - c.Files = append(c.Files, &common.File{Name: f.Name, Data: f.Data}) + c.Files = append(c.Files, &common.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data}) } } diff --git a/pkg/chart/v2/loader/load_test.go b/pkg/chart/v2/loader/load_test.go index 7eca5f402..ee0be5b18 100644 --- a/pkg/chart/v2/loader/load_test.go +++ b/pkg/chart/v2/loader/load_test.go @@ -219,8 +219,9 @@ func TestLoadFiles_BadCases(t *testing.T) { name: "These files contain only requirements.lock", bufferedFiles: []*archive.BufferedFile{ { - Name: "requirements.lock", - Data: []byte(""), + Name: "requirements.lock", + ModTime: time.Now(), + Data: []byte(""), }, }, expectError: "validation: chart.metadata.apiVersion is required"}, @@ -236,9 +237,11 @@ func TestLoadFiles_BadCases(t *testing.T) { } func TestLoadFiles(t *testing.T) { + modTime := time.Now() goodFiles := []*archive.BufferedFile{ { - Name: "Chart.yaml", + Name: "Chart.yaml", + ModTime: modTime, Data: []byte(`apiVersion: v1 name: frobnitz description: This is a frobnitz. @@ -259,20 +262,24 @@ icon: https://example.com/64x64.png `), }, { - Name: "values.yaml", - Data: []byte("var: some values"), + Name: "values.yaml", + ModTime: modTime, + Data: []byte("var: some values"), }, { - Name: "values.schema.json", - Data: []byte("type: Values"), + Name: "values.schema.json", + ModTime: modTime, + Data: []byte("type: Values"), }, { - Name: "templates/deployment.yaml", - Data: []byte("some deployment"), + Name: "templates/deployment.yaml", + ModTime: modTime, + Data: []byte("some deployment"), }, { - Name: "templates/service.yaml", - Data: []byte("some service"), + Name: "templates/service.yaml", + ModTime: modTime, + Data: []byte("some service"), }, } @@ -312,26 +319,32 @@ icon: https://example.com/64x64.png // Test the order of file loading. The Chart.yaml file needs to come first for // later comparison checks. See https://github.com/helm/helm/pull/8948 func TestLoadFilesOrder(t *testing.T) { + modTime := time.Now() goodFiles := []*archive.BufferedFile{ { - Name: "requirements.yaml", - Data: []byte("dependencies:"), + Name: "requirements.yaml", + ModTime: modTime, + Data: []byte("dependencies:"), }, { - Name: "values.yaml", - Data: []byte("var: some values"), + Name: "values.yaml", + ModTime: modTime, + Data: []byte("var: some values"), }, { - Name: "templates/deployment.yaml", - Data: []byte("some deployment"), + Name: "templates/deployment.yaml", + ModTime: modTime, + Data: []byte("some deployment"), }, { - Name: "templates/service.yaml", - Data: []byte("some service"), + Name: "templates/service.yaml", + ModTime: modTime, + Data: []byte("some service"), }, { - Name: "Chart.yaml", + Name: "Chart.yaml", + ModTime: modTime, Data: []byte(`apiVersion: v1 name: frobnitz description: This is a frobnitz. diff --git a/pkg/chart/v2/util/create.go b/pkg/chart/v2/util/create.go index d7c1fe31c..bf572c707 100644 --- a/pkg/chart/v2/util/create.go +++ b/pkg/chart/v2/util/create.go @@ -660,7 +660,7 @@ func CreateFrom(chartfile *chart.Metadata, dest, src string) error { for _, template := range schart.Templates { newData := transform(string(template.Data), schart.Name()) - updatedTemplates = append(updatedTemplates, &common.File{Name: template.Name, Data: newData}) + updatedTemplates = append(updatedTemplates, &common.File{Name: template.Name, ModTime: template.ModTime, Data: newData}) } schart.Templates = updatedTemplates diff --git a/pkg/chart/v2/util/save.go b/pkg/chart/v2/util/save.go index 69a98924c..632588b68 100644 --- a/pkg/chart/v2/util/save.go +++ b/pkg/chart/v2/util/save.go @@ -175,7 +175,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error { if err != nil { return err } - if err := writeToTar(out, filepath.Join(base, ChartfileName), cdata); err != nil { + if err := writeToTar(out, filepath.Join(base, ChartfileName), cdata, c.ModTime); err != nil { return err } @@ -187,7 +187,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error { if err != nil { return err } - if err := writeToTar(out, filepath.Join(base, "Chart.lock"), ldata); err != nil { + if err := writeToTar(out, filepath.Join(base, "Chart.lock"), ldata, c.Lock.Generated); err != nil { return err } } @@ -196,7 +196,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error { // Save values.yaml for _, f := range c.Raw { if f.Name == ValuesfileName { - if err := writeToTar(out, filepath.Join(base, ValuesfileName), f.Data); err != nil { + if err := writeToTar(out, filepath.Join(base, ValuesfileName), f.Data, f.ModTime); err != nil { return err } } @@ -207,7 +207,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error { if !json.Valid(c.Schema) { return errors.New("invalid JSON in " + SchemafileName) } - if err := writeToTar(out, filepath.Join(base, SchemafileName), c.Schema); err != nil { + if err := writeToTar(out, filepath.Join(base, SchemafileName), c.Schema, c.SchemaModTime); err != nil { return err } } @@ -215,7 +215,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error { // Save templates for _, f := range c.Templates { n := filepath.Join(base, f.Name) - if err := writeToTar(out, n, f.Data); err != nil { + if err := writeToTar(out, n, f.Data, f.ModTime); err != nil { return err } } @@ -223,7 +223,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error { // Save files for _, f := range c.Files { n := filepath.Join(base, f.Name) - if err := writeToTar(out, n, f.Data); err != nil { + if err := writeToTar(out, n, f.Data, f.ModTime); err != nil { return err } } @@ -238,13 +238,13 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error { } // writeToTar writes a single file to a tar archive. -func writeToTar(out *tar.Writer, name string, body []byte) error { +func writeToTar(out *tar.Writer, name string, body []byte, modTime time.Time) error { // TODO: Do we need to create dummy parent directory names if none exist? h := &tar.Header{ Name: filepath.ToSlash(name), Mode: 0644, Size: int64(len(body)), - ModTime: time.Now(), + ModTime: modTime, } if err := out.WriteHeader(h); err != nil { return err diff --git a/pkg/chart/v2/util/save_test.go b/pkg/chart/v2/util/save_test.go index ef822a82a..5dc36e786 100644 --- a/pkg/chart/v2/util/save_test.go +++ b/pkg/chart/v2/util/save_test.go @@ -20,6 +20,8 @@ import ( "archive/tar" "bytes" "compress/gzip" + "crypto/sha256" + "fmt" "io" "os" "path" @@ -49,7 +51,7 @@ func TestSave(t *testing.T) { Digest: "testdigest", }, Files: []*common.File{ - {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, + {Name: "scheherazade/shahryar.txt", ModTime: time.Now(), Data: []byte("1,001 Nights")}, }, Schema: []byte("{\n \"title\": \"Values\"\n}"), } @@ -118,7 +120,7 @@ func TestSave(t *testing.T) { Digest: "testdigest", }, Files: []*common.File{ - {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, + {Name: "scheherazade/shahryar.txt", ModTime: time.Now(), Data: []byte("1,001 Nights")}, }, } _, err := Save(c, tmp) @@ -153,14 +155,16 @@ func TestSavePreservesTimestamps(t *testing.T) { Name: "ahab", Version: "1.2.3", }, + ModTime: initialCreateTime, Values: map[string]interface{}{ "imageName": "testimage", "imageId": 42, }, Files: []*common.File{ - {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, + {Name: "scheherazade/shahryar.txt", ModTime: initialCreateTime, Data: []byte("1,001 Nights")}, }, - Schema: []byte("{\n \"title\": \"Values\"\n}"), + Schema: []byte("{\n \"title\": \"Values\"\n}"), + SchemaModTime: initialCreateTime, } where, err := Save(c, tmp) @@ -173,8 +177,9 @@ func TestSavePreservesTimestamps(t *testing.T) { t.Fatalf("Failed to parse tar: %v", err) } + roundedTime := initialCreateTime.Round(time.Second) for _, header := range allHeaders { - if header.ModTime.Before(initialCreateTime) { + if !header.ModTime.Equal(roundedTime) { t.Fatalf("File timestamp not preserved: %v", header.ModTime) } } @@ -217,6 +222,7 @@ func retrieveAllHeadersFromTar(path string) ([]*tar.Header, error) { func TestSaveDir(t *testing.T) { tmp := t.TempDir() + modTime := time.Now() c := &chart.Chart{ Metadata: &chart.Metadata{ APIVersion: chart.APIVersionV1, @@ -224,10 +230,10 @@ func TestSaveDir(t *testing.T) { Version: "1.2.3", }, Files: []*common.File{ - {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, + {Name: "scheherazade/shahryar.txt", ModTime: modTime, Data: []byte("1,001 Nights")}, }, Templates: []*common.File{ - {Name: path.Join(TemplatesDir, "nested", "dir", "thing.yaml"), Data: []byte("abc: {{ .Values.abc }}")}, + {Name: path.Join(TemplatesDir, "nested", "dir", "thing.yaml"), ModTime: modTime, Data: []byte("abc: {{ .Values.abc }}")}, }, } @@ -263,3 +269,90 @@ func TestSaveDir(t *testing.T) { t.Fatalf("Did not get expected error for chart named %q", c.Name()) } } + +func TestRepeatableSave(t *testing.T) { + tmp := t.TempDir() + defer os.RemoveAll(tmp) + modTime := time.Date(2021, 9, 1, 20, 34, 58, 651387237, time.UTC) + tests := []struct { + name string + chart *chart.Chart + want string + }{ + { + name: "Package 1 file", + chart: &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV1, + Name: "ahab", + Version: "1.2.3", + }, + ModTime: modTime, + Lock: &chart.Lock{ + Digest: "testdigest", + }, + Files: []*common.File{ + {Name: "scheherazade/shahryar.txt", ModTime: modTime, Data: []byte("1,001 Nights")}, + }, + Schema: []byte("{\n \"title\": \"Values\"\n}"), + SchemaModTime: modTime, + }, + want: "5e14a06037e5d4cb277c7b21770639d4e1a337be9ae391460e50653bac5a80ed", + }, + { + name: "Package 2 files", + chart: &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV1, + Name: "ahab", + Version: "1.2.3", + }, + ModTime: modTime, + Lock: &chart.Lock{ + Digest: "testdigest", + }, + Files: []*common.File{ + {Name: "scheherazade/shahryar.txt", ModTime: modTime, Data: []byte("1,001 Nights")}, + {Name: "scheherazade/dunyazad.txt", ModTime: modTime, Data: []byte("1,001 Nights again")}, + }, + Schema: []byte("{\n \"title\": \"Values\"\n}"), + SchemaModTime: modTime, + }, + want: "6967787da46fbfcc563cad31240e564e14f2602e6f66302129a59a9669622a36", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // create package + dest := path.Join(tmp, "newdir") + where, err := Save(test.chart, dest) + if err != nil { + t.Fatalf("Failed to save: %s", err) + } + // get shasum for package + result, err := sha256Sum(where) + if err != nil { + t.Fatalf("Failed to check shasum: %s", err) + } + // assert that the package SHA is what we wanted. + if result != test.want { + t.Errorf("FormatName() result = %v, want %v", result, test.want) + } + }) + } +} + +func sha256Sum(filePath string) (string, error) { + f, err := os.Open(filePath) + if err != nil { + return "", err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + + return fmt.Sprintf("%x", h.Sum(nil)), nil +} diff --git a/pkg/cmd/upgrade_test.go b/pkg/cmd/upgrade_test.go index fd715a1fa..8729be0ec 100644 --- a/pkg/cmd/upgrade_test.go +++ b/pkg/cmd/upgrade_test.go @@ -23,6 +23,7 @@ import ( "reflect" "strings" "testing" + "time" "helm.sh/helm/v4/pkg/chart/common" chart "helm.sh/helm/v4/pkg/chart/v2" @@ -408,7 +409,7 @@ func prepareMockRelease(t *testing.T, releaseName string) (func(n string, v int, Description: "A Helm chart for Kubernetes", Version: "0.1.0", }, - Templates: []*common.File{{Name: "templates/configmap.yaml", Data: configmapData}}, + Templates: []*common.File{{Name: "templates/configmap.yaml", ModTime: time.Now(), Data: configmapData}}, } chartPath := filepath.Join(tmpChart, cfile.Metadata.Name) if err := chartutil.SaveDir(cfile, tmpChart); err != nil { @@ -513,6 +514,7 @@ func prepareMockReleaseWithSecret(t *testing.T, releaseName string) (func(n stri if err != nil { t.Fatalf("Error loading template yaml %v", err) } + modTime := time.Now() cfile := &chart.Chart{ Metadata: &chart.Metadata{ APIVersion: chart.APIVersionV1, @@ -520,7 +522,7 @@ func prepareMockReleaseWithSecret(t *testing.T, releaseName string) (func(n stri Description: "A Helm chart for Kubernetes", Version: "0.1.0", }, - Templates: []*common.File{{Name: "templates/configmap.yaml", Data: configmapData}, {Name: "templates/secret.yaml", Data: secretData}}, + Templates: []*common.File{{Name: "templates/configmap.yaml", ModTime: modTime, Data: configmapData}, {Name: "templates/secret.yaml", ModTime: modTime, Data: secretData}}, } chartPath := filepath.Join(tmpChart, cfile.Metadata.Name) if err := chartutil.SaveDir(cfile, tmpChart); err != nil { diff --git a/pkg/engine/engine_test.go b/pkg/engine/engine_test.go index 542ac2a9c..e541ef9d7 100644 --- a/pkg/engine/engine_test.go +++ b/pkg/engine/engine_test.go @@ -23,6 +23,7 @@ import ( "sync" "testing" "text/template" + "time" "github.com/stretchr/testify/assert" @@ -90,17 +91,18 @@ func TestFuncMap(t *testing.T) { } func TestRender(t *testing.T) { + modTime := time.Now() c := &chart.Chart{ Metadata: &chart.Metadata{ Name: "moby", Version: "1.2.3", }, Templates: []*common.File{ - {Name: "templates/test1", Data: []byte("{{.Values.outer | title }} {{.Values.inner | title}}")}, - {Name: "templates/test2", Data: []byte("{{.Values.global.callme | lower }}")}, - {Name: "templates/test3", Data: []byte("{{.noValue}}")}, - {Name: "templates/test4", Data: []byte("{{toJson .Values}}")}, - {Name: "templates/test5", Data: []byte("{{getHostByName \"helm.sh\"}}")}, + {Name: "templates/test1", ModTime: modTime, Data: []byte("{{.Values.outer | title }} {{.Values.inner | title}}")}, + {Name: "templates/test2", ModTime: modTime, Data: []byte("{{.Values.global.callme | lower }}")}, + {Name: "templates/test3", ModTime: modTime, Data: []byte("{{.noValue}}")}, + {Name: "templates/test4", ModTime: modTime, Data: []byte("{{toJson .Values}}")}, + {Name: "templates/test5", ModTime: modTime, Data: []byte("{{getHostByName \"helm.sh\"}}")}, }, Values: map[string]interface{}{"outer": "DEFAULT", "inner": "DEFAULT"}, } @@ -140,14 +142,16 @@ func TestRender(t *testing.T) { } func TestRenderRefsOrdering(t *testing.T) { + modTime := time.Now() + parentChart := &chart.Chart{ Metadata: &chart.Metadata{ Name: "parent", Version: "1.2.3", }, Templates: []*common.File{ - {Name: "templates/_helpers.tpl", Data: []byte(`{{- define "test" -}}parent value{{- end -}}`)}, - {Name: "templates/test.yaml", Data: []byte(`{{ tpl "{{ include \"test\" . }}" . }}`)}, + {Name: "templates/_helpers.tpl", ModTime: modTime, Data: []byte(`{{- define "test" -}}parent value{{- end -}}`)}, + {Name: "templates/test.yaml", ModTime: modTime, Data: []byte(`{{ tpl "{{ include \"test\" . }}" . }}`)}, }, } childChart := &chart.Chart{ @@ -156,7 +160,7 @@ func TestRenderRefsOrdering(t *testing.T) { Version: "1.2.3", }, Templates: []*common.File{ - {Name: "templates/_helpers.tpl", Data: []byte(`{{- define "test" -}}child value{{- end -}}`)}, + {Name: "templates/_helpers.tpl", ModTime: modTime, Data: []byte(`{{- define "test" -}}child value{{- end -}}`)}, }, } parentChart.AddDependency(childChart) @@ -220,7 +224,7 @@ func TestRenderWithDNS(t *testing.T) { Version: "1.2.3", }, Templates: []*common.File{ - {Name: "templates/test1", Data: []byte("{{getHostByName \"helm.sh\"}}")}, + {Name: "templates/test1", ModTime: time.Now(), Data: []byte("{{getHostByName \"helm.sh\"}}")}, }, Values: map[string]interface{}{}, } @@ -355,10 +359,12 @@ func TestRenderWithClientProvider(t *testing.T) { Values: map[string]interface{}{}, } + modTime := time.Now() for name, exp := range cases { c.Templates = append(c.Templates, &common.File{ - Name: path.Join("templates", name), - Data: []byte(exp.template), + Name: path.Join("templates", name), + ModTime: modTime, + Data: []byte(exp.template), }) } @@ -393,7 +399,7 @@ func TestRenderWithClientProvider_error(t *testing.T) { Version: "1.2.3", }, Templates: []*common.File{ - {Name: "templates/error", Data: []byte(`{{ lookup "v1" "Error" "" "" }}`)}, + {Name: "templates/error", ModTime: time.Now(), Data: []byte(`{{ lookup "v1" "Error" "" "" }}`)}, }, Values: map[string]interface{}{}, } @@ -558,18 +564,19 @@ func TestFailErrors(t *testing.T) { } func TestAllTemplates(t *testing.T) { + modTime := time.Now() ch1 := &chart.Chart{ Metadata: &chart.Metadata{Name: "ch1"}, Templates: []*common.File{ - {Name: "templates/foo", Data: []byte("foo")}, - {Name: "templates/bar", Data: []byte("bar")}, + {Name: "templates/foo", ModTime: modTime, Data: []byte("foo")}, + {Name: "templates/bar", ModTime: modTime, Data: []byte("bar")}, }, } dep1 := &chart.Chart{ Metadata: &chart.Metadata{Name: "laboratory mice"}, Templates: []*common.File{ - {Name: "templates/pinky", Data: []byte("pinky")}, - {Name: "templates/brain", Data: []byte("brain")}, + {Name: "templates/pinky", ModTime: modTime, Data: []byte("pinky")}, + {Name: "templates/brain", ModTime: modTime, Data: []byte("brain")}, }, } ch1.AddDependency(dep1) @@ -577,7 +584,7 @@ func TestAllTemplates(t *testing.T) { dep2 := &chart.Chart{ Metadata: &chart.Metadata{Name: "same thing we do every night"}, Templates: []*common.File{ - {Name: "templates/innermost", Data: []byte("innermost")}, + {Name: "templates/innermost", ModTime: modTime, Data: []byte("innermost")}, }, } dep1.AddDependency(dep2) @@ -589,16 +596,17 @@ func TestAllTemplates(t *testing.T) { } func TestChartValuesContainsIsRoot(t *testing.T) { + modTime := time.Now() ch1 := &chart.Chart{ Metadata: &chart.Metadata{Name: "parent"}, Templates: []*common.File{ - {Name: "templates/isroot", Data: []byte("{{.Chart.IsRoot}}")}, + {Name: "templates/isroot", ModTime: modTime, Data: []byte("{{.Chart.IsRoot}}")}, }, } dep1 := &chart.Chart{ Metadata: &chart.Metadata{Name: "child"}, Templates: []*common.File{ - {Name: "templates/isroot", Data: []byte("{{.Chart.IsRoot}}")}, + {Name: "templates/isroot", ModTime: modTime, Data: []byte("{{.Chart.IsRoot}}")}, }, } ch1.AddDependency(dep1) @@ -621,16 +629,17 @@ func TestChartValuesContainsIsRoot(t *testing.T) { func TestRenderDependency(t *testing.T) { deptpl := `{{define "myblock"}}World{{end}}` toptpl := `Hello {{template "myblock"}}` + modTime := time.Now() ch := &chart.Chart{ Metadata: &chart.Metadata{Name: "outerchart"}, Templates: []*common.File{ - {Name: "templates/outer", Data: []byte(toptpl)}, + {Name: "templates/outer", ModTime: modTime, Data: []byte(toptpl)}, }, } ch.AddDependency(&chart.Chart{ Metadata: &chart.Metadata{Name: "innerchart"}, Templates: []*common.File{ - {Name: "templates/inner", Data: []byte(deptpl)}, + {Name: "templates/inner", ModTime: modTime, Data: []byte(deptpl)}, }, }) @@ -659,11 +668,12 @@ func TestRenderNestedValues(t *testing.T) { // Ensure subcharts scopes are working. subchartspath := "templates/subcharts.tpl" + modTime := time.Now() deepest := &chart.Chart{ Metadata: &chart.Metadata{Name: "deepest"}, Templates: []*common.File{ - {Name: deepestpath, Data: []byte(`And this same {{.Values.what}} that smiles {{.Values.global.when}}`)}, - {Name: checkrelease, Data: []byte(`Tomorrow will be {{default "happy" .Release.Name }}`)}, + {Name: deepestpath, ModTime: modTime, Data: []byte(`And this same {{.Values.what}} that smiles {{.Values.global.when}}`)}, + {Name: checkrelease, ModTime: modTime, Data: []byte(`Tomorrow will be {{default "happy" .Release.Name }}`)}, }, Values: map[string]interface{}{"what": "milkshake", "where": "here"}, } @@ -671,7 +681,7 @@ func TestRenderNestedValues(t *testing.T) { inner := &chart.Chart{ Metadata: &chart.Metadata{Name: "herrick"}, Templates: []*common.File{ - {Name: innerpath, Data: []byte(`Old {{.Values.who}} is still a-flyin'`)}, + {Name: innerpath, ModTime: modTime, Data: []byte(`Old {{.Values.who}} is still a-flyin'`)}, }, Values: map[string]interface{}{"who": "Robert", "what": "glasses"}, } @@ -680,8 +690,8 @@ func TestRenderNestedValues(t *testing.T) { outer := &chart.Chart{ Metadata: &chart.Metadata{Name: "top"}, Templates: []*common.File{ - {Name: outerpath, Data: []byte(`Gather ye {{.Values.what}} while ye may`)}, - {Name: subchartspath, Data: []byte(`The glorious Lamp of {{.Subcharts.herrick.Subcharts.deepest.Values.where}}, the {{.Subcharts.herrick.Values.what}}`)}, + {Name: outerpath, ModTime: modTime, Data: []byte(`Gather ye {{.Values.what}} while ye may`)}, + {Name: subchartspath, ModTime: modTime, Data: []byte(`The glorious Lamp of {{.Subcharts.herrick.Subcharts.deepest.Values.where}}, the {{.Subcharts.herrick.Values.what}}`)}, }, Values: map[string]interface{}{ "what": "stinkweed", @@ -754,23 +764,24 @@ func TestRenderNestedValues(t *testing.T) { } func TestRenderBuiltinValues(t *testing.T) { + modTime := time.Now() inner := &chart.Chart{ Metadata: &chart.Metadata{Name: "Latium", APIVersion: chart.APIVersionV2}, Templates: []*common.File{ - {Name: "templates/Lavinia", Data: []byte(`{{.Template.Name}}{{.Chart.Name}}{{.Release.Name}}`)}, - {Name: "templates/From", Data: []byte(`{{.Files.author | printf "%s"}} {{.Files.Get "book/title.txt"}}`)}, + {Name: "templates/Lavinia", ModTime: modTime, Data: []byte(`{{.Template.Name}}{{.Chart.Name}}{{.Release.Name}}`)}, + {Name: "templates/From", ModTime: modTime, Data: []byte(`{{.Files.author | printf "%s"}} {{.Files.Get "book/title.txt"}}`)}, }, Files: []*common.File{ - {Name: "author", Data: []byte("Virgil")}, - {Name: "book/title.txt", Data: []byte("Aeneid")}, + {Name: "author", ModTime: modTime, Data: []byte("Virgil")}, + {Name: "book/title.txt", ModTime: modTime, Data: []byte("Aeneid")}, }, } outer := &chart.Chart{ Metadata: &chart.Metadata{Name: "Troy", APIVersion: chart.APIVersionV2}, Templates: []*common.File{ - {Name: "templates/Aeneas", Data: []byte(`{{.Template.Name}}{{.Chart.Name}}{{.Release.Name}}`)}, - {Name: "templates/Amata", Data: []byte(`{{.Subcharts.Latium.Chart.Name}} {{.Subcharts.Latium.Files.author | printf "%s"}}`)}, + {Name: "templates/Aeneas", ModTime: modTime, Data: []byte(`{{.Template.Name}}{{.Chart.Name}}{{.Release.Name}}`)}, + {Name: "templates/Amata", ModTime: modTime, Data: []byte(`{{.Subcharts.Latium.Chart.Name}} {{.Subcharts.Latium.Files.author | printf "%s"}}`)}, }, } outer.AddDependency(inner) @@ -805,11 +816,12 @@ func TestRenderBuiltinValues(t *testing.T) { } func TestAlterFuncMap_include(t *testing.T) { + modTime := time.Now() c := &chart.Chart{ Metadata: &chart.Metadata{Name: "conrad"}, Templates: []*common.File{ - {Name: "templates/quote", Data: []byte(`{{include "conrad/templates/_partial" . | indent 2}} dead.`)}, - {Name: "templates/_partial", Data: []byte(`{{.Release.Name}} - he`)}, + {Name: "templates/quote", ModTime: modTime, Data: []byte(`{{include "conrad/templates/_partial" . | indent 2}} dead.`)}, + {Name: "templates/_partial", ModTime: modTime, Data: []byte(`{{.Release.Name}} - he`)}, }, } @@ -817,8 +829,8 @@ func TestAlterFuncMap_include(t *testing.T) { d := &chart.Chart{ Metadata: &chart.Metadata{Name: "nested"}, Templates: []*common.File{ - {Name: "templates/quote", Data: []byte(`{{include "nested/templates/quote" . | indent 2}} dead.`)}, - {Name: "templates/_partial", Data: []byte(`{{.Release.Name}} - he`)}, + {Name: "templates/quote", ModTime: modTime, Data: []byte(`{{include "nested/templates/quote" . | indent 2}} dead.`)}, + {Name: "templates/_partial", ModTime: modTime, Data: []byte(`{{.Release.Name}} - he`)}, }, } @@ -848,11 +860,12 @@ func TestAlterFuncMap_include(t *testing.T) { } func TestAlterFuncMap_require(t *testing.T) { + modTime := time.Now() c := &chart.Chart{ Metadata: &chart.Metadata{Name: "conan"}, Templates: []*common.File{ - {Name: "templates/quote", Data: []byte(`All your base are belong to {{ required "A valid 'who' is required" .Values.who }}`)}, - {Name: "templates/bases", Data: []byte(`All {{ required "A valid 'bases' is required" .Values.bases }} of them!`)}, + {Name: "templates/quote", ModTime: modTime, Data: []byte(`All your base are belong to {{ required "A valid 'who' is required" .Values.who }}`)}, + {Name: "templates/bases", ModTime: modTime, Data: []byte(`All {{ required "A valid 'bases' is required" .Values.bases }} of them!`)}, }, } @@ -913,7 +926,7 @@ func TestAlterFuncMap_tpl(t *testing.T) { c := &chart.Chart{ Metadata: &chart.Metadata{Name: "TplFunction"}, Templates: []*common.File{ - {Name: "templates/base", Data: []byte(`Evaluate tpl {{tpl "Value: {{ .Values.value}}" .}}`)}, + {Name: "templates/base", ModTime: time.Now(), Data: []byte(`Evaluate tpl {{tpl "Value: {{ .Values.value}}" .}}`)}, }, } @@ -942,7 +955,7 @@ func TestAlterFuncMap_tplfunc(t *testing.T) { c := &chart.Chart{ Metadata: &chart.Metadata{Name: "TplFunction"}, Templates: []*common.File{ - {Name: "templates/base", Data: []byte(`Evaluate tpl {{tpl "Value: {{ .Values.value | quote}}" .}}`)}, + {Name: "templates/base", ModTime: time.Now(), Data: []byte(`Evaluate tpl {{tpl "Value: {{ .Values.value | quote}}" .}}`)}, }, } @@ -968,11 +981,12 @@ func TestAlterFuncMap_tplfunc(t *testing.T) { } func TestAlterFuncMap_tplinclude(t *testing.T) { + modTime := time.Now() c := &chart.Chart{ Metadata: &chart.Metadata{Name: "TplFunction"}, Templates: []*common.File{ - {Name: "templates/base", Data: []byte(`{{ tpl "{{include ` + "`" + `TplFunction/templates/_partial` + "`" + ` . | quote }}" .}}`)}, - {Name: "templates/_partial", Data: []byte(`{{.Template.Name}}`)}, + {Name: "templates/base", ModTime: modTime, Data: []byte(`{{ tpl "{{include ` + "`" + `TplFunction/templates/_partial` + "`" + ` . | quote }}" .}}`)}, + {Name: "templates/_partial", ModTime: modTime, Data: []byte(`{{.Template.Name}}`)}, }, } v := common.Values{ @@ -998,12 +1012,14 @@ func TestAlterFuncMap_tplinclude(t *testing.T) { } func TestRenderRecursionLimit(t *testing.T) { + modTime := time.Now() + // endless recursion should produce an error c := &chart.Chart{ Metadata: &chart.Metadata{Name: "bad"}, Templates: []*common.File{ - {Name: "templates/base", Data: []byte(`{{include "recursion" . }}`)}, - {Name: "templates/recursion", Data: []byte(`{{define "recursion"}}{{include "recursion" . }}{{end}}`)}, + {Name: "templates/base", ModTime: modTime, Data: []byte(`{{include "recursion" . }}`)}, + {Name: "templates/recursion", ModTime: modTime, Data: []byte(`{{define "recursion"}}{{include "recursion" . }}{{end}}`)}, }, } v := common.Values{ @@ -1032,8 +1048,8 @@ func TestRenderRecursionLimit(t *testing.T) { d := &chart.Chart{ Metadata: &chart.Metadata{Name: "overlook"}, Templates: []*common.File{ - {Name: "templates/quote", Data: []byte(repeatedIncl.String())}, - {Name: "templates/_function", Data: []byte(printFunc)}, + {Name: "templates/quote", ModTime: modTime, Data: []byte(repeatedIncl.String())}, + {Name: "templates/_function", ModTime: modTime, Data: []byte(printFunc)}, }, } @@ -1053,15 +1069,16 @@ func TestRenderRecursionLimit(t *testing.T) { } func TestRenderLoadTemplateForTplFromFile(t *testing.T) { + modTime := time.Now() c := &chart.Chart{ Metadata: &chart.Metadata{Name: "TplLoadFromFile"}, Templates: []*common.File{ - {Name: "templates/base", Data: []byte(`{{ tpl (.Files.Get .Values.filename) . }}`)}, - {Name: "templates/_function", Data: []byte(`{{define "test-function"}}test-function{{end}}`)}, + {Name: "templates/base", ModTime: modTime, Data: []byte(`{{ tpl (.Files.Get .Values.filename) . }}`)}, + {Name: "templates/_function", ModTime: modTime, Data: []byte(`{{define "test-function"}}test-function{{end}}`)}, }, Files: []*common.File{ - {Name: "test", Data: []byte(`{{ tpl (.Files.Get .Values.filename2) .}}`)}, - {Name: "test2", Data: []byte(`{{include "test-function" .}}{{define "nested-define"}}nested-define-content{{end}} {{include "nested-define" .}}`)}, + {Name: "test", ModTime: modTime, Data: []byte(`{{ tpl (.Files.Get .Values.filename2) .}}`)}, + {Name: "test2", ModTime: modTime, Data: []byte(`{{include "test-function" .}}{{define "nested-define"}}nested-define-content{{end}} {{include "nested-define" .}}`)}, }, } @@ -1088,12 +1105,13 @@ func TestRenderLoadTemplateForTplFromFile(t *testing.T) { } func TestRenderTplEmpty(t *testing.T) { + modTime := time.Now() c := &chart.Chart{ Metadata: &chart.Metadata{Name: "TplEmpty"}, Templates: []*common.File{ - {Name: "templates/empty-string", Data: []byte(`{{tpl "" .}}`)}, - {Name: "templates/empty-action", Data: []byte(`{{tpl "{{ \"\"}}" .}}`)}, - {Name: "templates/only-defines", Data: []byte(`{{tpl "{{define \"not-invoked\"}}not-rendered{{end}}" .}}`)}, + {Name: "templates/empty-string", ModTime: modTime, Data: []byte(`{{tpl "" .}}`)}, + {Name: "templates/empty-action", ModTime: modTime, Data: []byte(`{{tpl "{{ \"\"}}" .}}`)}, + {Name: "templates/only-defines", ModTime: modTime, Data: []byte(`{{tpl "{{define \"not-invoked\"}}not-rendered{{end}}" .}}`)}, }, } v := common.Values{ @@ -1121,15 +1139,16 @@ func TestRenderTplEmpty(t *testing.T) { } func TestRenderTplTemplateNames(t *testing.T) { + modTime := time.Now() // .Template.BasePath and .Name make it through c := &chart.Chart{ Metadata: &chart.Metadata{Name: "TplTemplateNames"}, Templates: []*common.File{ - {Name: "templates/default-basepath", Data: []byte(`{{tpl "{{ .Template.BasePath }}" .}}`)}, - {Name: "templates/default-name", Data: []byte(`{{tpl "{{ .Template.Name }}" .}}`)}, - {Name: "templates/modified-basepath", Data: []byte(`{{tpl "{{ .Template.BasePath }}" .Values.dot}}`)}, - {Name: "templates/modified-name", Data: []byte(`{{tpl "{{ .Template.Name }}" .Values.dot}}`)}, - {Name: "templates/modified-field", Data: []byte(`{{tpl "{{ .Template.Field }}" .Values.dot}}`)}, + {Name: "templates/default-basepath", ModTime: modTime, Data: []byte(`{{tpl "{{ .Template.BasePath }}" .}}`)}, + {Name: "templates/default-name", ModTime: modTime, Data: []byte(`{{tpl "{{ .Template.Name }}" .}}`)}, + {Name: "templates/modified-basepath", ModTime: modTime, Data: []byte(`{{tpl "{{ .Template.BasePath }}" .Values.dot}}`)}, + {Name: "templates/modified-name", ModTime: modTime, Data: []byte(`{{tpl "{{ .Template.Name }}" .Values.dot}}`)}, + {Name: "templates/modified-field", ModTime: modTime, Data: []byte(`{{tpl "{{ .Template.Field }}" .Values.dot}}`)}, }, } v := common.Values{ @@ -1168,12 +1187,13 @@ func TestRenderTplTemplateNames(t *testing.T) { } func TestRenderTplRedefines(t *testing.T) { + modTime := time.Now() // Redefining a template inside 'tpl' does not affect the outer definition c := &chart.Chart{ Metadata: &chart.Metadata{Name: "TplRedefines"}, Templates: []*common.File{ - {Name: "templates/_partials", Data: []byte(`{{define "partial"}}original-in-partial{{end}}`)}, - {Name: "templates/partial", Data: []byte( + {Name: "templates/_partials", ModTime: modTime, Data: []byte(`{{define "partial"}}original-in-partial{{end}}`)}, + {Name: "templates/partial", ModTime: modTime, Data: []byte( `before: {{include "partial" .}}\n{{tpl .Values.partialText .}}\nafter: {{include "partial" .}}`, )}, {Name: "templates/manifest", Data: []byte( @@ -1238,7 +1258,7 @@ func TestRenderTplMissingKey(t *testing.T) { c := &chart.Chart{ Metadata: &chart.Metadata{Name: "TplMissingKey"}, Templates: []*common.File{ - {Name: "templates/manifest", Data: []byte( + {Name: "templates/manifest", ModTime: time.Now(), Data: []byte( `missingValue: {{tpl "{{.Values.noSuchKey}}" .}}`, )}, }, @@ -1271,7 +1291,7 @@ func TestRenderTplMissingKeyString(t *testing.T) { c := &chart.Chart{ Metadata: &chart.Metadata{Name: "TplMissingKeyStrict"}, Templates: []*common.File{ - {Name: "templates/manifest", Data: []byte( + {Name: "templates/manifest", ModTime: time.Now(), Data: []byte( `missingValue: {{tpl "{{.Values.noSuchKey}}" .}}`, )}, }, @@ -1300,16 +1320,17 @@ func TestRenderTplMissingKeyString(t *testing.T) { } func TestNestedHelpersProducesMultilineStacktrace(t *testing.T) { + modTime := time.Now() c := &chart.Chart{ Metadata: &chart.Metadata{Name: "NestedHelperFunctions"}, Templates: []*common.File{ - {Name: "templates/svc.yaml", Data: []byte( + {Name: "templates/svc.yaml", ModTime: modTime, Data: []byte( `name: {{ include "nested_helper.name" . }}`, )}, - {Name: "templates/_helpers_1.tpl", Data: []byte( + {Name: "templates/_helpers_1.tpl", ModTime: modTime, Data: []byte( `{{- define "nested_helper.name" -}}{{- include "common.names.get_name" . -}}{{- end -}}`, )}, - {Name: "charts/common/templates/_helpers_2.tpl", Data: []byte( + {Name: "charts/common/templates/_helpers_2.tpl", ModTime: modTime, Data: []byte( `{{- define "common.names.get_name" -}}{{- .Values.nonexistant.key | trunc 63 | trimSuffix "-" -}}{{- end -}}`, )}, }, @@ -1338,16 +1359,17 @@ NestedHelperFunctions/charts/common/templates/_helpers_2.tpl:1:49 } func TestMultilineNoTemplateAssociatedError(t *testing.T) { + modTime := time.Now() c := &chart.Chart{ Metadata: &chart.Metadata{Name: "multiline"}, Templates: []*common.File{ - {Name: "templates/svc.yaml", Data: []byte( + {Name: "templates/svc.yaml", ModTime: modTime, Data: []byte( `name: {{ include "nested_helper.name" . }}`, )}, - {Name: "templates/test.yaml", Data: []byte( + {Name: "templates/test.yaml", ModTime: modTime, Data: []byte( `{{ toYaml .Values }}`, )}, - {Name: "charts/common/templates/_helpers_2.tpl", Data: []byte( + {Name: "charts/common/templates/_helpers_2.tpl", ModTime: modTime, Data: []byte( `{{ toYaml .Values }}`, )}, }, @@ -1371,17 +1393,21 @@ template: no template "nested_helper.name" associated with template "gotpl"` } func TestRenderCustomTemplateFuncs(t *testing.T) { + modTime := time.Now() + // Create a chart with two templates that use custom functions c := &chart.Chart{ Metadata: &chart.Metadata{Name: "CustomFunc"}, Templates: []*common.File{ { - Name: "templates/manifest", - Data: []byte(`{{exclaim .Values.message}}`), + Name: "templates/manifest", + ModTime: modTime, + Data: []byte(`{{exclaim .Values.message}}`), }, { - Name: "templates/override", - Data: []byte(`{{ upper .Values.message }}`), + Name: "templates/override", + ModTime: modTime, + Data: []byte(`{{ upper .Values.message }}`), }, }, } diff --git a/pkg/release/v1/mock.go b/pkg/release/v1/mock.go index 06ad90e8f..dc135a24a 100644 --- a/pkg/release/v1/mock.go +++ b/pkg/release/v1/mock.go @@ -101,7 +101,7 @@ func Mock(opts *MockReleaseOptions) *Release { }, }, Templates: []*common.File{ - {Name: "templates/foo.tpl", Data: []byte(MockManifest)}, + {Name: "templates/foo.tpl", ModTime: time.Now(), Data: []byte(MockManifest)}, }, } } From 15300549f085edbbb395ec2845518d20e89748b1 Mon Sep 17 00:00:00 2001 From: Matt Farina Date: Wed, 29 Oct 2025 15:11:20 -0400 Subject: [PATCH 2/2] When time not available, using time.Now Note, when time is not available, the builds are not reproducible. This problem would only happen when an SDK user is using parts of the API to build their own tooling. Helm will consistently inject the dates through the higher level APIs. Signed-off-by: Matt Farina --- internal/chart/v3/util/save.go | 3 +++ internal/chart/v3/util/save_test.go | 10 ++++++---- pkg/chart/v2/util/save.go | 3 +++ pkg/chart/v2/util/save_test.go | 14 ++++++++------ 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/internal/chart/v3/util/save.go b/internal/chart/v3/util/save.go index f755300ba..f886c6175 100644 --- a/internal/chart/v3/util/save.go +++ b/internal/chart/v3/util/save.go @@ -234,6 +234,9 @@ func writeToTar(out *tar.Writer, name string, body []byte, modTime time.Time) er Size: int64(len(body)), ModTime: modTime, } + if h.ModTime.IsZero() { + h.ModTime = time.Now() + } if err := out.WriteHeader(h); err != nil { return err } diff --git a/internal/chart/v3/util/save_test.go b/internal/chart/v3/util/save_test.go index 93da34470..62625919b 100644 --- a/internal/chart/v3/util/save_test.go +++ b/internal/chart/v3/util/save_test.go @@ -285,7 +285,8 @@ func TestRepeatableSave(t *testing.T) { }, ModTime: modTime, Lock: &chart.Lock{ - Digest: "testdigest", + Digest: "testdigest", + Generated: modTime, }, Files: []*common.File{ {Name: "scheherazade/shahryar.txt", ModTime: modTime, Data: []byte("1,001 Nights")}, @@ -293,7 +294,7 @@ func TestRepeatableSave(t *testing.T) { Schema: []byte("{\n \"title\": \"Values\"\n}"), SchemaModTime: modTime, }, - want: "bcb52ba7b7c2801be84cdc96d395f00749896a4679a7c9deacdfe934d0c49c1b", + want: "5bfea18cc3c8cbc265744bc32bffa9489a4dbe87d6b51b90f4255e4839d35e03", }, { name: "Package 2 files", @@ -305,7 +306,8 @@ func TestRepeatableSave(t *testing.T) { }, ModTime: modTime, Lock: &chart.Lock{ - Digest: "testdigest", + Digest: "testdigest", + Generated: modTime, }, Files: []*common.File{ {Name: "scheherazade/shahryar.txt", ModTime: modTime, Data: []byte("1,001 Nights")}, @@ -314,7 +316,7 @@ func TestRepeatableSave(t *testing.T) { Schema: []byte("{\n \"title\": \"Values\"\n}"), SchemaModTime: modTime, }, - want: "566bb87d0a044828e1e3acc4e9849b2c378eb9156a8662ceb618ea41b279bb10", + want: "a240365c21e0a2f4a57873132a9b686566a612d08bcb3f20c9446bfff005ccce", }, } for _, test := range tests { diff --git a/pkg/chart/v2/util/save.go b/pkg/chart/v2/util/save.go index 632588b68..e66d86991 100644 --- a/pkg/chart/v2/util/save.go +++ b/pkg/chart/v2/util/save.go @@ -246,6 +246,9 @@ func writeToTar(out *tar.Writer, name string, body []byte, modTime time.Time) er Size: int64(len(body)), ModTime: modTime, } + if h.ModTime.IsZero() { + h.ModTime = time.Now() + } if err := out.WriteHeader(h); err != nil { return err } diff --git a/pkg/chart/v2/util/save_test.go b/pkg/chart/v2/util/save_test.go index 5dc36e786..e317d1c09 100644 --- a/pkg/chart/v2/util/save_test.go +++ b/pkg/chart/v2/util/save_test.go @@ -283,13 +283,14 @@ func TestRepeatableSave(t *testing.T) { name: "Package 1 file", chart: &chart.Chart{ Metadata: &chart.Metadata{ - APIVersion: chart.APIVersionV1, + APIVersion: chart.APIVersionV2, Name: "ahab", Version: "1.2.3", }, ModTime: modTime, Lock: &chart.Lock{ - Digest: "testdigest", + Digest: "testdigest", + Generated: modTime, }, Files: []*common.File{ {Name: "scheherazade/shahryar.txt", ModTime: modTime, Data: []byte("1,001 Nights")}, @@ -297,19 +298,20 @@ func TestRepeatableSave(t *testing.T) { Schema: []byte("{\n \"title\": \"Values\"\n}"), SchemaModTime: modTime, }, - want: "5e14a06037e5d4cb277c7b21770639d4e1a337be9ae391460e50653bac5a80ed", + want: "fea2662522317b65c2788ff9e5fc446a9264830038dac618d4449493d99b3257", }, { name: "Package 2 files", chart: &chart.Chart{ Metadata: &chart.Metadata{ - APIVersion: chart.APIVersionV1, + APIVersion: chart.APIVersionV2, Name: "ahab", Version: "1.2.3", }, ModTime: modTime, Lock: &chart.Lock{ - Digest: "testdigest", + Digest: "testdigest", + Generated: modTime, }, Files: []*common.File{ {Name: "scheherazade/shahryar.txt", ModTime: modTime, Data: []byte("1,001 Nights")}, @@ -318,7 +320,7 @@ func TestRepeatableSave(t *testing.T) { Schema: []byte("{\n \"title\": \"Values\"\n}"), SchemaModTime: modTime, }, - want: "6967787da46fbfcc563cad31240e564e14f2602e6f66302129a59a9669622a36", + want: "7ae92b2f274bb51ea3f1969e4187d78cc52b5f6f663b44b8fb3b40bcb8ee46f3", }, } for _, test := range tests {