From da03b23667a0fff1ff355ab253fdc0cdc3ec9f11 Mon Sep 17 00:00:00 2001 From: Billy Zha Date: Fri, 2 Feb 2024 01:16:38 +0800 Subject: [PATCH] feat: support formatted output in oras attach and push (#1237) Signed-off-by: Billy Zha --- cmd/oras/internal/display/handler.go | 74 +++++++++++ .../internal/display/metadata/interface.go | 32 +++++ .../internal/display/metadata/json/attach.go | 36 +++++ .../internal/display/metadata/json/json.go | 27 ++++ .../internal/display/metadata/json/push.go | 44 +++++++ .../display/metadata/model/descriptor.go | 72 ++++++++++ .../internal/display/metadata/model/push.go | 28 ++++ .../display/metadata/template/attach.go | 38 ++++++ .../display/metadata/template/push.go | 45 +++++++ .../display/metadata/template/template.go | 31 +++++ .../internal/display/metadata/text/attach.go | 47 +++++++ .../internal/display/metadata/text/push.go | 44 +++++++ .../display/{ => status}/console/console.go | 0 .../{ => status}/console/console_test.go | 0 .../console/testutils/testutils.go | 0 .../internal/display/{ => status}/convert.go | 2 +- cmd/oras/internal/display/status/discard.go | 47 +++++++ cmd/oras/internal/display/status/interface.go | 32 +++++ .../internal/display/{ => status}/print.go | 2 +- .../{ => status}/progress/humanize/bytes.go | 0 .../progress/humanize/bytes_test.go | 0 .../display/{ => status}/progress/manager.go | 2 +- .../display/{ => status}/progress/spinner.go | 0 .../{ => status}/progress/spinner_test.go | 0 .../display/{ => status}/progress/status.go | 2 +- .../{ => status}/progress/status_test.go | 8 +- cmd/oras/internal/display/status/text.go | 88 +++++++++++++ .../display/{ => status}/track/reader.go | 2 +- .../display/{ => status}/track/target.go | 2 +- .../display/{ => status}/track/target_test.go | 4 +- cmd/oras/internal/display/status/tty.go | 88 +++++++++++++ cmd/oras/internal/display/status/tty_test.go | 124 ++++++++++++++++++ cmd/oras/internal/option/common_unix_test.go | 2 +- cmd/oras/internal/option/format.go | 35 +++++ cmd/oras/root/attach.go | 18 +-- cmd/oras/root/blob/fetch.go | 2 +- cmd/oras/root/blob/fetch_test.go | 2 +- cmd/oras/root/blob/push.go | 10 +- cmd/oras/root/blob/push_test.go | 2 +- cmd/oras/root/cp.go | 18 +-- cmd/oras/root/cp_test.go | 2 +- cmd/oras/root/file.go | 13 +- cmd/oras/root/manifest/push.go | 12 +- cmd/oras/root/pull.go | 26 +++- cmd/oras/root/pull_test.go | 61 +++++++++ cmd/oras/root/push.go | 78 +++-------- cmd/oras/root/tag.go | 4 +- go.mod | 11 ++ go.sum | 72 ++++++++++ test/e2e/suite/command/attach.go | 33 +++++ test/e2e/suite/command/push.go | 38 ++++++ 51 files changed, 1240 insertions(+), 120 deletions(-) create mode 100644 cmd/oras/internal/display/handler.go create mode 100644 cmd/oras/internal/display/metadata/interface.go create mode 100644 cmd/oras/internal/display/metadata/json/attach.go create mode 100644 cmd/oras/internal/display/metadata/json/json.go create mode 100644 cmd/oras/internal/display/metadata/json/push.go create mode 100644 cmd/oras/internal/display/metadata/model/descriptor.go create mode 100644 cmd/oras/internal/display/metadata/model/push.go create mode 100644 cmd/oras/internal/display/metadata/template/attach.go create mode 100644 cmd/oras/internal/display/metadata/template/push.go create mode 100644 cmd/oras/internal/display/metadata/template/template.go create mode 100644 cmd/oras/internal/display/metadata/text/attach.go create mode 100644 cmd/oras/internal/display/metadata/text/push.go rename cmd/oras/internal/display/{ => status}/console/console.go (100%) rename cmd/oras/internal/display/{ => status}/console/console_test.go (100%) rename cmd/oras/internal/display/{ => status}/console/testutils/testutils.go (100%) rename cmd/oras/internal/display/{ => status}/convert.go (98%) create mode 100644 cmd/oras/internal/display/status/discard.go create mode 100644 cmd/oras/internal/display/status/interface.go rename cmd/oras/internal/display/{ => status}/print.go (99%) rename cmd/oras/internal/display/{ => status}/progress/humanize/bytes.go (100%) rename cmd/oras/internal/display/{ => status}/progress/humanize/bytes_test.go (100%) rename cmd/oras/internal/display/{ => status}/progress/manager.go (98%) rename cmd/oras/internal/display/{ => status}/progress/spinner.go (100%) rename cmd/oras/internal/display/{ => status}/progress/spinner_test.go (100%) rename cmd/oras/internal/display/{ => status}/progress/status.go (98%) rename cmd/oras/internal/display/{ => status}/progress/status_test.go (94%) create mode 100644 cmd/oras/internal/display/status/text.go rename cmd/oras/internal/display/{ => status}/track/reader.go (97%) rename cmd/oras/internal/display/{ => status}/track/target.go (98%) rename cmd/oras/internal/display/{ => status}/track/target_test.go (94%) create mode 100644 cmd/oras/internal/display/status/tty.go create mode 100644 cmd/oras/internal/display/status/tty_test.go create mode 100644 cmd/oras/internal/option/format.go create mode 100644 cmd/oras/root/pull_test.go diff --git a/cmd/oras/internal/display/handler.go b/cmd/oras/internal/display/handler.go new file mode 100644 index 000000000..56e05ae63 --- /dev/null +++ b/cmd/oras/internal/display/handler.go @@ -0,0 +1,74 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package display + +import ( + "os" + + "oras.land/oras/cmd/oras/internal/display/metadata" + "oras.land/oras/cmd/oras/internal/display/metadata/json" + "oras.land/oras/cmd/oras/internal/display/metadata/template" + "oras.land/oras/cmd/oras/internal/display/metadata/text" + "oras.land/oras/cmd/oras/internal/display/status" +) + +// NewPushHandler returns status and metadata handlers for push command. +func NewPushHandler(format string, tty *os.File, verbose bool) (status.PushHandler, metadata.PushHandler) { + var statusHandler status.PushHandler + if tty != nil { + statusHandler = status.NewTTYPushHandler(tty) + } else if format == "" { + statusHandler = status.NewTextPushHandler(verbose) + } else { + statusHandler = status.NewDiscardHandler() + } + + var metadataHandler metadata.PushHandler + switch format { + case "": + metadataHandler = text.NewPushHandler() + case "json": + metadataHandler = json.NewPushHandler() + default: + metadataHandler = template.NewPushHandler(format) + } + + return statusHandler, metadataHandler +} + +// NewAttachHandler returns status and metadata handlers for attach command. +func NewAttachHandler(format string, tty *os.File, verbose bool) (status.AttachHandler, metadata.AttachHandler) { + var statusHandler status.AttachHandler + if tty != nil { + statusHandler = status.NewTTYAttachHandler(tty) + } else if format == "" { + statusHandler = status.NewTextAttachHandler(verbose) + } else { + statusHandler = status.NewDiscardHandler() + } + + var metadataHandler metadata.AttachHandler + switch format { + case "": + metadataHandler = text.NewAttachHandler() + case "json": + metadataHandler = json.NewAttachHandler() + default: + metadataHandler = template.NewAttachHandler(format) + } + + return statusHandler, metadataHandler +} diff --git a/cmd/oras/internal/display/metadata/interface.go b/cmd/oras/internal/display/metadata/interface.go new file mode 100644 index 000000000..577f9eb2e --- /dev/null +++ b/cmd/oras/internal/display/metadata/interface.go @@ -0,0 +1,32 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package metadata + +import ( + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras/cmd/oras/internal/option" +) + +// PushHandler handles metadata output for push events. +type PushHandler interface { + OnCopied(opts *option.Target) error + OnCompleted(root ocispec.Descriptor) error +} + +// AttachHandler handles metadata output for attach events. +type AttachHandler interface { + OnCompleted(opts *option.Target, root, subject ocispec.Descriptor) error +} diff --git a/cmd/oras/internal/display/metadata/json/attach.go b/cmd/oras/internal/display/metadata/json/attach.go new file mode 100644 index 000000000..47548452e --- /dev/null +++ b/cmd/oras/internal/display/metadata/json/attach.go @@ -0,0 +1,36 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package json + +import ( + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras/cmd/oras/internal/display/metadata" + "oras.land/oras/cmd/oras/internal/display/metadata/model" + "oras.land/oras/cmd/oras/internal/option" +) + +// AttachHandler handles json metadata output for attach events. +type AttachHandler struct{} + +// NewAttachHandler creates a new handler for attach events. +func NewAttachHandler() metadata.AttachHandler { + return AttachHandler{} +} + +// OnCompleted is called when the attach command is completed. +func (AttachHandler) OnCompleted(opts *option.Target, root, subject ocispec.Descriptor) error { + return printJSON(model.NewPush(root, opts.Path)) +} diff --git a/cmd/oras/internal/display/metadata/json/json.go b/cmd/oras/internal/display/metadata/json/json.go new file mode 100644 index 000000000..4f060f34b --- /dev/null +++ b/cmd/oras/internal/display/metadata/json/json.go @@ -0,0 +1,27 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package json + +import ( + "encoding/json" + "os" +) + +func printJSON(object any) error { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(object) +} diff --git a/cmd/oras/internal/display/metadata/json/push.go b/cmd/oras/internal/display/metadata/json/push.go new file mode 100644 index 000000000..ef3af142c --- /dev/null +++ b/cmd/oras/internal/display/metadata/json/push.go @@ -0,0 +1,44 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package json + +import ( + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras/cmd/oras/internal/display/metadata" + "oras.land/oras/cmd/oras/internal/display/metadata/model" + "oras.land/oras/cmd/oras/internal/option" +) + +// PushHandler handles JSON metadata output for push events. +type PushHandler struct { + path string +} + +// NewPushHandler creates a new handler for push events. +func NewPushHandler() metadata.PushHandler { + return &PushHandler{} +} + +// OnCopied is called after files are copied. +func (ph *PushHandler) OnCopied(opts *option.Target) error { + ph.path = opts.Path + return nil +} + +// OnCompleted is called after the push is completed. +func (ph *PushHandler) OnCompleted(root ocispec.Descriptor) error { + return printJSON(model.NewPush(root, ph.path)) +} diff --git a/cmd/oras/internal/display/metadata/model/descriptor.go b/cmd/oras/internal/display/metadata/model/descriptor.go new file mode 100644 index 000000000..646293c75 --- /dev/null +++ b/cmd/oras/internal/display/metadata/model/descriptor.go @@ -0,0 +1,72 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package model + +import ( + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +// DigestReference is a reference to an artifact with digest. +type DigestReference struct { + Ref string +} + +// NewDigestReference creates a new digest reference. +func NewDigestReference(name string, digest string) DigestReference { + return DigestReference{ + Ref: name + "@" + digest, + } +} + +// Descriptor is a descriptor with digest reference. +// We cannot use ocispec.Descriptor here since the first letter of the json +// annotation key is not uppercase. +type Descriptor struct { + DigestReference + + // MediaType is the media type of the object this schema refers to. + MediaType string + + // Digest is the digest of the targeted content. + Digest digest.Digest + + // Size specifies the size in bytes of the blob. + Size int64 + + // URLs specifies a list of URLs from which this object MAY be downloaded + URLs []string `json:",omitempty"` + + // Annotations contains arbitrary metadata relating to the targeted content. + Annotations map[string]string `json:",omitempty"` + + // ArtifactType is the IANA media type of this artifact. + ArtifactType string +} + +// FromDescriptor converts a OCI descriptor to a descriptor with digest reference. +func FromDescriptor(name string, desc ocispec.Descriptor) Descriptor { + ret := Descriptor{ + DigestReference: NewDigestReference(name, desc.Digest.String()), + MediaType: desc.MediaType, + Digest: desc.Digest, + Size: desc.Size, + URLs: desc.URLs, + Annotations: desc.Annotations, + ArtifactType: desc.ArtifactType, + } + return ret +} diff --git a/cmd/oras/internal/display/metadata/model/push.go b/cmd/oras/internal/display/metadata/model/push.go new file mode 100644 index 000000000..c07f53c75 --- /dev/null +++ b/cmd/oras/internal/display/metadata/model/push.go @@ -0,0 +1,28 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package model + +import ocispec "github.com/opencontainers/image-spec/specs-go/v1" + +// push contains metadata formatted by oras push. +type push struct { + Descriptor +} + +// NewPush returns a metadata getter for push command. +func NewPush(desc ocispec.Descriptor, path string) any { + return push{FromDescriptor(path, desc)} +} diff --git a/cmd/oras/internal/display/metadata/template/attach.go b/cmd/oras/internal/display/metadata/template/attach.go new file mode 100644 index 000000000..4338dc0b5 --- /dev/null +++ b/cmd/oras/internal/display/metadata/template/attach.go @@ -0,0 +1,38 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package template + +import ( + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras/cmd/oras/internal/display/metadata" + "oras.land/oras/cmd/oras/internal/display/metadata/model" + "oras.land/oras/cmd/oras/internal/option" +) + +// AttachHandler handles go-template metadata output for attach events. +type AttachHandler struct { + template string +} + +// NewAttachHandler returns a new handler for attach metadata events. +func NewAttachHandler(template string) metadata.AttachHandler { + return &AttachHandler{template: template} +} + +// OnCompleted formats the metadata of attach command. +func (ah *AttachHandler) OnCompleted(opts *option.Target, root, subject ocispec.Descriptor) error { + return parseAndWrite(model.NewPush(root, opts.Path), ah.template) +} diff --git a/cmd/oras/internal/display/metadata/template/push.go b/cmd/oras/internal/display/metadata/template/push.go new file mode 100644 index 000000000..6fe8d385a --- /dev/null +++ b/cmd/oras/internal/display/metadata/template/push.go @@ -0,0 +1,45 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package template + +import ( + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras/cmd/oras/internal/display/metadata" + "oras.land/oras/cmd/oras/internal/display/metadata/model" + "oras.land/oras/cmd/oras/internal/option" +) + +// PushHandler handles go-template metadata output for push events. +type PushHandler struct { + template string + path string +} + +// NewPushHandler returns a new handler for push events. +func NewPushHandler(template string) metadata.PushHandler { + return &PushHandler{template: template} +} + +// OnStarted is called after files are copied. +func (ph *PushHandler) OnCopied(opts *option.Target) error { + ph.path = opts.Path + return nil +} + +// OnCompleted is called after the push is completed. +func (ph *PushHandler) OnCompleted(root ocispec.Descriptor) error { + return parseAndWrite(model.NewPush(root, ph.path), ph.template) +} diff --git a/cmd/oras/internal/display/metadata/template/template.go b/cmd/oras/internal/display/metadata/template/template.go new file mode 100644 index 000000000..3f8038f3a --- /dev/null +++ b/cmd/oras/internal/display/metadata/template/template.go @@ -0,0 +1,31 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package template + +import ( + "os" + "text/template" + + "github.com/Masterminds/sprig/v3" +) + +func parseAndWrite(object any, templateStr string) error { + t, err := template.New("format output").Funcs(sprig.FuncMap()).Parse(templateStr) + if err != nil { + return err + } + return t.Execute(os.Stdout, object) +} diff --git a/cmd/oras/internal/display/metadata/text/attach.go b/cmd/oras/internal/display/metadata/text/attach.go new file mode 100644 index 000000000..94c4bec0a --- /dev/null +++ b/cmd/oras/internal/display/metadata/text/attach.go @@ -0,0 +1,47 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package text + +import ( + "fmt" + "strings" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras/cmd/oras/internal/display/metadata" + "oras.land/oras/cmd/oras/internal/option" +) + +// AttachHandler handles text metadata output for attach events. +type AttachHandler struct{} + +// NewAttachHandler returns a new handler for attach events. +func NewAttachHandler() metadata.AttachHandler { + return AttachHandler{} +} + +// OnCompleted is called when the attach command is completed. +func (AttachHandler) OnCompleted(opts *option.Target, root, subject ocispec.Descriptor) error { + digest := subject.Digest.String() + if !strings.HasSuffix(opts.RawReference, digest) { + opts.RawReference = fmt.Sprintf("%s@%s", opts.Path, subject.Digest) + } + _, err := fmt.Println("Attached to", opts.AnnotatedReference()) + if err != nil { + return err + } + _, err = fmt.Println("Digest:", root.Digest) + return err +} diff --git a/cmd/oras/internal/display/metadata/text/push.go b/cmd/oras/internal/display/metadata/text/push.go new file mode 100644 index 000000000..ef5fa4680 --- /dev/null +++ b/cmd/oras/internal/display/metadata/text/push.go @@ -0,0 +1,44 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package text + +import ( + "fmt" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras/cmd/oras/internal/display/metadata" + "oras.land/oras/cmd/oras/internal/option" +) + +// PushHandler handles text metadata output for push events. +type PushHandler struct{} + +// NewPushHandler returns a new handler for push events. +func NewPushHandler() metadata.PushHandler { + return PushHandler{} +} + +// OnCopied is called after files are copied. +func (PushHandler) OnCopied(opts *option.Target) error { + _, err := fmt.Println("Pushed", opts.AnnotatedReference()) + return err +} + +// OnCompleted is called after the push is completed. +func (PushHandler) OnCompleted(root ocispec.Descriptor) error { + _, err := fmt.Println("Digest:", root.Digest) + return err +} diff --git a/cmd/oras/internal/display/console/console.go b/cmd/oras/internal/display/status/console/console.go similarity index 100% rename from cmd/oras/internal/display/console/console.go rename to cmd/oras/internal/display/status/console/console.go diff --git a/cmd/oras/internal/display/console/console_test.go b/cmd/oras/internal/display/status/console/console_test.go similarity index 100% rename from cmd/oras/internal/display/console/console_test.go rename to cmd/oras/internal/display/status/console/console_test.go diff --git a/cmd/oras/internal/display/console/testutils/testutils.go b/cmd/oras/internal/display/status/console/testutils/testutils.go similarity index 100% rename from cmd/oras/internal/display/console/testutils/testutils.go rename to cmd/oras/internal/display/status/console/testutils/testutils.go diff --git a/cmd/oras/internal/display/convert.go b/cmd/oras/internal/display/status/convert.go similarity index 98% rename from cmd/oras/internal/display/convert.go rename to cmd/oras/internal/display/status/convert.go index 03ce78f70..b34ff383e 100644 --- a/cmd/oras/internal/display/convert.go +++ b/cmd/oras/internal/display/status/convert.go @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package display +package status import ( "github.com/opencontainers/go-digest" diff --git a/cmd/oras/internal/display/status/discard.go b/cmd/oras/internal/display/status/discard.go new file mode 100644 index 000000000..e2cf35a31 --- /dev/null +++ b/cmd/oras/internal/display/status/discard.go @@ -0,0 +1,47 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package status + +import ( + "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content" +) + +// DiscardHandler is a no-op handler that discards all status updates. +type DiscardHandler struct{} + +// NewDiscardHandler returns a new no-op handler. +func NewDiscardHandler() DiscardHandler { + return DiscardHandler{} +} + +// OnFileLoading is called before a file is being loaded. +func (DiscardHandler) OnFileLoading(name string) error { + return nil +} + +// OnEmptyArtifact is called when no file is loaded for an artifact push. +func (DiscardHandler) OnEmptyArtifact() error { + return nil +} + +// TrackTarget returns a target with status tracking +func (DiscardHandler) TrackTarget(gt oras.GraphTarget) (oras.GraphTarget, error) { + return gt, nil +} + +// UpdateCopyOptions updates the copy options for the artifact push. +func (DiscardHandler) UpdateCopyOptions(opts *oras.CopyGraphOptions, fetcher content.Fetcher) {} diff --git a/cmd/oras/internal/display/status/interface.go b/cmd/oras/internal/display/status/interface.go new file mode 100644 index 000000000..d5b884245 --- /dev/null +++ b/cmd/oras/internal/display/status/interface.go @@ -0,0 +1,32 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package status + +import ( + "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content" +) + +// PushHandler handles status output for push command. +type PushHandler interface { + OnFileLoading(name string) error + OnEmptyArtifact() error + TrackTarget(gt oras.GraphTarget) (oras.GraphTarget, error) + UpdateCopyOptions(opts *oras.CopyGraphOptions, fetcher content.Fetcher) +} + +// AttachHandler handles text status output for attach command. +type AttachHandler PushHandler diff --git a/cmd/oras/internal/display/print.go b/cmd/oras/internal/display/status/print.go similarity index 99% rename from cmd/oras/internal/display/print.go rename to cmd/oras/internal/display/status/print.go index 470410fc9..85fcd21e9 100644 --- a/cmd/oras/internal/display/print.go +++ b/cmd/oras/internal/display/status/print.go @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package display +package status import ( "context" diff --git a/cmd/oras/internal/display/progress/humanize/bytes.go b/cmd/oras/internal/display/status/progress/humanize/bytes.go similarity index 100% rename from cmd/oras/internal/display/progress/humanize/bytes.go rename to cmd/oras/internal/display/status/progress/humanize/bytes.go diff --git a/cmd/oras/internal/display/progress/humanize/bytes_test.go b/cmd/oras/internal/display/status/progress/humanize/bytes_test.go similarity index 100% rename from cmd/oras/internal/display/progress/humanize/bytes_test.go rename to cmd/oras/internal/display/status/progress/humanize/bytes_test.go diff --git a/cmd/oras/internal/display/progress/manager.go b/cmd/oras/internal/display/status/progress/manager.go similarity index 98% rename from cmd/oras/internal/display/progress/manager.go rename to cmd/oras/internal/display/status/progress/manager.go index 80d614b06..470679976 100644 --- a/cmd/oras/internal/display/progress/manager.go +++ b/cmd/oras/internal/display/status/progress/manager.go @@ -21,7 +21,7 @@ import ( "sync" "time" - "oras.land/oras/cmd/oras/internal/display/console" + "oras.land/oras/cmd/oras/internal/display/status/console" ) const ( diff --git a/cmd/oras/internal/display/progress/spinner.go b/cmd/oras/internal/display/status/progress/spinner.go similarity index 100% rename from cmd/oras/internal/display/progress/spinner.go rename to cmd/oras/internal/display/status/progress/spinner.go diff --git a/cmd/oras/internal/display/progress/spinner_test.go b/cmd/oras/internal/display/status/progress/spinner_test.go similarity index 100% rename from cmd/oras/internal/display/progress/spinner_test.go rename to cmd/oras/internal/display/status/progress/spinner_test.go diff --git a/cmd/oras/internal/display/progress/status.go b/cmd/oras/internal/display/status/progress/status.go similarity index 98% rename from cmd/oras/internal/display/progress/status.go rename to cmd/oras/internal/display/status/progress/status.go index 5c67c2053..aa2e90f0b 100644 --- a/cmd/oras/internal/display/progress/status.go +++ b/cmd/oras/internal/display/status/progress/status.go @@ -24,7 +24,7 @@ import ( "github.com/morikuni/aec" ocispec "github.com/opencontainers/image-spec/specs-go/v1" - "oras.land/oras/cmd/oras/internal/display/progress/humanize" + "oras.land/oras/cmd/oras/internal/display/status/progress/humanize" ) const ( diff --git a/cmd/oras/internal/display/progress/status_test.go b/cmd/oras/internal/display/status/progress/status_test.go similarity index 94% rename from cmd/oras/internal/display/progress/status_test.go rename to cmd/oras/internal/display/status/progress/status_test.go index 4ce98bace..91a61c2d9 100644 --- a/cmd/oras/internal/display/progress/status_test.go +++ b/cmd/oras/internal/display/status/progress/status_test.go @@ -1,3 +1,5 @@ +//go:build darwin || freebsd || linux || netbsd || openbsd || solaris + /* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); @@ -20,9 +22,9 @@ import ( "time" ocispec "github.com/opencontainers/image-spec/specs-go/v1" - "oras.land/oras/cmd/oras/internal/display/console" - "oras.land/oras/cmd/oras/internal/display/console/testutils" - "oras.land/oras/cmd/oras/internal/display/progress/humanize" + "oras.land/oras/cmd/oras/internal/display/status/console" + "oras.land/oras/cmd/oras/internal/display/status/console/testutils" + "oras.land/oras/cmd/oras/internal/display/status/progress/humanize" ) func Test_status_String(t *testing.T) { diff --git a/cmd/oras/internal/display/status/text.go b/cmd/oras/internal/display/status/text.go new file mode 100644 index 000000000..a62c2a313 --- /dev/null +++ b/cmd/oras/internal/display/status/text.go @@ -0,0 +1,88 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package status + +import ( + "context" + "fmt" + "sync" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content" +) + +// TextPushHandler handles text status output for push events. +type TextPushHandler struct { + verbose bool +} + +// NewTextPushHandler returns a new handler for push command. +func NewTextPushHandler(verbose bool) PushHandler { + return &TextPushHandler{ + verbose: verbose, + } +} + +// OnFileLoading is called when a file is being prepared for upload. +func (ph *TextPushHandler) OnFileLoading(name string) error { + if !ph.verbose { + return nil + } + _, err := fmt.Println("Preparing", name) + return err +} + +// OnEmptyArtifact is called when an empty artifact is being uploaded. +func (ph *TextPushHandler) OnEmptyArtifact() error { + _, err := fmt.Println("Uploading empty artifact") + return err +} + +// TrackTarget returns a tracked target. +func (ph *TextPushHandler) TrackTarget(gt oras.GraphTarget) (oras.GraphTarget, error) { + return gt, nil +} + +// UpdateCopyOptions adds status update to the copy options. +func (ph *TextPushHandler) UpdateCopyOptions(opts *oras.CopyGraphOptions, fetcher content.Fetcher) { + const ( + promptSkipped = "Skipped " + promptUploaded = "Uploaded " + promptExists = "Exists " + promptUploading = "Uploading" + ) + committed := &sync.Map{} + opts.OnCopySkipped = func(ctx context.Context, desc ocispec.Descriptor) error { + committed.Store(desc.Digest.String(), desc.Annotations[ocispec.AnnotationTitle]) + return PrintStatus(desc, promptExists, ph.verbose) + } + opts.PreCopy = func(ctx context.Context, desc ocispec.Descriptor) error { + return PrintStatus(desc, promptUploading, ph.verbose) + } + opts.PostCopy = func(ctx context.Context, desc ocispec.Descriptor) error { + committed.Store(desc.Digest.String(), desc.Annotations[ocispec.AnnotationTitle]) + if err := PrintSuccessorStatus(ctx, desc, fetcher, committed, StatusPrinter(promptSkipped, ph.verbose)); err != nil { + return err + } + return PrintStatus(desc, promptUploaded, ph.verbose) + } +} + +// NewTextAttachHandler returns a new handler for attach command. +func NewTextAttachHandler(verbose bool) AttachHandler { + return NewTextPushHandler(verbose) +} diff --git a/cmd/oras/internal/display/track/reader.go b/cmd/oras/internal/display/status/track/reader.go similarity index 97% rename from cmd/oras/internal/display/track/reader.go rename to cmd/oras/internal/display/status/track/reader.go index 97bb3883e..c0301f928 100644 --- a/cmd/oras/internal/display/track/reader.go +++ b/cmd/oras/internal/display/status/track/reader.go @@ -20,7 +20,7 @@ import ( "os" ocispec "github.com/opencontainers/image-spec/specs-go/v1" - "oras.land/oras/cmd/oras/internal/display/progress" + "oras.land/oras/cmd/oras/internal/display/status/progress" ) type reader struct { diff --git a/cmd/oras/internal/display/track/target.go b/cmd/oras/internal/display/status/track/target.go similarity index 98% rename from cmd/oras/internal/display/track/target.go rename to cmd/oras/internal/display/status/track/target.go index 3342f7ee2..95e087b73 100644 --- a/cmd/oras/internal/display/track/target.go +++ b/cmd/oras/internal/display/status/track/target.go @@ -23,7 +23,7 @@ import ( ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2" "oras.land/oras-go/v2/registry" - "oras.land/oras/cmd/oras/internal/display/progress" + "oras.land/oras/cmd/oras/internal/display/status/progress" ) // GraphTarget is a tracked oras.GraphTarget. diff --git a/cmd/oras/internal/display/track/target_test.go b/cmd/oras/internal/display/status/track/target_test.go similarity index 94% rename from cmd/oras/internal/display/track/target_test.go rename to cmd/oras/internal/display/status/track/target_test.go index fa2c19a7d..31f1ab7f7 100644 --- a/cmd/oras/internal/display/track/target_test.go +++ b/cmd/oras/internal/display/status/track/target_test.go @@ -1,3 +1,5 @@ +//go:build darwin || freebsd || linux || netbsd || openbsd || solaris + /* Copyright The ORAS Authors. Licensed under the Apache License, Version 2.0 (the "License"); @@ -26,7 +28,7 @@ import ( "oras.land/oras-go/v2" "oras.land/oras-go/v2/content/memory" "oras.land/oras-go/v2/registry/remote" - "oras.land/oras/cmd/oras/internal/display/console/testutils" + "oras.land/oras/cmd/oras/internal/display/status/console/testutils" ) type testReferenceGraphTarget struct { diff --git a/cmd/oras/internal/display/status/tty.go b/cmd/oras/internal/display/status/tty.go new file mode 100644 index 000000000..ac9797467 --- /dev/null +++ b/cmd/oras/internal/display/status/tty.go @@ -0,0 +1,88 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package status + +import ( + "context" + "os" + "sync" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content" + "oras.land/oras/cmd/oras/internal/display/status/track" +) + +// TTYPushHandler handles TTY status output for push command. +type TTYPushHandler struct { + tty *os.File + tracked track.GraphTarget +} + +// NewTTYPushHandler returns a new handler for push status events. +func NewTTYPushHandler(tty *os.File) PushHandler { + return &TTYPushHandler{ + tty: tty, + } +} + +// OnFileLoading is called before loading a file. +func (ph *TTYPushHandler) OnFileLoading(name string) error { + return nil +} + +// OnEmptyArtifact is called when no file is loaded for an artifact push. +func (ph *TTYPushHandler) OnEmptyArtifact() error { + return nil +} + +// TrackTarget returns a tracked target. +func (ph *TTYPushHandler) TrackTarget(gt oras.GraphTarget) (oras.GraphTarget, error) { + const ( + promptUploaded = "Uploaded " + promptUploading = "Uploading" + ) + tracked, err := track.NewTarget(gt, promptUploading, promptUploaded, ph.tty) + if err != nil { + return nil, err + } + ph.tracked = tracked + return tracked, nil +} + +// UpdateCopyOptions adds TTY status output to the copy options. +func (ph *TTYPushHandler) UpdateCopyOptions(opts *oras.CopyGraphOptions, fetcher content.Fetcher) { + const ( + promptSkipped = "Skipped " + promptExists = "Exists " + ) + committed := &sync.Map{} + opts.OnCopySkipped = func(ctx context.Context, desc ocispec.Descriptor) error { + committed.Store(desc.Digest.String(), desc.Annotations[ocispec.AnnotationTitle]) + return ph.tracked.Prompt(desc, promptExists) + } + opts.PostCopy = func(ctx context.Context, desc ocispec.Descriptor) error { + committed.Store(desc.Digest.String(), desc.Annotations[ocispec.AnnotationTitle]) + return PrintSuccessorStatus(ctx, desc, fetcher, committed, func(d ocispec.Descriptor) error { + return ph.tracked.Prompt(d, promptSkipped) + }) + } +} + +// NewTTYAttachHandler returns a new handler for attach status events. +func NewTTYAttachHandler(tty *os.File) AttachHandler { + return NewTTYPushHandler(tty) +} diff --git a/cmd/oras/internal/display/status/tty_test.go b/cmd/oras/internal/display/status/tty_test.go new file mode 100644 index 000000000..5ebd4844f --- /dev/null +++ b/cmd/oras/internal/display/status/tty_test.go @@ -0,0 +1,124 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package status + +import ( + "bytes" + "context" + "fmt" + "os" + "testing" + + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content/memory" + "oras.land/oras/cmd/oras/internal/display/status/console/testutils" + "oras.land/oras/cmd/oras/internal/display/status/track" +) + +var ( + memStore *memory.Store + memDesc ocispec.Descriptor +) + +func TestMain(m *testing.M) { + // memory store for testing + memStore = memory.New() + content := []byte("test") + r := bytes.NewReader(content) + memDesc = ocispec.Descriptor{ + MediaType: "application/octet-stream", + Digest: digest.FromBytes(content), + Size: int64(len(content)), + } + if err := memStore.Push(context.Background(), memDesc, r); err != nil { + fmt.Println("Setup failed:", err) + os.Exit(1) + } + if err := memStore.Tag(context.Background(), memDesc, memDesc.Digest.String()); err != nil { + fmt.Println("Setup failed:", err) + os.Exit(1) + } + m.Run() +} + +func TestTTYPushHandler_OnFileLoading(t *testing.T) { + ph := NewTTYPushHandler(os.Stdout) + if ph.OnFileLoading("test") != nil { + t.Error("OnFileLoading() should not return an error") + } +} + +func TestTTYPushHandler_OnEmptyArtifact(t *testing.T) { + ph := NewTTYAttachHandler(os.Stdout) + if ph.OnEmptyArtifact() != nil { + t.Error("OnEmptyArtifact() should not return an error") + } +} + +func TestTTYPushHandler_TrackTarget(t *testing.T) { + // prepare pty + _, slave, err := testutils.NewPty() + if err != nil { + t.Fatal(err) + } + defer slave.Close() + ph := NewTTYPushHandler(slave) + store := memory.New() + // test + _, err = ph.TrackTarget(store) + if err != nil { + t.Error("TrackTarget() should not return an error") + } + if ttyPushHandler, ok := ph.(*TTYPushHandler); !ok { + t.Errorf("TrackTarget() should return a *TTYPushHandler, got %T", ttyPushHandler) + } else if ttyPushHandler.tracked.Inner() != store { + t.Errorf("TrackTarget() tracks unexpected tracked target: %T", ttyPushHandler.tracked) + } +} + +func TestTTYPushHandler_UpdateCopyOptions(t *testing.T) { + // prepare pty + pty, slave, err := testutils.NewPty() + if err != nil { + t.Fatal(err) + } + defer slave.Close() + ph := NewTTYPushHandler(slave) + gt, err := ph.TrackTarget(memory.New()) + if err != nil { + t.Errorf("TrackTarget() should not return an error: %v", err) + } + // test + opts := oras.CopyGraphOptions{} + ph.UpdateCopyOptions(&opts, memStore) + if err := oras.CopyGraph(context.Background(), memStore, gt, memDesc, opts); err != nil { + t.Errorf("CopyGraph() should not return an error: %v", err) + } + if err := oras.CopyGraph(context.Background(), memStore, gt, memDesc, opts); err != nil { + t.Errorf("CopyGraph() should not return an error: %v", err) + } + if tracked, ok := gt.(track.GraphTarget); !ok { + t.Errorf("TrackTarget() should return a *track.GraphTarget, got %T", tracked) + } else { + tracked.Close() + } + // validate + if err = testutils.MatchPty(pty, slave, "Exists", memDesc.MediaType, "100.00%", memDesc.Digest.String()); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/oras/internal/option/common_unix_test.go b/cmd/oras/internal/option/common_unix_test.go index 2ad72620f..d2363f835 100644 --- a/cmd/oras/internal/option/common_unix_test.go +++ b/cmd/oras/internal/option/common_unix_test.go @@ -20,7 +20,7 @@ package option import ( "testing" - "oras.land/oras/cmd/oras/internal/display/console/testutils" + "oras.land/oras/cmd/oras/internal/display/status/console/testutils" ) func TestCommon_parseTTY(t *testing.T) { diff --git a/cmd/oras/internal/option/format.go b/cmd/oras/internal/option/format.go new file mode 100644 index 000000000..07e9489b9 --- /dev/null +++ b/cmd/oras/internal/option/format.go @@ -0,0 +1,35 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package option + +import "github.com/spf13/pflag" + +// Format is a flag to format metadata into output. +type Format struct { + Template string +} + +// ApplyFlag implements FlagProvider.ApplyFlag. +func (opts *Format) ApplyFlags(fs *pflag.FlagSet) { + const name = "format" + if fs.Lookup(name) != nil { + // allow command to overwrite the flag + return + } + fs.StringVar(&opts.Template, name, "", `[Experimental] Format output using a custom template: +'json': Print in JSON format +'$TEMPLATE': Print output using the given Go template.`) +} diff --git a/cmd/oras/root/attach.go b/cmd/oras/root/attach.go index 7104596aa..b1711e869 100644 --- a/cmd/oras/root/attach.go +++ b/cmd/oras/root/attach.go @@ -19,7 +19,6 @@ import ( "context" "errors" "fmt" - "strings" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/spf13/cobra" @@ -28,6 +27,7 @@ import ( "oras.land/oras-go/v2/content/file" "oras.land/oras-go/v2/registry/remote/auth" "oras.land/oras/cmd/oras/internal/argument" + "oras.land/oras/cmd/oras/internal/display" oerrors "oras.land/oras/cmd/oras/internal/errors" "oras.land/oras/cmd/oras/internal/option" "oras.land/oras/internal/graph" @@ -38,6 +38,7 @@ type attachOptions struct { option.Common option.Packer option.Target + option.Format artifactType string concurrency int @@ -112,6 +113,7 @@ func runAttach(cmd *cobra.Command, opts *attachOptions) error { Recommendation: `To attach to an existing artifact, please provide files via argument or annotations via flag "--annotation". Run "oras attach -h" for more options and examples`, } } + displayStatus, displayMetadata := display.NewAttachHandler(opts.Template, opts.TTY, opts.Verbose) // prepare manifest store, err := file.New("") @@ -134,19 +136,19 @@ func runAttach(cmd *cobra.Command, opts *attachOptions) error { if err != nil { return err } - descs, err := loadFiles(ctx, store, annotations, opts.FileRefs, opts.Verbose) + descs, err := loadFiles(ctx, store, annotations, opts.FileRefs, displayStatus) if err != nil { return err } // prepare push - dst, err = getTrackedTarget(dst, opts.TTY, "Uploading", "Uploaded ") + dst, err = displayStatus.TrackTarget(dst) if err != nil { return err } graphCopyOptions := oras.DefaultCopyGraphOptions graphCopyOptions.Concurrency = opts.concurrency - updateDisplayOption(&graphCopyOptions, store, opts.Verbose, dst) + displayStatus.UpdateCopyOptions(&graphCopyOptions, store) packOpts := oras.PackManifestOptions{ Subject: &subject, @@ -180,12 +182,10 @@ func runAttach(cmd *cobra.Command, opts *attachOptions) error { if err != nil { return err } - digest := subject.Digest.String() - if !strings.HasSuffix(opts.RawReference, digest) { - opts.RawReference = fmt.Sprintf("%s@%s", opts.Path, subject.Digest) + err = displayMetadata.OnCompleted(&opts.Target, root, subject) + if err != nil { + return err } - fmt.Println("Attached to", opts.AnnotatedReference()) - fmt.Println("Digest:", root.Digest) // Export manifest return opts.ExportManifest(ctx, store, root) diff --git a/cmd/oras/root/blob/fetch.go b/cmd/oras/root/blob/fetch.go index b1dc87a67..72cb3a256 100644 --- a/cmd/oras/root/blob/fetch.go +++ b/cmd/oras/root/blob/fetch.go @@ -27,7 +27,7 @@ import ( "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/registry/remote" "oras.land/oras/cmd/oras/internal/argument" - "oras.land/oras/cmd/oras/internal/display/track" + "oras.land/oras/cmd/oras/internal/display/status/track" oerrors "oras.land/oras/cmd/oras/internal/errors" "oras.land/oras/cmd/oras/internal/option" ) diff --git a/cmd/oras/root/blob/fetch_test.go b/cmd/oras/root/blob/fetch_test.go index ba3363850..24dfbe9c3 100644 --- a/cmd/oras/root/blob/fetch_test.go +++ b/cmd/oras/root/blob/fetch_test.go @@ -25,7 +25,7 @@ import ( "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content/memory" - "oras.land/oras/cmd/oras/internal/display/console/testutils" + "oras.land/oras/cmd/oras/internal/display/status/console/testutils" ) func Test_fetchBlobOptions_doFetch(t *testing.T) { diff --git a/cmd/oras/root/blob/push.go b/cmd/oras/root/blob/push.go index 849c79573..8847d3715 100644 --- a/cmd/oras/root/blob/push.go +++ b/cmd/oras/root/blob/push.go @@ -26,8 +26,8 @@ import ( "github.com/spf13/cobra" "oras.land/oras-go/v2" "oras.land/oras/cmd/oras/internal/argument" - "oras.land/oras/cmd/oras/internal/display" - "oras.land/oras/cmd/oras/internal/display/track" + "oras.land/oras/cmd/oras/internal/display/status" + "oras.land/oras/cmd/oras/internal/display/status/track" oerrors "oras.land/oras/cmd/oras/internal/errors" "oras.land/oras/cmd/oras/internal/option" "oras.land/oras/internal/file" @@ -121,7 +121,7 @@ func pushBlob(ctx context.Context, opts *pushBlobOptions) (err error) { } verbose := opts.Verbose && !opts.OutputDescriptor if exists { - err = display.PrintStatus(desc, "Exists", verbose) + err = status.PrintStatus(desc, "Exists", verbose) } else { err = opts.doPush(ctx, target, desc, rc) } @@ -145,13 +145,13 @@ func pushBlob(ctx context.Context, opts *pushBlobOptions) (err error) { func (opts *pushBlobOptions) doPush(ctx context.Context, t oras.Target, desc ocispec.Descriptor, r io.Reader) error { if opts.TTY == nil { // none TTY output - if err := display.PrintStatus(desc, "Uploading", opts.Verbose); err != nil { + if err := status.PrintStatus(desc, "Uploading", opts.Verbose); err != nil { return err } if err := t.Push(ctx, desc, r); err != nil { return err } - return display.PrintStatus(desc, "Uploaded ", opts.Verbose) + return status.PrintStatus(desc, "Uploaded ", opts.Verbose) } // TTY output diff --git a/cmd/oras/root/blob/push_test.go b/cmd/oras/root/blob/push_test.go index b47bcfbee..b76085d1b 100644 --- a/cmd/oras/root/blob/push_test.go +++ b/cmd/oras/root/blob/push_test.go @@ -25,7 +25,7 @@ import ( "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content/memory" - "oras.land/oras/cmd/oras/internal/display/console/testutils" + "oras.land/oras/cmd/oras/internal/display/status/console/testutils" ) func Test_pushBlobOptions_doPush(t *testing.T) { diff --git a/cmd/oras/root/cp.go b/cmd/oras/root/cp.go index 6df3d2d58..a08ce4695 100644 --- a/cmd/oras/root/cp.go +++ b/cmd/oras/root/cp.go @@ -32,8 +32,8 @@ import ( "oras.land/oras-go/v2/registry/remote" "oras.land/oras-go/v2/registry/remote/auth" "oras.land/oras/cmd/oras/internal/argument" - "oras.land/oras/cmd/oras/internal/display" - "oras.land/oras/cmd/oras/internal/display/track" + "oras.land/oras/cmd/oras/internal/display/status" + "oras.land/oras/cmd/oras/internal/display/status/track" oerrors "oras.land/oras/cmd/oras/internal/errors" "oras.land/oras/cmd/oras/internal/option" "oras.land/oras/internal/docker" @@ -139,7 +139,7 @@ func runCopy(cmd *cobra.Command, opts *copyOptions) error { if len(opts.extraRefs) != 0 { tagNOpts := oras.DefaultTagNOptions tagNOpts.Concurrency = opts.concurrency - if _, err = oras.TagN(ctx, display.NewTagStatusPrinter(dst), opts.To.Reference, opts.extraRefs, tagNOpts); err != nil { + if _, err = oras.TagN(ctx, status.NewTagStatusPrinter(dst), opts.To.Reference, opts.extraRefs, tagNOpts); err != nil { return err } } @@ -176,21 +176,21 @@ func doCopy(ctx context.Context, src oras.ReadOnlyGraphTarget, dst oras.GraphTar // none TTY output extendedCopyOptions.OnCopySkipped = func(ctx context.Context, desc ocispec.Descriptor) error { committed.Store(desc.Digest.String(), desc.Annotations[ocispec.AnnotationTitle]) - return display.PrintStatus(desc, promptExists, opts.Verbose) + return status.PrintStatus(desc, promptExists, opts.Verbose) } extendedCopyOptions.PreCopy = func(ctx context.Context, desc ocispec.Descriptor) error { - return display.PrintStatus(desc, promptCopying, opts.Verbose) + return status.PrintStatus(desc, promptCopying, opts.Verbose) } extendedCopyOptions.PostCopy = func(ctx context.Context, desc ocispec.Descriptor) error { committed.Store(desc.Digest.String(), desc.Annotations[ocispec.AnnotationTitle]) - if err := display.PrintSuccessorStatus(ctx, desc, dst, committed, display.StatusPrinter(promptSkipped, opts.Verbose)); err != nil { + if err := status.PrintSuccessorStatus(ctx, desc, dst, committed, status.StatusPrinter(promptSkipped, opts.Verbose)); err != nil { return err } - return display.PrintStatus(desc, promptCopied, opts.Verbose) + return status.PrintStatus(desc, promptCopied, opts.Verbose) } extendedCopyOptions.OnMounted = func(ctx context.Context, desc ocispec.Descriptor) error { committed.Store(desc.Digest.String(), desc.Annotations[ocispec.AnnotationTitle]) - return display.PrintStatus(desc, promptMounted, opts.Verbose) + return status.PrintStatus(desc, promptMounted, opts.Verbose) } } else { // TTY output @@ -206,7 +206,7 @@ func doCopy(ctx context.Context, src oras.ReadOnlyGraphTarget, dst oras.GraphTar } extendedCopyOptions.PostCopy = func(ctx context.Context, desc ocispec.Descriptor) error { committed.Store(desc.Digest.String(), desc.Annotations[ocispec.AnnotationTitle]) - return display.PrintSuccessorStatus(ctx, desc, tracked, committed, func(desc ocispec.Descriptor) error { + return status.PrintSuccessorStatus(ctx, desc, tracked, committed, func(desc ocispec.Descriptor) error { return tracked.Prompt(desc, promptSkipped) }) } diff --git a/cmd/oras/root/cp_test.go b/cmd/oras/root/cp_test.go index 629cafb43..411cb4863 100644 --- a/cmd/oras/root/cp_test.go +++ b/cmd/oras/root/cp_test.go @@ -31,7 +31,7 @@ import ( ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content/memory" "oras.land/oras-go/v2/registry/remote" - "oras.land/oras/cmd/oras/internal/display/console/testutils" + "oras.land/oras/cmd/oras/internal/display/status/console/testutils" ) var ( diff --git a/cmd/oras/root/file.go b/cmd/oras/root/file.go index a0c8c09f1..1dc9f47a2 100644 --- a/cmd/oras/root/file.go +++ b/cmd/oras/root/file.go @@ -18,16 +18,16 @@ package root import ( "context" "errors" - "fmt" "io/fs" "path/filepath" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content/file" + "oras.land/oras/cmd/oras/internal/display/status" "oras.land/oras/cmd/oras/internal/fileref" ) -func loadFiles(ctx context.Context, store *file.Store, annotations map[string]map[string]string, fileRefs []string, verbose bool) ([]ocispec.Descriptor, error) { +func loadFiles(ctx context.Context, store *file.Store, annotations map[string]map[string]string, fileRefs []string, displayStatus status.PushHandler) ([]ocispec.Descriptor, error) { var files []ocispec.Descriptor for _, fileRef := range fileRefs { filename, mediaType, err := fileref.Parse(fileRef, "") @@ -41,8 +41,9 @@ func loadFiles(ctx context.Context, store *file.Store, annotations map[string]ma name = filepath.ToSlash(name) } - if verbose { - fmt.Println("Preparing", name) + err = displayStatus.OnFileLoading(name) + if err != nil { + return nil, err } file, err := addFile(ctx, store, name, mediaType, filename) if err != nil { @@ -60,7 +61,9 @@ func loadFiles(ctx context.Context, store *file.Store, annotations map[string]ma files = append(files, file) } if len(files) == 0 { - fmt.Println("Uploading empty artifact") + if err := displayStatus.OnEmptyArtifact(); err != nil { + return nil, err + } } return files, nil } diff --git a/cmd/oras/root/manifest/push.go b/cmd/oras/root/manifest/push.go index 74e8135b5..8afee2b0b 100644 --- a/cmd/oras/root/manifest/push.go +++ b/cmd/oras/root/manifest/push.go @@ -29,7 +29,7 @@ import ( "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/registry/remote" "oras.land/oras/cmd/oras/internal/argument" - "oras.land/oras/cmd/oras/internal/display" + "oras.land/oras/cmd/oras/internal/display/status" oerrors "oras.land/oras/cmd/oras/internal/errors" "oras.land/oras/cmd/oras/internal/manifest" "oras.land/oras/cmd/oras/internal/option" @@ -153,17 +153,17 @@ func pushManifest(cmd *cobra.Command, opts pushOptions) error { } verbose := opts.Verbose && !opts.OutputDescriptor if match { - if err := display.PrintStatus(desc, "Exists", verbose); err != nil { + if err := status.PrintStatus(desc, "Exists", verbose); err != nil { return err } } else { - if err = display.PrintStatus(desc, "Uploading", verbose); err != nil { + if err = status.PrintStatus(desc, "Uploading", verbose); err != nil { return err } if _, err := oras.TagBytes(ctx, target, mediaType, contentBytes, ref); err != nil { return err } - if err = display.PrintStatus(desc, "Uploaded ", verbose); err != nil { + if err = status.PrintStatus(desc, "Uploaded ", verbose); err != nil { return err } } @@ -184,9 +184,9 @@ func pushManifest(cmd *cobra.Command, opts pushOptions) error { } return opts.Output(os.Stdout, descJSON) } - display.Print("Pushed", opts.AnnotatedReference()) + status.Print("Pushed", opts.AnnotatedReference()) if len(opts.extraRefs) != 0 { - if _, err = oras.TagBytesN(ctx, display.NewTagStatusPrinter(target), mediaType, contentBytes, opts.extraRefs, tagBytesNOpts); err != nil { + if _, err = oras.TagBytesN(ctx, status.NewTagStatusPrinter(target), mediaType, contentBytes, opts.extraRefs, tagBytesNOpts); err != nil { return err } } diff --git a/cmd/oras/root/pull.go b/cmd/oras/root/pull.go index 82cc3264c..da2b99d6c 100644 --- a/cmd/oras/root/pull.go +++ b/cmd/oras/root/pull.go @@ -20,6 +20,7 @@ import ( "errors" "fmt" "io" + "os" "sync" "sync/atomic" @@ -29,8 +30,8 @@ import ( "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/content/file" "oras.land/oras/cmd/oras/internal/argument" - "oras.land/oras/cmd/oras/internal/display" - "oras.land/oras/cmd/oras/internal/display/track" + "oras.land/oras/cmd/oras/internal/display/status" + "oras.land/oras/cmd/oras/internal/display/status/track" oerrors "oras.land/oras/cmd/oras/internal/errors" "oras.land/oras/cmd/oras/internal/fileref" "oras.land/oras/cmd/oras/internal/option" @@ -188,7 +189,7 @@ func doPull(ctx context.Context, src oras.ReadOnlyTarget, dst oras.GraphTarget, } if po.TTY == nil { // none TTY, print status log for first-time fetching - if err := display.PrintStatus(target, promptDownloading, po.Verbose); err != nil { + if err := status.PrintStatus(target, promptDownloading, po.Verbose); err != nil { return nil, err } } @@ -203,7 +204,7 @@ func doPull(ctx context.Context, src oras.ReadOnlyTarget, dst oras.GraphTarget, }() if po.TTY == nil { // none TTY, add logs for processing manifest - return rc, display.PrintStatus(target, promptProcessing, po.Verbose) + return rc, status.PrintStatus(target, promptProcessing, po.Verbose) } return rc, nil }) @@ -264,7 +265,7 @@ func doPull(ctx context.Context, src oras.ReadOnlyTarget, dst oras.GraphTarget, } if po.TTY == nil { // none TTY, print status log for downloading - return display.PrintStatus(desc, promptDownloading, po.Verbose) + return status.PrintStatus(desc, promptDownloading, po.Verbose) } // TTY return nil @@ -290,7 +291,7 @@ func doPull(ctx context.Context, src oras.ReadOnlyTarget, dst oras.GraphTarget, name = desc.MediaType } printed.Store(generateContentKey(desc), true) - return display.Print(promptDownloaded, display.ShortDigest(desc), name) + return status.Print(promptDownloaded, status.ShortDigest(desc), name) } // Copy @@ -314,5 +315,16 @@ func printOnce(printed *sync.Map, s ocispec.Descriptor, msg string, verbose bool } // none TTY - return display.PrintStatus(s, msg, verbose) + return status.PrintStatus(s, msg, verbose) +} + +func getTrackedTarget(gt oras.GraphTarget, tty *os.File, actionPrompt, doneprompt string) (oras.GraphTarget, error) { + if tty == nil { + return gt, nil + } + tracked, err := track.NewTarget(gt, actionPrompt, doneprompt, tty) + if err != nil { + return nil, err + } + return tracked, nil } diff --git a/cmd/oras/root/pull_test.go b/cmd/oras/root/pull_test.go new file mode 100644 index 000000000..fcc5fd67b --- /dev/null +++ b/cmd/oras/root/pull_test.go @@ -0,0 +1,61 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package root + +import ( + "os" + "testing" + + "oras.land/oras-go/v2/content/memory" + "oras.land/oras/cmd/oras/internal/display/status/console/testutils" +) + +func Test_getTrackedTarget(t *testing.T) { + _, device, err := testutils.NewPty() + if err != nil { + t.Fatal(err) + } + defer device.Close() + src := memory.New() + actionPrompt := "action" + donePrompt := "done" + + t.Run("no TTY", func(t *testing.T) { + got, err := getTrackedTarget(src, nil, actionPrompt, donePrompt) + if err != nil { + t.Fatal(err) + } + if got != src { + t.Fatal("GraphTarget should not be modified if no TTY") + } + }) + + t.Run("has TTY", func(t *testing.T) { + got, err := getTrackedTarget(src, device, actionPrompt, donePrompt) + if err != nil { + t.Fatal(err) + } + if got == src { + t.Fatal("GraphTarget not be modified on TTY") + } + }) + + t.Run("invalid TTY", func(t *testing.T) { + if _, err := getTrackedTarget(src, os.Stdin, actionPrompt, donePrompt); err == nil { + t.Fatal("expected error for no tty but got nil") + } + }) +} diff --git a/cmd/oras/root/push.go b/cmd/oras/root/push.go index 444b09525..2cb0199ce 100644 --- a/cmd/oras/root/push.go +++ b/cmd/oras/root/push.go @@ -18,10 +18,7 @@ package root import ( "context" "errors" - "fmt" - "os" "strings" - "sync" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/spf13/cobra" @@ -32,7 +29,8 @@ import ( "oras.land/oras-go/v2/registry/remote/auth" "oras.land/oras/cmd/oras/internal/argument" "oras.land/oras/cmd/oras/internal/display" - "oras.land/oras/cmd/oras/internal/display/track" + "oras.land/oras/cmd/oras/internal/display/status" + "oras.land/oras/cmd/oras/internal/display/status/track" oerrors "oras.land/oras/cmd/oras/internal/errors" "oras.land/oras/cmd/oras/internal/fileref" "oras.land/oras/cmd/oras/internal/option" @@ -45,6 +43,7 @@ type pushOptions struct { option.Packer option.ImageSpec option.Target + option.Format extraRefs []string manifestConfigRef string @@ -77,7 +76,6 @@ Example - Push file "hi.txt" with artifact type "application/vnd.example+type": Example - Push file "hi.txt" with config type "application/vnd.me.config": oras push --image-spec v1.0 --artifact-type application/vnd.me.config localhost:5000/hello:v1 hi.txt - Example - Push file "hi.txt" with the custom manifest config "config.json" of the custom media type "application/vnd.me.config": oras push --config config.json:application/vnd.me.config localhost:5000/hello:v1 hi.txt @@ -141,6 +139,7 @@ func runPush(ctx context.Context, opts *pushOptions) error { if err != nil { return err } + displayStatus, displayMetadata := display.NewPushHandler(opts.Template, opts.TTY, opts.Verbose) // prepare pack packOpts := oras.PackManifestOptions{ @@ -164,7 +163,7 @@ func runPush(ctx context.Context, opts *pushOptions) error { desc.Annotations = packOpts.ConfigAnnotations packOpts.ConfigDescriptor = &desc } - descs, err := loadFiles(ctx, store, annotations, opts.FileRefs, opts.Verbose) + descs, err := loadFiles(ctx, store, annotations, opts.FileRefs, displayStatus) if err != nil { return err } @@ -186,14 +185,14 @@ func runPush(ctx context.Context, opts *pushOptions) error { if err != nil { return err } - dst, err = getTrackedTarget(dst, opts.TTY, "Uploading", "Uploaded ") + dst, err = displayStatus.TrackTarget(dst) if err != nil { return err } copyOptions := oras.DefaultCopyOptions copyOptions.Concurrency = opts.concurrency union := contentutil.MultiReadOnlyTarget(memoryStore, store) - updateDisplayOption(©Options.CopyGraphOptions, union, opts.Verbose, dst) + displayStatus.UpdateCopyOptions(©Options.CopyGraphOptions, union) copy := func(root ocispec.Descriptor) error { // add both pull and push scope hints for dst repository // to save potential push-scope token requests during copy @@ -212,7 +211,10 @@ func runPush(ctx context.Context, opts *pushOptions) error { if err != nil { return err } - fmt.Println("Pushed", opts.AnnotatedReference()) + err = displayMetadata.OnCopied(&opts.Target) + if err != nil { + return err + } if len(opts.extraRefs) != 0 { taggable := dst @@ -225,12 +227,15 @@ func runPush(ctx context.Context, opts *pushOptions) error { } tagBytesNOpts := oras.DefaultTagBytesNOptions tagBytesNOpts.Concurrency = opts.concurrency - if _, err = oras.TagBytesN(ctx, display.NewTagStatusPrinter(taggable), root.MediaType, contentBytes, opts.extraRefs, tagBytesNOpts); err != nil { + if _, err = oras.TagBytesN(ctx, status.NewTagStatusPrinter(taggable), root.MediaType, contentBytes, opts.extraRefs, tagBytesNOpts); err != nil { return err } } - fmt.Println("Digest:", root.Digest) + err = displayMetadata.OnCompleted(root) + if err != nil { + return err + } // Export manifest return opts.ExportManifest(ctx, memoryStore, root) @@ -244,60 +249,9 @@ func doPush(dst oras.Target, pack packFunc, copy copyFunc) (ocispec.Descriptor, return pushArtifact(dst, pack, copy) } -func updateDisplayOption(opts *oras.CopyGraphOptions, fetcher content.Fetcher, verbose bool, dst any) { - committed := &sync.Map{} - - const ( - promptSkipped = "Skipped " - promptUploaded = "Uploaded " - promptExists = "Exists " - promptUploading = "Uploading" - ) - if tracked, ok := dst.(track.GraphTarget); ok { - // TTY - opts.OnCopySkipped = func(ctx context.Context, desc ocispec.Descriptor) error { - committed.Store(desc.Digest.String(), desc.Annotations[ocispec.AnnotationTitle]) - return tracked.Prompt(desc, promptExists) - } - opts.PostCopy = func(ctx context.Context, desc ocispec.Descriptor) error { - committed.Store(desc.Digest.String(), desc.Annotations[ocispec.AnnotationTitle]) - return display.PrintSuccessorStatus(ctx, desc, fetcher, committed, func(d ocispec.Descriptor) error { - return tracked.Prompt(d, promptSkipped) - }) - } - return - } - // non TTY - opts.OnCopySkipped = func(ctx context.Context, desc ocispec.Descriptor) error { - committed.Store(desc.Digest.String(), desc.Annotations[ocispec.AnnotationTitle]) - return display.PrintStatus(desc, promptExists, verbose) - } - opts.PreCopy = func(ctx context.Context, desc ocispec.Descriptor) error { - return display.PrintStatus(desc, promptUploading, verbose) - } - opts.PostCopy = func(ctx context.Context, desc ocispec.Descriptor) error { - committed.Store(desc.Digest.String(), desc.Annotations[ocispec.AnnotationTitle]) - if err := display.PrintSuccessorStatus(ctx, desc, fetcher, committed, display.StatusPrinter(promptSkipped, verbose)); err != nil { - return err - } - return display.PrintStatus(desc, promptUploaded, verbose) - } -} - type packFunc func() (ocispec.Descriptor, error) type copyFunc func(desc ocispec.Descriptor) error -func getTrackedTarget(gt oras.GraphTarget, tty *os.File, actionPrompt, doneprompt string) (oras.GraphTarget, error) { - if tty == nil { - return gt, nil - } - tracked, err := track.NewTarget(gt, actionPrompt, doneprompt, tty) - if err != nil { - return nil, err - } - return tracked, nil -} - func pushArtifact(dst oras.Target, pack packFunc, copy copyFunc) (ocispec.Descriptor, error) { root, err := pack() if err != nil { diff --git a/cmd/oras/root/tag.go b/cmd/oras/root/tag.go index 7eaba777b..42576f7ba 100644 --- a/cmd/oras/root/tag.go +++ b/cmd/oras/root/tag.go @@ -23,7 +23,7 @@ import ( "oras.land/oras-go/v2" "oras.land/oras-go/v2/registry" "oras.land/oras/cmd/oras/internal/argument" - "oras.land/oras/cmd/oras/internal/display" + "oras.land/oras/cmd/oras/internal/display/status" oerrors "oras.land/oras/cmd/oras/internal/errors" "oras.land/oras/cmd/oras/internal/option" ) @@ -104,7 +104,7 @@ func tagManifest(cmd *cobra.Command, opts *tagOptions) error { tagNOpts.Concurrency = opts.concurrency _, err = oras.TagN( ctx, - display.NewTagStatusHintPrinter(target, fmt.Sprintf("[%s] %s", opts.Type, opts.Path)), + status.NewTagStatusHintPrinter(target, fmt.Sprintf("[%s] %s", opts.Type, opts.Path)), opts.Reference, opts.targetRefs, tagNOpts, diff --git a/go.mod b/go.mod index 1942a4c3f..da3065a2a 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module oras.land/oras go 1.21 require ( + github.com/Masterminds/sprig/v3 v3.2.3 github.com/containerd/console v1.0.3 github.com/morikuni/aec v1.0.0 github.com/opencontainers/go-digest v1.0.0 @@ -17,6 +18,16 @@ require ( ) require ( + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.2.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/huandu/xstrings v1.4.0 // indirect + github.com/imdario/mergo v0.3.16 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/shopspring/decimal v1.3.1 // indirect + github.com/spf13/cast v1.6.0 // indirect + golang.org/x/crypto v0.18.0 // indirect golang.org/x/sys v0.16.0 // indirect ) diff --git a/go.sum b/go.sum index ca2290cde..f57d78f8e 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,41 @@ +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA= +github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU= +github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= +github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -14,26 +44,68 @@ github.com/opencontainers/image-spec v1.1.0-rc6 h1:XDqvyKsJEbRtATzkgItUqBA7QHk58 github.com/opencontainers/image-spec v1.1.0-rc6/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/test/e2e/suite/command/attach.go b/test/e2e/suite/command/attach.go index 9302bbb00..f25c7859f 100644 --- a/test/e2e/suite/command/attach.go +++ b/test/e2e/suite/command/attach.go @@ -20,6 +20,7 @@ import ( "fmt" "path/filepath" "regexp" + "strings" . "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" @@ -118,6 +119,38 @@ var _ = Describe("1.1 registry users:", func() { MatchFile(filepath.Join(tempDir, exportName), string(fetched), DefaultTimeout) }) + It("should attach a file to a subject and format the digest reference", func() { + // prepare + testRepo := attachTestRepo("format-ref") + tempDir := PrepareTempFiles() + exportName := "manifest.json" + subjectRef := RegistryRef(ZOTHost, testRepo, foobar.Tag) + CopyZOTRepo(ImageRepo, testRepo) + // test + delimitter := "---" + output := ORAS("attach", "--artifact-type", "test/attach", subjectRef, fmt.Sprintf("%s:%s", foobar.AttachFileName, foobar.AttachFileMedia), "--export-manifest", exportName, "--format", fmt.Sprintf("{{.Ref}}%s{{.ArtifactType}}", delimitter)). + WithWorkDir(tempDir).Exec().Out.Contents() + ref, artifactType, _ := strings.Cut(string(output), delimitter) + // validate + Expect(artifactType).To(Equal("test/attach")) + fetched := ORAS("manifest", "fetch", ref).Exec().Out.Contents() + MatchFile(filepath.Join(tempDir, exportName), string(fetched), DefaultTimeout) + }) + + It("should attach a file to a subject and format json", func() { + // prepare + testRepo := attachTestRepo("format-json") + tempDir := PrepareTempFiles() + exportName := "manifest.json" + subjectRef := RegistryRef(ZOTHost, testRepo, foobar.Tag) + CopyZOTRepo(ImageRepo, testRepo) + // test + out := ORAS("attach", "--artifact-type", "test/attach", subjectRef, fmt.Sprintf("%s:%s", foobar.AttachFileName, foobar.AttachFileMedia), "--export-manifest", exportName, "--format", "json"). + WithWorkDir(tempDir).Exec().Out + // validate + Expect(out).To(gbytes.Say(RegistryRef(ZOTHost, testRepo, ""))) + }) + It("should attach a file via a OCI Image", func() { testRepo := attachTestRepo("image") tempDir := PrepareTempFiles() diff --git a/test/e2e/suite/command/push.go b/test/e2e/suite/command/push.go index d0d9437c9..19a89ad39 100644 --- a/test/e2e/suite/command/push.go +++ b/test/e2e/suite/command/push.go @@ -316,6 +316,44 @@ var _ = Describe("Remote registry users:", func() { Expect(manifest.Annotations[annotationKey]).Should(Equal(annotationValue)) }) + It("should push artifact and format reference", func() { + repo := pushTestRepo("format-go-template") + tempDir := PrepareTempFiles() + annotationKey := "key" + annotationValue := "value" + + // test + out := ORAS("push", RegistryRef(ZOTHost, repo, tag), "-a", fmt.Sprintf("%s=%s", annotationKey, annotationValue), "--format", "{{.Ref}}"). + WithWorkDir(tempDir).Exec().Out + + // validate + ref := string(out.Contents()) + fetched := ORAS("manifest", "fetch", ref).Exec().Out.Contents() + var manifest ocispec.Manifest + Expect(json.Unmarshal(fetched, &manifest)).ShouldNot(HaveOccurred()) + Expect(manifest.Layers).Should(HaveLen(1)) + Expect(manifest.Layers[0]).Should(Equal(artifact.EmptyLayerJSON)) + Expect(manifest.Config).Should(Equal(artifact.EmptyLayerJSON)) + Expect(manifest.Annotations).NotTo(BeNil()) + Expect(manifest.Annotations[annotationKey]).Should(Equal(annotationValue)) + }) + + It("should push artifact and format json", func() { + repo := pushTestRepo("format-json") + tempDir := PrepareTempFiles() + artifactType := "test/artifact+json" + annotationKey := "key" + annotationValue := "value" + + // test + out := ORAS("push", RegistryRef(ZOTHost, repo, tag), "-a", fmt.Sprintf("%s=%s", annotationKey, annotationValue), "--format", "json", "--artifact-type", artifactType). + WithWorkDir(tempDir).Exec().Out + + // validate + Expect(out).To(gbytes.Say(RegistryRef(ZOTHost, repo, ""))) + Expect(out).To(gbytes.Say(regexp.QuoteMeta(fmt.Sprintf(`"ArtifactType": "%s"`, artifactType)))) + }) + It("should push files", func() { repo := pushTestRepo("artifact-with-blob") tempDir := PrepareTempFiles()