mirror of
				https://gitea.com/Lydanne/buildx.git
				synced 2025-11-01 00:23:56 +08:00 
			
		
		
		
	imagetools: add create support
Signed-off-by: Tonis Tiigi <tonistiigi@gmail.com>
This commit is contained in:
		| @@ -1,27 +1,214 @@ | |||||||
| package commands | package commands | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"fmt" | ||||||
|  | 	"io/ioutil" | ||||||
|  | 	"strings" | ||||||
|  |  | ||||||
| 	"github.com/docker/cli/cli/command" | 	"github.com/docker/cli/cli/command" | ||||||
|  | 	"github.com/docker/distribution/reference" | ||||||
|  | 	"github.com/moby/buildkit/util/appcontext" | ||||||
|  | 	"github.com/opencontainers/go-digest" | ||||||
|  | 	ocispec "github.com/opencontainers/image-spec/specs-go/v1" | ||||||
| 	"github.com/pkg/errors" | 	"github.com/pkg/errors" | ||||||
| 	"github.com/spf13/cobra" | 	"github.com/spf13/cobra" | ||||||
|  | 	"github.com/tonistiigi/buildx/util/imagetools" | ||||||
|  | 	"golang.org/x/sync/errgroup" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| type createOptions struct { | type createOptions struct { | ||||||
| 	files  []string | 	files        []string | ||||||
| 	tags   []string | 	tags         []string | ||||||
| 	dryrun bool | 	dryrun       bool | ||||||
| 	append bool | 	actionAppend bool | ||||||
| } | } | ||||||
|  |  | ||||||
| func runCreate(dockerCli command.Cli, in createOptions, args []string) error { | func runCreate(dockerCli command.Cli, in createOptions, args []string) error { | ||||||
| 	return errors.Errorf("not-implemented") | 	if len(args) == 0 && len(in.files) == 0 { | ||||||
|  | 		return errors.Errorf("no sources specified") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if !in.dryrun && len(in.tags) == 0 { | ||||||
|  | 		return errors.Errorf("can't push with no tags specified, please set --tag or --dry-run") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	fileArgs := make([]string, len(in.files)) | ||||||
|  | 	for i, f := range in.files { | ||||||
|  | 		dt, err := ioutil.ReadFile(f) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} | ||||||
|  | 		fileArgs[i] = string(dt) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	args = append(fileArgs, args...) | ||||||
|  |  | ||||||
|  | 	tags, err := parseRefs(in.tags) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if in.actionAppend && len(in.tags) > 0 { | ||||||
|  | 		args = append([]string{in.tags[0]}, args...) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	srcs, err := parseSources(args) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	repos := map[string]struct{}{} | ||||||
|  |  | ||||||
|  | 	for _, t := range tags { | ||||||
|  | 		repos[t.Name()] = struct{}{} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	sourceRefs := false | ||||||
|  | 	for _, s := range srcs { | ||||||
|  | 		if s.Ref != nil { | ||||||
|  | 			repos[s.Ref.Name()] = struct{}{} | ||||||
|  | 			sourceRefs = true | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if len(repos) == 0 { | ||||||
|  | 		return errors.Errorf("no repositories specified, please set a reference in tag or source") | ||||||
|  | 	} | ||||||
|  | 	if len(repos) > 1 { | ||||||
|  | 		return errors.Errorf("multiple repositories currently not supported, found %v", repos) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var repo string | ||||||
|  | 	for r := range repos { | ||||||
|  | 		repo = r | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	for i, s := range srcs { | ||||||
|  | 		if s.Ref == nil && s.Desc.MediaType == "" && s.Desc.Digest != "" { | ||||||
|  | 			n, err := reference.ParseNormalizedNamed(repo) | ||||||
|  | 			if err != nil { | ||||||
|  | 				return err | ||||||
|  | 			} | ||||||
|  | 			r, err := reference.WithDigest(n, s.Desc.Digest) | ||||||
|  | 			if err != nil { | ||||||
|  | 				return err | ||||||
|  | 			} | ||||||
|  | 			srcs[i].Ref = r | ||||||
|  | 			sourceRefs = true | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	ctx := appcontext.Context() | ||||||
|  |  | ||||||
|  | 	r := imagetools.New(imagetools.Opt{ | ||||||
|  | 		Auth: dockerCli.ConfigFile(), | ||||||
|  | 	}) | ||||||
|  |  | ||||||
|  | 	if sourceRefs { | ||||||
|  | 		eg, ctx2 := errgroup.WithContext(ctx) | ||||||
|  | 		for i, s := range srcs { | ||||||
|  | 			if s.Ref == nil { | ||||||
|  | 				continue | ||||||
|  | 			} | ||||||
|  | 			func(i int) { | ||||||
|  | 				eg.Go(func() error { | ||||||
|  | 					_, desc, err := r.Resolve(ctx2, srcs[i].Ref.String()) | ||||||
|  | 					if err != nil { | ||||||
|  | 						return err | ||||||
|  | 					} | ||||||
|  | 					srcs[i].Ref = nil | ||||||
|  | 					srcs[i].Desc = desc | ||||||
|  | 					return nil | ||||||
|  | 				}) | ||||||
|  | 			}(i) | ||||||
|  | 		} | ||||||
|  | 		if err := eg.Wait(); err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	descs := make([]ocispec.Descriptor, len(srcs)) | ||||||
|  | 	for i := range descs { | ||||||
|  | 		descs[i] = srcs[i].Desc | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	dt, desc, err := r.Combine(ctx, repo, descs) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	_ = desc | ||||||
|  |  | ||||||
|  | 	if in.dryrun { | ||||||
|  | 		fmt.Printf("%s\n", dt) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type src struct { | ||||||
|  | 	Desc ocispec.Descriptor | ||||||
|  | 	Ref  reference.Named | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func parseSources(in []string) ([]*src, error) { | ||||||
|  | 	out := make([]*src, len(in)) | ||||||
|  | 	for i, in := range in { | ||||||
|  | 		s, err := parseSource(in) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return nil, errors.Wrapf(err, "failed to parse source %q, valid sources are digests, refereces and descriptors", in) | ||||||
|  | 		} | ||||||
|  | 		out[i] = s | ||||||
|  | 	} | ||||||
|  | 	return out, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func parseRefs(in []string) ([]reference.Named, error) { | ||||||
|  | 	refs := make([]reference.Named, len(in)) | ||||||
|  | 	for i, in := range in { | ||||||
|  | 		n, err := reference.ParseNormalizedNamed(in) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return nil, err | ||||||
|  | 		} | ||||||
|  | 		refs[i] = n | ||||||
|  | 	} | ||||||
|  | 	return refs, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func parseSource(in string) (*src, error) { | ||||||
|  | 	// source can be a digest, reference or a descriptor JSON | ||||||
|  | 	dgst, err := digest.Parse(in) | ||||||
|  | 	if err == nil { | ||||||
|  | 		return &src{ | ||||||
|  | 			Desc: ocispec.Descriptor{ | ||||||
|  | 				Digest: dgst, | ||||||
|  | 			}, | ||||||
|  | 		}, nil | ||||||
|  | 	} else if strings.HasPrefix(in, "sha256") { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	ref, err := reference.ParseNormalizedNamed(in) | ||||||
|  | 	if err == nil { | ||||||
|  | 		return &src{ | ||||||
|  | 			Ref: ref, | ||||||
|  | 		}, nil | ||||||
|  | 	} else if !strings.HasPrefix(in, "{") { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var s src | ||||||
|  | 	if err := json.Unmarshal([]byte(in), &s.Desc); err != nil { | ||||||
|  | 		return nil, errors.WithStack(err) | ||||||
|  | 	} | ||||||
|  | 	return &s, nil | ||||||
| } | } | ||||||
|  |  | ||||||
| func createCmd(dockerCli command.Cli) *cobra.Command { | func createCmd(dockerCli command.Cli) *cobra.Command { | ||||||
| 	var options createOptions | 	var options createOptions | ||||||
|  |  | ||||||
| 	cmd := &cobra.Command{ | 	cmd := &cobra.Command{ | ||||||
| 		Use:   "create [OPTIONS] [SOURCE...]", | 		Use:   "create [OPTIONS] [SOURCE] [SOURCE...]", | ||||||
| 		Short: "Create a new image based on source images", | 		Short: "Create a new image based on source images", | ||||||
| 		RunE: func(cmd *cobra.Command, args []string) error { | 		RunE: func(cmd *cobra.Command, args []string) error { | ||||||
| 			return runCreate(dockerCli, options, args) | 			return runCreate(dockerCli, options, args) | ||||||
| @@ -33,7 +220,7 @@ func createCmd(dockerCli command.Cli) *cobra.Command { | |||||||
| 	flags.StringArrayVarP(&options.files, "file", "f", []string{}, "Read source descriptor from file") | 	flags.StringArrayVarP(&options.files, "file", "f", []string{}, "Read source descriptor from file") | ||||||
| 	flags.StringArrayVarP(&options.tags, "tag", "t", []string{}, "Set reference for new image") | 	flags.StringArrayVarP(&options.tags, "tag", "t", []string{}, "Set reference for new image") | ||||||
| 	flags.BoolVar(&options.dryrun, "dry-run", false, "Show final image instead of pushing") | 	flags.BoolVar(&options.dryrun, "dry-run", false, "Show final image instead of pushing") | ||||||
| 	flags.BoolVar(&options.append, "append", false, "Append to existing manifest") | 	flags.BoolVar(&options.actionAppend, "append", false, "Append to existing manifest") | ||||||
|  |  | ||||||
| 	_ = flags | 	_ = flags | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										186
									
								
								util/imagetools/create.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										186
									
								
								util/imagetools/create.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,186 @@ | |||||||
|  | package imagetools | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"context" | ||||||
|  | 	"encoding/json" | ||||||
|  |  | ||||||
|  | 	"github.com/containerd/containerd/images" | ||||||
|  | 	"github.com/opencontainers/go-digest" | ||||||
|  | 	"github.com/opencontainers/image-spec/specs-go" | ||||||
|  | 	ocispec "github.com/opencontainers/image-spec/specs-go/v1" | ||||||
|  | 	"github.com/pkg/errors" | ||||||
|  | 	"golang.org/x/sync/errgroup" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | func (r *Resolver) Combine(ctx context.Context, in string, descs []ocispec.Descriptor) ([]byte, ocispec.Descriptor, error) { | ||||||
|  | 	ref, err := parseRef(in) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, ocispec.Descriptor{}, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	eg, ctx := errgroup.WithContext(ctx) | ||||||
|  |  | ||||||
|  | 	dts := make([][]byte, len(descs)) | ||||||
|  | 	for i := range dts { | ||||||
|  | 		func(i int) { | ||||||
|  | 			eg.Go(func() error { | ||||||
|  | 				dt, err := r.GetDescriptor(ctx, ref.String(), descs[i]) | ||||||
|  | 				if err != nil { | ||||||
|  | 					return err | ||||||
|  | 				} | ||||||
|  | 				dts[i] = dt | ||||||
|  |  | ||||||
|  | 				if descs[i].MediaType == "" { | ||||||
|  | 					mt, err := detectMediaType(dt) | ||||||
|  | 					if err != nil { | ||||||
|  | 						return err | ||||||
|  | 					} | ||||||
|  | 					descs[i].MediaType = mt | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				mt := descs[i].MediaType | ||||||
|  |  | ||||||
|  | 				switch mt { | ||||||
|  | 				case images.MediaTypeDockerSchema2Manifest, ocispec.MediaTypeImageManifest: | ||||||
|  | 					if descs[i].Platform == nil { | ||||||
|  | 						cfg, err := r.loadConfig(ctx, in, dt) | ||||||
|  | 						if err != nil { | ||||||
|  | 							return err | ||||||
|  | 						} | ||||||
|  | 						descs[i].Platform = &ocispec.Platform{ | ||||||
|  | 							OS:           cfg.OS, | ||||||
|  | 							Architecture: cfg.Architecture, | ||||||
|  | 						} | ||||||
|  | 					} | ||||||
|  | 				case images.MediaTypeDockerSchema1Manifest: | ||||||
|  | 					return errors.Errorf("schema1 manifests are not allowed in manifest lists") | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				return nil | ||||||
|  | 			}) | ||||||
|  | 		}(i) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if err := eg.Wait(); err != nil { | ||||||
|  | 		return nil, ocispec.Descriptor{}, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// on single source, return original bytes | ||||||
|  | 	if len(descs) == 1 { | ||||||
|  | 		if mt := descs[0].MediaType; mt == images.MediaTypeDockerSchema2ManifestList || mt == ocispec.MediaTypeImageIndex { | ||||||
|  | 			return dts[0], descs[0], nil | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	m := map[digest.Digest]int{} | ||||||
|  | 	newDescs := make([]ocispec.Descriptor, 0, len(descs)) | ||||||
|  |  | ||||||
|  | 	addDesc := func(d ocispec.Descriptor) { | ||||||
|  | 		idx, ok := m[d.Digest] | ||||||
|  | 		if ok { | ||||||
|  | 			old := newDescs[idx] | ||||||
|  | 			if old.MediaType == "" { | ||||||
|  | 				old.MediaType = d.MediaType | ||||||
|  | 			} | ||||||
|  | 			if d.Platform != nil { | ||||||
|  | 				old.Platform = d.Platform | ||||||
|  | 			} | ||||||
|  | 			if old.Annotations == nil { | ||||||
|  | 				old.Annotations = map[string]string{} | ||||||
|  | 			} | ||||||
|  | 			for k, v := range d.Annotations { | ||||||
|  | 				old.Annotations[k] = v | ||||||
|  | 			} | ||||||
|  | 			newDescs[idx] = old | ||||||
|  | 		} else { | ||||||
|  | 			m[d.Digest] = len(newDescs) | ||||||
|  | 			newDescs = append(newDescs, d) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	for i, desc := range descs { | ||||||
|  | 		switch desc.MediaType { | ||||||
|  | 		case images.MediaTypeDockerSchema2ManifestList, ocispec.MediaTypeImageIndex: | ||||||
|  | 			var mfst ocispec.Index | ||||||
|  | 			if err := json.Unmarshal(dts[i], &mfst); err != nil { | ||||||
|  | 				return nil, ocispec.Descriptor{}, errors.WithStack(err) | ||||||
|  | 			} | ||||||
|  | 			for _, d := range mfst.Manifests { | ||||||
|  | 				addDesc(d) | ||||||
|  | 			} | ||||||
|  | 		default: | ||||||
|  | 			addDesc(desc) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	mt := images.MediaTypeDockerSchema2ManifestList //ocispec.MediaTypeImageIndex | ||||||
|  | 	idx := struct { | ||||||
|  | 		// MediaType is reserved in the OCI spec but | ||||||
|  | 		// excluded from go types. | ||||||
|  | 		MediaType string `json:"mediaType,omitempty"` | ||||||
|  |  | ||||||
|  | 		ocispec.Index | ||||||
|  | 	}{ | ||||||
|  | 		MediaType: mt, | ||||||
|  | 		Index: ocispec.Index{ | ||||||
|  | 			Versioned: specs.Versioned{ | ||||||
|  | 				SchemaVersion: 2, | ||||||
|  | 			}, | ||||||
|  | 			Manifests: newDescs, | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	idxBytes, err := json.MarshalIndent(idx, "", "   ") | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, ocispec.Descriptor{}, errors.Wrap(err, "failed to marshal index") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return idxBytes, ocispec.Descriptor{ | ||||||
|  | 		MediaType: mt, | ||||||
|  | 		Size:      int64(len(idxBytes)), | ||||||
|  | 		Digest:    digest.FromBytes(idxBytes), | ||||||
|  | 	}, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (r *Resolver) loadConfig(ctx context.Context, in string, dt []byte) (*ocispec.Image, error) { | ||||||
|  | 	var manifest ocispec.Manifest | ||||||
|  | 	if err := json.Unmarshal(dt, &manifest); err != nil { | ||||||
|  | 		return nil, errors.WithStack(err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	dt, err := r.GetDescriptor(ctx, in, manifest.Config) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var img ocispec.Image | ||||||
|  | 	if err := json.Unmarshal(dt, &img); err != nil { | ||||||
|  | 		return nil, errors.WithStack(err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return &img, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func detectMediaType(dt []byte) (string, error) { | ||||||
|  | 	var mfst struct { | ||||||
|  | 		MediaType string          `json:"mediaType"` | ||||||
|  | 		Config    json.RawMessage `json:"config"` | ||||||
|  | 		FSLayers  []string        `json:"fsLayers"` | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if err := json.Unmarshal(dt, &mfst); err != nil { | ||||||
|  | 		return "", errors.WithStack(err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if mfst.MediaType != "" { | ||||||
|  | 		return mfst.MediaType, nil | ||||||
|  | 	} | ||||||
|  | 	if mfst.Config != nil { | ||||||
|  | 		return images.MediaTypeDockerSchema2Manifest, nil | ||||||
|  | 	} | ||||||
|  | 	if len(mfst.FSLayers) > 0 { | ||||||
|  | 		return images.MediaTypeDockerSchema1Manifest, nil | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return images.MediaTypeDockerSchema2ManifestList, nil | ||||||
|  | } | ||||||
| @@ -35,35 +35,52 @@ func New(opt Opt) *Resolver { | |||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| func (r *Resolver) Get(ctx context.Context, in string) ([]byte, ocispec.Descriptor, error) { | func (r *Resolver) Resolve(ctx context.Context, in string) (string, ocispec.Descriptor, error) { | ||||||
| 	ref, err := parseRef(in) | 	ref, err := parseRef(in) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, ocispec.Descriptor{}, err | 		return "", ocispec.Descriptor{}, err | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	in, desc, err := r.r.Resolve(ctx, ref.String()) | 	in, desc, err := r.r.Resolve(ctx, ref.String()) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return "", ocispec.Descriptor{}, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return in, desc, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (r *Resolver) Get(ctx context.Context, in string) ([]byte, ocispec.Descriptor, error) { | ||||||
|  | 	in, desc, err := r.Resolve(ctx, in) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, ocispec.Descriptor{}, err | 		return nil, ocispec.Descriptor{}, err | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	fetcher, err := r.r.Fetcher(ctx, in) | 	dt, err := r.GetDescriptor(ctx, in, desc) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, ocispec.Descriptor{}, err | 		return nil, ocispec.Descriptor{}, err | ||||||
| 	} | 	} | ||||||
|  | 	return dt, desc, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (r *Resolver) GetDescriptor(ctx context.Context, in string, desc ocispec.Descriptor) ([]byte, error) { | ||||||
|  | 	fetcher, err := r.r.Fetcher(ctx, in) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	rc, err := fetcher.Fetch(ctx, desc) | 	rc, err := fetcher.Fetch(ctx, desc) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, ocispec.Descriptor{}, err | 		return nil, err | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	buf := &bytes.Buffer{} | 	buf := &bytes.Buffer{} | ||||||
| 	_, err = io.Copy(buf, rc) | 	_, err = io.Copy(buf, rc) | ||||||
| 	rc.Close() | 	rc.Close() | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, ocispec.Descriptor{}, err | 		return nil, err | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	return buf.Bytes(), desc, nil | 	return buf.Bytes(), nil | ||||||
| } | } | ||||||
|  |  | ||||||
| func parseRef(s string) (reference.Named, error) { | func parseRef(s string) (reference.Named, error) { | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Tonis Tiigi
					Tonis Tiigi