cuelang.org/go@v0.10.1/mod/modregistry/client.go (about)

     1  // Copyright 2023 CUE Authors
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // Package modregistry provides functionality for reading and writing
    16  // CUE modules from an OCI registry.
    17  //
    18  // WARNING: THIS PACKAGE IS EXPERIMENTAL.
    19  // ITS API MAY CHANGE AT ANY TIME.
    20  package modregistry
    21  
    22  import (
    23  	"archive/zip"
    24  	"bytes"
    25  	"context"
    26  	"encoding/json"
    27  	"errors"
    28  	"fmt"
    29  	"io"
    30  	"net/http"
    31  	"strings"
    32  
    33  	"cuelabs.dev/go/oci/ociregistry"
    34  	"cuelabs.dev/go/oci/ociregistry/ociref"
    35  	"cuelang.org/go/internal/mod/semver"
    36  	digest "github.com/opencontainers/go-digest"
    37  	specs "github.com/opencontainers/image-spec/specs-go"
    38  	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
    39  
    40  	"cuelang.org/go/mod/modfile"
    41  	"cuelang.org/go/mod/module"
    42  	"cuelang.org/go/mod/modzip"
    43  )
    44  
    45  var ErrNotFound = fmt.Errorf("module not found")
    46  
    47  // Client represents a OCI-registry-backed client that
    48  // provides a store for CUE modules.
    49  type Client struct {
    50  	resolver Resolver
    51  }
    52  
    53  // Resolver resolves module paths to a registry and a location
    54  // within that registry.
    55  type Resolver interface {
    56  	// ResolveToRegistry resolves a base module path (without a version)
    57  	// and optional version to the location for that path.
    58  	//
    59  	// If the version is empty, the Tag in the returned Location
    60  	// will hold the prefix that all versions of the module in its
    61  	// repository have. That prefix will be followed by the version
    62  	// itself.
    63  	//
    64  	// If there is no registry configured for the module, it returns
    65  	// an [ErrRegistryNotFound] error.
    66  	ResolveToRegistry(mpath, vers string) (RegistryLocation, error)
    67  }
    68  
    69  // ErrRegistryNotFound is returned by [Resolver.ResolveToRegistry]
    70  // when there is no registry configured for a module.
    71  var ErrRegistryNotFound = fmt.Errorf("no registry configured for module")
    72  
    73  // RegistryLocation holds a registry and a location within it
    74  // that a specific module (or set of versions for a module)
    75  // will be stored.
    76  type RegistryLocation struct {
    77  	// Registry holds the registry to use to access the module.
    78  	Registry ociregistry.Interface
    79  	// Repository holds the repository where the module is located.
    80  	Repository string
    81  	// Tag holds the tag for the module version. If an empty version
    82  	// was passed to Resolve, it holds the prefix shared by all
    83  	// version tags for the module.
    84  	Tag string
    85  }
    86  
    87  const (
    88  	moduleArtifactType  = "application/vnd.cue.module.v1+json"
    89  	moduleFileMediaType = "application/vnd.cue.modulefile.v1"
    90  	moduleAnnotation    = "works.cue.module"
    91  )
    92  
    93  // NewClient returns a new client that talks to the registry at the given
    94  // hostname.
    95  func NewClient(registry ociregistry.Interface) *Client {
    96  	return &Client{
    97  		resolver: singleResolver{registry},
    98  	}
    99  }
   100  
   101  // NewClientWithResolver returns a new client that uses the given
   102  // resolver to decide which registries to fetch from or push to.
   103  func NewClientWithResolver(resolver Resolver) *Client {
   104  	return &Client{
   105  		resolver: resolver,
   106  	}
   107  }
   108  
   109  // GetModule returns the module instance for the given version.
   110  // It returns an error that satisfies errors.Is(ErrNotFound) if the
   111  // module is not present in the store at this version.
   112  func (c *Client) GetModule(ctx context.Context, m module.Version) (*Module, error) {
   113  	loc, err := c.resolve(m)
   114  	if err != nil {
   115  		if errors.Is(err, ErrRegistryNotFound) {
   116  			return nil, ErrNotFound
   117  		}
   118  		return nil, err
   119  	}
   120  	rd, err := loc.Registry.GetTag(ctx, loc.Repository, loc.Tag)
   121  	if err != nil {
   122  		if isNotExist(err) {
   123  			return nil, fmt.Errorf("module %v: %w", m, ErrNotFound)
   124  		}
   125  		return nil, fmt.Errorf("module %v: %w", m, err)
   126  	}
   127  	defer rd.Close()
   128  	data, err := io.ReadAll(rd)
   129  	if err != nil {
   130  		return nil, err
   131  	}
   132  
   133  	return c.GetModuleWithManifest(m, data, rd.Descriptor().MediaType)
   134  }
   135  
   136  // GetModuleWithManifest returns a module instance given
   137  // the top level manifest contents, without querying its tag.
   138  // It assumes that the module will be tagged with the given version.
   139  func (c *Client) GetModuleWithManifest(m module.Version, contents []byte, mediaType string) (*Module, error) {
   140  	loc, err := c.resolve(m)
   141  	if err != nil {
   142  		// Note: don't return [ErrNotFound] here because if we've got the
   143  		// manifest we should be pretty sure that the module actually
   144  		// exists, so it's a harder error than if we're getting the module
   145  		// by tag.
   146  		return nil, err
   147  	}
   148  
   149  	manifest, err := unmarshalManifest(contents, mediaType)
   150  	if err != nil {
   151  		return nil, fmt.Errorf("module %v: %v", m, err)
   152  	}
   153  	if !isModule(manifest) {
   154  		return nil, fmt.Errorf("%v does not resolve to a manifest (media type is %q)", m, mediaType)
   155  	}
   156  	// TODO check type of manifest too.
   157  	if n := len(manifest.Layers); n != 2 {
   158  		return nil, fmt.Errorf("module manifest should refer to exactly two blobs, but got %d", n)
   159  	}
   160  	if !isModuleFile(manifest.Layers[1]) {
   161  		return nil, fmt.Errorf("unexpected media type %q for module file blob", manifest.Layers[1].MediaType)
   162  	}
   163  	// TODO check that the other blobs are of the expected type (application/zip).
   164  	return &Module{
   165  		client:         c,
   166  		loc:            loc,
   167  		version:        m,
   168  		manifest:       *manifest,
   169  		manifestDigest: digest.FromBytes(contents),
   170  	}, nil
   171  }
   172  
   173  // ModuleVersions returns all the versions for the module with the given path
   174  // sorted in semver order.
   175  // If m has a major version suffix, only versions with that major version will
   176  // be returned.
   177  func (c *Client) ModuleVersions(ctx context.Context, m string) (_req []string, _err0 error) {
   178  	mpath, major, hasMajor := module.SplitPathVersion(m)
   179  	if !hasMajor {
   180  		mpath = m
   181  	}
   182  	loc, err := c.resolver.ResolveToRegistry(mpath, "")
   183  	if err != nil {
   184  		if errors.Is(err, ErrRegistryNotFound) {
   185  			return nil, nil
   186  		}
   187  		return nil, err
   188  	}
   189  	versions := []string{}
   190  	if !ociref.IsValidRepository(loc.Repository) {
   191  		// If it's not a valid repository, it can't be used in an OCI
   192  		// request, so return an empty slice rather than the
   193  		// "invalid OCI request" error that a registry can return.
   194  		return nil, nil
   195  	}
   196  	// Note: do not use c.repoName because that always expects
   197  	// a module path with a major version.
   198  	iter := loc.Registry.Tags(ctx, loc.Repository, "")
   199  	var _err error
   200  	iter(func(tag string, err error) bool {
   201  		if err != nil {
   202  			_err = err
   203  			return false
   204  		}
   205  		vers, ok := strings.CutPrefix(tag, loc.Tag)
   206  		if !ok || !semver.IsValid(vers) {
   207  			return true
   208  		}
   209  		if !hasMajor || semver.Major(vers) == major {
   210  			versions = append(versions, vers)
   211  		}
   212  		return true
   213  	})
   214  	if _err != nil && !isNotExist(_err) {
   215  		return nil, fmt.Errorf("module %v: %w", m, _err)
   216  	}
   217  	semver.Sort(versions)
   218  	return versions, nil
   219  }
   220  
   221  // checkedModule represents module content that has passed the same
   222  // checks made by [Client.PutModule]. The caller should not mutate
   223  // any of the values returned by its methods.
   224  type checkedModule struct {
   225  	mv             module.Version
   226  	blobr          io.ReaderAt
   227  	size           int64
   228  	zipr           *zip.Reader
   229  	modFile        *modfile.File
   230  	modFileContent []byte
   231  }
   232  
   233  // putCheckedModule is like [Client.PutModule] except that it allows the
   234  // caller to do some additional checks (see [CheckModule] for more info).
   235  func (c *Client) putCheckedModule(ctx context.Context, m *checkedModule, meta *Metadata) error {
   236  	var annotations map[string]string
   237  	if meta != nil {
   238  		annotations0, err := meta.annotations()
   239  		if err != nil {
   240  			return fmt.Errorf("invalid metadata: %v", err)
   241  		}
   242  		annotations = annotations0
   243  	}
   244  	loc, err := c.resolve(m.mv)
   245  	if err != nil {
   246  		return err
   247  	}
   248  	selfDigest, err := digest.FromReader(io.NewSectionReader(m.blobr, 0, m.size))
   249  	if err != nil {
   250  		return fmt.Errorf("cannot read module zip file: %v", err)
   251  	}
   252  	// Upload the actual module's content
   253  	// TODO should we use a custom media type for this?
   254  	configDesc, err := c.scratchConfig(ctx, loc, moduleArtifactType)
   255  	if err != nil {
   256  		return fmt.Errorf("cannot make scratch config: %v", err)
   257  	}
   258  	manifest := &ocispec.Manifest{
   259  		Versioned: specs.Versioned{
   260  			SchemaVersion: 2, // historical value. does not pertain to OCI or docker version
   261  		},
   262  		MediaType: ocispec.MediaTypeImageManifest,
   263  		Config:    configDesc,
   264  		// One for self, one for module file.
   265  		Layers: []ocispec.Descriptor{{
   266  			Digest:    selfDigest,
   267  			MediaType: "application/zip",
   268  			Size:      m.size,
   269  		}, {
   270  			Digest:    digest.FromBytes(m.modFileContent),
   271  			MediaType: moduleFileMediaType,
   272  			Size:      int64(len(m.modFileContent)),
   273  		}},
   274  		Annotations: annotations,
   275  	}
   276  
   277  	if _, err := loc.Registry.PushBlob(ctx, loc.Repository, manifest.Layers[0], io.NewSectionReader(m.blobr, 0, m.size)); err != nil {
   278  		return fmt.Errorf("cannot push module contents: %v", err)
   279  	}
   280  	if _, err := loc.Registry.PushBlob(ctx, loc.Repository, manifest.Layers[1], bytes.NewReader(m.modFileContent)); err != nil {
   281  		return fmt.Errorf("cannot push cue.mod/module.cue contents: %v", err)
   282  	}
   283  	manifestData, err := json.Marshal(manifest)
   284  	if err != nil {
   285  		return fmt.Errorf("cannot marshal manifest: %v", err)
   286  	}
   287  	if _, err := loc.Registry.PushManifest(ctx, loc.Repository, loc.Tag, manifestData, ocispec.MediaTypeImageManifest); err != nil {
   288  		return fmt.Errorf("cannot tag %v: %v", m.mv, err)
   289  	}
   290  	return nil
   291  }
   292  
   293  // PutModule puts a module whose contents are held as a zip archive inside f.
   294  // It assumes all the module dependencies are correctly resolved and present
   295  // inside the cue.mod/module.cue file.
   296  func (c *Client) PutModule(ctx context.Context, m module.Version, r io.ReaderAt, size int64) error {
   297  	return c.PutModuleWithMetadata(ctx, m, r, size, nil)
   298  }
   299  
   300  // PutModuleWithMetadata is like [Client.PutModule] except that it also
   301  // includes the given metadata inside the module's manifest.
   302  // If meta is nil, no metadata will be included, otherwise
   303  // all fields in meta must be valid and non-empty.
   304  func (c *Client) PutModuleWithMetadata(ctx context.Context, m module.Version, r io.ReaderAt, size int64, meta *Metadata) error {
   305  	cm, err := checkModule(m, r, size)
   306  	if err != nil {
   307  		return err
   308  	}
   309  	return c.putCheckedModule(ctx, cm, meta)
   310  }
   311  
   312  // checkModule checks a module's zip file before uploading it.
   313  // This does the same checks that [Client.PutModule] does, so
   314  // can be used to avoid doing duplicate work when an uploader
   315  // wishes to do more checks that are implemented by that method.
   316  //
   317  // Note that the returned [CheckedModule] value contains r, so will
   318  // be invalidated if r is closed.
   319  func checkModule(m module.Version, blobr io.ReaderAt, size int64) (*checkedModule, error) {
   320  	zipr, modf, _, err := modzip.CheckZip(m, blobr, size)
   321  	if err != nil {
   322  		return nil, fmt.Errorf("module zip file check failed: %v", err)
   323  	}
   324  	modFileContent, mf, err := checkModFile(m, modf)
   325  	if err != nil {
   326  		return nil, fmt.Errorf("module.cue file check failed: %v", err)
   327  	}
   328  	return &checkedModule{
   329  		mv:             m,
   330  		blobr:          blobr,
   331  		size:           size,
   332  		zipr:           zipr,
   333  		modFile:        mf,
   334  		modFileContent: modFileContent,
   335  	}, nil
   336  }
   337  
   338  func checkModFile(m module.Version, f *zip.File) ([]byte, *modfile.File, error) {
   339  	r, err := f.Open()
   340  	if err != nil {
   341  		return nil, nil, err
   342  	}
   343  	defer r.Close()
   344  	// TODO check max size?
   345  	data, err := io.ReadAll(r)
   346  	if err != nil {
   347  		return nil, nil, err
   348  	}
   349  	mf, err := modfile.Parse(data, f.Name)
   350  	if err != nil {
   351  		return nil, nil, err
   352  	}
   353  	if mf.QualifiedModule() != m.Path() {
   354  		return nil, nil, fmt.Errorf("module path %q found in %s does not match module path being published %q", mf.QualifiedModule(), f.Name, m.Path())
   355  	}
   356  	wantMajor := semver.Major(m.Version())
   357  	if major := mf.MajorVersion(); major != wantMajor {
   358  		// This can't actually happen because the zip checker checks the major version
   359  		// that's being published to, so the above path check also implicitly checks that.
   360  		return nil, nil, fmt.Errorf("major version %q found in %s does not match version being published %q", major, f.Name, m.Version())
   361  	}
   362  	// Check that all dependency versions look valid.
   363  	for modPath, dep := range mf.Deps {
   364  		_, err := module.NewVersion(modPath, dep.Version)
   365  		if err != nil {
   366  			return nil, nil, fmt.Errorf("invalid dependency: %v @ %v", modPath, dep.Version)
   367  		}
   368  	}
   369  	return data, mf, nil
   370  }
   371  
   372  // Module represents a CUE module instance.
   373  type Module struct {
   374  	client         *Client
   375  	loc            RegistryLocation
   376  	version        module.Version
   377  	manifest       ocispec.Manifest
   378  	manifestDigest ociregistry.Digest
   379  }
   380  
   381  func (m *Module) Version() module.Version {
   382  	return m.version
   383  }
   384  
   385  // ModuleFile returns the contents of the cue.mod/module.cue file.
   386  func (m *Module) ModuleFile(ctx context.Context) ([]byte, error) {
   387  	r, err := m.loc.Registry.GetBlob(ctx, m.loc.Repository, m.manifest.Layers[1].Digest)
   388  	if err != nil {
   389  		return nil, err
   390  	}
   391  	defer r.Close()
   392  	return io.ReadAll(r)
   393  }
   394  
   395  // Metadata returns the metadata associated with the module.
   396  // If there is none, it returns (nil, nil).
   397  func (m *Module) Metadata() (*Metadata, error) {
   398  	return newMetadataFromAnnotations(m.manifest.Annotations)
   399  }
   400  
   401  // GetZip returns a reader that can be used to read the contents of the zip
   402  // archive containing the module files. The reader should be closed after use,
   403  // and the contents should not be assumed to be correct until the close
   404  // error has been checked.
   405  func (m *Module) GetZip(ctx context.Context) (io.ReadCloser, error) {
   406  	return m.loc.Registry.GetBlob(ctx, m.loc.Repository, m.manifest.Layers[0].Digest)
   407  }
   408  
   409  // ManifestDigest returns the digest of the manifest representing
   410  // the module.
   411  func (m *Module) ManifestDigest() ociregistry.Digest {
   412  	return m.manifestDigest
   413  }
   414  
   415  func (c *Client) resolve(m module.Version) (RegistryLocation, error) {
   416  	loc, err := c.resolver.ResolveToRegistry(m.BasePath(), m.Version())
   417  	if err != nil {
   418  		return RegistryLocation{}, err
   419  	}
   420  	if loc.Registry == nil {
   421  		return RegistryLocation{}, fmt.Errorf("module %v unexpectedly resolved to nil registry", m)
   422  	}
   423  	if loc.Repository == "" {
   424  		return RegistryLocation{}, fmt.Errorf("module %v unexpectedly resolved to empty location", m)
   425  	}
   426  	if loc.Tag == "" {
   427  		return RegistryLocation{}, fmt.Errorf("module %v unexpectedly resolved to empty tag", m)
   428  	}
   429  	return loc, nil
   430  }
   431  
   432  func unmarshalManifest(data []byte, mediaType string) (*ociregistry.Manifest, error) {
   433  	if !isJSON(mediaType) {
   434  		return nil, fmt.Errorf("expected JSON media type but %q does not look like JSON", mediaType)
   435  	}
   436  	var m ociregistry.Manifest
   437  	if err := json.Unmarshal(data, &m); err != nil {
   438  		return nil, fmt.Errorf("cannot decode %s content as manifest: %v", mediaType, err)
   439  	}
   440  	return &m, nil
   441  }
   442  
   443  func isNotExist(err error) bool {
   444  	if errors.Is(err, ociregistry.ErrNameUnknown) ||
   445  		errors.Is(err, ociregistry.ErrNameInvalid) {
   446  		return true
   447  	}
   448  	// A 403 error might have been sent as a response
   449  	// without explicitly including a "denied" error code.
   450  	// We treat this as a "not found" error because there's
   451  	// nothing the user can do about it.
   452  	//
   453  	// Also, some registries return an invalid error code with a 404
   454  	// response (see https://cuelang.org/issue/2982), so it
   455  	// seems reasonable to treat that as a non-found error too.
   456  	if herr := ociregistry.HTTPError(nil); errors.As(err, &herr) {
   457  		statusCode := herr.StatusCode()
   458  		return statusCode == http.StatusForbidden ||
   459  			statusCode == http.StatusNotFound
   460  	}
   461  	return false
   462  }
   463  
   464  func isModule(m *ocispec.Manifest) bool {
   465  	// TODO check m.ArtifactType too when that's defined?
   466  	// See https://github.com/opencontainers/image-spec/blob/main/manifest.md#image-manifest-property-descriptions
   467  	return m.Config.MediaType == moduleArtifactType
   468  }
   469  
   470  func isModuleFile(desc ocispec.Descriptor) bool {
   471  	return desc.ArtifactType == moduleFileMediaType ||
   472  		desc.MediaType == moduleFileMediaType
   473  }
   474  
   475  // isJSON reports whether the given media type has JSON as an underlying encoding.
   476  // TODO this is a guess. There's probably a more correct way to do it.
   477  func isJSON(mediaType string) bool {
   478  	return strings.HasSuffix(mediaType, "+json") || strings.HasSuffix(mediaType, "/json")
   479  }
   480  
   481  // scratchConfig returns a dummy configuration consisting only of the
   482  // two-byte configuration {}.
   483  // https://github.com/opencontainers/image-spec/blob/main/manifest.md#example-of-a-scratch-config-or-layer-descriptor
   484  func (c *Client) scratchConfig(ctx context.Context, loc RegistryLocation, mediaType string) (ocispec.Descriptor, error) {
   485  	// TODO check if it exists already to avoid push?
   486  	content := []byte("{}")
   487  	desc := ocispec.Descriptor{
   488  		Digest:    digest.FromBytes(content),
   489  		MediaType: mediaType,
   490  		Size:      int64(len(content)),
   491  	}
   492  	if _, err := loc.Registry.PushBlob(ctx, loc.Repository, desc, bytes.NewReader(content)); err != nil {
   493  		return ocispec.Descriptor{}, err
   494  	}
   495  	return desc, nil
   496  }
   497  
   498  // singleResolver implements Resolver by always returning R,
   499  // and mapping module paths directly to repository paths in
   500  // the registry.
   501  type singleResolver struct {
   502  	R ociregistry.Interface
   503  }
   504  
   505  func (r singleResolver) ResolveToRegistry(mpath, vers string) (RegistryLocation, error) {
   506  	return RegistryLocation{
   507  		Registry:   r.R,
   508  		Repository: mpath,
   509  		Tag:        vers,
   510  	}, nil
   511  }