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 }