Skip to content

Commit

Permalink
Add attach command (oras-project#433)
Browse files Browse the repository at this point in the history
Signed-off-by: Billy Zha <[email protected]>
  • Loading branch information
qweeah authored Jul 8, 2022
1 parent 2b400f4 commit 8125941
Show file tree
Hide file tree
Showing 14 changed files with 408 additions and 148 deletions.
149 changes: 149 additions & 0 deletions cmd/oras/attach.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/*
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 main

import (
"context"
"fmt"

ocispec "github.com/opencontainers/image-spec/specs-go/v1"
artifactspec "github.com/oras-project/artifacts-spec/specs-go/v1"
"github.com/spf13/cobra"
"oras.land/oras-go/v2"
"oras.land/oras-go/v2/content"
"oras.land/oras-go/v2/content/file"
"oras.land/oras/cmd/oras/internal/display"
"oras.land/oras/cmd/oras/internal/option"
)

type attachOptions struct {
option.Common
option.Remote
option.Pusher

targetRef string
artifactType string
}

func attachCmd() *cobra.Command {
var opts attachOptions
cmd := &cobra.Command{
Use: "attach name<:tag|@digest> file[:type] [file...]",
Short: "[Preview] Attach files to an existing artifact",
Long: `[Preview] Attach files to an existing artifact
** This command is in preview and under development. **
Example - Attach file 'hi.txt' with type 'doc/example' to manifest 'hello:test' in registry 'localhost:5000'
oras attach localhost:5000/hello:test hi.txt --artifact-type doc/example
`,
Args: cobra.MinimumNArgs(2),
PreRunE: func(cmd *cobra.Command, args []string) error {
return opts.ReadPassword()
},
RunE: func(cmd *cobra.Command, args []string) error {
opts.targetRef = args[0]
opts.FileRefs = args[1:]
return runAttach(opts)
},
}

cmd.Flags().StringVarP(&opts.artifactType, "artifact-type", "", "", "artifact type")
option.ApplyFlags(&opts, cmd.Flags())
return cmd
}

func runAttach(opts attachOptions) error {
ctx, _ := opts.SetLoggerLevel()
annotations, err := opts.LoadManifestAnnotations()
if err != nil {
return err
}

// Prepare manifest
store := file.New("")
defer store.Close()
store.AllowPathTraversalOnWrite = opts.PathValidationDisabled

dst, err := opts.NewRepository(opts.targetRef, opts.Common)
if err != nil {
return err
}
if dst.Reference.Reference == "" {
return newErrInvalidReference(dst.Reference)
}
ociSubject, err := dst.Resolve(ctx, dst.Reference.Reference)
if err != nil {
return err
}
subject := ociToArtifact(ociSubject)
ociDescs, err := loadFiles(ctx, store, annotations, opts.FileRefs, opts.Verbose)
if err != nil {
return err
}
orasDescs := make([]artifactspec.Descriptor, len(ociDescs))
for i := range ociDescs {
orasDescs[i] = ociToArtifact(ociDescs[i])
}
desc, err := oras.PackArtifact(
ctx, store, opts.artifactType, orasDescs,
oras.PackArtifactOptions{
Subject: &subject,
})
if err != nil {
return err
}

// Prepare Push
graphCopyOptions := oras.DefaultCopyGraphOptions
graphCopyOptions.FindSuccessors = func(ctx context.Context, fetcher content.Fetcher, node ocispec.Descriptor) ([]ocispec.Descriptor, error) {
if isEqualOCIDescriptor(node, desc) {
// Skip subject
return ociDescs, nil
}
return content.Successors(ctx, fetcher, node)
}
graphCopyOptions.PreCopy = display.StatusPrinter("Uploading", opts.Verbose)
graphCopyOptions.OnCopySkipped = display.StatusPrinter("Exists ", opts.Verbose)
graphCopyOptions.PostCopy = display.StatusPrinter("Uploaded ", opts.Verbose)

// Push
err = oras.CopyGraph(ctx, store, dst, desc, graphCopyOptions)
if err != nil {
return err
}

fmt.Println("Attached to", opts.targetRef)
fmt.Println("Digest:", desc.Digest)

// Export manifest
return opts.ExportManifest(ctx, store, desc)
}

func isEqualOCIDescriptor(a, b ocispec.Descriptor) bool {
return a.Size == b.Size && a.Digest == b.Digest && a.MediaType == b.MediaType
}

// ociToArtifact converts OCI descriptor to artifact descriptor.
func ociToArtifact(desc ocispec.Descriptor) artifactspec.Descriptor {
return artifactspec.Descriptor{
MediaType: desc.MediaType,
Digest: desc.Digest,
Size: desc.Size,
URLs: desc.URLs,
Annotations: desc.Annotations,
}
}
58 changes: 58 additions & 0 deletions cmd/oras/file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
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 main

import (
"context"
"fmt"
"path/filepath"

ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"oras.land/oras-go/v2/content/file"
)

func loadFiles(ctx context.Context, store *file.Store, annotations map[string]map[string]string, fileRefs []string, verbose bool) ([]ocispec.Descriptor, error) {
var files []ocispec.Descriptor
for _, fileRef := range fileRefs {
filename, mediaType := parseFileReference(fileRef, "")
name := filepath.Clean(filename)
if !filepath.IsAbs(name) {
// convert to slash-separated path unless it is absolute path
name = filepath.ToSlash(name)
}
if verbose {
fmt.Println("Preparing", name)
}
file, err := store.Add(ctx, name, mediaType, filename)
if err != nil {
return nil, err
}
if value, ok := annotations[filename]; ok {
if file.Annotations == nil {
file.Annotations = value
} else {
for k, v := range value {
file.Annotations[k] = v
}
}
}
files = append(files, file)
}
if len(files) == 0 {
fmt.Println("Uploading empty artifact")
}
return files, nil
}
30 changes: 30 additions & 0 deletions cmd/oras/file_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//go:build !windows

/*
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 main

import "strings"

// parseFileReference parses file reference on unix.
func parseFileReference(reference string, mediaType string) (filePath, mediatype string) {
i := strings.LastIndex(reference, ":")
if i < 0 {
return reference, mediaType
}
return reference[:i], reference[i+1:]

}
40 changes: 40 additions & 0 deletions cmd/oras/file_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
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 main

import (
"strings"
"unicode"
)

// parseFileReference parses file reference on windows.
// Windows systems does not allow ':' in the file path except for drive letter.
func parseFileReference(reference string, mediaType string) (filePath, mediatype string) {
i := strings.Index(reference, ":")
if i < 0 {
return reference, mediaType
}

// In case it is C:\
if i == 1 && len(reference) > 2 && reference[2] == '\\' && unicode.IsLetter(rune(reference[0])) {
i = strings.Index(reference[3:], ":")
if i < 0 {
return reference, mediaType
}
i += 3
}
return reference[:i], reference[i+1:]
}
17 changes: 17 additions & 0 deletions cmd/oras/internal/display/print.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@ limitations under the License.
package display

import (
"context"
"fmt"
"sync"

ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)

var printLock sync.Mutex
Expand All @@ -29,3 +32,17 @@ func Print(a ...any) error {
_, err := fmt.Println(a...)
return err
}

// StatusPrinter returns a tracking function for transfer status.
func StatusPrinter(status string, verbose bool) func(context.Context, ocispec.Descriptor) error {
return func(ctx context.Context, desc ocispec.Descriptor) error {
name, ok := desc.Annotations[ocispec.AnnotationTitle]
if !ok {
if !verbose {
return nil
}
name = desc.MediaType
}
return Print(status, ShortDigest(desc), name)
}
}
75 changes: 75 additions & 0 deletions cmd/oras/internal/option/pusher.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
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 (
"context"
"encoding/json"
"os"

ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/spf13/pflag"
"oras.land/oras-go/v2/content"
)

// Pusher option struct.
type Pusher struct {
ManifestExportPath string
PathValidationDisabled bool
ManifestAnnotations string

FileRefs []string
}

// ApplyFlags applies flags to a command flag set.
func (opts *Pusher) ApplyFlags(fs *pflag.FlagSet) {
fs.StringVarP(&opts.ManifestExportPath, "export-manifest", "", "", "export the pushed manifest")
fs.StringVarP(&opts.ManifestAnnotations, "manifest-annotations", "", "", "manifest annotation file")
fs.BoolVarP(&opts.PathValidationDisabled, "disable-path-validation", "", false, "skip path validation")
}

// ExportManifest saves the pushed manifest to a local file.
func (opts *Pusher) ExportManifest(ctx context.Context, fetcher content.Fetcher, desc ocispec.Descriptor) error {
if opts.ManifestExportPath == "" {
return nil
}
manifestBytes, err := content.FetchAll(ctx, fetcher, desc)
if err != nil {
return err
}
return os.WriteFile(opts.ManifestExportPath, manifestBytes, 0666)
}

// LoadManifestAnnotations loads the manifest annotation map.
func (opts *Pusher) LoadManifestAnnotations() (map[string]map[string]string, error) {
var annotations map[string]map[string]string
if opts.ManifestAnnotations != "" {
if err := decodeJSON(opts.ManifestAnnotations, &annotations); err != nil {
return nil, err
}
}
return annotations, nil
}

// decodeJSON decodes a json file v to filename.
func decodeJSON(filename string, v interface{}) error {
file, err := os.Open(filename)
if err != nil {
return err
}
defer file.Close()
return json.NewDecoder(file).Decode(v)
}
2 changes: 1 addition & 1 deletion cmd/oras/internal/option/remote_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ func TestRemote_NewRepository(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if err = repo.Tags(context.Background(), func(got []string) error {
if err = repo.Tags(context.Background(), "", func(got []string) error {
want := []string{"tag"}
if len(got) != len(testTagList.Tags) || !reflect.DeepEqual(got, want) {
return fmt.Errorf("expect: %v, got: %v", testTagList.Tags, got)
Expand Down
Loading

0 comments on commit 8125941

Please sign in to comment.