Skip to content

Commit

Permalink
Add --ctime parameter to tkn bundle push
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
zregvart authored and tekton-robot committed Sep 25, 2023
1 parent 3995fa9 commit 29048c4
Show file tree
Hide file tree
Showing 7 changed files with 161 additions and 44 deletions.
1 change: 1 addition & 0 deletions docs/cmd/tkn_bundle_push.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/man/man1/tkn-bundle-push.1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions pkg/bundle/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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]))
Expand Down Expand Up @@ -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)
}
Expand Down
91 changes: 54 additions & 37 deletions pkg/bundle/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"math/rand"
"sort"
"testing"
"time"

"github.com/google/go-cmp/cmp"
"github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1"
Expand All @@ -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) {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
}
3 changes: 2 additions & 1 deletion pkg/cmd/bundle/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
Expand Down
44 changes: 41 additions & 3 deletions pkg/cmd/bundle/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"fmt"
"io"
"os"
"time"

"github.com/google/go-containerregistry/pkg/name"
"github.com/spf13/cobra"
Expand All @@ -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 {
Expand All @@ -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",
Expand All @@ -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{
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
56 changes: 56 additions & 0 deletions pkg/cmd/bundle/push_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"os"
"path"
"testing"
"time"

"github.com/google/go-cmp/cmp"
"github.com/google/go-containerregistry/pkg/registry"
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
})
}
}

0 comments on commit 29048c4

Please sign in to comment.