Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: --annotation for index create #1499

Merged
merged 15 commits into from
Sep 23, 2024
67 changes: 67 additions & 0 deletions cmd/oras/internal/option/annotation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
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 (
"errors"
"fmt"
"strings"

"github.com/spf13/cobra"
"github.com/spf13/pflag"
oerrors "oras.land/oras/cmd/oras/internal/errors"
)

var (
errAnnotationFormat = errors.New("annotation value doesn't match the required format")
errAnnotationDuplication = errors.New("duplicate annotation key")
)

// Annotation option struct.
type Annotation struct {
// ManifestAnnotations contains raw input of manifest annotation "key=value" pairs
ManifestAnnotations []string

// Annotations contains parsed manifest and config annotations
Annotations map[string]map[string]string
}

// ApplyFlags applies flags to a command flag set.
func (opts *Annotation) ApplyFlags(fs *pflag.FlagSet) {
fs.StringArrayVarP(&opts.ManifestAnnotations, "annotation", "a", nil, "manifest annotations")
}

// Parse parses the input annotation flags.
func (opts *Annotation) Parse(*cobra.Command) error {
qweeah marked this conversation as resolved.
Show resolved Hide resolved
manifestAnnotations := make(map[string]string)
for _, anno := range opts.ManifestAnnotations {
key, val, success := strings.Cut(anno, "=")
if !success {
return &oerrors.Error{
Err: errAnnotationFormat,
Recommendation: `Please use the correct format in the flag: --annotation "key=value"`,
}
}
if _, ok := manifestAnnotations[key]; ok {
return fmt.Errorf("%w: %v, ", errAnnotationDuplication, key)
}
manifestAnnotations[key] = val
}
opts.Annotations = map[string]map[string]string{
AnnotationManifest: manifestAnnotations,
}
return nil
}
54 changes: 16 additions & 38 deletions cmd/oras/internal/option/packer.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,26 +39,26 @@ const (
)

var (
errAnnotationConflict = errors.New("`--annotation` and `--annotation-file` cannot be both specified")
errAnnotationFormat = errors.New("annotation value doesn't match the required format")
errAnnotationDuplication = errors.New("duplicate annotation key")
errPathValidation = errors.New("absolute file path detected. If it's intentional, use --disable-path-validation flag to skip this check")
errAnnotationConflict = errors.New("`--annotation` and `--annotation-file` cannot be both specified")
errPathValidation = errors.New("absolute file path detected. If it's intentional, use --disable-path-validation flag to skip this check")
)

// Packer option struct.
type Packer struct {
Annotation

ManifestExportPath string
PathValidationDisabled bool
AnnotationFilePath string
ManifestAnnotations []string

FileRefs []string
}

