From 5f75069e0436f82be446c410a7dd769d71c2038b Mon Sep 17 00:00:00 2001 From: Zoran Regvart Date: Fri, 22 Sep 2023 13:00:46 +0200 Subject: [PATCH 1/2] Sort bundle layers according to their digest Given the same set of files in any order this allows the layers to be added to the image manifest in the deterministic order as they are now sorted according to their sha256 digest. --- pkg/bundle/builder.go | 11 +++++++ pkg/bundle/builder_test.go | 65 +++++++++++++++++++++++++++++++++++++ pkg/cmd/bundle/list_test.go | 6 ++-- 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/pkg/bundle/builder.go b/pkg/bundle/builder.go index 1319d6a03..693b012a0 100644 --- a/pkg/bundle/builder.go +++ b/pkg/bundle/builder.go @@ -3,9 +3,11 @@ package bundle import ( "archive/tar" "bytes" + "crypto/sha256" "errors" "fmt" "io" + "sort" "strings" "time" @@ -30,6 +32,15 @@ func BuildTektonBundle(contents []string, annotations map[string]string, log io. fmt.Fprint(log, "Creating Tekton Bundle:\n") + // sort the contens based on the digest of the content, this keeps the layer + // order in the image manifest deterministic + sort.Slice(contents, func(i, j int) bool { + iDigest := sha256.Sum256([]byte(contents[i])) + jDigest := sha256.Sum256([]byte(contents[j])) + + return bytes.Compare(iDigest[:], jDigest[:]) < 0 + }) + // For each block of input, attempt to parse all of the YAML/JSON objects as Tekton objects and compress them into // the OCI image as a tar layer. for _, content := range contents { diff --git a/pkg/bundle/builder_test.go b/pkg/bundle/builder_test.go index 5461e4c7d..ca72abb29 100644 --- a/pkg/bundle/builder_test.go +++ b/pkg/bundle/builder_test.go @@ -6,6 +6,8 @@ import ( "errors" "fmt" "io" + "math/rand" + "sort" "testing" "github.com/google/go-cmp/cmp" @@ -214,3 +216,66 @@ func TestTooManyInBundle(t *testing.T) { t.Errorf("expected error: %v", toManyObjErr) } } + +func TestDeterministicLayers(t *testing.T) { + contents := []string{ + `apiVersion: tekton.dev/v1 +kind: Task +metadata: + name: task1 +spec: + description: task1 +`, + `apiVersion: tekton.dev/v1 +kind: Task +metadata: + name: task2 +spec: + description: task2 +`, + `apiVersion: tekton.dev/v1 +kind: Task +metadata: + name: task3 +spec: + description: task3 +`, + } + + // shuffle the contents + sort.Slice(contents, func(i, j int) bool { + return rand.Intn(2) == 0 + }) + + t.Log(contents) + + img, err := BuildTektonBundle(contents, nil, &bytes.Buffer{}) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + layers, err := img.Layers() + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if l := len(layers); l != 3 { + t.Errorf("expecting 3 layers got: %d", l) + } + + compare := func(n int, expected string) { + digest, err := layers[n].Digest() + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + got := digest.String() + if got != expected { + t.Errorf("unexpected digest for layer %d: %s, expecting %s", n, got, expected) + } + } + + compare(0, "sha256:561b99bf08733028cbc799caf7f8b74e1f633d3acb7c6d25d880bae4b32cd0b5") + compare(1, "sha256:bd941a3b5d1618820ba5283fd0dd4138379fef0e927864d35629cfdc1bdd2f3f") + compare(2, "sha256:751deb7e696b6a4f30a2e23f25f97a886cbff22fe832a0c7ed956598ec489f58") +} diff --git a/pkg/cmd/bundle/list_test.go b/pkg/cmd/bundle/list_test.go index d597a1c91..0b2539f36 100644 --- a/pkg/cmd/bundle/list_test.go +++ b/pkg/cmd/bundle/list_test.go @@ -47,15 +47,15 @@ func TestListCommand(t *testing.T) { { name: "no-format", format: "", - expectedStdout: "*Warning*: This is an experimental command, it's usage and behavior can change in the next release(s)\ntask.tekton.dev/foobar\npipeline.tekton.dev/foobar\n", + expectedStdout: "*Warning*: This is an experimental command, it's usage and behavior can change in the next release(s)\npipeline.tekton.dev/foobar\ntask.tekton.dev/foobar\n", }, { name: "name-format", format: "name", - expectedStdout: "*Warning*: This is an experimental command, it's usage and behavior can change in the next release(s)\ntask.tekton.dev/foobar\npipeline.tekton.dev/foobar\n", + expectedStdout: "*Warning*: This is an experimental command, it's usage and behavior can change in the next release(s)\npipeline.tekton.dev/foobar\ntask.tekton.dev/foobar\n", }, { name: "yaml-format", format: "yaml", - expectedStdout: "*Warning*: This is an experimental command, it's usage and behavior can change in the next release(s)\n" + examplePullTask + examplePullPipeline, + expectedStdout: "*Warning*: This is an experimental command, it's usage and behavior can change in the next release(s)\n" + examplePullPipeline + examplePullTask, }, { name: "specify-kind-task", format: "name", From 6089ae538b350a11f4d90acc9ad3acbea9781b9c Mon Sep 17 00:00:00 2001 From: Zoran Regvart Date: Fri, 22 Sep 2023 15:40:53 +0200 Subject: [PATCH 2/2] Add `--ctime` parameter to `tkn bundle push` This allows specifying the created time in the image config instead of using the current time. If `--ctime` is not provided the current time is used. `--ctime` supports date, date and time in UTC timezone and RFC3339 formatted date that can include the timezone. --- docs/cmd/tkn_bundle_push.md | 1 + docs/man/man1/tkn-bundle-push.1 | 4 ++ pkg/bundle/builder.go | 6 +-- pkg/bundle/builder_test.go | 91 +++++++++++++++++++-------------- pkg/cmd/bundle/list_test.go | 3 +- pkg/cmd/bundle/push.go | 44 ++++++++++++++-- pkg/cmd/bundle/push_test.go | 56 ++++++++++++++++++++ 7 files changed, 161 insertions(+), 44 deletions(-) diff --git a/docs/cmd/tkn_bundle_push.md b/docs/cmd/tkn_bundle_push.md index dc73819fc..d35259b3e 100644 --- a/docs/cmd/tkn_bundle_push.md +++ b/docs/cmd/tkn_bundle_push.md @@ -30,6 +30,7 @@ Input: ``` --annotate strings OCI Manifest annotation in the form of key=value to be added to the OCI image. Can be provided multiple times to add multiple annotations. + --ctime string YYYY-MM-DD, YYYY-MM-DDTHH:MM:SS or RFC3339 formatted created time to set, defaults to current time. In non RFC3339 syntax dates are in UTC timezone. -f, --filenames strings List of fully-qualified file paths containing YAML or JSON defined Tekton objects to include in this bundle -h, --help help for push --remote-bearer string A Bearer token to authenticate against the repository diff --git a/docs/man/man1/tkn-bundle-push.1 b/docs/man/man1/tkn-bundle-push.1 index b9241a4c9..d820ac3c5 100644 --- a/docs/man/man1/tkn-bundle-push.1 +++ b/docs/man/man1/tkn-bundle-push.1 @@ -45,6 +45,10 @@ Input: \fB\-\-annotate\fP=[] OCI Manifest annotation in the form of key=value to be added to the OCI image. Can be provided multiple times to add multiple annotations. +.PP +\fB\-\-ctime\fP="" + YYYY\-MM\-DD, YYYY\-MM\-DDTHH:MM:SS or RFC3339 formatted created time to set, defaults to current time. In non RFC3339 syntax dates are in UTC timezone. + .PP \fB\-f\fP, \fB\-\-filenames\fP=[] List of fully\-qualified file paths containing YAML or JSON defined Tekton objects to include in this bundle diff --git a/pkg/bundle/builder.go b/pkg/bundle/builder.go index 693b012a0..6abb1e22d 100644 --- a/pkg/bundle/builder.go +++ b/pkg/bundle/builder.go @@ -23,7 +23,7 @@ import ( // BuildTektonBundle will return a complete OCI Image usable as a Tekton Bundle built by parsing, decoding, and // compressing the provided contents as Tekton objects. -func BuildTektonBundle(contents []string, annotations map[string]string, log io.Writer) (v1.Image, error) { +func BuildTektonBundle(contents []string, annotations map[string]string, ctime time.Time, log io.Writer) (v1.Image, error) { img := mutate.Annotations(empty.Image, annotations).(v1.Image) if len(contents) > tkremote.MaximumBundleObjects { @@ -32,7 +32,7 @@ func BuildTektonBundle(contents []string, annotations map[string]string, log io. fmt.Fprint(log, "Creating Tekton Bundle:\n") - // sort the contens based on the digest of the content, this keeps the layer + // sort the contents based on the digest of the content, this keeps the layer // order in the image manifest deterministic sort.Slice(contents, func(i, j int) bool { iDigest := sha256.Sum256([]byte(contents[i])) @@ -96,7 +96,7 @@ func BuildTektonBundle(contents []string, annotations map[string]string, log io. } // Set created time for bundle image - img, err := mutate.CreatedAt(img, v1.Time{Time: time.Now()}) + img, err := mutate.CreatedAt(img, v1.Time{Time: ctime}) if err != nil { return nil, fmt.Errorf("failed to add created time to image: %w", err) } diff --git a/pkg/bundle/builder_test.go b/pkg/bundle/builder_test.go index ca72abb29..2d96f2821 100644 --- a/pkg/bundle/builder_test.go +++ b/pkg/bundle/builder_test.go @@ -9,6 +9,7 @@ import ( "math/rand" "sort" "testing" + "time" "github.com/google/go-cmp/cmp" "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" @@ -18,6 +19,37 @@ import ( "sigs.k8s.io/yaml" ) +var threeTasks = []string{ + `apiVersion: tekton.dev/v1 +kind: Task +metadata: + name: task1 +spec: + description: task1 +`, + `apiVersion: tekton.dev/v1 +kind: Task +metadata: + name: task2 +spec: + description: task2 +`, + `apiVersion: tekton.dev/v1 +kind: Task +metadata: + name: task3 +spec: + description: task3 +`, +} + +func init() { + // shuffle the test tasks + sort.Slice(threeTasks, func(i, j int) bool { + return rand.Intn(2) == 0 + }) +} + // Note, that for this test we are only using one object type to precisely test the image contents. The // #TestDecodeFromRaw tests the general parsing logic. func TestBuildTektonBundle(t *testing.T) { @@ -37,7 +69,7 @@ func TestBuildTektonBundle(t *testing.T) { } annotations := map[string]string{"org.opencontainers.image.license": "Apache-2.0", "org.opencontainers.image.url": "https://example.org"} - img, err := BuildTektonBundle([]string{string(raw)}, annotations, &bytes.Buffer{}) + img, err := BuildTektonBundle([]string{string(raw)}, annotations, time.Now(), &bytes.Buffer{}) if err != nil { t.Error(err) } @@ -131,7 +163,7 @@ func TestBadObj(t *testing.T) { t.Error(err) return } - _, err = BuildTektonBundle([]string{string(raw)}, nil, &bytes.Buffer{}) + _, err = BuildTektonBundle([]string{string(raw)}, nil, time.Now(), &bytes.Buffer{}) noNameErr := errors.New("kubernetes resources should have a name") if err == nil { t.Errorf("expected error: %v", noNameErr) @@ -154,7 +186,7 @@ func TestLessThenMaxBundle(t *testing.T) { return } // no error for less then max - _, err = BuildTektonBundle([]string{string(raw)}, nil, &bytes.Buffer{}) + _, err = BuildTektonBundle([]string{string(raw)}, nil, time.Now(), &bytes.Buffer{}) if err != nil { t.Error(err) } @@ -182,7 +214,7 @@ func TestJustEnoughBundleSize(t *testing.T) { justEnoughObj = append(justEnoughObj, string(raw)) } // no error for the max - _, err := BuildTektonBundle(justEnoughObj, nil, &bytes.Buffer{}) + _, err := BuildTektonBundle(justEnoughObj, nil, time.Now(), &bytes.Buffer{}) if err != nil { t.Error(err) } @@ -211,45 +243,14 @@ func TestTooManyInBundle(t *testing.T) { } // expect error when we hit the max - _, err := BuildTektonBundle(toMuchObj, nil, &bytes.Buffer{}) + _, err := BuildTektonBundle(toMuchObj, nil, time.Now(), &bytes.Buffer{}) if err == nil { t.Errorf("expected error: %v", toManyObjErr) } } func TestDeterministicLayers(t *testing.T) { - contents := []string{ - `apiVersion: tekton.dev/v1 -kind: Task -metadata: - name: task1 -spec: - description: task1 -`, - `apiVersion: tekton.dev/v1 -kind: Task -metadata: - name: task2 -spec: - description: task2 -`, - `apiVersion: tekton.dev/v1 -kind: Task -metadata: - name: task3 -spec: - description: task3 -`, - } - - // shuffle the contents - sort.Slice(contents, func(i, j int) bool { - return rand.Intn(2) == 0 - }) - - t.Log(contents) - - img, err := BuildTektonBundle(contents, nil, &bytes.Buffer{}) + img, err := BuildTektonBundle(threeTasks, nil, time.Now(), &bytes.Buffer{}) if err != nil { t.Errorf("unexpected error: %v", err) } @@ -279,3 +280,19 @@ spec: compare(1, "sha256:bd941a3b5d1618820ba5283fd0dd4138379fef0e927864d35629cfdc1bdd2f3f") compare(2, "sha256:751deb7e696b6a4f30a2e23f25f97a886cbff22fe832a0c7ed956598ec489f58") } + +func TestDeterministicManifest(t *testing.T) { + img, err := BuildTektonBundle(threeTasks, nil, time.Time{}, &bytes.Buffer{}) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + digest, err := img.Digest() + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if expected, got := "sha256:7a4f604555b84cdb06cbfebda3fb599cd7485ef2c9c9375ab589f192a3addb4c", digest.String(); expected != got { + t.Errorf("unexpected image digest: %s, expecting %s", got, expected) + } +} diff --git a/pkg/cmd/bundle/list_test.go b/pkg/cmd/bundle/list_test.go index 0b2539f36..1bc6d5c06 100644 --- a/pkg/cmd/bundle/list_test.go +++ b/pkg/cmd/bundle/list_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "net/url" "testing" + "time" "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/registry" @@ -104,7 +105,7 @@ func TestListCommand(t *testing.T) { t.Fatal(err) } - img, err := bundle.BuildTektonBundle([]string{examplePullTask, examplePullPipeline}, nil, &bytes.Buffer{}) + img, err := bundle.BuildTektonBundle([]string{examplePullTask, examplePullPipeline}, nil, time.Now(), &bytes.Buffer{}) if err != nil { t.Fatal(err) } diff --git a/pkg/cmd/bundle/push.go b/pkg/cmd/bundle/push.go index c56efa009..b734cb373 100644 --- a/pkg/cmd/bundle/push.go +++ b/pkg/cmd/bundle/push.go @@ -17,6 +17,7 @@ import ( "fmt" "io" "os" + "time" "github.com/google/go-containerregistry/pkg/name" "github.com/spf13/cobra" @@ -34,6 +35,7 @@ type pushOptions struct { remoteOptions bundle.RemoteOptions annotationParams []string annotations map[string]string + ctime time.Time } func pushCommand(_ cli.Params) *cobra.Command { @@ -55,6 +57,8 @@ Input: Valid input in any form is valid Tekton YAML or JSON with a fully-specified "apiVersion" and "kind". To pass multiple objects in a single input, use "---" separators in YAML or a top-level "[]" in JSON. ` + var ctime string + c := &cobra.Command{ Use: "push", Short: "Create or replace a Tekton bundle", @@ -69,8 +73,16 @@ Input: return errInvalidRef } - _, err := name.ParseReference(args[0], name.StrictValidation, name.Insecure) - return err + if _, err := name.ParseReference(args[0], name.StrictValidation, name.Insecure); err != nil { + return err + } + + var err error + if opts.ctime, err = parseTime(ctime); err != nil { + return err + } + + return nil }, RunE: func(cmd *cobra.Command, args []string) error { opts.stream = &cli.Stream{ @@ -84,6 +96,7 @@ Input: } c.Flags().StringSliceVarP(&opts.bundleContentPaths, "filenames", "f", []string{}, "List of fully-qualified file paths containing YAML or JSON defined Tekton objects to include in this bundle") c.Flags().StringSliceVarP(&opts.annotationParams, "annotate", "", []string{}, "OCI Manifest annotation in the form of key=value to be added to the OCI image. Can be provided multiple times to add multiple annotations.") + c.Flags().StringVar(&ctime, "ctime", "", "YYYY-MM-DD, YYYY-MM-DDTHH:MM:SS or RFC3339 formatted created time to set, defaults to current time. In non RFC3339 syntax dates are in UTC timezone.") bundle.AddRemoteFlags(c.Flags(), &opts.remoteOptions) return c @@ -124,7 +137,7 @@ func (p *pushOptions) Run(args []string) error { return err } - img, err := bundle.BuildTektonBundle(p.bundleContents, p.annotations, p.stream.Out) + img, err := bundle.BuildTektonBundle(p.bundleContents, p.annotations, p.ctime, p.stream.Out) if err != nil { return err } @@ -136,3 +149,28 @@ func (p *pushOptions) Run(args []string) error { fmt.Fprintf(p.stream.Out, "\nPushed Tekton Bundle to %s\n", outputDigest) return err } + +// to help with testing +var now = time.Now + +func parseTime(t string) (parsed time.Time, err error) { + if t == "" { + return now(), nil + } + + parsed, err = time.Parse(time.DateOnly, t) + + if err != nil { + parsed, err = time.Parse("2006-01-02T15:04:05", t) + } + + if err != nil { + parsed, err = time.Parse(time.RFC3339, t) + } + + if err != nil { + return parsed, fmt.Errorf("unable to parse provided time %q: %w", t, err) + } + + return parsed, nil +} diff --git a/pkg/cmd/bundle/push_test.go b/pkg/cmd/bundle/push_test.go index e60652137..be2601afd 100644 --- a/pkg/cmd/bundle/push_test.go +++ b/pkg/cmd/bundle/push_test.go @@ -10,6 +10,7 @@ import ( "os" "path" "testing" + "time" "github.com/google/go-cmp/cmp" "github.com/google/go-containerregistry/pkg/registry" @@ -57,6 +58,7 @@ func TestPushCommand(t *testing.T) { annotations []string expectedContents map[string]expected expectedAnnotations map[string]string + ctime time.Time }{ { name: "single-input", @@ -85,6 +87,14 @@ func TestPushCommand(t *testing.T) { "org.opencontainers.image.url": "https://example.org", }, }, + { + name: "with-ctime", + files: map[string]string{ + "simple.yaml": exampleTask, + }, + expectedContents: map[string]expected{exampleTaskExpected.name: exampleTaskExpected}, + ctime: time.Now(), + }, } for _, tc := range testcases { @@ -128,6 +138,7 @@ func TestPushCommand(t *testing.T) { bundleContentPaths: paths, annotationParams: tc.annotations, remoteOptions: bundle.RemoteOptions{}, + ctime: tc.ctime, } if err := opts.Run([]string{ref}); err != nil { t.Errorf("Unexpected failure calling run: %v", err) @@ -144,6 +155,14 @@ func TestPushCommand(t *testing.T) { t.Fatal(err) } + config, err := img.ConfigFile() + if err != nil { + t.Fatal(err) + } + if config.Created.Time.Unix() != tc.ctime.Unix() { + t.Errorf("Expected created time to be %s, but it was %s", tc.ctime, config.Created.Time) + } + layers, err := img.Layers() if err != nil { t.Fatal(err) @@ -211,3 +230,40 @@ func readTarLayer(t *testing.T, layer v1.Layer) string { } return string(contents) } + +func TestParseTime(t *testing.T) { + now = func() time.Time { + return time.Date(2023, 9, 22, 1, 2, 3, 0, time.UTC) + } + + cases := []struct { + name string + given string + err string + expected time.Time + }{ + {name: "now", expected: now()}, + {name: "date", given: "2023-09-22", expected: time.Date(2023, 9, 22, 0, 0, 0, 0, time.UTC)}, + {name: "date and time", given: "2023-09-22T01:02:03", expected: time.Date(2023, 9, 22, 1, 2, 3, 0, time.UTC)}, + {name: "utc with fraction", given: "2023-09-22T01:02:03.45Z", expected: time.Date(2023, 9, 22, 1, 2, 3, 45, time.UTC)}, + {name: "full", given: "2023-09-22T01:02:03+04:30", expected: time.Date(2023, 9, 22, 1, 2, 3, 0, time.FixedZone("", 16200))}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := parseTime(c.given) + + if err != nil { + if err.Error() != c.err { + t.Errorf("expected error %q, got %q", c.err, err) + } else { + t.Fatalf("unexpected error: %v", err) + } + } + + if got.Unix() != c.expected.Unix() { + t.Errorf("expected parsed time to be %s, got %s", c.expected, got) + } + }) + } +}