Skip to content

Commit

Permalink
feat: --annotation for index create (#1499)
Browse files Browse the repository at this point in the history
Signed-off-by: Xiaoxuan Wang <xiaoxuanwang@microsoft.com>
  • Loading branch information
wangxiaoxuan273 authored Sep 23, 2024
1 parent 961e9f8 commit 8dc05a7
Show file tree
Hide file tree
Showing 8 changed files with 179 additions and 82 deletions.
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 {
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

0 comments on commit 8dc05a7

Please sign in to comment.