// ApplyFlags applies flags to a command flag set.
func (opts *Packer) ApplyFlags(fs *pflag.FlagSet) {
opts.Annotation.ApplyFlags(fs)

fs.StringVarP(&opts.ManifestExportPath, "export-manifest", "", "", "`path` of the pushed manifest")
fs.StringArrayVarP(&opts.ManifestAnnotations, "annotation", "a", nil, "manifest annotations")
fs.StringVarP(&opts.AnnotationFilePath, "annotation-file", "", "", "path of the annotation file")
fs.BoolVarP(&opts.PathValidationDisabled, "disable-path-validation", "", false, "skip path validation")
}
Expand All @@ -74,7 +74,8 @@ func (opts *Packer) ExportManifest(ctx context.Context, fetcher content.Fetcher,
}
return os.WriteFile(opts.ManifestExportPath, manifestBytes, 0666)
}
func (opts *Packer) Parse(*cobra.Command) error {

func (opts *Packer) Parse(cmd *cobra.Command) error {
if !opts.PathValidationDisabled {
var failedPaths []string
for _, path := range opts.FileRefs {
Expand All @@ -91,29 +92,26 @@ func (opts *Packer) Parse(*cobra.Command) error {
return fmt.Errorf("%w: %v", errPathValidation, strings.Join(failedPaths, ", "))
}
}
return nil
return opts.parseAnnotations(cmd)
}

// LoadManifestAnnotations loads the manifest annotation map.
func (opts *Packer) LoadManifestAnnotations() (annotations map[string]map[string]string, err error) {
// parseAnnotations loads the manifest annotation map.
func (opts *Packer) parseAnnotations(cmd *cobra.Command) error {
if opts.AnnotationFilePath != "" && len(opts.ManifestAnnotations) != 0 {
return nil, errAnnotationConflict
return errAnnotationConflict
}
if opts.AnnotationFilePath != "" {
if err = decodeJSON(opts.AnnotationFilePath, &annotations); err != nil {
return nil, &oerrors.Error{
if err := decodeJSON(opts.AnnotationFilePath, &opts.Annotations); err != nil {
return &oerrors.Error{
Err: fmt.Errorf(`invalid annotation json file: failed to load annotations from %s`, opts.AnnotationFilePath),
Recommendation: `Annotation file doesn't match the required format. Please refer to the document at https://oras.land/docs/how_to_guides/manifest_annotations`,
}
}
}
if len(opts.ManifestAnnotations) != 0 {
annotations = make(map[string]map[string]string)
if err = parseAnnotationFlags(opts.ManifestAnnotations, annotations); err != nil {
return nil, err
}
return opts.Annotation.Parse(cmd)
}
return
return nil
}

// decodeJSON decodes a json file v to filename.
Expand All @@ -125,23 +123,3 @@ func decodeJSON(filename string, v interface{}) error {
defer file.Close()
return json.NewDecoder(file).Decode(v)
}

// parseAnnotationFlags parses annotation flags into a map.
func parseAnnotationFlags(flags []string, annotations map[string]map[string]string) error {
manifestAnnotations := make(map[string]string)
for _, anno := range flags {
key, val, success := strings.Cut(anno, "=")
if !success {
return &oerrors.Error{
Err: errAnnotationFormat,
Recommendation: `Please use the correct format in the flag: --annotation "key=value"`,
}
}
if _, ok := manifestAnnotations[key]; ok {
return fmt.Errorf("%w: %v, ", errAnnotationDuplication, key)
}
manifestAnnotations[key] = val
}
annotations[AnnotationManifest] = manifestAnnotations
return nil
}
67 changes: 43 additions & 24 deletions cmd/oras/internal/option/packer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,62 +37,73 @@ func TestPacker_FlagInit(t *testing.T) {
ApplyFlags(&test, pflag.NewFlagSet("oras-test", pflag.ExitOnError))
}

func TestPacker_LoadManifestAnnotations_err(t *testing.T) {
func TestPacker_parseAnnotations_err(t *testing.T) {
opts := Packer{
AnnotationFilePath: "this is not a file", // testFile,
ManifestAnnotations: []string{"Key=Val"},
Annotation: Annotation{
ManifestAnnotations: []string{"Key=Val"},
},
AnnotationFilePath: "this is not a file", // testFile,
}
if _, err := opts.LoadManifestAnnotations(); !errors.Is(err, errAnnotationConflict) {
if err := opts.parseAnnotations(nil); !errors.Is(err, errAnnotationConflict) {
t.Fatalf("unexpected error: %v", err)
}

opts = Packer{
AnnotationFilePath: "this is not a file", // testFile,
}
if _, err := opts.LoadManifestAnnotations(); err == nil {
if err := opts.parseAnnotations(nil); err == nil {
t.Fatalf("unexpected error: %v", err)
}

opts = Packer{
ManifestAnnotations: []string{"KeyVal"},
Annotation: Annotation{
ManifestAnnotations: []string{"KeyVal"},
},
}
if _, err := opts.LoadManifestAnnotations(); !errors.Is(err, errAnnotationFormat) {
if err := opts.parseAnnotations(nil); !errors.Is(err, errAnnotationFormat) {
t.Fatalf("unexpected error: %v", err)
}

opts = Packer{
ManifestAnnotations: []string{"Key=Val1", "Key=Val2"},
Annotation: Annotation{
ManifestAnnotations: []string{"Key=Val1", "Key=Val2"},
},
}
if _, err := opts.LoadManifestAnnotations(); !errors.Is(err, errAnnotationDuplication) {
if err := opts.parseAnnotations(nil); !errors.Is(err, errAnnotationDuplication) {
t.Fatalf("unexpected error: %v", err)
}
}

func TestPacker_LoadManifestAnnotations_annotationFile(t *testing.T) {
func TestPacker_parseAnnotations_annotationFile(t *testing.T) {
testFile := filepath.Join(t.TempDir(), "testAnnotationFile")
err := os.WriteFile(testFile, []byte(testContent), fs.ModePerm)
if err != nil {
t.Fatalf("Error writing %s: %v", testFile, err)
}
opts := Packer{AnnotationFilePath: testFile}
opts := Packer{
AnnotationFilePath: testFile,
}

anno, err := opts.LoadManifestAnnotations()
err = opts.parseAnnotations(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !reflect.DeepEqual(anno, expectedResult) {
t.Fatalf("unexpected error: %v", anno)
if !reflect.DeepEqual(opts.Annotations, expectedResult) {
t.Fatalf("unexpected error: %v", opts.Annotations)
}
}

func TestPacker_LoadManifestAnnotations_annotationFlag(t *testing.T) {
func TestPacker_parseAnnotations_annotationFlag(t *testing.T) {
// Item do not contains '='
invalidFlag0 := []string{
"Key",
}
var annotations map[string]map[string]string
opts := Packer{ManifestAnnotations: invalidFlag0}
_, err := opts.LoadManifestAnnotations()
opts := Packer{
Annotation: Annotation{
ManifestAnnotations: invalidFlag0,
},
}
err := opts.parseAnnotations(nil)
if !errors.Is(err, errAnnotationFormat) {
t.Fatalf("unexpected error: %v", err)
}
Expand All @@ -102,8 +113,12 @@ func TestPacker_LoadManifestAnnotations_annotationFlag(t *testing.T) {
"Key=0",
"Key=1",
}
opts = Packer{ManifestAnnotations: invalidFlag1}
_, err = opts.LoadManifestAnnotations()
opts = Packer{
Annotation: Annotation{
ManifestAnnotations: invalidFlag1,
},
}
err = opts.parseAnnotations(nil)
if !errors.Is(err, errAnnotationDuplication) {
t.Fatalf("unexpected error: %v", err)
}
Expand All @@ -114,15 +129,19 @@ func TestPacker_LoadManifestAnnotations_annotationFlag(t *testing.T) {
"Key1=Val", // 2. Normal Item
"Key2=${env:USERNAME}", // 3. Item contains variable eg. "${env:USERNAME}"
}
opts = Packer{ManifestAnnotations: validFlag}
annotations, err = opts.LoadManifestAnnotations()
opts = Packer{
Annotation: Annotation{
ManifestAnnotations: validFlag,
},
}
err = opts.parseAnnotations(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, ok := annotations["$manifest"]; !ok {
if _, ok := opts.Annotations["$manifest"]; !ok {
t.Fatalf("unexpected error: failed when looking for '$manifest' in annotations")
}
if !reflect.DeepEqual(annotations,
if !reflect.DeepEqual(opts.Annotations,
map[string]map[string]string{
"$manifest": {
"Key0": "",
Expand Down
10 changes: 3 additions & 7 deletions cmd/oras/root/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,7 @@ Example - Attach file to the manifest tagged 'v1' in an OCI image layout folder

func runAttach(cmd *cobra.Command, opts *attachOptions) error {
ctx, logger := command.GetLogger(cmd, &opts.Common)
annotations, err := opts.LoadManifestAnnotations()
if err != nil {
return err
}
if len(opts.FileRefs) == 0 && len(annotations[option.AnnotationManifest]) == 0 {
if len(opts.FileRefs) == 0 && len(opts.Annotations[option.AnnotationManifest]) == 0 {
return &oerrors.Error{
Err: errors.New(`neither file nor annotation provided in the command`),
Usage: fmt.Sprintf("%s %s", cmd.Parent().CommandPath(), cmd.Use),
Expand Down Expand Up @@ -161,7 +157,7 @@ func runAttach(cmd *cobra.Command, opts *attachOptions) error {
if err != nil {
return err
}
descs, err := loadFiles(ctx, store, annotations, opts.FileRefs, displayStatus)
descs, err := loadFiles(ctx, store, opts.Annotations, opts.FileRefs, displayStatus)
if err != nil {
return err
}
Expand All @@ -179,7 +175,7 @@ func runAttach(cmd *cobra.Command, opts *attachOptions) error {

packOpts := oras.PackManifestOptions{
Subject: &subject,
ManifestAnnotations: annotations[option.AnnotationManifest],
ManifestAnnotations: opts.Annotations[option.AnnotationManifest],
Layers: descs,
}
pack := func() (ocispec.Descriptor, error) {
Expand Down
10 changes: 6 additions & 4 deletions cmd/oras/root/attach_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,20 @@ import (
)

func Test_runAttach_errType(t *testing.T) {
// prpare
// prepare
cmd := &cobra.Command{}
cmd.SetContext(context.Background())

// test
opts := &attachOptions{
Packer: option.Packer{
AnnotationFilePath: "/tmp/whatever",
ManifestAnnotations: []string{"one", "two"},
Annotation: option.Annotation{
ManifestAnnotations: []string{"one", "two"},
},
AnnotationFilePath: "/tmp/whatever",
},
}
got := runAttach(cmd, opts).Error()
got := opts.Packer.Parse(cmd).Error()
want := errors.New("`--annotation` and `--annotation-file` cannot be both specified").Error()
if got != want {
t.Fatalf("got %v, want %v", got, want)
Expand Down
9 changes: 7 additions & 2 deletions cmd/oras/root/manifest/index/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ type createOptions struct {
option.Common
option.Target
option.Pretty
option.Annotation

sources []string
extraRefs []string
Expand All @@ -72,6 +73,9 @@ Example - Create an index from source manifests using both tags and digests, and
Example - Create an index and push it with multiple tags:
oras manifest index create localhost:5000/hello:tag1,tag2,tag3 linux-amd64 linux-arm64 sha256:99e4703fbf30916f549cd6bfa9cdbab614b5392fbe64fdee971359a77073cdf9

Example - Create and push an index with annotations:
oras manifest index create localhost:5000/hello:v1 linux-amd64 --annotation "key=val"

Example - Create an index and push to an OCI image layout folder 'layout-dir' and tag with 'v1':
oras manifest index create layout-dir:v1 linux-amd64 sha256:99e4703fbf30916f549cd6bfa9cdbab614b5392fbe64fdee971359a77073cdf9

Expand Down Expand Up @@ -113,8 +117,9 @@ func createIndex(cmd *cobra.Command, opts createOptions) error {
Versioned: specs.Versioned{
SchemaVersion: 2,
},
MediaType: ocispec.MediaTypeImageIndex,
Manifests: manifests,
MediaType: ocispec.MediaTypeImageIndex,
Manifests: manifests,
Annotations: opts.Annotations[option.AnnotationManifest],
}
indexBytes, err := json.Marshal(index)
if err != nil {
Expand Down
Loading
Loading