working commit
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
# 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.
|
||||
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, build with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# VS Code
|
||||
.vscode
|
||||
debug
|
||||
|
||||
# Jetbrains
|
||||
.idea
|
||||
|
||||
# Custom
|
||||
coverage.txt
|
||||
bin/
|
||||
dist/
|
||||
*.tar.gz
|
||||
vendor/
|
||||
_dist/
|
||||
.cover
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
# Derived from OWNERS.md
|
||||
* @sajayantony @shizhMSFT @stevelasker @Wwwsylvia
|
||||
@@ -0,0 +1,3 @@
|
||||
# Code of Conduct
|
||||
|
||||
OCI Registry As Storage (ORAS) follows the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/master/code-of-conduct.md).
|
||||
Vendored
+201
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2021 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.
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
# Migration Guide
|
||||
|
||||
In version `v2`, ORAS Go library has been completely refreshed with:
|
||||
|
||||
- More unified interfaces
|
||||
- Notably fewer dependencies
|
||||
- Higher test coverage
|
||||
- Better documentation
|
||||
|
||||
**Additionally, ORAS Go `v2` is now a registry client.**
|
||||
|
||||
## Major Changes in `v2`
|
||||
|
||||
- Content store
|
||||
- [`content.File`](https://pkg.go.dev/oras.land/oras-go/pkg/content#File) is now [`file.Store`](https://pkg.go.dev/oras.land/oras-go/v2/content/file#Store)
|
||||
- [`content.OCI`](https://pkg.go.dev/oras.land/oras-go/pkg/content#OCI) is now [`oci.Store`](https://pkg.go.dev/oras.land/oras-go/v2/content/oci#Store)
|
||||
- [`content.Memory`](https://pkg.go.dev/oras.land/oras-go/pkg/content#Memory) is now [`memory.Store`](https://pkg.go.dev/oras.land/oras-go/v2/content/memory#Store)
|
||||
- Registry interaction
|
||||
- Introduces an [SDK](https://pkg.go.dev/oras.land/oras-go/v2/registry/remote) to interact with OCI-compliant and Docker-compliant registries
|
||||
- Authentication
|
||||
- Implements authentication through [`auth.Client`](https://pkg.go.dev/oras.land/oras-go/v2/registry/remote/auth#Client) and supports credential management via [`credentials`](https://pkg.go.dev/oras.land/oras-go/v2/registry/remote/credentials)
|
||||
- Copy operations
|
||||
- Enhances artifact [copying](https://pkg.go.dev/oras.land/oras-go/v2#Copy) capabilities between various [`Target`](https://pkg.go.dev/oras.land/oras-go/v2#Target) with flexible options
|
||||
- Enables [extended-copying](https://pkg.go.dev/oras.land/oras-go/v2#ExtendedCopy) of artifacts along with their predecessors (e.g., referrers)
|
||||
|
||||
## Migrating from `v1` to `v2`
|
||||
|
||||
1. Get the `v2` package
|
||||
|
||||
```sh
|
||||
go get oras.land/oras-go/v2
|
||||
```
|
||||
|
||||
2. Import and use the `v2` package
|
||||
|
||||
```go
|
||||
import "oras.land/oras-go/v2"
|
||||
```
|
||||
|
||||
3. Run
|
||||
|
||||
```sh
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
Since breaking changes are introduced in `v2`, code refactoring is required for migrating from `v1` to `v2`.
|
||||
The migration can be done in an iterative fashion, as `v1` and `v2` can be imported and used at the same time.
|
||||
|
||||
For comprehensive documentation and examples, please refer to [pkg.go.dev](https://pkg.go.dev/oras.land/oras-go/v2).
|
||||
|
||||
## FAQs
|
||||
|
||||
### Is there a 1:1 mapping of APIs between `v1` and `v2`?
|
||||
|
||||
No, `v2` does not have a direct 1:1 mapping of APIs with `v1`, as the structure of the APIs has been significantly redesigned. Instead of looking for a direct replacement, see this as a chance to upgrade your application with `v2`'s new features.
|
||||
|
||||
You can explore the [end-to-end examples](https://pkg.go.dev/oras.land/oras-go/v2#pkg-overview) that demonstrate the usage of v2 in practical scenarios.
|
||||
|
||||
## Community Support
|
||||
|
||||
If you encounter challenges during migration, seek assistance from the community by [submitting GitHub issues](https://github.com/oras-project/oras-go/issues/new) or asking in the [#oras](https://cloud-native.slack.com/archives/CJ1KHJM5Z) Slack channel.
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
# Owners
|
||||
|
||||
Owners:
|
||||
- Sajay Antony (@sajayantony)
|
||||
- Shiwei Zhang (@shizhMSFT)
|
||||
- Steve Lasker (@stevelasker)
|
||||
- Sylvia Lei (@Wwwsylvia)
|
||||
|
||||
Emeritus:
|
||||
- Avi Deitcher (@deitch)
|
||||
- Josh Dolitsky (@jdolitsky)
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
# ORAS Go library
|
||||
|
||||
[](https://github.com/oras-project/oras-go/actions/workflows/build.yml?query=workflow%3Abuild+event%3Apush+branch%3Amain)
|
||||
[](https://codecov.io/gh/oras-project/oras-go)
|
||||
[](https://goreportcard.com/report/oras.land/oras-go/v2)
|
||||
[](https://pkg.go.dev/oras.land/oras-go/v2)
|
||||
|
||||
<p align="left">
|
||||
<a href="https://oras.land/"><img src="https://oras.land/img/oras.svg" alt="ORAS logo" width="100px"></a>
|
||||
</p>
|
||||
|
||||
`oras-go` is a Go library for managing OCI artifacts, compliant with the [OCI Image Format Specification](https://github.com/opencontainers/image-spec) and the [OCI Distribution Specification](https://github.com/opencontainers/distribution-spec). It provides unified APIs for pushing, pulling, and managing artifacts across OCI-compliant registries, local file systems, and in-memory stores.
|
||||
|
||||
> [!Note]
|
||||
> The `main` branch follows [Go's Security Policy](https://github.com/golang/go/security/policy) and supports the two latest versions of Go (currently `1.23` and `1.24`).
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Concepts
|
||||
|
||||
Gain insights into the fundamental concepts:
|
||||
|
||||
- [Modeling Artifacts](docs/Modeling-Artifacts.md)
|
||||
- [Targets and Content Stores](docs/Targets.md)
|
||||
|
||||
### Quickstart
|
||||
|
||||
Follow the step-by-step tutorial to use `oras-go` v2:
|
||||
|
||||
- [Quickstart: Managing OCI Artifacts with `oras-go` v2](docs/tutorial/quickstart.md)
|
||||
|
||||
### Examples
|
||||
|
||||
Check out sample code for common use cases:
|
||||
|
||||
- [Artifact copying](https://pkg.go.dev/oras.land/oras-go/v2#pkg-examples)
|
||||
- [Registry operations](https://pkg.go.dev/oras.land/oras-go/v2/registry#pkg-examples)
|
||||
- [Repository operations](https://pkg.go.dev/oras.land/oras-go/v2/registry/remote#pkg-examples)
|
||||
- [Authentication](https://pkg.go.dev/oras.land/oras-go/v2/registry/remote/auth#pkg-examples)
|
||||
- [Credentials management](https://pkg.go.dev/oras.land/oras-go/v2/registry/remote/credentials#pkg-examples)
|
||||
|
||||
Find more API examples at [pkg.go.dev](https://pkg.go.dev/oras.land/oras-go/v2).
|
||||
|
||||
|
||||
## Versioning
|
||||
|
||||
This project follows [Semantic Versioning](https://semver.org/) (`MAJOR`.`MINOR`.`PATCH`), with `MAJOR` for breaking changes, `MINOR` for backward-compatible features, and `PATCH` for backward-compatible fixes.
|
||||
|
||||
## Previous Major Versions
|
||||
|
||||
### v1 (maintenance)
|
||||
|
||||
[](https://github.com/oras-project/oras-go/actions/workflows/build.yml?query=workflow%3Abuild+event%3Apush+branch%3Av1)
|
||||
[](https://goreportcard.com/report/oras.land/oras-go)
|
||||
[](https://pkg.go.dev/oras.land/oras-go)
|
||||
|
||||
The [`v1`](https://github.com/oras-project/oras-go/tree/v1) branch is maintained for dependency updates and security fixes only. All feature development happens in the [`main`](https://github.com/oras-project/oras-go/tree/main) branch.
|
||||
|
||||
To migrate from `v1` to `v2`, see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md).
|
||||
|
||||
## Community
|
||||
|
||||
- Code of Conduct: [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
|
||||
- Security Policy: [SECURITY.md](SECURITY.md)
|
||||
- Reviewing Guide: [Reviewing Guide](https://github.com/oras-project/community/blob/main/REVIEWING.md)
|
||||
- Slack: [`#oras`](https://cloud-native.slack.com/archives/CJ1KHJM5Z) channel on CNCF Slack
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
# Security Policy
|
||||
|
||||
Please follow the [security policy](https://oras.land/docs/community/reporting_security_concerns) to report a security vulnerability or concern.
|
||||
+411
@@ -0,0 +1,411 @@
|
||||
/*
|
||||
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 oras
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"oras.land/oras-go/v2/content"
|
||||
"oras.land/oras-go/v2/errdef"
|
||||
"oras.land/oras-go/v2/internal/cas"
|
||||
"oras.land/oras-go/v2/internal/docker"
|
||||
"oras.land/oras-go/v2/internal/interfaces"
|
||||
"oras.land/oras-go/v2/internal/platform"
|
||||
"oras.land/oras-go/v2/internal/syncutil"
|
||||
"oras.land/oras-go/v2/registry"
|
||||
"oras.land/oras-go/v2/registry/remote/auth"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultTagConcurrency is the default concurrency of tagging.
|
||||
defaultTagConcurrency int = 5 // This value is consistent with dockerd
|
||||
|
||||
// defaultTagNMaxMetadataBytes is the default value of
|
||||
// TagNOptions.MaxMetadataBytes.
|
||||
defaultTagNMaxMetadataBytes int64 = 4 * 1024 * 1024 // 4 MiB
|
||||
|
||||
// defaultResolveMaxMetadataBytes is the default value of
|
||||
// ResolveOptions.MaxMetadataBytes.
|
||||
defaultResolveMaxMetadataBytes int64 = 4 * 1024 * 1024 // 4 MiB
|
||||
|
||||
// defaultMaxBytes is the default value of FetchBytesOptions.MaxBytes.
|
||||
defaultMaxBytes int64 = 4 * 1024 * 1024 // 4 MiB
|
||||
)
|
||||
|
||||
// DefaultTagNOptions provides the default TagNOptions.
|
||||
var DefaultTagNOptions TagNOptions
|
||||
|
||||
// TagNOptions contains parameters for [oras.TagN].
|
||||
type TagNOptions struct {
|
||||
// Concurrency limits the maximum number of concurrent tag tasks.
|
||||
// If less than or equal to 0, a default (currently 5) is used.
|
||||
Concurrency int
|
||||
|
||||
// MaxMetadataBytes limits the maximum size of metadata that can be cached
|
||||
// in the memory.
|
||||
// If less than or equal to 0, a default (currently 4 MiB) is used.
|
||||
MaxMetadataBytes int64
|
||||
}
|
||||
|
||||
// TagN tags the descriptor identified by srcReference with dstReferences.
|
||||
func TagN(ctx context.Context, target Target, srcReference string, dstReferences []string, opts TagNOptions) (ocispec.Descriptor, error) {
|
||||
switch len(dstReferences) {
|
||||
case 0:
|
||||
return ocispec.Descriptor{}, fmt.Errorf("dstReferences cannot be empty: %w", errdef.ErrMissingReference)
|
||||
case 1:
|
||||
return Tag(ctx, target, srcReference, dstReferences[0])
|
||||
}
|
||||
|
||||
if opts.Concurrency <= 0 {
|
||||
opts.Concurrency = defaultTagConcurrency
|
||||
}
|
||||
if opts.MaxMetadataBytes <= 0 {
|
||||
opts.MaxMetadataBytes = defaultTagNMaxMetadataBytes
|
||||
}
|
||||
|
||||
_, isRefFetcher := target.(registry.ReferenceFetcher)
|
||||
_, isRefPusher := target.(registry.ReferencePusher)
|
||||
if isRefFetcher && isRefPusher {
|
||||
if repo, ok := target.(interfaces.ReferenceParser); ok {
|
||||
// add scope hints to minimize the number of auth requests
|
||||
ref, err := repo.ParseReference(srcReference)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
ctx = auth.AppendRepositoryScope(ctx, ref, auth.ActionPull, auth.ActionPush)
|
||||
}
|
||||
|
||||
desc, contentBytes, err := FetchBytes(ctx, target, srcReference, FetchBytesOptions{
|
||||
MaxBytes: opts.MaxMetadataBytes,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, errdef.ErrSizeExceedsLimit) {
|
||||
err = fmt.Errorf(
|
||||
"content size %v exceeds MaxMetadataBytes %v: %w",
|
||||
desc.Size,
|
||||
opts.MaxMetadataBytes,
|
||||
errdef.ErrSizeExceedsLimit)
|
||||
}
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
|
||||
if err := tagBytesN(ctx, target, desc, contentBytes, dstReferences, TagBytesNOptions{
|
||||
Concurrency: opts.Concurrency,
|
||||
}); err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
return desc, nil
|
||||
}
|
||||
|
||||
desc, err := target.Resolve(ctx, srcReference)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
eg, egCtx := syncutil.LimitGroup(ctx, opts.Concurrency)
|
||||
for _, dstRef := range dstReferences {
|
||||
eg.Go(func(dst string) func() error {
|
||||
return func() error {
|
||||
if err := target.Tag(egCtx, desc, dst); err != nil {
|
||||
return fmt.Errorf("failed to tag %s as %s: %w", srcReference, dst, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}(dstRef))
|
||||
}
|
||||
|
||||
if err := eg.Wait(); err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
return desc, nil
|
||||
}
|
||||
|
||||
// Tag tags the descriptor identified by src with dst.
|
||||
func Tag(ctx context.Context, target Target, src, dst string) (ocispec.Descriptor, error) {
|
||||
refFetcher, okFetch := target.(registry.ReferenceFetcher)
|
||||
refPusher, okPush := target.(registry.ReferencePusher)
|
||||
if okFetch && okPush {
|
||||
if repo, ok := target.(interfaces.ReferenceParser); ok {
|
||||
// add scope hints to minimize the number of auth requests
|
||||
ref, err := repo.ParseReference(src)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
ctx = auth.AppendRepositoryScope(ctx, ref, auth.ActionPull, auth.ActionPush)
|
||||
}
|
||||
desc, rc, err := refFetcher.FetchReference(ctx, src)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
defer rc.Close()
|
||||
if err := refPusher.PushReference(ctx, desc, rc, dst); err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
return desc, nil
|
||||
}
|
||||
|
||||
desc, err := target.Resolve(ctx, src)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
if err := target.Tag(ctx, desc, dst); err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
return desc, nil
|
||||
}
|
||||
|
||||
// DefaultResolveOptions provides the default ResolveOptions.
|
||||
var DefaultResolveOptions ResolveOptions
|
||||
|
||||
// ResolveOptions contains parameters for [oras.Resolve].
|
||||
type ResolveOptions struct {
|
||||
// TargetPlatform ensures the resolved content matches the target platform
|
||||
// if the node is a manifest, or selects the first resolved content that
|
||||
// matches the target platform if the node is a manifest list.
|
||||
TargetPlatform *ocispec.Platform
|
||||
|
||||
// MaxMetadataBytes limits the maximum size of metadata that can be cached
|
||||
// in the memory.
|
||||
// If less than or equal to 0, a default (currently 4 MiB) is used.
|
||||
MaxMetadataBytes int64
|
||||
}
|
||||
|
||||
// Resolve resolves a descriptor with provided reference from the target.
|
||||
func Resolve(ctx context.Context, target ReadOnlyTarget, reference string, opts ResolveOptions) (ocispec.Descriptor, error) {
|
||||
if opts.TargetPlatform == nil {
|
||||
return target.Resolve(ctx, reference)
|
||||
}
|
||||
return resolve(ctx, target, nil, reference, opts)
|
||||
}
|
||||
|
||||
// resolve resolves a descriptor with provided reference from the target, with
|
||||
// specified caching.
|
||||
func resolve(ctx context.Context, target ReadOnlyTarget, proxy *cas.Proxy, reference string, opts ResolveOptions) (ocispec.Descriptor, error) {
|
||||
if opts.MaxMetadataBytes <= 0 {
|
||||
opts.MaxMetadataBytes = defaultResolveMaxMetadataBytes
|
||||
}
|
||||
|
||||
if refFetcher, ok := target.(registry.ReferenceFetcher); ok {
|
||||
// optimize performance for ReferenceFetcher targets
|
||||
desc, rc, err := refFetcher.FetchReference(ctx, reference)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
switch desc.MediaType {
|
||||
case docker.MediaTypeManifestList, ocispec.MediaTypeImageIndex,
|
||||
docker.MediaTypeManifest, ocispec.MediaTypeImageManifest:
|
||||
// cache the fetched content
|
||||
if desc.Size > opts.MaxMetadataBytes {
|
||||
return ocispec.Descriptor{}, fmt.Errorf(
|
||||
"content size %v exceeds MaxMetadataBytes %v: %w",
|
||||
desc.Size,
|
||||
opts.MaxMetadataBytes,
|
||||
errdef.ErrSizeExceedsLimit)
|
||||
}
|
||||
if proxy == nil {
|
||||
proxy = cas.NewProxyWithLimit(target, cas.NewMemory(), opts.MaxMetadataBytes)
|
||||
}
|
||||
if err := proxy.Cache.Push(ctx, desc, rc); err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
// stop caching as SelectManifest may fetch a config blob
|
||||
proxy.StopCaching = true
|
||||
return platform.SelectManifest(ctx, proxy, desc, opts.TargetPlatform)
|
||||
default:
|
||||
return ocispec.Descriptor{}, fmt.Errorf("%s: %s: %w", desc.Digest, desc.MediaType, errdef.ErrUnsupported)
|
||||
}
|
||||
}
|
||||
|
||||
desc, err := target.Resolve(ctx, reference)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
return platform.SelectManifest(ctx, target, desc, opts.TargetPlatform)
|
||||
}
|
||||
|
||||
// DefaultFetchOptions provides the default FetchOptions.
|
||||
var DefaultFetchOptions FetchOptions
|
||||
|
||||
// FetchOptions contains parameters for [oras.Fetch].
|
||||
type FetchOptions struct {
|
||||
// ResolveOptions contains parameters for resolving reference.
|
||||
ResolveOptions
|
||||
}
|
||||
|
||||
// Fetch fetches the content identified by the reference.
|
||||
func Fetch(ctx context.Context, target ReadOnlyTarget, reference string, opts FetchOptions) (ocispec.Descriptor, io.ReadCloser, error) {
|
||||
if opts.TargetPlatform == nil {
|
||||
if refFetcher, ok := target.(registry.ReferenceFetcher); ok {
|
||||
return refFetcher.FetchReference(ctx, reference)
|
||||
}
|
||||
|
||||
desc, err := target.Resolve(ctx, reference)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, nil, err
|
||||
}
|
||||
rc, err := target.Fetch(ctx, desc)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, nil, err
|
||||
}
|
||||
return desc, rc, nil
|
||||
}
|
||||
|
||||
if opts.MaxMetadataBytes <= 0 {
|
||||
opts.MaxMetadataBytes = defaultResolveMaxMetadataBytes
|
||||
}
|
||||
proxy := cas.NewProxyWithLimit(target, cas.NewMemory(), opts.MaxMetadataBytes)
|
||||
desc, err := resolve(ctx, target, proxy, reference, opts.ResolveOptions)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, nil, err
|
||||
}
|
||||
// if the content exists in cache, fetch it from cache
|
||||
// otherwise fetch without caching
|
||||
proxy.StopCaching = true
|
||||
rc, err := proxy.Fetch(ctx, desc)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, nil, err
|
||||
}
|
||||
return desc, rc, nil
|
||||
}
|
||||
|
||||
// DefaultFetchBytesOptions provides the default FetchBytesOptions.
|
||||
var DefaultFetchBytesOptions FetchBytesOptions
|
||||
|
||||
// FetchBytesOptions contains parameters for [oras.FetchBytes].
|
||||
type FetchBytesOptions struct {
|
||||
// FetchOptions contains parameters for fetching content.
|
||||
FetchOptions
|
||||
// MaxBytes limits the maximum size of the fetched content bytes.
|
||||
// If less than or equal to 0, a default (currently 4 MiB) is used.
|
||||
MaxBytes int64
|
||||
}
|
||||
|
||||
// FetchBytes fetches the content bytes identified by the reference.
|
||||
func FetchBytes(ctx context.Context, target ReadOnlyTarget, reference string, opts FetchBytesOptions) (ocispec.Descriptor, []byte, error) {
|
||||
if opts.MaxBytes <= 0 {
|
||||
opts.MaxBytes = defaultMaxBytes
|
||||
}
|
||||
|
||||
desc, rc, err := Fetch(ctx, target, reference, opts.FetchOptions)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
if desc.Size > opts.MaxBytes {
|
||||
return ocispec.Descriptor{}, nil, fmt.Errorf(
|
||||
"content size %v exceeds MaxBytes %v: %w",
|
||||
desc.Size,
|
||||
opts.MaxBytes,
|
||||
errdef.ErrSizeExceedsLimit)
|
||||
}
|
||||
bytes, err := content.ReadAll(rc, desc)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, nil, err
|
||||
}
|
||||
|
||||
return desc, bytes, nil
|
||||
}
|
||||
|
||||
// PushBytes describes the contentBytes using the given mediaType and pushes it.
|
||||
// If mediaType is not specified, "application/octet-stream" is used.
|
||||
func PushBytes(ctx context.Context, pusher content.Pusher, mediaType string, contentBytes []byte) (ocispec.Descriptor, error) {
|
||||
desc := content.NewDescriptorFromBytes(mediaType, contentBytes)
|
||||
r := bytes.NewReader(contentBytes)
|
||||
if err := pusher.Push(ctx, desc, r); err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
|
||||
return desc, nil
|
||||
}
|
||||
|
||||
// DefaultTagBytesNOptions provides the default TagBytesNOptions.
|
||||
var DefaultTagBytesNOptions TagBytesNOptions
|
||||
|
||||
// TagBytesNOptions contains parameters for [oras.TagBytesN].
|
||||
type TagBytesNOptions struct {
|
||||
// Concurrency limits the maximum number of concurrent tag tasks.
|
||||
// If less than or equal to 0, a default (currently 5) is used.
|
||||
Concurrency int
|
||||
}
|
||||
|
||||
// TagBytesN describes the contentBytes using the given mediaType, pushes it,
|
||||
// and tag it with the given references.
|
||||
// If mediaType is not specified, "application/octet-stream" is used.
|
||||
func TagBytesN(ctx context.Context, target Target, mediaType string, contentBytes []byte, references []string, opts TagBytesNOptions) (ocispec.Descriptor, error) {
|
||||
if len(references) == 0 {
|
||||
return PushBytes(ctx, target, mediaType, contentBytes)
|
||||
}
|
||||
|
||||
desc := content.NewDescriptorFromBytes(mediaType, contentBytes)
|
||||
if opts.Concurrency <= 0 {
|
||||
opts.Concurrency = defaultTagConcurrency
|
||||
}
|
||||
|
||||
if err := tagBytesN(ctx, target, desc, contentBytes, references, opts); err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
return desc, nil
|
||||
}
|
||||
|
||||
// tagBytesN pushes the contentBytes using the given desc, and tag it with the
|
||||
// given references.
|
||||
func tagBytesN(ctx context.Context, target Target, desc ocispec.Descriptor, contentBytes []byte, references []string, opts TagBytesNOptions) error {
|
||||
eg, egCtx := syncutil.LimitGroup(ctx, opts.Concurrency)
|
||||
if refPusher, ok := target.(registry.ReferencePusher); ok {
|
||||
for _, reference := range references {
|
||||
eg.Go(func(ref string) func() error {
|
||||
return func() error {
|
||||
r := bytes.NewReader(contentBytes)
|
||||
if err := refPusher.PushReference(egCtx, desc, r, ref); err != nil && !errors.Is(err, errdef.ErrAlreadyExists) {
|
||||
return fmt.Errorf("failed to tag %s: %w", ref, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}(reference))
|
||||
}
|
||||
} else {
|
||||
r := bytes.NewReader(contentBytes)
|
||||
if err := target.Push(ctx, desc, r); err != nil && !errors.Is(err, errdef.ErrAlreadyExists) {
|
||||
return fmt.Errorf("failed to push content: %w", err)
|
||||
}
|
||||
for _, reference := range references {
|
||||
eg.Go(func(ref string) func() error {
|
||||
return func() error {
|
||||
if err := target.Tag(egCtx, desc, ref); err != nil {
|
||||
return fmt.Errorf("failed to tag %s: %w", ref, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}(reference))
|
||||
}
|
||||
}
|
||||
|
||||
return eg.Wait()
|
||||
}
|
||||
|
||||
// TagBytes describes the contentBytes using the given mediaType, pushes it,
|
||||
// and tag it with the given reference.
|
||||
// If mediaType is not specified, "application/octet-stream" is used.
|
||||
func TagBytes(ctx context.Context, target Target, mediaType string, contentBytes []byte, reference string) (ocispec.Descriptor, error) {
|
||||
return TagBytesN(ctx, target, mediaType, contentBytes, []string{reference}, DefaultTagBytesNOptions)
|
||||
}
|
||||
@@ -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 content
|
||||
|
||||
import (
|
||||
"github.com/opencontainers/go-digest"
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"oras.land/oras-go/v2/internal/descriptor"
|
||||
)
|
||||
|
||||
// NewDescriptorFromBytes returns a descriptor, given the content and media type.
|
||||
// If no media type is specified, "application/octet-stream" will be used.
|
||||
func NewDescriptorFromBytes(mediaType string, content []byte) ocispec.Descriptor {
|
||||
if mediaType == "" {
|
||||
mediaType = descriptor.DefaultMediaType
|
||||
}
|
||||
return ocispec.Descriptor{
|
||||
MediaType: mediaType,
|
||||
Digest: digest.FromBytes(content),
|
||||
Size: int64(len(content)),
|
||||
}
|
||||
}
|
||||
|
||||
// Equal returns true if two descriptors point to the same content.
|
||||
func Equal(a, b ocispec.Descriptor) bool {
|
||||
return a.Size == b.Size && a.Digest == b.Digest && a.MediaType == b.MediaType
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
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 content
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"oras.land/oras-go/v2/internal/docker"
|
||||
"oras.land/oras-go/v2/internal/spec"
|
||||
)
|
||||
|
||||
// PredecessorFinder finds out the nodes directly pointing to a given node of a
|
||||
// directed acyclic graph.
|
||||
// In other words, returns the "parents" of the current descriptor.
|
||||
// PredecessorFinder is an extension of Storage.
|
||||
type PredecessorFinder interface {
|
||||
// Predecessors returns the nodes directly pointing to the current node.
|
||||
Predecessors(ctx context.Context, node ocispec.Descriptor) ([]ocispec.Descriptor, error)
|
||||
}
|
||||
|
||||
// GraphStorage represents a CAS that supports direct predecessor node finding.
|
||||
type GraphStorage interface {
|
||||
Storage
|
||||
PredecessorFinder
|
||||
}
|
||||
|
||||
// ReadOnlyGraphStorage represents a read-only GraphStorage.
|
||||
type ReadOnlyGraphStorage interface {
|
||||
ReadOnlyStorage
|
||||
PredecessorFinder
|
||||
}
|
||||
|
||||
// Successors returns the nodes directly pointed by the current node.
|
||||
// In other words, returns the "children" of the current descriptor.
|
||||
func Successors(ctx context.Context, fetcher Fetcher, node ocispec.Descriptor) ([]ocispec.Descriptor, error) {
|
||||
switch node.MediaType {
|
||||
case docker.MediaTypeManifest:
|
||||
content, err := FetchAll(ctx, fetcher, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// OCI manifest schema can be used to marshal docker manifest
|
||||
var manifest ocispec.Manifest
|
||||
if err := json.Unmarshal(content, &manifest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append([]ocispec.Descriptor{manifest.Config}, manifest.Layers...), nil
|
||||
case ocispec.MediaTypeImageManifest:
|
||||
content, err := FetchAll(ctx, fetcher, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var manifest ocispec.Manifest
|
||||
if err := json.Unmarshal(content, &manifest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var nodes []ocispec.Descriptor
|
||||
if manifest.Subject != nil {
|
||||
nodes = append(nodes, *manifest.Subject)
|
||||
}
|
||||
nodes = append(nodes, manifest.Config)
|
||||
return append(nodes, manifest.Layers...), nil
|
||||
case docker.MediaTypeManifestList:
|
||||
content, err := FetchAll(ctx, fetcher, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// OCI manifest index schema can be used to marshal docker manifest list
|
||||
var index ocispec.Index
|
||||
if err := json.Unmarshal(content, &index); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return index.Manifests, nil
|
||||
case ocispec.MediaTypeImageIndex:
|
||||
content, err := FetchAll(ctx, fetcher, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var index ocispec.Index
|
||||
if err := json.Unmarshal(content, &index); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var nodes []ocispec.Descriptor
|
||||
if index.Subject != nil {
|
||||
nodes = append(nodes, *index.Subject)
|
||||
}
|
||||
return append(nodes, index.Manifests...), nil
|
||||
case spec.MediaTypeArtifactManifest:
|
||||
content, err := FetchAll(ctx, fetcher, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var manifest spec.Artifact
|
||||
if err := json.Unmarshal(content, &manifest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var nodes []ocispec.Descriptor
|
||||
if manifest.Subject != nil {
|
||||
nodes = append(nodes, *manifest.Subject)
|
||||
}
|
||||
return append(nodes, manifest.Blobs...), nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
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 content
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"oras.land/oras-go/v2/errdef"
|
||||
)
|
||||
|
||||
// LimitedStorage represents a CAS with a push size limit.
|
||||
type LimitedStorage struct {
|
||||
Storage // underlying storage
|
||||
PushLimit int64 // max size for push
|
||||
}
|
||||
|
||||
// Push pushes the content, matching the expected descriptor.
|
||||
// The size of the content cannot exceed the push size limit.
|
||||
func (ls *LimitedStorage) Push(ctx context.Context, expected ocispec.Descriptor, content io.Reader) error {
|
||||
if expected.Size > ls.PushLimit {
|
||||
return fmt.Errorf(
|
||||
"content size %v exceeds push size limit %v: %w",
|
||||
expected.Size,
|
||||
ls.PushLimit,
|
||||
errdef.ErrSizeExceedsLimit)
|
||||
}
|
||||
|
||||
return ls.Storage.Push(ctx, expected, io.LimitReader(content, expected.Size))
|
||||
}
|
||||
|
||||
// LimitStorage returns a storage with a push size limit.
|
||||
func LimitStorage(s Storage, n int64) *LimitedStorage {
|
||||
return &LimitedStorage{s, n}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
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 memory provides implementation of a memory backed content store.
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"oras.land/oras-go/v2/content"
|
||||
"oras.land/oras-go/v2/errdef"
|
||||
"oras.land/oras-go/v2/internal/cas"
|
||||
"oras.land/oras-go/v2/internal/graph"
|
||||
"oras.land/oras-go/v2/internal/resolver"
|
||||
)
|
||||
|
||||
// Store represents a memory based store, which implements `oras.Target`.
|
||||
type Store struct {
|
||||
storage content.Storage
|
||||
resolver content.TagResolver
|
||||
graph *graph.Memory
|
||||
}
|
||||
|
||||
// New creates a new memory based store.
|
||||
func New() *Store {
|
||||
return &Store{
|
||||
storage: cas.NewMemory(),
|
||||
resolver: resolver.NewMemory(),
|
||||
graph: graph.NewMemory(),
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch fetches the content identified by the descriptor.
|
||||
func (s *Store) Fetch(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) {
|
||||
return s.storage.Fetch(ctx, target)
|
||||
}
|
||||
|
||||
// Push pushes the content, matching the expected descriptor.
|
||||
func (s *Store) Push(ctx context.Context, expected ocispec.Descriptor, reader io.Reader) error {
|
||||
if err := s.storage.Push(ctx, expected, reader); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// index predecessors.
|
||||
// there is no data consistency issue as long as deletion is not implemented
|
||||
// for the memory store.
|
||||
return s.graph.Index(ctx, s.storage, expected)
|
||||
}
|
||||
|
||||
// Exists returns true if the described content exists.
|
||||
func (s *Store) Exists(ctx context.Context, target ocispec.Descriptor) (bool, error) {
|
||||
return s.storage.Exists(ctx, target)
|
||||
}
|
||||
|
||||
// Resolve resolves a reference to a descriptor.
|
||||
func (s *Store) Resolve(ctx context.Context, reference string) (ocispec.Descriptor, error) {
|
||||
return s.resolver.Resolve(ctx, reference)
|
||||
}
|
||||
|
||||
// Tag tags a descriptor with a reference string.
|
||||
// Returns ErrNotFound if the tagged content does not exist.
|
||||
func (s *Store) Tag(ctx context.Context, desc ocispec.Descriptor, reference string) error {
|
||||
exists, err := s.storage.Exists(ctx, desc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("%s: %s: %w", desc.Digest, desc.MediaType, errdef.ErrNotFound)
|
||||
}
|
||||
return s.resolver.Tag(ctx, desc, reference)
|
||||
}
|
||||
|
||||
// Predecessors returns the nodes directly pointing to the current node.
|
||||
// Predecessors returns nil without error if the node does not exists in the
|
||||
// store.
|
||||
// Like other operations, calling Predecessors() is go-routine safe. However,
|
||||
// it does not necessarily correspond to any consistent snapshot of the stored
|
||||
// contents.
|
||||
func (s *Store) Predecessors(ctx context.Context, node ocispec.Descriptor) ([]ocispec.Descriptor, error) {
|
||||
return s.graph.Predecessors(ctx, node)
|
||||
}
|
||||
+149
@@ -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 content
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/opencontainers/go-digest"
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidDescriptorSize is returned by ReadAll() when
|
||||
// the descriptor has an invalid size.
|
||||
ErrInvalidDescriptorSize = errors.New("invalid descriptor size")
|
||||
|
||||
// ErrMismatchedDigest is returned by ReadAll() when
|
||||
// the descriptor has an invalid digest.
|
||||
ErrMismatchedDigest = errors.New("mismatched digest")
|
||||
|
||||
// ErrTrailingData is returned by ReadAll() when
|
||||
// there exists trailing data unread when the read terminates.
|
||||
ErrTrailingData = errors.New("trailing data")
|
||||
)
|
||||
|
||||
var (
|
||||
// errEarlyVerify is returned by VerifyReader.Verify() when
|
||||
// Verify() is called before completing reading the entire content blob.
|
||||
errEarlyVerify = errors.New("early verify")
|
||||
)
|
||||
|
||||
// VerifyReader reads the content described by its descriptor and verifies
|
||||
// against its size and digest.
|
||||
type VerifyReader struct {
|
||||
base *io.LimitedReader
|
||||
verifier digest.Verifier
|
||||
verified bool
|
||||
err error
|
||||
}
|
||||
|
||||
// Read reads up to len(p) bytes into p. It returns the number of bytes
|
||||
// read (0 <= n <= len(p)) and any error encountered.
|
||||
func (vr *VerifyReader) Read(p []byte) (n int, err error) {
|
||||
if vr.err != nil {
|
||||
return 0, vr.err
|
||||
}
|
||||
|
||||
n, err = vr.base.Read(p)
|
||||
if err != nil {
|
||||
if err == io.EOF && vr.base.N > 0 {
|
||||
err = io.ErrUnexpectedEOF
|
||||
}
|
||||
vr.err = err
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Verify checks for remaining unread content and verifies the read content against the digest
|
||||
func (vr *VerifyReader) Verify() error {
|
||||
if vr.verified {
|
||||
return nil
|
||||
}
|
||||
if vr.err == nil {
|
||||
if vr.base.N > 0 {
|
||||
return errEarlyVerify
|
||||
}
|
||||
} else if vr.err != io.EOF {
|
||||
return vr.err
|
||||
}
|
||||
|
||||
if err := ensureEOF(vr.base.R); err != nil {
|
||||
vr.err = err
|
||||
return vr.err
|
||||
}
|
||||
if !vr.verifier.Verified() {
|
||||
vr.err = ErrMismatchedDigest
|
||||
return vr.err
|
||||
}
|
||||
|
||||
vr.verified = true
|
||||
vr.err = io.EOF
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewVerifyReader wraps r for reading content with verification against desc.
|
||||
func NewVerifyReader(r io.Reader, desc ocispec.Descriptor) *VerifyReader {
|
||||
if err := desc.Digest.Validate(); err != nil {
|
||||
return &VerifyReader{
|
||||
err: fmt.Errorf("failed to validate %s: %w", desc.Digest, err),
|
||||
}
|
||||
}
|
||||
verifier := desc.Digest.Verifier()
|
||||
lr := &io.LimitedReader{
|
||||
R: io.TeeReader(r, verifier),
|
||||
N: desc.Size,
|
||||
}
|
||||
return &VerifyReader{
|
||||
base: lr,
|
||||
verifier: verifier,
|
||||
}
|
||||
}
|
||||
|
||||
// ReadAll safely reads the content described by the descriptor.
|
||||
// The read content is verified against the size and the digest
|
||||
// using a VerifyReader.
|
||||
func ReadAll(r io.Reader, desc ocispec.Descriptor) ([]byte, error) {
|
||||
if desc.Size < 0 {
|
||||
return nil, ErrInvalidDescriptorSize
|
||||
}
|
||||
buf := make([]byte, desc.Size)
|
||||
|
||||
vr := NewVerifyReader(r, desc)
|
||||
if n, err := io.ReadFull(vr, buf); err != nil {
|
||||
if errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return nil, fmt.Errorf("read failed: expected content size of %d, got %d, for digest %s: %w", desc.Size, n, desc.Digest.String(), err)
|
||||
}
|
||||
return nil, fmt.Errorf("read failed: %w", err)
|
||||
}
|
||||
if err := vr.Verify(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// ensureEOF ensures the read operation ends with an EOF and no
|
||||
// trailing data is present.
|
||||
func ensureEOF(r io.Reader) error {
|
||||
var peek [1]byte
|
||||
_, err := io.ReadFull(r, peek[:])
|
||||
if err != io.EOF {
|
||||
return ErrTrailingData
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
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 content provides implementations to access content stores.
|
||||
package content
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
)
|
||||
|
||||
// Resolver resolves reference tags.
|
||||
type Resolver interface {
|
||||
// Resolve resolves a reference to a descriptor.
|
||||
Resolve(ctx context.Context, reference string) (ocispec.Descriptor, error)
|
||||
}
|
||||
|
||||
// Tagger tags reference tags.
|
||||
type Tagger interface {
|
||||
// Tag tags a descriptor with a reference string.
|
||||
Tag(ctx context.Context, desc ocispec.Descriptor, reference string) error
|
||||
}
|
||||
|
||||
// TagResolver provides reference tag indexing services.
|
||||
type TagResolver interface {
|
||||
Tagger
|
||||
Resolver
|
||||
}
|
||||
|
||||
// Untagger untags reference tags.
|
||||
type Untagger interface {
|
||||
// Untag untags the given reference string.
|
||||
Untag(ctx context.Context, reference string) error
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
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 content
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
)
|
||||
|
||||
// Fetcher fetches content.
|
||||
type Fetcher interface {
|
||||
// Fetch fetches the content identified by the descriptor.
|
||||
Fetch(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
// Pusher pushes content.
|
||||
type Pusher interface {
|
||||
// Push pushes the content, matching the expected descriptor.
|
||||
// Reader is preferred to Writer so that the suitable buffer size can be
|
||||
// chosen by the underlying implementation. Furthermore, the implementation
|
||||
// can also do reflection on the Reader for more advanced I/O optimization.
|
||||
Push(ctx context.Context, expected ocispec.Descriptor, content io.Reader) error
|
||||
}
|
||||
|
||||
// Storage represents a content-addressable storage (CAS) where contents are
|
||||
// accessed via Descriptors.
|
||||
// The storage is designed to handle blobs of large sizes.
|
||||
type Storage interface {
|
||||
ReadOnlyStorage
|
||||
Pusher
|
||||
}
|
||||
|
||||
// ReadOnlyStorage represents a read-only Storage.
|
||||
type ReadOnlyStorage interface {
|
||||
Fetcher
|
||||
|
||||
// Exists returns true if the described content exists.
|
||||
Exists(ctx context.Context, target ocispec.Descriptor) (bool, error)
|
||||
}
|
||||
|
||||
// Deleter removes content.
|
||||
// Deleter is an extension of Storage.
|
||||
type Deleter interface {
|
||||
// Delete removes the content identified by the descriptor.
|
||||
Delete(ctx context.Context, target ocispec.Descriptor) error
|
||||
}
|
||||
|
||||
// FetchAll safely fetches the content described by the descriptor.
|
||||
// The fetched content is verified against the size and the digest.
|
||||
func FetchAll(ctx context.Context, fetcher Fetcher, desc ocispec.Descriptor) ([]byte, error) {
|
||||
rc, err := fetcher.Fetch(ctx, desc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
return ReadAll(rc, desc)
|
||||
}
|
||||
|
||||
// FetcherFunc is the basic Fetch method defined in Fetcher.
|
||||
type FetcherFunc func(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error)
|
||||
|
||||
// Fetch performs Fetch operation by the FetcherFunc.
|
||||
func (fn FetcherFunc) Fetch(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) {
|
||||
return fn(ctx, target)
|
||||
}
|
||||
Vendored
+533
@@ -0,0 +1,533 @@
|
||||
/*
|
||||
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 oras
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"golang.org/x/sync/semaphore"
|
||||
"oras.land/oras-go/v2/content"
|
||||
"oras.land/oras-go/v2/errdef"
|
||||
"oras.land/oras-go/v2/internal/cas"
|
||||
"oras.land/oras-go/v2/internal/descriptor"
|
||||
"oras.land/oras-go/v2/internal/platform"
|
||||
"oras.land/oras-go/v2/internal/registryutil"
|
||||
"oras.land/oras-go/v2/internal/status"
|
||||
"oras.land/oras-go/v2/internal/syncutil"
|
||||
"oras.land/oras-go/v2/registry"
|
||||
)
|
||||
|
||||
// defaultConcurrency is the default value of CopyGraphOptions.Concurrency.
|
||||
const defaultConcurrency int = 3 // This value is consistent with dockerd and containerd.
|
||||
|
||||
// SkipNode signals to stop copying a node. When returned from PreCopy the blob must exist in the target.
|
||||
// This can be used to signal that a blob has been made available in the target repository by "Mount()" or some other technique.
|
||||
var SkipNode = errors.New("skip node")
|
||||
|
||||
// DefaultCopyOptions provides the default CopyOptions.
|
||||
var DefaultCopyOptions CopyOptions = CopyOptions{
|
||||
CopyGraphOptions: DefaultCopyGraphOptions,
|
||||
}
|
||||
|
||||
// CopyOptions contains parameters for [oras.Copy].
|
||||
type CopyOptions struct {
|
||||
CopyGraphOptions
|
||||
// MapRoot maps the resolved root node to a desired root node for copy.
|
||||
// When MapRoot is provided, the descriptor resolved from the source
|
||||
// reference will be passed to MapRoot, and the mapped descriptor will be
|
||||
// used as the root node for copy.
|
||||
MapRoot func(ctx context.Context, src content.ReadOnlyStorage, root ocispec.Descriptor) (ocispec.Descriptor, error)
|
||||
}
|
||||
|
||||
// WithTargetPlatform configures opts.MapRoot to select the manifest whose
|
||||
// platform matches the given platform. When MapRoot is provided, the platform
|
||||
// selection will be applied on the mapped root node.
|
||||
// - If the given platform is nil, no platform selection will be applied.
|
||||
// - If the root node is a manifest, it will remain the same if platform
|
||||
// matches, otherwise ErrNotFound will be returned.
|
||||
// - If the root node is a manifest list, it will be mapped to the first
|
||||
// matching manifest if exists, otherwise ErrNotFound will be returned.
|
||||
// - Otherwise ErrUnsupported will be returned.
|
||||
func (opts *CopyOptions) WithTargetPlatform(p *ocispec.Platform) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
mapRoot := opts.MapRoot
|
||||
opts.MapRoot = func(ctx context.Context, src content.ReadOnlyStorage, root ocispec.Descriptor) (desc ocispec.Descriptor, err error) {
|
||||
if mapRoot != nil {
|
||||
if root, err = mapRoot(ctx, src, root); err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
}
|
||||
return platform.SelectManifest(ctx, src, root, p)
|
||||
}
|
||||
}
|
||||
|
||||
// defaultCopyMaxMetadataBytes is the default value of
|
||||
// CopyGraphOptions.MaxMetadataBytes.
|
||||
const defaultCopyMaxMetadataBytes int64 = 4 * 1024 * 1024 // 4 MiB
|
||||
|
||||
// DefaultCopyGraphOptions provides the default CopyGraphOptions.
|
||||
var DefaultCopyGraphOptions CopyGraphOptions
|
||||
|
||||
// CopyGraphOptions contains parameters for [oras.CopyGraph].
|
||||
type CopyGraphOptions struct {
|
||||
// Concurrency limits the maximum number of concurrent copy tasks.
|
||||
// If less than or equal to 0, a default (currently 3) is used.
|
||||
Concurrency int
|
||||
// MaxMetadataBytes limits the maximum size of the metadata that can be
|
||||
// cached in the memory.
|
||||
// If less than or equal to 0, a default (currently 4 MiB) is used.
|
||||
MaxMetadataBytes int64
|
||||
// PreCopy handles the current descriptor before it is copied. PreCopy can
|
||||
// return a SkipNode to signal that desc should be skipped when it already
|
||||
// exists in the target.
|
||||
PreCopy func(ctx context.Context, desc ocispec.Descriptor) error
|
||||
// PostCopy handles the current descriptor after it is copied.
|
||||
PostCopy func(ctx context.Context, desc ocispec.Descriptor) error
|
||||
// OnCopySkipped will be called when the sub-DAG rooted by the current node
|
||||
// is skipped.
|
||||
OnCopySkipped func(ctx context.Context, desc ocispec.Descriptor) error
|
||||
// MountFrom returns the candidate repositories that desc may be mounted from.
|
||||
// The OCI references will be tried in turn. If mounting fails on all of them,
|
||||
// then it falls back to a copy.
|
||||
MountFrom func(ctx context.Context, desc ocispec.Descriptor) ([]string, error)
|
||||
// OnMounted will be invoked when desc is mounted.
|
||||
OnMounted func(ctx context.Context, desc ocispec.Descriptor) error
|
||||
// FindSuccessors finds the successors of the current node.
|
||||
// fetcher provides cached access to the source storage, and is suitable
|
||||
// for fetching non-leaf nodes like manifests. Since anything fetched from
|
||||
// fetcher will be cached in the memory, it is recommended to use original
|
||||
// source storage to fetch large blobs.
|
||||
// If FindSuccessors is nil, content.Successors will be used.
|
||||
FindSuccessors func(ctx context.Context, fetcher content.Fetcher, desc ocispec.Descriptor) ([]ocispec.Descriptor, error)
|
||||
}
|
||||
|
||||
// Copy copies a rooted directed acyclic graph (DAG), such as an artifact,
|
||||
// from the source Target to the destination Target.
|
||||
//
|
||||
// The root node (e.g. a tagged manifest of the artifact) is identified by the
|
||||
// source reference.
|
||||
// The destination reference will be the same as the source reference if the
|
||||
// destination reference is left blank.
|
||||
//
|
||||
// Returns the descriptor of the root node on successful copy.
|
||||
func Copy(ctx context.Context, src ReadOnlyTarget, srcRef string, dst Target, dstRef string, opts CopyOptions) (ocispec.Descriptor, error) {
|
||||
if src == nil {
|
||||
return ocispec.Descriptor{}, newCopyError("Copy", CopyErrorOriginSource, errors.New("nil source target"))
|
||||
}
|
||||
if dst == nil {
|
||||
return ocispec.Descriptor{}, newCopyError("Copy", CopyErrorOriginDestination, errors.New("nil destination target"))
|
||||
}
|
||||
if dstRef == "" {
|
||||
dstRef = srcRef
|
||||
}
|
||||
|
||||
// use caching proxy on non-leaf nodes
|
||||
if opts.MaxMetadataBytes <= 0 {
|
||||
opts.MaxMetadataBytes = defaultCopyMaxMetadataBytes
|
||||
}
|
||||
proxy := cas.NewProxyWithLimit(src, cas.NewMemory(), opts.MaxMetadataBytes)
|
||||
root, err := resolveRoot(ctx, src, srcRef, proxy)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
|
||||
if opts.MapRoot != nil {
|
||||
proxy.StopCaching = true
|
||||
root, err = opts.MapRoot(ctx, proxy, root)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, newCopyError("MapRoot", CopyErrorOriginSource, err)
|
||||
}
|
||||
proxy.StopCaching = false
|
||||
}
|
||||
|
||||
if err := prepareCopy(ctx, dst, dstRef, proxy, root, &opts); err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
|
||||
if err := copyGraph(ctx, src, dst, root, proxy, nil, nil, opts.CopyGraphOptions); err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// CopyGraph copies a rooted directed acyclic graph (DAG), such as an artifact,
|
||||
// from the source CAS to the destination CAS.
|
||||
// The root node (e.g. a manifest of the artifact) is identified by a descriptor.
|
||||
func CopyGraph(ctx context.Context, src content.ReadOnlyStorage, dst content.Storage, root ocispec.Descriptor, opts CopyGraphOptions) error {
|
||||
if src == nil {
|
||||
return newCopyError("CopyGraph", CopyErrorOriginSource, errors.New("nil source target"))
|
||||
}
|
||||
if dst == nil {
|
||||
return newCopyError("CopyGraph", CopyErrorOriginDestination, errors.New("nil destination target"))
|
||||
}
|
||||
return copyGraph(ctx, src, dst, root, nil, nil, nil, opts)
|
||||
}
|
||||
|
||||
// copyGraph copies a rooted directed acyclic graph (DAG) from the source CAS to
|
||||
// the destination CAS with specified caching, concurrency limiter and tracker.
|
||||
func copyGraph(ctx context.Context, src content.ReadOnlyStorage, dst content.Storage, root ocispec.Descriptor,
|
||||
proxy *cas.Proxy, limiter *semaphore.Weighted, tracker *status.Tracker, opts CopyGraphOptions) error {
|
||||
if proxy == nil {
|
||||
// use caching proxy on non-leaf nodes
|
||||
if opts.MaxMetadataBytes <= 0 {
|
||||
opts.MaxMetadataBytes = defaultCopyMaxMetadataBytes
|
||||
}
|
||||
proxy = cas.NewProxyWithLimit(src, cas.NewMemory(), opts.MaxMetadataBytes)
|
||||
}
|
||||
if limiter == nil {
|
||||
// if Concurrency is not set or invalid, use the default concurrency
|
||||
if opts.Concurrency <= 0 {
|
||||
opts.Concurrency = defaultConcurrency
|
||||
}
|
||||
limiter = semaphore.NewWeighted(int64(opts.Concurrency))
|
||||
}
|
||||
if tracker == nil {
|
||||
// track content status
|
||||
tracker = status.NewTracker()
|
||||
}
|
||||
// if FindSuccessors is not provided, use the default one
|
||||
if opts.FindSuccessors == nil {
|
||||
opts.FindSuccessors = content.Successors
|
||||
}
|
||||
|
||||
// traverse the graph
|
||||
var fn syncutil.GoFunc[ocispec.Descriptor]
|
||||
fn = func(ctx context.Context, region *syncutil.LimitedRegion, desc ocispec.Descriptor) (err error) {
|
||||
// skip the descriptor if other go routine is working on it
|
||||
done, committed := tracker.TryCommit(desc)
|
||||
if !committed {
|
||||
return nil
|
||||
}
|
||||
defer func() {
|
||||
if err == nil {
|
||||
// mark the content as done on success
|
||||
close(done)
|
||||
}
|
||||
}()
|
||||
|
||||
// skip if a rooted sub-DAG exists
|
||||
exists, err := dst.Exists(ctx, desc)
|
||||
if err != nil {
|
||||
return newCopyError("Exists", CopyErrorOriginDestination, err)
|
||||
}
|
||||
if exists {
|
||||
if opts.OnCopySkipped != nil {
|
||||
if err := opts.OnCopySkipped(ctx, desc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// find successors while non-leaf nodes will be fetched and cached
|
||||
successors, err := opts.FindSuccessors(ctx, proxy, desc)
|
||||
if err != nil {
|
||||
return newCopyError("FindSuccessors", CopyErrorOriginSource, err)
|
||||
}
|
||||
successors = removeForeignLayers(successors)
|
||||
|
||||
if len(successors) != 0 {
|
||||
// for non-leaf nodes, process successors and wait for them to complete
|
||||
region.End()
|
||||
if err := syncutil.Go(ctx, limiter, fn, successors...); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, node := range successors {
|
||||
done, committed := tracker.TryCommit(node)
|
||||
if committed {
|
||||
return fmt.Errorf("%s: %s: successor not committed", desc.Digest, node.Digest)
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
if err := region.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
exists, err = proxy.Cache.Exists(ctx, desc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check cache existence: %s: %w", desc.Digest, err)
|
||||
}
|
||||
if exists {
|
||||
return copyNode(ctx, proxy.Cache, dst, desc, opts)
|
||||
}
|
||||
return mountOrCopyNode(ctx, src, dst, desc, opts)
|
||||
}
|
||||
|
||||
return syncutil.Go(ctx, limiter, fn, root)
|
||||
}
|
||||
|
||||
// mountOrCopyNode tries to mount the node, if not falls back to copying.
|
||||
func mountOrCopyNode(ctx context.Context, src content.ReadOnlyStorage, dst content.Storage, desc ocispec.Descriptor, opts CopyGraphOptions) error {
|
||||
// Need MountFrom and it must be a blob
|
||||
if opts.MountFrom == nil || descriptor.IsManifest(desc) {
|
||||
return copyNode(ctx, src, dst, desc, opts)
|
||||
}
|
||||
|
||||
mounter, ok := dst.(registry.Mounter)
|
||||
if !ok {
|
||||
// mounting is not supported by the destination
|
||||
return copyNode(ctx, src, dst, desc, opts)
|
||||
}
|
||||
|
||||
sourceRepositories, err := opts.MountFrom(ctx, desc)
|
||||
if err != nil {
|
||||
// Technically this error is not fatal, we can still attempt to copy the node
|
||||
// But for consistency with the other callbacks we bail out.
|
||||
return err
|
||||
}
|
||||
|
||||
if len(sourceRepositories) == 0 {
|
||||
return copyNode(ctx, src, dst, desc, opts)
|
||||
}
|
||||
|
||||
skipSource := errors.New("skip source")
|
||||
for i, sourceRepository := range sourceRepositories {
|
||||
// try mounting this source repository
|
||||
var mountFailed bool
|
||||
getContent := func() (io.ReadCloser, error) {
|
||||
// the invocation of getContent indicates that mounting has failed
|
||||
mountFailed = true
|
||||
|
||||
if i < len(sourceRepositories)-1 {
|
||||
// If this is not the last one, skip this source and try next one
|
||||
// We want to return an error that we will test for from mounter.Mount()
|
||||
return nil, skipSource
|
||||
}
|
||||
// this is the last iteration so we need to actually get the content and do the copy
|
||||
// but first call the PreCopy function
|
||||
if opts.PreCopy != nil {
|
||||
if err := opts.PreCopy(ctx, desc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return src.Fetch(ctx, desc)
|
||||
}
|
||||
|
||||
// Mount or copy
|
||||
if err := mounter.Mount(ctx, desc, sourceRepository, getContent); err != nil && !errors.Is(err, skipSource) {
|
||||
return newCopyError("Mount", CopyErrorOriginDestination, err)
|
||||
}
|
||||
|
||||
if !mountFailed {
|
||||
// mounted, success
|
||||
if opts.OnMounted != nil {
|
||||
if err := opts.OnMounted(ctx, desc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// we copied it
|
||||
if opts.PostCopy != nil {
|
||||
if err := opts.PostCopy(ctx, desc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// doCopyNode copies a single content from the source CAS to the destination CAS.
|
||||
func doCopyNode(ctx context.Context, src content.ReadOnlyStorage, dst content.Storage, desc ocispec.Descriptor) error {
|
||||
rc, err := src.Fetch(ctx, desc)
|
||||
if err != nil {
|
||||
return newCopyError("Fetch", CopyErrorOriginSource, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
err = dst.Push(ctx, desc, rc)
|
||||
if err != nil && !errors.Is(err, errdef.ErrAlreadyExists) {
|
||||
return newCopyError("Push", CopyErrorOriginDestination, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyNode copies a single content from the source CAS to the destination CAS,
|
||||
// and apply the given options.
|
||||
func copyNode(ctx context.Context, src content.ReadOnlyStorage, dst content.Storage, desc ocispec.Descriptor, opts CopyGraphOptions) error {
|
||||
if opts.PreCopy != nil {
|
||||
if err := opts.PreCopy(ctx, desc); err != nil {
|
||||
if err == SkipNode {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := doCopyNode(ctx, src, dst, desc); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.PostCopy != nil {
|
||||
return opts.PostCopy(ctx, desc)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyCachedNodeWithReference copies a single content with a reference from the
|
||||
// source cache to the destination ReferencePusher.
|
||||
func copyCachedNodeWithReference(ctx context.Context, src *cas.Proxy, dst registry.ReferencePusher, desc ocispec.Descriptor, dstRef string) error {
|
||||
rc, err := src.FetchCached(ctx, desc)
|
||||
if err != nil {
|
||||
return newCopyError("Fetch", CopyErrorOriginSource, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
err = dst.PushReference(ctx, desc, rc, dstRef)
|
||||
if err != nil && !errors.Is(err, errdef.ErrAlreadyExists) {
|
||||
return newCopyError("PushReference", CopyErrorOriginDestination, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveRoot resolves the source reference to the root node.
|
||||
func resolveRoot(ctx context.Context, src ReadOnlyTarget, srcRef string, proxy *cas.Proxy) (ocispec.Descriptor, error) {
|
||||
refFetcher, ok := src.(registry.ReferenceFetcher)
|
||||
if !ok {
|
||||
desc, err := src.Resolve(ctx, srcRef)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, newCopyError("Resolve", CopyErrorOriginSource, err)
|
||||
}
|
||||
return desc, nil
|
||||
}
|
||||
|
||||
// optimize performance for ReferenceFetcher targets
|
||||
refProxy := ®istryutil.Proxy{
|
||||
ReferenceFetcher: refFetcher,
|
||||
Proxy: proxy,
|
||||
}
|
||||
root, rc, err := refProxy.FetchReference(ctx, srcRef)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, newCopyError("FetchReference", CopyErrorOriginSource, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
// cache root if it is a non-leaf node
|
||||
fetcher := content.FetcherFunc(func(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) {
|
||||
if content.Equal(target, root) {
|
||||
return rc, nil
|
||||
}
|
||||
return nil, errors.New("fetching only root node expected")
|
||||
})
|
||||
if _, err = content.Successors(ctx, fetcher, root); err != nil {
|
||||
return ocispec.Descriptor{}, newCopyError("Successors", CopyErrorOriginSource, err)
|
||||
}
|
||||
|
||||
// TODO: optimize special case where root is a leaf node (i.e. a blob)
|
||||
// and dst is a ReferencePusher.
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// prepareCopy prepares the hooks for copy.
|
||||
func prepareCopy(_ context.Context, dst Target, dstRef string, proxy *cas.Proxy, root ocispec.Descriptor, opts *CopyOptions) error {
|
||||
if refPusher, ok := dst.(registry.ReferencePusher); ok {
|
||||
// optimize performance for ReferencePusher targets
|
||||
preCopy := opts.PreCopy
|
||||
opts.PreCopy = func(ctx context.Context, desc ocispec.Descriptor) error {
|
||||
if preCopy != nil {
|
||||
if err := preCopy(ctx, desc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !content.Equal(desc, root) {
|
||||
// for non-root node, do nothing
|
||||
return nil
|
||||
}
|
||||
|
||||
// for root node, prepare optimized copy
|
||||
if err := copyCachedNodeWithReference(ctx, proxy, refPusher, desc, dstRef); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.PostCopy != nil {
|
||||
if err := opts.PostCopy(ctx, desc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// skip the regular copy workflow
|
||||
return SkipNode
|
||||
}
|
||||
} else {
|
||||
postCopy := opts.PostCopy
|
||||
opts.PostCopy = func(ctx context.Context, desc ocispec.Descriptor) error {
|
||||
if content.Equal(desc, root) {
|
||||
// for root node, tag it after copying it
|
||||
if err := dst.Tag(ctx, root, dstRef); err != nil {
|
||||
return newCopyError("Tag", CopyErrorOriginDestination, err)
|
||||
}
|
||||
}
|
||||
if postCopy != nil {
|
||||
return postCopy(ctx, desc)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
onCopySkipped := opts.OnCopySkipped
|
||||
opts.OnCopySkipped = func(ctx context.Context, desc ocispec.Descriptor) error {
|
||||
if !content.Equal(desc, root) {
|
||||
if onCopySkipped != nil {
|
||||
return onCopySkipped(ctx, desc)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// enforce tagging when the skipped node is root
|
||||
if refPusher, ok := dst.(registry.ReferencePusher); ok {
|
||||
// NOTE: refPusher tags the node by copying it with the reference,
|
||||
// so onCopySkipped shouldn't be invoked in this case
|
||||
return copyCachedNodeWithReference(ctx, proxy, refPusher, desc, dstRef)
|
||||
}
|
||||
|
||||
// invoke onCopySkipped before tagging
|
||||
if onCopySkipped != nil {
|
||||
if err := onCopySkipped(ctx, desc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := dst.Tag(ctx, root, dstRef); err != nil {
|
||||
return newCopyError("Tag", CopyErrorOriginDestination, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeForeignLayers in-place removes all foreign layers in the given slice.
|
||||
func removeForeignLayers(descs []ocispec.Descriptor) []ocispec.Descriptor {
|
||||
var j int
|
||||
for i, desc := range descs {
|
||||
if !descriptor.IsForeignLayer(desc) {
|
||||
if i != j {
|
||||
descs[j] = desc
|
||||
}
|
||||
j++
|
||||
}
|
||||
}
|
||||
return descs[:j]
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
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 oras
|
||||
|
||||
import "fmt"
|
||||
|
||||
// CopyErrorOrigin defines the source of a copy error.
|
||||
type CopyErrorOrigin int
|
||||
|
||||
const (
|
||||
// CopyErrorOriginSource indicates the error occurred at the source side.
|
||||
CopyErrorOriginSource CopyErrorOrigin = 1
|
||||
|
||||
// CopyErrorOriginDestination indicates the error occurred at the destination side.
|
||||
CopyErrorOriginDestination CopyErrorOrigin = 2
|
||||
)
|
||||
|
||||
// String returns the string representation of the CopyErrorOrigin.
|
||||
func (o CopyErrorOrigin) String() string {
|
||||
switch o {
|
||||
case CopyErrorOriginSource:
|
||||
return "source"
|
||||
case CopyErrorOriginDestination:
|
||||
return "destination"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// CopyError represents an error encountered during a copy operation.
|
||||
type CopyError struct {
|
||||
// Op is the operation that caused the error.
|
||||
Op string
|
||||
// Origin indicates the source of the error.
|
||||
Origin CopyErrorOrigin
|
||||
// Err is the underlying error.
|
||||
Err error
|
||||
}
|
||||
|
||||
// newCopyError creates a new CopyError.
|
||||
func newCopyError(op string, origin CopyErrorOrigin, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return &CopyError{
|
||||
Op: op,
|
||||
Origin: origin,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// Error implements the error interface for CopyError.
|
||||
func (e *CopyError) Error() string {
|
||||
switch e.Origin {
|
||||
case CopyErrorOriginSource, CopyErrorOriginDestination:
|
||||
return fmt.Sprintf("failed to perform %q on %s: %v", e.Op, e.Origin, e.Err)
|
||||
default:
|
||||
return fmt.Sprintf("failed to perform %q: %v", e.Op, e.Err)
|
||||
}
|
||||
}
|
||||
|
||||
// Unwrap implements the errors.Unwrap interface for CopyError.
|
||||
func (e *CopyError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
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 errdef
|
||||
|
||||
import "errors"
|
||||
|
||||
// Common errors used in ORAS
|
||||
var (
|
||||
ErrAlreadyExists = errors.New("already exists")
|
||||
ErrInvalidDigest = errors.New("invalid digest")
|
||||
ErrInvalidReference = errors.New("invalid reference")
|
||||
ErrInvalidMediaType = errors.New("invalid media type")
|
||||
ErrMissingReference = errors.New("missing reference")
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrSizeExceedsLimit = errors.New("size exceeds limit")
|
||||
ErrUnsupported = errors.New("unsupported")
|
||||
ErrUnsupportedVersion = errors.New("unsupported version")
|
||||
)
|
||||
+404
@@ -0,0 +1,404 @@
|
||||
/*
|
||||
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 oras
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"golang.org/x/sync/semaphore"
|
||||
"oras.land/oras-go/v2/content"
|
||||
"oras.land/oras-go/v2/internal/cas"
|
||||
"oras.land/oras-go/v2/internal/container/set"
|
||||
"oras.land/oras-go/v2/internal/copyutil"
|
||||
"oras.land/oras-go/v2/internal/descriptor"
|
||||
"oras.land/oras-go/v2/internal/docker"
|
||||
"oras.land/oras-go/v2/internal/spec"
|
||||
"oras.land/oras-go/v2/internal/status"
|
||||
"oras.land/oras-go/v2/internal/syncutil"
|
||||
"oras.land/oras-go/v2/registry"
|
||||
)
|
||||
|
||||
// DefaultExtendedCopyOptions provides the default ExtendedCopyOptions.
|
||||
var DefaultExtendedCopyOptions ExtendedCopyOptions = ExtendedCopyOptions{
|
||||
ExtendedCopyGraphOptions: DefaultExtendedCopyGraphOptions,
|
||||
}
|
||||
|
||||
// ExtendedCopyOptions contains parameters for [oras.ExtendedCopy].
|
||||
type ExtendedCopyOptions struct {
|
||||
ExtendedCopyGraphOptions
|
||||
}
|
||||
|
||||
// DefaultExtendedCopyGraphOptions provides the default ExtendedCopyGraphOptions.
|
||||
var DefaultExtendedCopyGraphOptions ExtendedCopyGraphOptions = ExtendedCopyGraphOptions{
|
||||
CopyGraphOptions: DefaultCopyGraphOptions,
|
||||
}
|
||||
|
||||
// ExtendedCopyGraphOptions contains parameters for [oras.ExtendedCopyGraph].
|
||||
type ExtendedCopyGraphOptions struct {
|
||||
CopyGraphOptions
|
||||
// Depth limits the maximum depth of the directed acyclic graph (DAG) that
|
||||
// will be extended-copied.
|
||||
// If Depth is no specified, or the specified value is less than or
|
||||
// equal to 0, the depth limit will be considered as infinity.
|
||||
Depth int
|
||||
// FindPredecessors finds the predecessors of the current node.
|
||||
// If FindPredecessors is nil, src.Predecessors will be adapted and used.
|
||||
FindPredecessors func(ctx context.Context, src content.ReadOnlyGraphStorage, desc ocispec.Descriptor) ([]ocispec.Descriptor, error)
|
||||
}
|
||||
|
||||
// ExtendedCopy copies the directed acyclic graph (DAG) that are reachable from
|
||||
// the given tagged node from the source GraphTarget to the destination Target.
|
||||
// In other words, it copies a tagged artifact along with its referrers or
|
||||
// other predecessor manifests referencing it.
|
||||
//
|
||||
// The tagged node (e.g. a tagged manifest of the artifact) is identified by the
|
||||
// source reference.
|
||||
// The destination reference will be the same as the source reference if the
|
||||
// destination reference is left blank.
|
||||
//
|
||||
// Returns the descriptor of the tagged node on successful copy.
|
||||
func ExtendedCopy(ctx context.Context, src ReadOnlyGraphTarget, srcRef string, dst Target, dstRef string, opts ExtendedCopyOptions) (ocispec.Descriptor, error) {
|
||||
if src == nil {
|
||||
return ocispec.Descriptor{}, newCopyError("ExtendedCopy", CopyErrorOriginSource, errors.New("nil source target"))
|
||||
}
|
||||
if dst == nil {
|
||||
return ocispec.Descriptor{}, newCopyError("ExtendedCopy", CopyErrorOriginDestination, errors.New("nil destination target"))
|
||||
}
|
||||
if dstRef == "" {
|
||||
dstRef = srcRef
|
||||
}
|
||||
|
||||
node, err := src.Resolve(ctx, srcRef)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, newCopyError("Resolve", CopyErrorOriginSource, err)
|
||||
}
|
||||
|
||||
if err := ExtendedCopyGraph(ctx, src, dst, node, opts.ExtendedCopyGraphOptions); err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
|
||||
if err := dst.Tag(ctx, node, dstRef); err != nil {
|
||||
return ocispec.Descriptor{}, newCopyError("Tag", CopyErrorOriginDestination, err)
|
||||
}
|
||||
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// ExtendedCopyGraph copies the directed acyclic graph (DAG) that are reachable
|
||||
// from the given node from the source GraphStorage to the destination Storage.
|
||||
// In other words, it copies an artifact along with its referrers or other
|
||||
// predecessor manifests referencing it.
|
||||
// The node (e.g. a manifest of the artifact) is identified by a descriptor.
|
||||
func ExtendedCopyGraph(ctx context.Context, src content.ReadOnlyGraphStorage, dst content.Storage, node ocispec.Descriptor, opts ExtendedCopyGraphOptions) error {
|
||||
if src == nil {
|
||||
return newCopyError("ExtendedCopyGraph", CopyErrorOriginSource, errors.New("nil source target"))
|
||||
}
|
||||
if dst == nil {
|
||||
return newCopyError("ExtendedCopyGraph", CopyErrorOriginDestination, errors.New("nil destination target"))
|
||||
}
|
||||
|
||||
roots, err := findRoots(ctx, src, node, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// if Concurrency is not set or invalid, use the default concurrency
|
||||
if opts.Concurrency <= 0 {
|
||||
opts.Concurrency = defaultConcurrency
|
||||
}
|
||||
limiter := semaphore.NewWeighted(int64(opts.Concurrency))
|
||||
// use caching proxy on non-leaf nodes
|
||||
if opts.MaxMetadataBytes <= 0 {
|
||||
opts.MaxMetadataBytes = defaultCopyMaxMetadataBytes
|
||||
}
|
||||
proxy := cas.NewProxyWithLimit(src, cas.NewMemory(), opts.MaxMetadataBytes)
|
||||
// track content status
|
||||
tracker := status.NewTracker()
|
||||
|
||||
// copy the sub-DAGs rooted by the root nodes
|
||||
return syncutil.Go(ctx, limiter, func(ctx context.Context, region *syncutil.LimitedRegion, root ocispec.Descriptor) error {
|
||||
// As a root can be a predecessor of other roots, release the limit here
|
||||
// for dispatching, to avoid dead locks where predecessor roots are
|
||||
// handled first and are waiting for its successors to complete.
|
||||
region.End()
|
||||
if err := copyGraph(ctx, src, dst, root, proxy, limiter, tracker, opts.CopyGraphOptions); err != nil {
|
||||
return err
|
||||
}
|
||||
return region.Start()
|
||||
}, roots...)
|
||||
}
|
||||
|
||||
// findRoots finds the root nodes reachable from the given node through a
|
||||
// depth-first search.
|
||||
func findRoots(ctx context.Context, storage content.ReadOnlyGraphStorage, node ocispec.Descriptor, opts ExtendedCopyGraphOptions) ([]ocispec.Descriptor, error) {
|
||||
visited := set.New[descriptor.Descriptor]()
|
||||
rootMap := make(map[descriptor.Descriptor]ocispec.Descriptor)
|
||||
addRoot := func(key descriptor.Descriptor, val ocispec.Descriptor) {
|
||||
if _, exists := rootMap[key]; !exists {
|
||||
rootMap[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
// if FindPredecessors is not provided, use the default one
|
||||
if opts.FindPredecessors == nil {
|
||||
opts.FindPredecessors = func(ctx context.Context, src content.ReadOnlyGraphStorage, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
|
||||
return src.Predecessors(ctx, desc)
|
||||
}
|
||||
}
|
||||
|
||||
var stack copyutil.Stack
|
||||
// push the initial node to the stack, set the depth to 0
|
||||
stack.Push(copyutil.NodeInfo{Node: node, Depth: 0})
|
||||
for {
|
||||
current, ok := stack.Pop()
|
||||
if !ok {
|
||||
// empty stack
|
||||
break
|
||||
}
|
||||
currentNode := current.Node
|
||||
currentKey := descriptor.FromOCI(currentNode)
|
||||
|
||||
if visited.Contains(currentKey) {
|
||||
// skip the current node if it has been visited
|
||||
continue
|
||||
}
|
||||
visited.Add(currentKey)
|
||||
|
||||
// stop finding predecessors if the target depth is reached
|
||||
if opts.Depth > 0 && current.Depth == opts.Depth {
|
||||
addRoot(currentKey, currentNode)
|
||||
continue
|
||||
}
|
||||
|
||||
predecessors, err := opts.FindPredecessors(ctx, storage, currentNode)
|
||||
if err != nil {
|
||||
return nil, newCopyError("FindPredecessors", CopyErrorOriginSource, err)
|
||||
}
|
||||
|
||||
// The current node has no predecessor node,
|
||||
// which means it is a root node of a sub-DAG.
|
||||
if len(predecessors) == 0 {
|
||||
addRoot(currentKey, currentNode)
|
||||
continue
|
||||
}
|
||||
|
||||
// The current node has predecessor nodes, which means it is NOT a root node.
|
||||
// Push the predecessor nodes to the stack and keep finding from there.
|
||||
for _, predecessor := range predecessors {
|
||||
predecessorKey := descriptor.FromOCI(predecessor)
|
||||
if !visited.Contains(predecessorKey) {
|
||||
// push the predecessor node with increased depth
|
||||
stack.Push(copyutil.NodeInfo{Node: predecessor, Depth: current.Depth + 1})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
roots := make([]ocispec.Descriptor, 0, len(rootMap))
|
||||
for _, root := range rootMap {
|
||||
roots = append(roots, root)
|
||||
}
|
||||
return roots, nil
|
||||
}
|
||||
|
||||
// FilterAnnotation configures opts.FindPredecessors to filter the predecessors
|
||||
// whose annotation matches a given regex pattern.
|
||||
//
|
||||
// A predecessor is kept if key is in its annotations and the annotation value
|
||||
// matches regex.
|
||||
// If regex is nil, predecessors whose annotations contain key will be kept,
|
||||
// no matter of the annotation value.
|
||||
//
|
||||
// For performance consideration, when using both FilterArtifactType and
|
||||
// FilterAnnotation, it's recommended to call FilterArtifactType first.
|
||||
func (opts *ExtendedCopyGraphOptions) FilterAnnotation(key string, regex *regexp.Regexp) {
|
||||
keep := func(desc ocispec.Descriptor) bool {
|
||||
value, ok := desc.Annotations[key]
|
||||
return ok && (regex == nil || regex.MatchString(value))
|
||||
}
|
||||
|
||||
fp := opts.FindPredecessors
|
||||
opts.FindPredecessors = func(ctx context.Context, src content.ReadOnlyGraphStorage, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
|
||||
var predecessors []ocispec.Descriptor
|
||||
var err error
|
||||
if fp == nil {
|
||||
if rf, ok := src.(registry.ReferrerLister); ok {
|
||||
// if src is a ReferrerLister, use Referrers() for possible memory saving
|
||||
if err := rf.Referrers(ctx, desc, "", func(referrers []ocispec.Descriptor) error {
|
||||
// for each page of the results, filter the referrers
|
||||
for _, r := range referrers {
|
||||
if keep(r) {
|
||||
predecessors = append(predecessors, r)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return predecessors, nil
|
||||
}
|
||||
predecessors, err = src.Predecessors(ctx, desc)
|
||||
} else {
|
||||
predecessors, err = fp(ctx, src, desc)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Predecessor descriptors that are not from Referrers API are not
|
||||
// guaranteed to include the annotations of the corresponding manifests.
|
||||
var kept []ocispec.Descriptor
|
||||
for _, p := range predecessors {
|
||||
if p.Annotations == nil {
|
||||
// If the annotations are not present in the descriptors,
|
||||
// fetch it from the manifest content.
|
||||
switch p.MediaType {
|
||||
case docker.MediaTypeManifest, ocispec.MediaTypeImageManifest,
|
||||
docker.MediaTypeManifestList, ocispec.MediaTypeImageIndex,
|
||||
spec.MediaTypeArtifactManifest:
|
||||
annotations, err := fetchAnnotations(ctx, src, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Annotations = annotations
|
||||
}
|
||||
}
|
||||
if keep(p) {
|
||||
kept = append(kept, p)
|
||||
}
|
||||
}
|
||||
return kept, nil
|
||||
}
|
||||
}
|
||||
|
||||
// fetchAnnotations fetches the annotations of the manifest described by desc.
|
||||
func fetchAnnotations(ctx context.Context, src content.ReadOnlyGraphStorage, desc ocispec.Descriptor) (map[string]string, error) {
|
||||
rc, err := src.Fetch(ctx, desc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
var manifest struct {
|
||||
Annotations map[string]string `json:"annotations"`
|
||||
}
|
||||
if err := json.NewDecoder(rc).Decode(&manifest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if manifest.Annotations == nil {
|
||||
// to differentiate with nil
|
||||
return make(map[string]string), nil
|
||||
}
|
||||
return manifest.Annotations, nil
|
||||
}
|
||||
|
||||
// FilterArtifactType configures opts.FindPredecessors to filter the
|
||||
// predecessors whose artifact type matches a given regex pattern.
|
||||
//
|
||||
// A predecessor is kept if its artifact type matches regex.
|
||||
// If regex is nil, all predecessors will be kept.
|
||||
//
|
||||
// For performance consideration, when using both FilterArtifactType and
|
||||
// FilterAnnotation, it's recommended to call FilterArtifactType first.
|
||||
func (opts *ExtendedCopyGraphOptions) FilterArtifactType(regex *regexp.Regexp) {
|
||||
if regex == nil {
|
||||
return
|
||||
}
|
||||
keep := func(desc ocispec.Descriptor) bool {
|
||||
return regex.MatchString(desc.ArtifactType)
|
||||
}
|
||||
|
||||
fp := opts.FindPredecessors
|
||||
opts.FindPredecessors = func(ctx context.Context, src content.ReadOnlyGraphStorage, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
|
||||
var predecessors []ocispec.Descriptor
|
||||
var err error
|
||||
if fp == nil {
|
||||
if rf, ok := src.(registry.ReferrerLister); ok {
|
||||
// if src is a ReferrerLister, use Referrers() for possible memory saving
|
||||
if err := rf.Referrers(ctx, desc, "", func(referrers []ocispec.Descriptor) error {
|
||||
// for each page of the results, filter the referrers
|
||||
for _, r := range referrers {
|
||||
if keep(r) {
|
||||
predecessors = append(predecessors, r)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return predecessors, nil
|
||||
}
|
||||
predecessors, err = src.Predecessors(ctx, desc)
|
||||
} else {
|
||||
predecessors, err = fp(ctx, src, desc)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// predecessor descriptors that are not from Referrers API are not
|
||||
// guaranteed to include the artifact type of the corresponding
|
||||
// manifests.
|
||||
var kept []ocispec.Descriptor
|
||||
for _, p := range predecessors {
|
||||
if p.ArtifactType == "" {
|
||||
// if the artifact type is not present in the descriptors,
|
||||
// fetch it from the manifest content.
|
||||
switch p.MediaType {
|
||||
case spec.MediaTypeArtifactManifest, ocispec.MediaTypeImageManifest:
|
||||
artifactType, err := fetchArtifactType(ctx, src, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.ArtifactType = artifactType
|
||||
}
|
||||
}
|
||||
if keep(p) {
|
||||
kept = append(kept, p)
|
||||
}
|
||||
}
|
||||
return kept, nil
|
||||
}
|
||||
}
|
||||
|
||||
// fetchArtifactType fetches the artifact type of the manifest described by desc.
|
||||
func fetchArtifactType(ctx context.Context, src content.ReadOnlyGraphStorage, desc ocispec.Descriptor) (string, error) {
|
||||
rc, err := src.Fetch(ctx, desc)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
switch desc.MediaType {
|
||||
case spec.MediaTypeArtifactManifest:
|
||||
var manifest spec.Artifact
|
||||
if err := json.NewDecoder(rc).Decode(&manifest); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return manifest.ArtifactType, nil
|
||||
case ocispec.MediaTypeImageManifest:
|
||||
var manifest ocispec.Manifest
|
||||
if err := json.NewDecoder(rc).Decode(&manifest); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return manifest.Config.MediaType, nil
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
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 cas
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
contentpkg "oras.land/oras-go/v2/content"
|
||||
"oras.land/oras-go/v2/errdef"
|
||||
"oras.land/oras-go/v2/internal/descriptor"
|
||||
)
|
||||
|
||||
// Memory is a memory based CAS.
|
||||
type Memory struct {
|
||||
content sync.Map // map[descriptor.Descriptor][]byte
|
||||
}
|
||||
|
||||
// NewMemory creates a new Memory CAS.
|
||||
func NewMemory() *Memory {
|
||||
return &Memory{}
|
||||
}
|
||||
|
||||
// Fetch fetches the content identified by the descriptor.
|
||||
func (m *Memory) Fetch(_ context.Context, target ocispec.Descriptor) (io.ReadCloser, error) {
|
||||
key := descriptor.FromOCI(target)
|
||||
content, exists := m.content.Load(key)
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("%s: %s: %w", key.Digest, key.MediaType, errdef.ErrNotFound)
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(content.([]byte))), nil
|
||||
}
|
||||
|
||||
// Push pushes the content, matching the expected descriptor.
|
||||
func (m *Memory) Push(_ context.Context, expected ocispec.Descriptor, content io.Reader) error {
|
||||
key := descriptor.FromOCI(expected)
|
||||
|
||||
// check if the content exists in advance to avoid reading from the content.
|
||||
if _, exists := m.content.Load(key); exists {
|
||||
return fmt.Errorf("%s: %s: %w", key.Digest, key.MediaType, errdef.ErrAlreadyExists)
|
||||
}
|
||||
|
||||
// read and try to store the content.
|
||||
value, err := contentpkg.ReadAll(content, expected)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := m.content.LoadOrStore(key, value); exists {
|
||||
return fmt.Errorf("%s: %s: %w", key.Digest, key.MediaType, errdef.ErrAlreadyExists)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exists returns true if the described content exists.
|
||||
func (m *Memory) Exists(_ context.Context, target ocispec.Descriptor) (bool, error) {
|
||||
key := descriptor.FromOCI(target)
|
||||
_, exists := m.content.Load(key)
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// Map dumps the memory into a built-in map structure.
|
||||
// Like other operations, calling Map() is go-routine safe. However, it does not
|
||||
// necessarily correspond to any consistent snapshot of the storage contents.
|
||||
func (m *Memory) Map() map[descriptor.Descriptor][]byte {
|
||||
res := make(map[descriptor.Descriptor][]byte)
|
||||
m.content.Range(func(key, value interface{}) bool {
|
||||
res[key.(descriptor.Descriptor)] = value.([]byte)
|
||||
return true
|
||||
})
|
||||
return res
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
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 cas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"oras.land/oras-go/v2/content"
|
||||
"oras.land/oras-go/v2/internal/ioutil"
|
||||
)
|
||||
|
||||
// Proxy is a caching proxy for the storage.
|
||||
// The first fetch call of a described content will read from the remote and
|
||||
// cache the fetched content.
|
||||
// The subsequent fetch call will read from the local cache.
|
||||
type Proxy struct {
|
||||
content.ReadOnlyStorage
|
||||
Cache content.Storage
|
||||
StopCaching bool
|
||||
}
|
||||
|
||||
// NewProxy creates a proxy for the `base` storage, using the `cache` storage as
|
||||
// the cache.
|
||||
func NewProxy(base content.ReadOnlyStorage, cache content.Storage) *Proxy {
|
||||
return &Proxy{
|
||||
ReadOnlyStorage: base,
|
||||
Cache: cache,
|
||||
}
|
||||
}
|
||||
|
||||
// NewProxyWithLimit creates a proxy for the `base` storage, using the `cache`
|
||||
// storage with a push size limit as the cache.
|
||||
func NewProxyWithLimit(base content.ReadOnlyStorage, cache content.Storage, pushLimit int64) *Proxy {
|
||||
limitedCache := content.LimitStorage(cache, pushLimit)
|
||||
return &Proxy{
|
||||
ReadOnlyStorage: base,
|
||||
Cache: limitedCache,
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch fetches the content identified by the descriptor.
|
||||
func (p *Proxy) Fetch(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) {
|
||||
if p.StopCaching {
|
||||
return p.FetchCached(ctx, target)
|
||||
}
|
||||
|
||||
rc, err := p.Cache.Fetch(ctx, target)
|
||||
if err == nil {
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
rc, err = p.ReadOnlyStorage.Fetch(ctx, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pr, pw := io.Pipe()
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
var pushErr error
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
pushErr = p.Cache.Push(ctx, target, pr)
|
||||
if pushErr != nil {
|
||||
pr.CloseWithError(pushErr)
|
||||
}
|
||||
}()
|
||||
closer := ioutil.CloserFunc(func() error {
|
||||
rcErr := rc.Close()
|
||||
if err := pw.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
wg.Wait()
|
||||
if pushErr != nil {
|
||||
return pushErr
|
||||
}
|
||||
return rcErr
|
||||
})
|
||||
|
||||
return struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}{
|
||||
Reader: io.TeeReader(rc, pw),
|
||||
Closer: closer,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FetchCached fetches the content identified by the descriptor.
|
||||
// If the content is not cached, it will be fetched from the remote without
|
||||
// caching.
|
||||
func (p *Proxy) FetchCached(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) {
|
||||
exists, err := p.Cache.Exists(ctx, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists {
|
||||
return p.Cache.Fetch(ctx, target)
|
||||
}
|
||||
return p.ReadOnlyStorage.Fetch(ctx, target)
|
||||
}
|
||||
|
||||
// Exists returns true if the described content exists.
|
||||
func (p *Proxy) Exists(ctx context.Context, target ocispec.Descriptor) (bool, error) {
|
||||
exists, err := p.Cache.Exists(ctx, target)
|
||||
if err == nil && exists {
|
||||
return true, nil
|
||||
}
|
||||
return p.ReadOnlyStorage.Exists(ctx, target)
|
||||
}
|
||||
@@ -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 set
|
||||
|
||||
// Set represents a set data structure.
|
||||
type Set[T comparable] map[T]struct{}
|
||||
|
||||
// New returns an initialized set.
|
||||
func New[T comparable]() Set[T] {
|
||||
return make(Set[T])
|
||||
}
|
||||
|
||||
// Add adds item into the set s.
|
||||
func (s Set[T]) Add(item T) {
|
||||
s[item] = struct{}{}
|
||||
}
|
||||
|
||||
// Contains returns true if the set s contains item.
|
||||
func (s Set[T]) Contains(item T) bool {
|
||||
_, ok := s[item]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Delete deletes an item from the set.
|
||||
func (s Set[T]) Delete(item T) {
|
||||
delete(s, item)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
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 copyutil
|
||||
|
||||
import (
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
)
|
||||
|
||||
// NodeInfo represents information of a node that is being visited in
|
||||
// ExtendedCopy.
|
||||
type NodeInfo struct {
|
||||
// Node represents a node in the graph.
|
||||
Node ocispec.Descriptor
|
||||
// Depth represents the depth of the node in the graph.
|
||||
Depth int
|
||||
}
|
||||
|
||||
// Stack represents a stack data structure that is used in ExtendedCopy for
|
||||
// storing node information.
|
||||
type Stack []NodeInfo
|
||||
|
||||
// IsEmpty returns true if the stack is empty, otherwise returns false.
|
||||
func (s *Stack) IsEmpty() bool {
|
||||
return len(*s) == 0
|
||||
}
|
||||
|
||||
// Push pushes an item to the stack.
|
||||
func (s *Stack) Push(i NodeInfo) {
|
||||
*s = append(*s, i)
|
||||
}
|
||||
|
||||
// Pop pops the top item out of the stack.
|
||||
func (s *Stack) Pop() (NodeInfo, bool) {
|
||||
if s.IsEmpty() {
|
||||
return NodeInfo{}, false
|
||||
}
|
||||
|
||||
last := len(*s) - 1
|
||||
top := (*s)[last]
|
||||
*s = (*s)[:last]
|
||||
return top, true
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
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 descriptor
|
||||
|
||||
import (
|
||||
"github.com/opencontainers/go-digest"
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"oras.land/oras-go/v2/internal/docker"
|
||||
"oras.land/oras-go/v2/internal/spec"
|
||||
)
|
||||
|
||||
// DefaultMediaType is the media type used when no media type is specified.
|
||||
const DefaultMediaType string = "application/octet-stream"
|
||||
|
||||
// Descriptor contains the minimun information to describe the disposition of
|
||||
// targeted content.
|
||||
// Since it only has strings and integers, Descriptor is a comparable struct.
|
||||
type Descriptor struct {
|
||||
// MediaType is the media type of the object this schema refers to.
|
||||
MediaType string `json:"mediaType,omitempty"`
|
||||
|
||||
// Digest is the digest of the targeted content.
|
||||
Digest digest.Digest `json:"digest"`
|
||||
|
||||
// Size specifies the size in bytes of the blob.
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// Empty is an empty descriptor
|
||||
var Empty Descriptor
|
||||
|
||||
// FromOCI shrinks the OCI descriptor to the minimum.
|
||||
func FromOCI(desc ocispec.Descriptor) Descriptor {
|
||||
return Descriptor{
|
||||
MediaType: desc.MediaType,
|
||||
Digest: desc.Digest,
|
||||
Size: desc.Size,
|
||||
}
|
||||
}
|
||||
|
||||
// IsForeignLayer checks if a descriptor describes a foreign layer.
|
||||
func IsForeignLayer(desc ocispec.Descriptor) bool {
|
||||
switch desc.MediaType {
|
||||
case ocispec.MediaTypeImageLayerNonDistributable,
|
||||
ocispec.MediaTypeImageLayerNonDistributableGzip,
|
||||
ocispec.MediaTypeImageLayerNonDistributableZstd,
|
||||
docker.MediaTypeForeignLayer:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsManifest checks if a descriptor describes a manifest.
|
||||
func IsManifest(desc ocispec.Descriptor) bool {
|
||||
switch desc.MediaType {
|
||||
case docker.MediaTypeManifest,
|
||||
docker.MediaTypeManifestList,
|
||||
ocispec.MediaTypeImageManifest,
|
||||
ocispec.MediaTypeImageIndex,
|
||||
spec.MediaTypeArtifactManifest:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Plain returns a plain descriptor that contains only MediaType, Digest and
|
||||
// Size.
|
||||
func Plain(desc ocispec.Descriptor) ocispec.Descriptor {
|
||||
return ocispec.Descriptor{
|
||||
MediaType: desc.MediaType,
|
||||
Digest: desc.Digest,
|
||||
Size: desc.Size,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
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 docker
|
||||
|
||||
// docker media types
|
||||
const (
|
||||
MediaTypeConfig = "application/vnd.docker.container.image.v1+json"
|
||||
MediaTypeManifestList = "application/vnd.docker.distribution.manifest.list.v2+json"
|
||||
MediaTypeManifest = "application/vnd.docker.distribution.manifest.v2+json"
|
||||
MediaTypeForeignLayer = "application/vnd.docker.image.rootfs.foreign.diff.tar.gzip"
|
||||
)
|
||||
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
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 graph
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/opencontainers/go-digest"
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"oras.land/oras-go/v2/content"
|
||||
"oras.land/oras-go/v2/errdef"
|
||||
"oras.land/oras-go/v2/internal/container/set"
|
||||
"oras.land/oras-go/v2/internal/descriptor"
|
||||
"oras.land/oras-go/v2/internal/status"
|
||||
"oras.land/oras-go/v2/internal/syncutil"
|
||||
)
|
||||
|
||||
// Memory is a memory based PredecessorFinder.
|
||||
type Memory struct {
|
||||
// nodes has the following properties and behaviors:
|
||||
// 1. a node exists in Memory.nodes if and only if it exists in the memory
|
||||
// 2. Memory.nodes saves the ocispec.Descriptor map keys, which are used by
|
||||
// the other fields.
|
||||
nodes map[descriptor.Descriptor]ocispec.Descriptor
|
||||
|
||||
// predecessors has the following properties and behaviors:
|
||||
// 1. a node exists in Memory.predecessors if it has at least one predecessor
|
||||
// in the memory, regardless of whether or not the node itself exists in
|
||||
// the memory.
|
||||
// 2. a node does not exist in Memory.predecessors, if it doesn't have any predecessors
|
||||
// in the memory.
|
||||
predecessors map[descriptor.Descriptor]set.Set[descriptor.Descriptor]
|
||||
|
||||
// successors has the following properties and behaviors:
|
||||
// 1. a node exists in Memory.successors if and only if it exists in the memory.
|
||||
// 2. a node's entry in Memory.successors is always consistent with the actual
|
||||
// content of the node, regardless of whether or not each successor exists
|
||||
// in the memory.
|
||||
successors map[descriptor.Descriptor]set.Set[descriptor.Descriptor]
|
||||
|
||||
lock sync.RWMutex
|
||||
}
|
||||
|
||||
// NewMemory creates a new memory PredecessorFinder.
|
||||
func NewMemory() *Memory {
|
||||
return &Memory{
|
||||
nodes: make(map[descriptor.Descriptor]ocispec.Descriptor),
|
||||
predecessors: make(map[descriptor.Descriptor]set.Set[descriptor.Descriptor]),
|
||||
successors: make(map[descriptor.Descriptor]set.Set[descriptor.Descriptor]),
|
||||
}
|
||||
}
|
||||
|
||||
// Index indexes predecessors for each direct successor of the given node.
|
||||
func (m *Memory) Index(ctx context.Context, fetcher content.Fetcher, node ocispec.Descriptor) error {
|
||||
_, err := m.index(ctx, fetcher, node)
|
||||
return err
|
||||
}
|
||||
|
||||
// Index indexes predecessors for all the successors of the given node.
|
||||
func (m *Memory) IndexAll(ctx context.Context, fetcher content.Fetcher, node ocispec.Descriptor) error {
|
||||
// track content status
|
||||
tracker := status.NewTracker()
|
||||
var fn syncutil.GoFunc[ocispec.Descriptor]
|
||||
fn = func(ctx context.Context, region *syncutil.LimitedRegion, desc ocispec.Descriptor) error {
|
||||
// skip the node if other go routine is working on it
|
||||
_, committed := tracker.TryCommit(desc)
|
||||
if !committed {
|
||||
return nil
|
||||
}
|
||||
successors, err := m.index(ctx, fetcher, desc)
|
||||
if err != nil {
|
||||
if errors.Is(err, errdef.ErrNotFound) {
|
||||
// skip the node if it does not exist
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if len(successors) > 0 {
|
||||
// traverse and index successors
|
||||
return syncutil.Go(ctx, nil, fn, successors...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return syncutil.Go(ctx, nil, fn, node)
|
||||
}
|
||||
|
||||
// Predecessors returns the nodes directly pointing to the current node.
|
||||
// Predecessors returns nil without error if the node does not exists in the
|
||||
// store. Like other operations, calling Predecessors() is go-routine safe.
|
||||
// However, it does not necessarily correspond to any consistent snapshot of
|
||||
// the stored contents.
|
||||
func (m *Memory) Predecessors(_ context.Context, node ocispec.Descriptor) ([]ocispec.Descriptor, error) {
|
||||
m.lock.RLock()
|
||||
defer m.lock.RUnlock()
|
||||
|
||||
key := descriptor.FromOCI(node)
|
||||
set, exists := m.predecessors[key]
|
||||
if !exists {
|
||||
return nil, nil
|
||||
}
|
||||
var res []ocispec.Descriptor
|
||||
for k := range set {
|
||||
res = append(res, m.nodes[k])
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Remove removes the node from its predecessors and successors, and returns the
|
||||
// dangling root nodes caused by the deletion.
|
||||
func (m *Memory) Remove(node ocispec.Descriptor) []ocispec.Descriptor {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
nodeKey := descriptor.FromOCI(node)
|
||||
var danglings []ocispec.Descriptor
|
||||
// remove the node from its successors' predecessor list
|
||||
for successorKey := range m.successors[nodeKey] {
|
||||
predecessorEntry := m.predecessors[successorKey]
|
||||
predecessorEntry.Delete(nodeKey)
|
||||
|
||||
// if none of the predecessors of the node still exists, we remove the
|
||||
// predecessors entry and return it as a dangling node. Otherwise, we do
|
||||
// not remove the entry.
|
||||
if len(predecessorEntry) == 0 {
|
||||
delete(m.predecessors, successorKey)
|
||||
if _, exists := m.nodes[successorKey]; exists {
|
||||
danglings = append(danglings, m.nodes[successorKey])
|
||||
}
|
||||
}
|
||||
}
|
||||
delete(m.successors, nodeKey)
|
||||
delete(m.nodes, nodeKey)
|
||||
return danglings
|
||||
}
|
||||
|
||||
// DigestSet returns the set of node digest in memory.
|
||||
func (m *Memory) DigestSet() set.Set[digest.Digest] {
|
||||
m.lock.RLock()
|
||||
defer m.lock.RUnlock()
|
||||
|
||||
s := set.New[digest.Digest]()
|
||||
for desc := range m.nodes {
|
||||
s.Add(desc.Digest)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// index indexes predecessors for each direct successor of the given node.
|
||||
func (m *Memory) index(ctx context.Context, fetcher content.Fetcher, node ocispec.Descriptor) ([]ocispec.Descriptor, error) {
|
||||
successors, err := content.Successors(ctx, fetcher, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
// index the node
|
||||
nodeKey := descriptor.FromOCI(node)
|
||||
m.nodes[nodeKey] = node
|
||||
|
||||
// for each successor, put it into the node's successors list, and
|
||||
// put node into the succeesor's predecessors list
|
||||
successorSet := set.New[descriptor.Descriptor]()
|
||||
m.successors[nodeKey] = successorSet
|
||||
for _, successor := range successors {
|
||||
successorKey := descriptor.FromOCI(successor)
|
||||
successorSet.Add(successorKey)
|
||||
predecessorSet, exists := m.predecessors[successorKey]
|
||||
if !exists {
|
||||
predecessorSet = set.New[descriptor.Descriptor]()
|
||||
m.predecessors[successorKey] = predecessorSet
|
||||
}
|
||||
predecessorSet.Add(nodeKey)
|
||||
}
|
||||
return successors, nil
|
||||
}
|
||||
|
||||
// Exists checks if the node exists in the graph
|
||||
func (m *Memory) Exists(node ocispec.Descriptor) bool {
|
||||
m.lock.RLock()
|
||||
defer m.lock.RUnlock()
|
||||
|
||||
nodeKey := descriptor.FromOCI(node)
|
||||
_, exists := m.nodes[nodeKey]
|
||||
return exists
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
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 httputil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Client is an interface for a HTTP client.
|
||||
// This interface is defined inside this package to prevent potential import
|
||||
// loop.
|
||||
type Client interface {
|
||||
// Do sends an HTTP request and returns an HTTP response.
|
||||
Do(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
// readSeekCloser seeks http body by starting new connections.
|
||||
type readSeekCloser struct {
|
||||
client Client
|
||||
req *http.Request
|
||||
rc io.ReadCloser
|
||||
size int64
|
||||
offset int64
|
||||
closed bool
|
||||
}
|
||||
|
||||
// NewReadSeekCloser returns a seeker to make the HTTP response seekable.
|
||||
// Callers should ensure that the server supports Range request.
|
||||
func NewReadSeekCloser(client Client, req *http.Request, respBody io.ReadCloser, size int64) io.ReadSeekCloser {
|
||||
return &readSeekCloser{
|
||||
client: client,
|
||||
req: req,
|
||||
rc: respBody,
|
||||
size: size,
|
||||
}
|
||||
}
|
||||
|
||||
// Read reads the content body and counts offset.
|
||||
func (rsc *readSeekCloser) Read(p []byte) (n int, err error) {
|
||||
if rsc.closed {
|
||||
return 0, errors.New("read: already closed")
|
||||
}
|
||||
n, err = rsc.rc.Read(p)
|
||||
rsc.offset += int64(n)
|
||||
return
|
||||
}
|
||||
|
||||
// Seek starts a new connection to the remote for reading if position changes.
|
||||
func (rsc *readSeekCloser) Seek(offset int64, whence int) (int64, error) {
|
||||
if rsc.closed {
|
||||
return 0, errors.New("seek: already closed")
|
||||
}
|
||||
switch whence {
|
||||
case io.SeekCurrent:
|
||||
offset += rsc.offset
|
||||
case io.SeekStart:
|
||||
// no-op
|
||||
case io.SeekEnd:
|
||||
offset += rsc.size
|
||||
default:
|
||||
return 0, errors.New("seek: invalid whence")
|
||||
}
|
||||
if offset < 0 {
|
||||
return 0, errors.New("seek: an attempt was made to move the pointer before the beginning of the content")
|
||||
}
|
||||
if offset == rsc.offset {
|
||||
return offset, nil
|
||||
}
|
||||
if offset >= rsc.size {
|
||||
rsc.rc.Close()
|
||||
rsc.rc = http.NoBody
|
||||
rsc.offset = offset
|
||||
return offset, nil
|
||||
}
|
||||
|
||||
req := rsc.req.Clone(rsc.req.Context())
|
||||
req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", offset, rsc.size-1))
|
||||
resp, err := rsc.client.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("seek: %s %q: %w", req.Method, req.URL, err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusPartialContent {
|
||||
resp.Body.Close()
|
||||
return 0, fmt.Errorf("seek: %s %q: unexpected status code %d", resp.Request.Method, resp.Request.URL, resp.StatusCode)
|
||||
}
|
||||
|
||||
rsc.rc.Close()
|
||||
rsc.rc = resp.Body
|
||||
rsc.offset = offset
|
||||
return offset, nil
|
||||
}
|
||||
|
||||
// Close closes the content body.
|
||||
func (rsc *readSeekCloser) Close() error {
|
||||
if rsc.closed {
|
||||
return nil
|
||||
}
|
||||
rsc.closed = true
|
||||
return rsc.rc.Close()
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
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 interfaces
|
||||
|
||||
import "oras.land/oras-go/v2/registry"
|
||||
|
||||
// ReferenceParser provides reference parsing.
|
||||
type ReferenceParser interface {
|
||||
// ParseReference parses a reference to a fully qualified reference.
|
||||
ParseReference(reference string) (registry.Reference, error)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
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 ioutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"oras.land/oras-go/v2/content"
|
||||
)
|
||||
|
||||
// CloserFunc is the basic Close method defined in io.Closer.
|
||||
type CloserFunc func() error
|
||||
|
||||
// Close performs close operation by the CloserFunc.
|
||||
func (fn CloserFunc) Close() error {
|
||||
return fn()
|
||||
}
|
||||
|
||||
// CopyBuffer copies from src to dst through the provided buffer
|
||||
// until either EOF is reached on src, or an error occurs.
|
||||
// The copied content is verified against the size and the digest.
|
||||
func CopyBuffer(dst io.Writer, src io.Reader, buf []byte, desc ocispec.Descriptor) error {
|
||||
// verify while copying
|
||||
vr := content.NewVerifyReader(src, desc)
|
||||
if _, err := io.CopyBuffer(dst, vr, buf); err != nil {
|
||||
return fmt.Errorf("copy failed: %w", err)
|
||||
}
|
||||
return vr.Verify()
|
||||
}
|
||||
|
||||
// Types returned by `io.NopCloser()`.
|
||||
var (
|
||||
nopCloserType = reflect.TypeOf(io.NopCloser(nil))
|
||||
nopCloserWriterToType = reflect.TypeOf(io.NopCloser(struct {
|
||||
io.Reader
|
||||
io.WriterTo
|
||||
}{}))
|
||||
)
|
||||
|
||||
// UnwrapNopCloser unwraps the reader wrapped by `io.NopCloser()`.
|
||||
// Similar implementation can be found in the built-in package `net/http`.
|
||||
// Reference: https://github.com/golang/go/blob/go1.22.1/src/net/http/transfer.go#L1090-L1105
|
||||
func UnwrapNopCloser(r io.Reader) io.Reader {
|
||||
switch reflect.TypeOf(r) {
|
||||
case nopCloserType, nopCloserWriterToType:
|
||||
return reflect.ValueOf(r).Field(0).Interface().(io.Reader)
|
||||
default:
|
||||
return r
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
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 manifestutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"oras.land/oras-go/v2/content"
|
||||
"oras.land/oras-go/v2/internal/docker"
|
||||
"oras.land/oras-go/v2/internal/spec"
|
||||
)
|
||||
|
||||
// Config returns the config of desc, if present.
|
||||
func Config(ctx context.Context, fetcher content.Fetcher, desc ocispec.Descriptor) (*ocispec.Descriptor, error) {
|
||||
switch desc.MediaType {
|
||||
case docker.MediaTypeManifest, ocispec.MediaTypeImageManifest:
|
||||
content, err := content.FetchAll(ctx, fetcher, desc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// OCI manifest schema can be used to marshal docker manifest
|
||||
var manifest ocispec.Manifest
|
||||
if err := json.Unmarshal(content, &manifest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &manifest.Config, nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Manifest returns the manifests of desc, if present.
|
||||
func Manifests(ctx context.Context, fetcher content.Fetcher, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
|
||||
switch desc.MediaType {
|
||||
case docker.MediaTypeManifestList, ocispec.MediaTypeImageIndex:
|
||||
content, err := content.FetchAll(ctx, fetcher, desc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// OCI manifest index schema can be used to marshal docker manifest list
|
||||
var index ocispec.Index
|
||||
if err := json.Unmarshal(content, &index); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return index.Manifests, nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Subject returns the subject of desc, if present.
|
||||
func Subject(ctx context.Context, fetcher content.Fetcher, desc ocispec.Descriptor) (*ocispec.Descriptor, error) {
|
||||
switch desc.MediaType {
|
||||
case ocispec.MediaTypeImageManifest, ocispec.MediaTypeImageIndex, spec.MediaTypeArtifactManifest:
|
||||
content, err := content.FetchAll(ctx, fetcher, desc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var manifest struct {
|
||||
Subject *ocispec.Descriptor `json:"subject,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(content, &manifest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return manifest.Subject, nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
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 platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"oras.land/oras-go/v2/content"
|
||||
"oras.land/oras-go/v2/errdef"
|
||||
"oras.land/oras-go/v2/internal/docker"
|
||||
"oras.land/oras-go/v2/internal/manifestutil"
|
||||
)
|
||||
|
||||
// Match checks whether the current platform matches the target platform.
|
||||
// Match will return true if all of the following conditions are met.
|
||||
// - Architecture and OS exactly match.
|
||||
// - Variant and OSVersion exactly match if target platform provided.
|
||||
// - OSFeatures of the target platform are the subsets of the OSFeatures
|
||||
// array of the current platform.
|
||||
//
|
||||
// Note: Variant, OSVersion and OSFeatures are optional fields, will skip
|
||||
// the comparison if the target platform does not provide specific value.
|
||||
func Match(got *ocispec.Platform, want *ocispec.Platform) bool {
|
||||
if got == nil && want == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if got == nil || want == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if got.Architecture != want.Architecture || got.OS != want.OS {
|
||||
return false
|
||||
}
|
||||
|
||||
if want.OSVersion != "" && got.OSVersion != want.OSVersion {
|
||||
return false
|
||||
}
|
||||
|
||||
if want.Variant != "" && got.Variant != want.Variant {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(want.OSFeatures) != 0 && !isSubset(want.OSFeatures, got.OSFeatures) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// isSubset returns true if all items in slice A are present in slice B.
|
||||
func isSubset(a, b []string) bool {
|
||||
set := make(map[string]bool, len(b))
|
||||
for _, v := range b {
|
||||
set[v] = true
|
||||
}
|
||||
for _, v := range a {
|
||||
if _, ok := set[v]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// SelectManifest implements platform filter and returns the descriptor of the
|
||||
// first matched manifest if the root is a manifest list. If the root is a
|
||||
// manifest, then return the root descriptor if platform matches.
|
||||
func SelectManifest(ctx context.Context, src content.ReadOnlyStorage, root ocispec.Descriptor, p *ocispec.Platform) (ocispec.Descriptor, error) {
|
||||
switch root.MediaType {
|
||||
case docker.MediaTypeManifestList, ocispec.MediaTypeImageIndex:
|
||||
manifests, err := manifestutil.Manifests(ctx, src, root)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
|
||||
// platform filter
|
||||
for _, m := range manifests {
|
||||
if Match(m.Platform, p) {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
return ocispec.Descriptor{}, fmt.Errorf("%s: %w: no matching manifest was found in the manifest list", root.Digest, errdef.ErrNotFound)
|
||||
case docker.MediaTypeManifest, ocispec.MediaTypeImageManifest:
|
||||
// config will be non-nil for docker manifest and OCI image manifest
|
||||
config, err := manifestutil.Config(ctx, src, root)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
|
||||
configMediaType := docker.MediaTypeConfig
|
||||
if root.MediaType == ocispec.MediaTypeImageManifest {
|
||||
configMediaType = ocispec.MediaTypeImageConfig
|
||||
}
|
||||
cfgPlatform, err := getPlatformFromConfig(ctx, src, *config, configMediaType)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
|
||||
if Match(cfgPlatform, p) {
|
||||
return root, nil
|
||||
}
|
||||
return ocispec.Descriptor{}, fmt.Errorf("%s: %w: platform in manifest does not match target platform", root.Digest, errdef.ErrNotFound)
|
||||
default:
|
||||
return ocispec.Descriptor{}, fmt.Errorf("%s: %s: %w", root.Digest, root.MediaType, errdef.ErrUnsupported)
|
||||
}
|
||||
}
|
||||
|
||||
// getPlatformFromConfig returns a platform object which is made up from the
|
||||
// fields in config blob.
|
||||
func getPlatformFromConfig(ctx context.Context, src content.ReadOnlyStorage, desc ocispec.Descriptor, targetConfigMediaType string) (*ocispec.Platform, error) {
|
||||
if desc.MediaType != targetConfigMediaType {
|
||||
return nil, fmt.Errorf("fail to recognize platform from unknown config %s: expect %s: %w", desc.MediaType, targetConfigMediaType, errdef.ErrUnsupported)
|
||||
}
|
||||
|
||||
rc, err := src.Fetch(ctx, desc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
var platform ocispec.Platform
|
||||
if err = json.NewDecoder(rc).Decode(&platform); err != nil && err != io.EOF {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &platform, nil
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
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 registryutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"oras.land/oras-go/v2/content"
|
||||
"oras.land/oras-go/v2/internal/cas"
|
||||
"oras.land/oras-go/v2/internal/ioutil"
|
||||
"oras.land/oras-go/v2/registry"
|
||||
)
|
||||
|
||||
// ReferenceStorage represents a CAS that supports registry.ReferenceFetcher.
|
||||
type ReferenceStorage interface {
|
||||
content.ReadOnlyStorage
|
||||
registry.ReferenceFetcher
|
||||
}
|
||||
|
||||
// Proxy is a caching proxy dedicated for registry.ReferenceFetcher.
|
||||
// The first fetch call of a described content will read from the remote and
|
||||
// cache the fetched content.
|
||||
// The subsequent fetch call will read from the local cache.
|
||||
type Proxy struct {
|
||||
registry.ReferenceFetcher
|
||||
*cas.Proxy
|
||||
}
|
||||
|
||||
// NewProxy creates a proxy for the `base` ReferenceStorage, using the `cache`
|
||||
// storage as the cache.
|
||||
func NewProxy(base ReferenceStorage, cache content.Storage) *Proxy {
|
||||
return &Proxy{
|
||||
ReferenceFetcher: base,
|
||||
Proxy: cas.NewProxy(base, cache),
|
||||
}
|
||||
}
|
||||
|
||||
// FetchReference fetches the content identified by the reference from the
|
||||
// remote and cache the fetched content.
|
||||
func (p *Proxy) FetchReference(ctx context.Context, reference string) (ocispec.Descriptor, io.ReadCloser, error) {
|
||||
target, rc, err := p.ReferenceFetcher.FetchReference(ctx, reference)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, nil, err
|
||||
}
|
||||
|
||||
// skip caching if the content already exists in cache
|
||||
exists, err := p.Cache.Exists(ctx, target)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, nil, err
|
||||
}
|
||||
if exists {
|
||||
return target, rc, nil
|
||||
}
|
||||
|
||||
// cache content while reading
|
||||
pr, pw := io.Pipe()
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
var pushErr error
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
pushErr = p.Cache.Push(ctx, target, pr)
|
||||
if pushErr != nil {
|
||||
pr.CloseWithError(pushErr)
|
||||
}
|
||||
}()
|
||||
closer := ioutil.CloserFunc(func() error {
|
||||
rcErr := rc.Close()
|
||||
if err := pw.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
wg.Wait()
|
||||
if pushErr != nil {
|
||||
return pushErr
|
||||
}
|
||||
return rcErr
|
||||
})
|
||||
|
||||
return target, struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}{
|
||||
Reader: io.TeeReader(rc, pw),
|
||||
Closer: closer,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
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 resolver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"sync"
|
||||
|
||||
"github.com/opencontainers/go-digest"
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"oras.land/oras-go/v2/errdef"
|
||||
"oras.land/oras-go/v2/internal/container/set"
|
||||
)
|
||||
|
||||
// Memory is a memory based resolver.
|
||||
type Memory struct {
|
||||
lock sync.RWMutex
|
||||
index map[string]ocispec.Descriptor
|
||||
tags map[digest.Digest]set.Set[string]
|
||||
}
|
||||
|
||||
// NewMemory creates a new Memory resolver.
|
||||
func NewMemory() *Memory {
|
||||
return &Memory{
|
||||
index: make(map[string]ocispec.Descriptor),
|
||||
tags: make(map[digest.Digest]set.Set[string]),
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve resolves a reference to a descriptor.
|
||||
func (m *Memory) Resolve(_ context.Context, reference string) (ocispec.Descriptor, error) {
|
||||
m.lock.RLock()
|
||||
defer m.lock.RUnlock()
|
||||
|
||||
desc, ok := m.index[reference]
|
||||
if !ok {
|
||||
return ocispec.Descriptor{}, fmt.Errorf("%s: %w", reference, errdef.ErrNotFound)
|
||||
}
|
||||
return desc, nil
|
||||
}
|
||||
|
||||
// Tag tags a descriptor with a reference string.
|
||||
func (m *Memory) Tag(_ context.Context, desc ocispec.Descriptor, reference string) error {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
m.index[reference] = desc
|
||||
tagSet, ok := m.tags[desc.Digest]
|
||||
if !ok {
|
||||
tagSet = set.New[string]()
|
||||
m.tags[desc.Digest] = tagSet
|
||||
}
|
||||
tagSet.Add(reference)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Untag removes a reference from index map.
|
||||
func (m *Memory) Untag(reference string) {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
desc, ok := m.index[reference]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
delete(m.index, reference)
|
||||
tagSet := m.tags[desc.Digest]
|
||||
tagSet.Delete(reference)
|
||||
if len(tagSet) == 0 {
|
||||
delete(m.tags, desc.Digest)
|
||||
}
|
||||
}
|
||||
|
||||
// Map dumps the memory into a built-in map structure.
|
||||
// Like other operations, calling Map() is go-routine safe.
|
||||
func (m *Memory) Map() map[string]ocispec.Descriptor {
|
||||
m.lock.RLock()
|
||||
defer m.lock.RUnlock()
|
||||
|
||||
return maps.Clone(m.index)
|
||||
}
|
||||
|
||||
// TagSet returns the set of tags of the descriptor.
|
||||
func (m *Memory) TagSet(desc ocispec.Descriptor) set.Set[string] {
|
||||
m.lock.RLock()
|
||||
defer m.lock.RUnlock()
|
||||
|
||||
tagSet := m.tags[desc.Digest]
|
||||
return maps.Clone(tagSet)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
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 spec
|
||||
|
||||
import ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
|
||||
const (
|
||||
// AnnotationArtifactCreated is the annotation key for the date and time on which the artifact was built, conforming to RFC 3339.
|
||||
AnnotationArtifactCreated = "org.opencontainers.artifact.created"
|
||||
|
||||
// AnnotationArtifactDescription is the annotation key for the human readable description for the artifact.
|
||||
AnnotationArtifactDescription = "org.opencontainers.artifact.description"
|
||||
|
||||
// AnnotationReferrersFiltersApplied is the annotation key for the comma separated list of filters applied by the registry in the referrers listing.
|
||||
AnnotationReferrersFiltersApplied = "org.opencontainers.referrers.filtersApplied"
|
||||
)
|
||||
|
||||
// MediaTypeArtifactManifest specifies the media type for a content descriptor.
|
||||
const MediaTypeArtifactManifest = "application/vnd.oci.artifact.manifest.v1+json"
|
||||
|
||||
// Artifact describes an artifact manifest.
|
||||
// This structure provides `application/vnd.oci.artifact.manifest.v1+json` mediatype when marshalled to JSON.
|
||||
//
|
||||
// This manifest type was introduced in image-spec v1.1.0-rc1 and was removed in
|
||||
// image-spec v1.1.0-rc3. It is not part of the current image-spec and is kept
|
||||
// here for Go compatibility.
|
||||
//
|
||||
// Reference: https://github.com/opencontainers/image-spec/pull/999
|
||||
type Artifact struct {
|
||||
// MediaType is the media type of the object this schema refers to.
|
||||
MediaType string `json:"mediaType"`
|
||||
|
||||
// ArtifactType is the IANA media type of the artifact this schema refers to.
|
||||
ArtifactType string `json:"artifactType"`
|
||||
|
||||
// Blobs is a collection of blobs referenced by this manifest.
|
||||
Blobs []ocispec.Descriptor `json:"blobs,omitempty"`
|
||||
|
||||
// Subject (reference) is an optional link from the artifact to another manifest forming an association between the artifact and the other manifest.
|
||||
Subject *ocispec.Descriptor `json:"subject,omitempty"`
|
||||
|
||||
// Annotations contains arbitrary metadata for the artifact manifest.
|
||||
Annotations map[string]string `json:"annotations,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
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 status
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"oras.land/oras-go/v2/internal/descriptor"
|
||||
)
|
||||
|
||||
// Tracker tracks content status described by a descriptor.
|
||||
type Tracker struct {
|
||||
status sync.Map // map[descriptor.Descriptor]chan struct{}
|
||||
}
|
||||
|
||||
// NewTracker creates a new content status tracker.
|
||||
func NewTracker() *Tracker {
|
||||
return &Tracker{}
|
||||
}
|
||||
|
||||
// TryCommit tries to commit the work for the target descriptor.
|
||||
// Returns true if committed. A channel is also returned for sending
|
||||
// notifications. Once the work is done, the channel should be closed.
|
||||
// Returns false if the work is done or still in progress.
|
||||
func (t *Tracker) TryCommit(target ocispec.Descriptor) (chan struct{}, bool) {
|
||||
key := descriptor.FromOCI(target)
|
||||
status, exists := t.status.LoadOrStore(key, make(chan struct{}))
|
||||
return status.(chan struct{}), !exists
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
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 syncutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/sync/semaphore"
|
||||
)
|
||||
|
||||
// LimitedRegion provides a way to bound concurrent access to a code block.
|
||||
type LimitedRegion struct {
|
||||
ctx context.Context
|
||||
limiter *semaphore.Weighted
|
||||
ended bool
|
||||
}
|
||||
|
||||
// LimitRegion creates a new LimitedRegion.
|
||||
func LimitRegion(ctx context.Context, limiter *semaphore.Weighted) *LimitedRegion {
|
||||
if limiter == nil {
|
||||
return nil
|
||||
}
|
||||
return &LimitedRegion{
|
||||
ctx: ctx,
|
||||
limiter: limiter,
|
||||
ended: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the region with concurrency limit.
|
||||
func (lr *LimitedRegion) Start() error {
|
||||
if lr == nil || !lr.ended {
|
||||
return nil
|
||||
}
|
||||
if err := lr.limiter.Acquire(lr.ctx, 1); err != nil {
|
||||
return err
|
||||
}
|
||||
lr.ended = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// End ends the region with concurrency limit.
|
||||
func (lr *LimitedRegion) End() {
|
||||
if lr == nil || lr.ended {
|
||||
return
|
||||
}
|
||||
lr.limiter.Release(1)
|
||||
lr.ended = true
|
||||
}
|
||||
|
||||
// GoFunc represents a function that can be invoked by Go.
|
||||
type GoFunc[T any] func(ctx context.Context, region *LimitedRegion, t T) error
|
||||
|
||||
// Go concurrently invokes fn on items.
|
||||
func Go[T any](ctx context.Context, limiter *semaphore.Weighted, fn GoFunc[T], items ...T) error {
|
||||
ctx, cancel := context.WithCancelCause(ctx)
|
||||
defer cancel(nil)
|
||||
|
||||
eg, egCtx := errgroup.WithContext(ctx)
|
||||
for _, item := range items {
|
||||
region := LimitRegion(egCtx, limiter)
|
||||
if err := region.Start(); err != nil {
|
||||
cancel(err)
|
||||
// break loop instead of returning to allow previously scheduled
|
||||
// goroutines to finish their deferred region.End() calls
|
||||
break
|
||||
}
|
||||
|
||||
eg.Go(func(t T, lr *LimitedRegion) func() error {
|
||||
return func() error {
|
||||
defer lr.End()
|
||||
|
||||
select {
|
||||
case <-egCtx.Done():
|
||||
// skip the task if the context is already cancelled
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
|
||||
if err := fn(egCtx, lr, t); err != nil {
|
||||
cancel(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}(item, region))
|
||||
}
|
||||
|
||||
if err := eg.Wait(); err != nil {
|
||||
cancel(err)
|
||||
}
|
||||
return context.Cause(ctx)
|
||||
}
|
||||
@@ -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 syncutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// LimitedGroup is a collection of goroutines working on subtasks that are part of
|
||||
// the same overall task.
|
||||
type LimitedGroup struct {
|
||||
grp *errgroup.Group
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// LimitGroup returns a new LimitedGroup and an associated Context derived from ctx.
|
||||
//
|
||||
// The number of active goroutines in this group is limited to the given limit.
|
||||
// A negative value indicates no limit.
|
||||
//
|
||||
// The derived Context is canceled the first time a function passed to Go
|
||||
// returns a non-nil error or the first time Wait returns, whichever occurs
|
||||
// first.
|
||||
func LimitGroup(ctx context.Context, limit int) (*LimitedGroup, context.Context) {
|
||||
grp, ctx := errgroup.WithContext(ctx)
|
||||
grp.SetLimit(limit)
|
||||
return &LimitedGroup{grp: grp, ctx: ctx}, ctx
|
||||
}
|
||||
|
||||
// Go calls the given function in a new goroutine.
|
||||
// It blocks until the new goroutine can be added without the number of
|
||||
// active goroutines in the group exceeding the configured limit.
|
||||
//
|
||||
// The first call to return a non-nil error cancels the group's context.
|
||||
// After which, any subsequent calls to Go will not execute their given function.
|
||||
// The error will be returned by Wait.
|
||||
func (g *LimitedGroup) Go(f func() error) {
|
||||
g.grp.Go(func() error {
|
||||
select {
|
||||
case <-g.ctx.Done():
|
||||
return g.ctx.Err()
|
||||
default:
|
||||
return f()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Wait blocks until all function calls from the Go method have returned, then
|
||||
// returns the first non-nil error (if any) from them.
|
||||
func (g *LimitedGroup) Wait() error {
|
||||
return g.grp.Wait()
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
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 syncutil
|
||||
|
||||
import "sync"
|
||||
|
||||
// mergeStatus represents the merge status of an item.
|
||||
type mergeStatus struct {
|
||||
// main indicates if items are being merged by the current go-routine.
|
||||
main bool
|
||||
// err represents the error of the merge operation.
|
||||
err error
|
||||
}
|
||||
|
||||
// Merge represents merge operations on items.
|
||||
// The state transfer is shown as below:
|
||||
//
|
||||
// +----------+
|
||||
// | Start +--------+-------------+
|
||||
// +----+-----+ | |
|
||||
// | | |
|
||||
// v v v
|
||||
// +----+-----+ +----+----+ +----+----+
|
||||
// +-------+ Prepare +<--+ Pending +-->+ Waiting |
|
||||
// | +----+-----+ +---------+ +----+----+
|
||||
// | | |
|
||||
// | v |
|
||||
// | + ---+---- + |
|
||||
// On Error | Resolve | |
|
||||
// | + ---+---- + |
|
||||
// | | |
|
||||
// | v |
|
||||
// | +----+-----+ |
|
||||
// +------>+ Complete +<---------------------+
|
||||
// +----+-----+
|
||||
// |
|
||||
// v
|
||||
// +----+-----+
|
||||
// | End |
|
||||
// +----------+
|
||||
type Merge[T any] struct {
|
||||
lock sync.Mutex
|
||||
committed bool
|
||||
items []T
|
||||
status chan mergeStatus
|
||||
pending []T
|
||||
pendingStatus chan mergeStatus
|
||||
}
|
||||
|
||||
// Do merges concurrent operations of items into a single call of prepare and
|
||||
// resolve.
|
||||
// If Do is called multiple times concurrently, only one of the calls will be
|
||||
// selected to invoke prepare and resolve.
|
||||
func (m *Merge[T]) Do(item T, prepare func() error, resolve func(items []T) error) error {
|
||||
status := <-m.assign(item)
|
||||
if status.main {
|
||||
err := prepare()
|
||||
items := m.commit()
|
||||
if err == nil {
|
||||
err = resolve(items)
|
||||
}
|
||||
m.complete(err)
|
||||
return err
|
||||
}
|
||||
return status.err
|
||||
}
|
||||
|
||||
// assign adds a new item into the item list.
|
||||
func (m *Merge[T]) assign(item T) <-chan mergeStatus {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
if m.committed {
|
||||
if m.pendingStatus == nil {
|
||||
m.pendingStatus = make(chan mergeStatus, 1)
|
||||
}
|
||||
m.pending = append(m.pending, item)
|
||||
return m.pendingStatus
|
||||
}
|
||||
|
||||
if m.status == nil {
|
||||
m.status = make(chan mergeStatus, 1)
|
||||
m.status <- mergeStatus{main: true}
|
||||
}
|
||||
m.items = append(m.items, item)
|
||||
return m.status
|
||||
}
|
||||
|
||||
// commit closes the assignment window, and the assigned items will be ready
|
||||
// for resolve.
|
||||
func (m *Merge[T]) commit() []T {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
m.committed = true
|
||||
return m.items
|
||||
}
|
||||
|
||||
// complete completes the previous merge, and moves the pending items to the
|
||||
// stage for the next merge.
|
||||
func (m *Merge[T]) complete(err error) {
|
||||
// notify results
|
||||
if err == nil {
|
||||
close(m.status)
|
||||
} else {
|
||||
remaining := len(m.items) - 1
|
||||
status := m.status
|
||||
for remaining > 0 {
|
||||
status <- mergeStatus{err: err}
|
||||
remaining--
|
||||
}
|
||||
}
|
||||
|
||||
// move pending items to the stage
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
m.committed = false
|
||||
m.items = m.pending
|
||||
m.status = m.pendingStatus
|
||||
m.pending = nil
|
||||
m.pendingStatus = nil
|
||||
|
||||
if m.status != nil {
|
||||
m.status <- mergeStatus{main: true}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
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 syncutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Once is an object that will perform exactly one action.
|
||||
// Unlike sync.Once, this Once allows the action to have return values.
|
||||
type Once struct {
|
||||
result interface{}
|
||||
err error
|
||||
status chan bool
|
||||
}
|
||||
|
||||
// NewOnce creates a new Once instance.
|
||||
func NewOnce() *Once {
|
||||
status := make(chan bool, 1)
|
||||
status <- true
|
||||
return &Once{
|
||||
status: status,
|
||||
}
|
||||
}
|
||||
|
||||
// Do calls the function f if and only if Do is being called first time or all
|
||||
// previous function calls are cancelled, deadline exceeded, or panicking.
|
||||
// When `once.Do(ctx, f)` is called multiple times, the return value of the
|
||||
// first call of the function f is stored, and is directly returned for other
|
||||
// calls.
|
||||
// Besides the return value of the function f, including the error, Do returns
|
||||
// true if the function f passed is called first and is not cancelled, deadline
|
||||
// exceeded, or panicking. Otherwise, returns false.
|
||||
func (o *Once) Do(ctx context.Context, f func() (interface{}, error)) (bool, interface{}, error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
o.status <- true
|
||||
panic(r)
|
||||
}
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case inProgress := <-o.status:
|
||||
if !inProgress {
|
||||
return false, o.result, o.err
|
||||
}
|
||||
result, err := f()
|
||||
if err == context.Canceled || err == context.DeadlineExceeded {
|
||||
o.status <- true
|
||||
return false, nil, err
|
||||
}
|
||||
o.result, o.err = result, err
|
||||
close(o.status)
|
||||
return true, result, err
|
||||
case <-ctx.Done():
|
||||
return false, nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OnceOrRetry is an object that will perform exactly one success action.
|
||||
type OnceOrRetry struct {
|
||||
done atomic.Bool
|
||||
lock sync.Mutex
|
||||
}
|
||||
|
||||
// OnceOrRetry calls the function f if and only if Do is being called for the
|
||||
// first time for this instance of Once or all previous calls to Do are failed.
|
||||
func (o *OnceOrRetry) Do(f func() error) error {
|
||||
// fast path
|
||||
if o.done.Load() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// slow path
|
||||
o.lock.Lock()
|
||||
defer o.lock.Unlock()
|
||||
|
||||
if o.done.Load() {
|
||||
return nil
|
||||
}
|
||||
if err := f(); err != nil {
|
||||
return err
|
||||
}
|
||||
o.done.Store(true)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
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 syncutil
|
||||
|
||||
import "sync"
|
||||
|
||||
// poolItem represents an item in Pool.
|
||||
type poolItem[T any] struct {
|
||||
value T
|
||||
refCount int
|
||||
}
|
||||
|
||||
// Pool is a scalable pool with items identified by keys.
|
||||
type Pool[T any] struct {
|
||||
// New optionally specifies a function to generate a value when Get would
|
||||
// otherwise return nil.
|
||||
// It may not be changed concurrently with calls to Get.
|
||||
New func() T
|
||||
|
||||
lock sync.Mutex
|
||||
items map[any]*poolItem[T]
|
||||
}
|
||||
|
||||
// Get gets the value identified by key.
|
||||
// The caller should invoke the returned function after using the returned item.
|
||||
func (p *Pool[T]) Get(key any) (*T, func()) {
|
||||
p.lock.Lock()
|
||||
defer p.lock.Unlock()
|
||||
|
||||
item, ok := p.items[key]
|
||||
if !ok {
|
||||
if p.items == nil {
|
||||
p.items = make(map[any]*poolItem[T])
|
||||
}
|
||||
item = &poolItem[T]{}
|
||||
if p.New != nil {
|
||||
item.value = p.New()
|
||||
}
|
||||
p.items[key] = item
|
||||
}
|
||||
item.refCount++
|
||||
|
||||
return &item.value, func() {
|
||||
p.lock.Lock()
|
||||
defer p.lock.Unlock()
|
||||
item.refCount--
|
||||
if item.refCount <= 0 {
|
||||
delete(p.items, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+448
@@ -0,0 +1,448 @@
|
||||
/*
|
||||
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 oras
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
specs "github.com/opencontainers/image-spec/specs-go"
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"oras.land/oras-go/v2/content"
|
||||
"oras.land/oras-go/v2/errdef"
|
||||
"oras.land/oras-go/v2/internal/spec"
|
||||
)
|
||||
|
||||
const (
|
||||
// MediaTypeUnknownConfig is the default config mediaType used
|
||||
// - for [Pack] when PackOptions.PackImageManifest is true and
|
||||
// PackOptions.ConfigDescriptor is not specified.
|
||||
// - for [PackManifest] when packManifestVersion is PackManifestVersion1_0
|
||||
// and PackManifestOptions.ConfigDescriptor is not specified.
|
||||
MediaTypeUnknownConfig = "application/vnd.unknown.config.v1+json"
|
||||
|
||||
// MediaTypeUnknownArtifact is the default artifactType used for [Pack]
|
||||
// when PackOptions.PackImageManifest is false and artifactType is
|
||||
// not specified.
|
||||
MediaTypeUnknownArtifact = "application/vnd.unknown.artifact.v1"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidDateTimeFormat is returned by [Pack] and [PackManifest] when
|
||||
// "org.opencontainers.artifact.created" or "org.opencontainers.image.created"
|
||||
// is provided, but its value is not in RFC 3339 format.
|
||||
// Reference: https://www.rfc-editor.org/rfc/rfc3339#section-5.6
|
||||
ErrInvalidDateTimeFormat = errors.New("invalid date and time format")
|
||||
|
||||
// ErrMissingArtifactType is returned by [PackManifest] when
|
||||
// packManifestVersion is PackManifestVersion1_1 and artifactType is
|
||||
// empty and the config media type is set to
|
||||
// "application/vnd.oci.empty.v1+json".
|
||||
ErrMissingArtifactType = errors.New("missing artifact type")
|
||||
)
|
||||
|
||||
// PackManifestVersion represents the manifest version used for [PackManifest].
|
||||
type PackManifestVersion int
|
||||
|
||||
const (
|
||||
// PackManifestVersion1_0 represents the OCI Image Manifest defined in
|
||||
// image-spec v1.0.2.
|
||||
// Reference: https://github.com/opencontainers/image-spec/blob/v1.0.2/manifest.md
|
||||
PackManifestVersion1_0 PackManifestVersion = 1
|
||||
|
||||
// PackManifestVersion1_1_RC4 represents the OCI Image Manifest defined
|
||||
// in image-spec v1.1.0-rc4.
|
||||
// Reference: https://github.com/opencontainers/image-spec/blob/v1.1.0-rc4/manifest.md
|
||||
//
|
||||
// Deprecated: This constant is deprecated and not recommended for future use.
|
||||
// Use [PackManifestVersion1_1] instead.
|
||||
PackManifestVersion1_1_RC4 PackManifestVersion = PackManifestVersion1_1
|
||||
|
||||
// PackManifestVersion1_1 represents the OCI Image Manifest defined in
|
||||
// image-spec v1.1.1.
|
||||
// Reference: https://github.com/opencontainers/image-spec/blob/v1.1.1/manifest.md
|
||||
PackManifestVersion1_1 PackManifestVersion = 2
|
||||
)
|
||||
|
||||
// PackManifestOptions contains optional parameters for [PackManifest].
|
||||
type PackManifestOptions struct {
|
||||
// Subject is the subject of the manifest.
|
||||
// This option is only valid when PackManifestVersion is
|
||||
// NOT PackManifestVersion1_0.
|
||||
Subject *ocispec.Descriptor
|
||||
|
||||
// Layers is the layers of the manifest.
|
||||
Layers []ocispec.Descriptor
|
||||
|
||||
// ManifestAnnotations is the annotation map of the manifest. In order to
|
||||
// make [PackManifest] reproducible, set the key ocispec.AnnotationCreated
|
||||
// (i.e. "org.opencontainers.image.created") to a fixed value. The value
|
||||
// must conform to RFC 3339.
|
||||
ManifestAnnotations map[string]string
|
||||
|
||||
// ConfigDescriptor is a pointer to the descriptor of the config blob.
|
||||
// If not nil, ConfigAnnotations will be ignored.
|
||||
ConfigDescriptor *ocispec.Descriptor
|
||||
|
||||
// ConfigAnnotations is the annotation map of the config descriptor.
|
||||
// This option is valid only when ConfigDescriptor is nil.
|
||||
ConfigAnnotations map[string]string
|
||||
}
|
||||
|
||||
// mediaTypeRegexp checks the format of media types.
|
||||
// References:
|
||||
// - https://github.com/opencontainers/image-spec/blob/v1.1.1/schema/defs-descriptor.json#L7
|
||||
// - https://datatracker.ietf.org/doc/html/rfc6838#section-4.2
|
||||
var mediaTypeRegexp = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}$`)
|
||||
|
||||
// PackManifest generates an OCI Image Manifest based on the given parameters
|
||||
// and pushes the packed manifest to a content storage using pusher. The version
|
||||
// of the manifest to be packed is determined by packManifestVersion
|
||||
// (Recommended value: PackManifestVersion1_1).
|
||||
//
|
||||
// - If packManifestVersion is [PackManifestVersion1_1]:
|
||||
// artifactType MUST NOT be empty unless opts.ConfigDescriptor is specified.
|
||||
// - If packManifestVersion is [PackManifestVersion1_0]:
|
||||
// if opts.ConfigDescriptor is nil, artifactType will be used as the
|
||||
// config media type; if artifactType is empty,
|
||||
// "application/vnd.unknown.config.v1+json" will be used.
|
||||
// if opts.ConfigDescriptor is NOT nil, artifactType will be ignored.
|
||||
//
|
||||
// artifactType and opts.ConfigDescriptor.MediaType MUST comply with RFC 6838.
|
||||
//
|
||||
// Each time when PackManifest is called, if a time stamp is not specified, a new time
|
||||
// stamp is generated in the manifest annotations with the key ocispec.AnnotationCreated
|
||||
// (i.e. "org.opencontainers.image.created"). To make [PackManifest] reproducible,
|
||||
// set the key ocispec.AnnotationCreated to a fixed value in
|
||||
// opts.ManifestAnnotations. The value MUST conform to RFC 3339.
|
||||
//
|
||||
// If succeeded, returns a descriptor of the packed manifest.
|
||||
func PackManifest(ctx context.Context, pusher content.Pusher, packManifestVersion PackManifestVersion, artifactType string, opts PackManifestOptions) (ocispec.Descriptor, error) {
|
||||
switch packManifestVersion {
|
||||
case PackManifestVersion1_0:
|
||||
return packManifestV1_0(ctx, pusher, artifactType, opts)
|
||||
case PackManifestVersion1_1:
|
||||
return packManifestV1_1(ctx, pusher, artifactType, opts)
|
||||
default:
|
||||
return ocispec.Descriptor{}, fmt.Errorf("PackManifestVersion(%v): %w", packManifestVersion, errdef.ErrUnsupported)
|
||||
}
|
||||
}
|
||||
|
||||
// PackOptions contains optional parameters for [Pack].
|
||||
//
|
||||
// Deprecated: This type is deprecated and not recommended for future use.
|
||||
// Use [PackManifestOptions] instead.
|
||||
type PackOptions struct {
|
||||
// Subject is the subject of the manifest.
|
||||
Subject *ocispec.Descriptor
|
||||
|
||||
// ManifestAnnotations is the annotation map of the manifest.
|
||||
ManifestAnnotations map[string]string
|
||||
|
||||
// PackImageManifest controls whether to pack an OCI Image Manifest or not.
|
||||
// - If true, pack an OCI Image Manifest.
|
||||
// - If false, pack an OCI Artifact Manifest (deprecated).
|
||||
//
|
||||
// Default value: false.
|
||||
PackImageManifest bool
|
||||
|
||||
// ConfigDescriptor is a pointer to the descriptor of the config blob.
|
||||
// If not nil, artifactType will be implied by the mediaType of the
|
||||
// specified ConfigDescriptor, and ConfigAnnotations will be ignored.
|
||||
// This option is valid only when PackImageManifest is true.
|
||||
ConfigDescriptor *ocispec.Descriptor
|
||||
|
||||
// ConfigAnnotations is the annotation map of the config descriptor.
|
||||
// This option is valid only when PackImageManifest is true
|
||||
// and ConfigDescriptor is nil.
|
||||
ConfigAnnotations map[string]string
|
||||
}
|
||||
|
||||
// Pack packs the given blobs, generates a manifest for the pack,
|
||||
// and pushes it to a content storage.
|
||||
//
|
||||
// When opts.PackImageManifest is true, artifactType will be used as the
|
||||
// the config descriptor mediaType of the image manifest.
|
||||
//
|
||||
// If succeeded, returns a descriptor of the manifest.
|
||||
//
|
||||
// Deprecated: This method is deprecated and not recommended for future use.
|
||||
// Use [PackManifest] instead.
|
||||
func Pack(ctx context.Context, pusher content.Pusher, artifactType string, blobs []ocispec.Descriptor, opts PackOptions) (ocispec.Descriptor, error) {
|
||||
if opts.PackImageManifest {
|
||||
return packManifestV1_1_RC2(ctx, pusher, artifactType, blobs, opts)
|
||||
}
|
||||
return packArtifact(ctx, pusher, artifactType, blobs, opts)
|
||||
}
|
||||
|
||||
// packArtifact packs an Artifact manifest as defined in image-spec v1.1.0-rc2.
|
||||
// Reference: https://github.com/opencontainers/image-spec/blob/v1.1.0-rc2/artifact.md
|
||||
func packArtifact(ctx context.Context, pusher content.Pusher, artifactType string, blobs []ocispec.Descriptor, opts PackOptions) (ocispec.Descriptor, error) {
|
||||
if artifactType == "" {
|
||||
artifactType = MediaTypeUnknownArtifact
|
||||
}
|
||||
|
||||
annotations, err := ensureAnnotationCreated(opts.ManifestAnnotations, spec.AnnotationArtifactCreated)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
manifest := spec.Artifact{
|
||||
MediaType: spec.MediaTypeArtifactManifest,
|
||||
ArtifactType: artifactType,
|
||||
Blobs: blobs,
|
||||
Subject: opts.Subject,
|
||||
Annotations: annotations,
|
||||
}
|
||||
return pushManifest(ctx, pusher, manifest, manifest.MediaType, manifest.ArtifactType, manifest.Annotations)
|
||||
}
|
||||
|
||||
// packManifestV1_0 packs an image manifest defined in image-spec v1.0.2.
|
||||
// Reference: https://github.com/opencontainers/image-spec/blob/v1.0.2/manifest.md
|
||||
func packManifestV1_0(ctx context.Context, pusher content.Pusher, artifactType string, opts PackManifestOptions) (ocispec.Descriptor, error) {
|
||||
if opts.Subject != nil {
|
||||
return ocispec.Descriptor{}, fmt.Errorf("subject is not supported for manifest version %v: %w", PackManifestVersion1_0, errdef.ErrUnsupported)
|
||||
}
|
||||
|
||||
// prepare config
|
||||
var configDesc ocispec.Descriptor
|
||||
if opts.ConfigDescriptor != nil {
|
||||
if err := validateMediaType(opts.ConfigDescriptor.MediaType); err != nil {
|
||||
return ocispec.Descriptor{}, fmt.Errorf("invalid config mediaType format: %w", err)
|
||||
}
|
||||
configDesc = *opts.ConfigDescriptor
|
||||
} else {
|
||||
if artifactType == "" {
|
||||
artifactType = MediaTypeUnknownConfig
|
||||
} else if err := validateMediaType(artifactType); err != nil {
|
||||
return ocispec.Descriptor{}, fmt.Errorf("invalid artifactType format: %w", err)
|
||||
}
|
||||
var err error
|
||||
configDesc, err = pushCustomEmptyConfig(ctx, pusher, artifactType, opts.ConfigAnnotations)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
}
|
||||
|
||||
annotations, err := ensureAnnotationCreated(opts.ManifestAnnotations, ocispec.AnnotationCreated)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
if opts.Layers == nil {
|
||||
opts.Layers = []ocispec.Descriptor{} // make it an empty array to prevent potential server-side bugs
|
||||
}
|
||||
manifest := ocispec.Manifest{
|
||||
Versioned: specs.Versioned{
|
||||
SchemaVersion: 2, // historical value. does not pertain to OCI or docker version
|
||||
},
|
||||
Config: configDesc,
|
||||
MediaType: ocispec.MediaTypeImageManifest,
|
||||
Layers: opts.Layers,
|
||||
Annotations: annotations,
|
||||
}
|
||||
return pushManifest(ctx, pusher, manifest, manifest.MediaType, manifest.Config.MediaType, manifest.Annotations)
|
||||
}
|
||||
|
||||
// packManifestV1_1_RC2 packs an image manifest as defined in image-spec
|
||||
// v1.1.0-rc2.
|
||||
// Reference: https://github.com/opencontainers/image-spec/blob/v1.1.0-rc2/manifest.md
|
||||
func packManifestV1_1_RC2(ctx context.Context, pusher content.Pusher, configMediaType string, layers []ocispec.Descriptor, opts PackOptions) (ocispec.Descriptor, error) {
|
||||
if configMediaType == "" {
|
||||
configMediaType = MediaTypeUnknownConfig
|
||||
}
|
||||
|
||||
// prepare config
|
||||
var configDesc ocispec.Descriptor
|
||||
if opts.ConfigDescriptor != nil {
|
||||
configDesc = *opts.ConfigDescriptor
|
||||
} else {
|
||||
var err error
|
||||
configDesc, err = pushCustomEmptyConfig(ctx, pusher, configMediaType, opts.ConfigAnnotations)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
}
|
||||
|
||||
annotations, err := ensureAnnotationCreated(opts.ManifestAnnotations, ocispec.AnnotationCreated)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
if layers == nil {
|
||||
layers = []ocispec.Descriptor{} // make it an empty array to prevent potential server-side bugs
|
||||
}
|
||||
manifest := ocispec.Manifest{
|
||||
Versioned: specs.Versioned{
|
||||
SchemaVersion: 2, // historical value. does not pertain to OCI or docker version
|
||||
},
|
||||
Config: configDesc,
|
||||
MediaType: ocispec.MediaTypeImageManifest,
|
||||
Layers: layers,
|
||||
Subject: opts.Subject,
|
||||
Annotations: annotations,
|
||||
}
|
||||
return pushManifest(ctx, pusher, manifest, manifest.MediaType, manifest.Config.MediaType, manifest.Annotations)
|
||||
}
|
||||
|
||||
// packManifestV1_1 packs an image manifest defined in image-spec v1.1.1.
|
||||
// Reference: https://github.com/opencontainers/image-spec/blob/v1.1.1/manifest.md#guidelines-for-artifact-usage
|
||||
func packManifestV1_1(ctx context.Context, pusher content.Pusher, artifactType string, opts PackManifestOptions) (ocispec.Descriptor, error) {
|
||||
if artifactType == "" && (opts.ConfigDescriptor == nil || opts.ConfigDescriptor.MediaType == ocispec.MediaTypeEmptyJSON) {
|
||||
// artifactType MUST be set when config.mediaType is set to the empty value
|
||||
return ocispec.Descriptor{}, ErrMissingArtifactType
|
||||
}
|
||||
if artifactType != "" {
|
||||
if err := validateMediaType(artifactType); err != nil {
|
||||
return ocispec.Descriptor{}, fmt.Errorf("invalid artifactType format: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// prepare config
|
||||
var emptyBlobExists bool
|
||||
var configDesc ocispec.Descriptor
|
||||
if opts.ConfigDescriptor != nil {
|
||||
if err := validateMediaType(opts.ConfigDescriptor.MediaType); err != nil {
|
||||
return ocispec.Descriptor{}, fmt.Errorf("invalid config mediaType format: %w", err)
|
||||
}
|
||||
configDesc = *opts.ConfigDescriptor
|
||||
} else {
|
||||
// use the empty descriptor for config
|
||||
configDesc = ocispec.DescriptorEmptyJSON
|
||||
configDesc.Annotations = opts.ConfigAnnotations
|
||||
configBytes := ocispec.DescriptorEmptyJSON.Data
|
||||
// push config
|
||||
if err := pushIfNotExist(ctx, pusher, configDesc, configBytes); err != nil {
|
||||
return ocispec.Descriptor{}, fmt.Errorf("failed to push config: %w", err)
|
||||
}
|
||||
emptyBlobExists = true
|
||||
}
|
||||
|
||||
annotations, err := ensureAnnotationCreated(opts.ManifestAnnotations, ocispec.AnnotationCreated)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
if len(opts.Layers) == 0 {
|
||||
// use the empty descriptor as the single layer
|
||||
layerDesc := ocispec.DescriptorEmptyJSON
|
||||
layerData := ocispec.DescriptorEmptyJSON.Data
|
||||
if !emptyBlobExists {
|
||||
if err := pushIfNotExist(ctx, pusher, layerDesc, layerData); err != nil {
|
||||
return ocispec.Descriptor{}, fmt.Errorf("failed to push layer: %w", err)
|
||||
}
|
||||
}
|
||||
opts.Layers = []ocispec.Descriptor{layerDesc}
|
||||
}
|
||||
|
||||
manifest := ocispec.Manifest{
|
||||
Versioned: specs.Versioned{
|
||||
SchemaVersion: 2, // historical value. does not pertain to OCI or docker version
|
||||
},
|
||||
Config: configDesc,
|
||||
MediaType: ocispec.MediaTypeImageManifest,
|
||||
Layers: opts.Layers,
|
||||
Subject: opts.Subject,
|
||||
ArtifactType: artifactType,
|
||||
Annotations: annotations,
|
||||
}
|
||||
return pushManifest(ctx, pusher, manifest, manifest.MediaType, manifest.ArtifactType, manifest.Annotations)
|
||||
}
|
||||
|
||||
// pushIfNotExist pushes data described by desc if it does not exist in the
|
||||
// target.
|
||||
func pushIfNotExist(ctx context.Context, pusher content.Pusher, desc ocispec.Descriptor, data []byte) error {
|
||||
if ros, ok := pusher.(content.ReadOnlyStorage); ok {
|
||||
exists, err := ros.Exists(ctx, desc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check existence: %s: %s: %w", desc.Digest.String(), desc.MediaType, err)
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := pusher.Push(ctx, desc, bytes.NewReader(data)); err != nil && !errors.Is(err, errdef.ErrAlreadyExists) {
|
||||
return fmt.Errorf("failed to push: %s: %s: %w", desc.Digest.String(), desc.MediaType, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pushManifest marshals manifest into JSON bytes and pushes it.
|
||||
func pushManifest(ctx context.Context, pusher content.Pusher, manifest any, mediaType string, artifactType string, annotations map[string]string) (ocispec.Descriptor, error) {
|
||||
manifestJSON, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, fmt.Errorf("failed to marshal manifest: %w", err)
|
||||
}
|
||||
manifestDesc := content.NewDescriptorFromBytes(mediaType, manifestJSON)
|
||||
// populate ArtifactType and Annotations of the manifest into manifestDesc
|
||||
manifestDesc.ArtifactType = artifactType
|
||||
manifestDesc.Annotations = annotations
|
||||
// push manifest
|
||||
if err := pusher.Push(ctx, manifestDesc, bytes.NewReader(manifestJSON)); err != nil && !errors.Is(err, errdef.ErrAlreadyExists) {
|
||||
return ocispec.Descriptor{}, fmt.Errorf("failed to push manifest: %w", err)
|
||||
}
|
||||
return manifestDesc, nil
|
||||
}
|
||||
|
||||
// pushCustomEmptyConfig generates and pushes an empty config blob.
|
||||
func pushCustomEmptyConfig(ctx context.Context, pusher content.Pusher, mediaType string, annotations map[string]string) (ocispec.Descriptor, error) {
|
||||
// Use an empty JSON object here, because some registries may not accept
|
||||
// empty config blob.
|
||||
// As of September 2022, GAR is known to return 400 on empty blob upload.
|
||||
// See https://github.com/oras-project/oras-go/issues/294 for details.
|
||||
configBytes := []byte("{}")
|
||||
configDesc := content.NewDescriptorFromBytes(mediaType, configBytes)
|
||||
configDesc.Annotations = annotations
|
||||
// push config
|
||||
if err := pushIfNotExist(ctx, pusher, configDesc, configBytes); err != nil {
|
||||
return ocispec.Descriptor{}, fmt.Errorf("failed to push config: %w", err)
|
||||
}
|
||||
return configDesc, nil
|
||||
}
|
||||
|
||||
// ensureAnnotationCreated ensures that annotationCreatedKey is in annotations,
|
||||
// and that its value conforms to RFC 3339. Otherwise returns a new annotation
|
||||
// map with annotationCreatedKey created.
|
||||
func ensureAnnotationCreated(annotations map[string]string, annotationCreatedKey string) (map[string]string, error) {
|
||||
if createdTime, ok := annotations[annotationCreatedKey]; ok {
|
||||
// if annotationCreatedKey is provided, validate its format
|
||||
if _, err := time.Parse(time.RFC3339, createdTime); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidDateTimeFormat, err)
|
||||
}
|
||||
return annotations, nil
|
||||
}
|
||||
|
||||
// copy the original annotation map
|
||||
copied := make(map[string]string, len(annotations)+1)
|
||||
maps.Copy(copied, annotations)
|
||||
|
||||
// set creation time in RFC 3339 format
|
||||
// reference: https://github.com/opencontainers/image-spec/blob/v1.1.0-rc2/annotations.md#pre-defined-annotation-keys
|
||||
now := time.Now().UTC()
|
||||
copied[annotationCreatedKey] = now.Format(time.RFC3339)
|
||||
return copied, nil
|
||||
}
|
||||
|
||||
// validateMediaType validates the format of mediaType.
|
||||
func validateMediaType(mediaType string) error {
|
||||
if !mediaTypeRegexp.MatchString(mediaType) {
|
||||
return fmt.Errorf("%s: %w", mediaType, errdef.ErrInvalidMediaType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
/*
|
||||
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 registry
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/opencontainers/go-digest"
|
||||
"oras.land/oras-go/v2/errdef"
|
||||
)
|
||||
|
||||
// regular expressions for components.
|
||||
var (
|
||||
// repositoryRegexp is adapted from the distribution implementation. The
|
||||
// repository name set under OCI distribution spec is a subset of the docker
|
||||
// spec. For maximum compatability, the docker spec is verified client-side.
|
||||
// Further checks are left to the server-side.
|
||||
//
|
||||
// References:
|
||||
// - https://github.com/distribution/distribution/blob/v2.7.1/reference/regexp.go#L53
|
||||
// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#pulling-manifests
|
||||
repositoryRegexp = regexp.MustCompile(`^[a-z0-9]+(?:(?:[._]|__|[-]*)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]*)[a-z0-9]+)*)*$`)
|
||||
|
||||
// tagRegexp checks the tag name.
|
||||
// The docker and OCI spec have the same regular expression.
|
||||
//
|
||||
// Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#pulling-manifests
|
||||
tagRegexp = regexp.MustCompile(`^[\w][\w.-]{0,127}$`)
|
||||
)
|
||||
|
||||
// Reference references either a resource descriptor (where Reference.Reference
|
||||
// is a tag or a digest), or a resource repository (where Reference.Reference
|
||||
// is the empty string).
|
||||
type Reference struct {
|
||||
// Registry is the name of the registry. It is usually the domain name of
|
||||
// the registry optionally with a port.
|
||||
Registry string
|
||||
|
||||
// Repository is the name of the repository.
|
||||
Repository string
|
||||
|
||||
// Reference is the reference of the object in the repository. This field
|
||||
// can take any one of the four valid forms (see ParseReference). In the
|
||||
// case where it's the empty string, it necessarily implies valid form D,
|
||||
// and where it is non-empty, then it is either a tag, or a digest
|
||||
// (implying one of valid forms A, B, or C).
|
||||
Reference string
|
||||
}
|
||||
|
||||
// ParseReference parses a string (artifact) into an `artifact reference`.
|
||||
// Corresponding cryptographic hash implementations are required to be imported
|
||||
// as specified by https://pkg.go.dev/github.com/opencontainers/go-digest#readme-usage
|
||||
// if the string contains a digest.
|
||||
//
|
||||
// Note: An "image" is an "artifact", however, an "artifact" is not necessarily
|
||||
// an "image".
|
||||
//
|
||||
// The token `artifact` is composed of other tokens, and those in turn are
|
||||
// composed of others. This definition recursivity requires a notation capable
|
||||
// of recursion, thus the following two forms have been adopted:
|
||||
//
|
||||
// 1. Backus–Naur Form (BNF) has been adopted to address the recursive nature
|
||||
// of the definition.
|
||||
// 2. Token opacity is revealed via its label letter-casing. That is, "opaque"
|
||||
// tokens (i.e., tokens that are not final, and must therefore be further
|
||||
// broken down into their constituents) are denoted in *lowercase*, while
|
||||
// final tokens (i.e., leaf-node tokens that are final) are denoted in
|
||||
// *uppercase*.
|
||||
//
|
||||
// Finally, note that a number of the opaque tokens are polymorphic in nature;
|
||||
// that is, they can take on one of numerous forms, not restricted to a single
|
||||
// defining form.
|
||||
//
|
||||
// The top-level token, `artifact`, is composed of two (opaque) tokens; namely
|
||||
// `socketaddr` and `path`:
|
||||
//
|
||||
// <artifact> ::= <socketaddr> "/" <path>
|
||||
//
|
||||
// The former is described as follows:
|
||||
//
|
||||
// <socketaddr> ::= <host> | <host> ":" <PORT>
|
||||
// <host> ::= <ip> | <FQDN>
|
||||
// <ip> ::= <IPV4-ADDR> | <IPV6-ADDR>
|
||||
//
|
||||
// The latter, which is of greater interest here, is described as follows:
|
||||
//
|
||||
// <path> ::= <REPOSITORY> | <REPOSITORY> <reference>
|
||||
// <reference> ::= "@" <digest> | ":" <TAG> "@" <DIGEST> | ":" <TAG>
|
||||
// <digest> ::= <ALGO> ":" <HASH>
|
||||
//
|
||||
// This second token--`path`--can take on exactly four forms, each of which will
|
||||
// now be illustrated:
|
||||
//
|
||||
// <--- path --------------------------------------------> | - Decode `path`
|
||||
// <=== REPOSITORY ===> <--- reference ------------------> | - Decode `reference`
|
||||
// <=== REPOSITORY ===> @ <=================== digest ===> | - Valid Form A
|
||||
// <=== REPOSITORY ===> : <!!! TAG !!!> @ <=== digest ===> | - Valid Form B (tag is dropped)
|
||||
// <=== REPOSITORY ===> : <=== TAG ======================> | - Valid Form C
|
||||
// <=== REPOSITORY ======================================> | - Valid Form D
|
||||
//
|
||||
// Note: In the case of Valid Form B, TAG is dropped without any validation or
|
||||
// further consideration.
|
||||
func ParseReference(artifact string) (Reference, error) {
|
||||
parts := strings.SplitN(artifact, "/", 2)
|
||||
if len(parts) == 1 {
|
||||
// Invalid Form
|
||||
return Reference{}, fmt.Errorf("%w: missing registry or repository", errdef.ErrInvalidReference)
|
||||
}
|
||||
registry, path := parts[0], parts[1]
|
||||
|
||||
var isTag bool
|
||||
var repository string
|
||||
var reference string
|
||||
if index := strings.Index(path, "@"); index != -1 {
|
||||
// `digest` found; Valid Form A (if not B)
|
||||
isTag = false
|
||||
repository = path[:index]
|
||||
reference = path[index+1:]
|
||||
|
||||
if index = strings.Index(repository, ":"); index != -1 {
|
||||
// `tag` found (and now dropped without validation) since `the
|
||||
// `digest` already present; Valid Form B
|
||||
repository = repository[:index]
|
||||
}
|
||||
} else if index = strings.Index(path, ":"); index != -1 {
|
||||
// `tag` found; Valid Form C
|
||||
isTag = true
|
||||
repository = path[:index]
|
||||
reference = path[index+1:]
|
||||
} else {
|
||||
// empty `reference`; Valid Form D
|
||||
repository = path
|
||||
}
|
||||
ref := Reference{
|
||||
Registry: registry,
|
||||
Repository: repository,
|
||||
Reference: reference,
|
||||
}
|
||||
|
||||
if err := ref.ValidateRegistry(); err != nil {
|
||||
return Reference{}, err
|
||||
}
|
||||
|
||||
if err := ref.ValidateRepository(); err != nil {
|
||||
return Reference{}, err
|
||||
}
|
||||
|
||||
if len(ref.Reference) == 0 {
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
validator := ref.ValidateReferenceAsDigest
|
||||
if isTag {
|
||||
validator = ref.ValidateReferenceAsTag
|
||||
}
|
||||
if err := validator(); err != nil {
|
||||
return Reference{}, err
|
||||
}
|
||||
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
// Validate the entire reference object; the registry, the repository, and the
|
||||
// reference.
|
||||
func (r Reference) Validate() error {
|
||||
if err := r.ValidateRegistry(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := r.ValidateRepository(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.ValidateReference()
|
||||
}
|
||||
|
||||
// ValidateRegistry validates the registry.
|
||||
func (r Reference) ValidateRegistry() error {
|
||||
if uri, err := url.ParseRequestURI("dummy://" + r.Registry); err != nil || uri.Host == "" || uri.Host != r.Registry {
|
||||
return fmt.Errorf("%w: invalid registry %q", errdef.ErrInvalidReference, r.Registry)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateRepository validates the repository.
|
||||
func (r Reference) ValidateRepository() error {
|
||||
if !repositoryRegexp.MatchString(r.Repository) {
|
||||
return fmt.Errorf("%w: invalid repository %q", errdef.ErrInvalidReference, r.Repository)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateReferenceAsTag validates the reference as a tag.
|
||||
func (r Reference) ValidateReferenceAsTag() error {
|
||||
if !tagRegexp.MatchString(r.Reference) {
|
||||
return fmt.Errorf("%w: invalid tag %q", errdef.ErrInvalidReference, r.Reference)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateReferenceAsDigest validates the reference as a digest.
|
||||
func (r Reference) ValidateReferenceAsDigest() error {
|
||||
if _, err := r.Digest(); err != nil {
|
||||
return fmt.Errorf("%w: invalid digest %q: %v", errdef.ErrInvalidReference, r.Reference, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateReference where the reference is first tried as an ampty string, then
|
||||
// as a digest, and if that fails, as a tag.
|
||||
func (r Reference) ValidateReference() error {
|
||||
if len(r.Reference) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if index := strings.IndexByte(r.Reference, ':'); index != -1 {
|
||||
return r.ValidateReferenceAsDigest()
|
||||
}
|
||||
|
||||
return r.ValidateReferenceAsTag()
|
||||
}
|
||||
|
||||
// Host returns the host name of the registry.
|
||||
func (r Reference) Host() string {
|
||||
if r.Registry == "docker.io" {
|
||||
return "registry-1.docker.io"
|
||||
}
|
||||
return r.Registry
|
||||
}
|
||||
|
||||
// ReferenceOrDefault returns the reference or the default reference if empty.
|
||||
func (r Reference) ReferenceOrDefault() string {
|
||||
if r.Reference == "" {
|
||||
return "latest"
|
||||
}
|
||||
return r.Reference
|
||||
}
|
||||
|
||||
// Digest returns the reference as a digest.
|
||||
// Corresponding cryptographic hash implementations are required to be imported
|
||||
// as specified by https://pkg.go.dev/github.com/opencontainers/go-digest#readme-usage
|
||||
func (r Reference) Digest() (digest.Digest, error) {
|
||||
return digest.Parse(r.Reference)
|
||||
}
|
||||
|
||||
// String implements `fmt.Stringer` and returns the reference string.
|
||||
// The resulted string is meaningful only if the reference is valid.
|
||||
func (r Reference) String() string {
|
||||
if r.Repository == "" {
|
||||
return r.Registry
|
||||
}
|
||||
ref := r.Registry + "/" + r.Repository
|
||||
if r.Reference == "" {
|
||||
return ref
|
||||
}
|
||||
if d, err := r.Digest(); err == nil {
|
||||
return ref + "@" + d.String()
|
||||
}
|
||||
return ref + ":" + r.Reference
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
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 registry provides high-level operations to manage registries.
|
||||
package registry
|
||||
|
||||
import "context"
|
||||
|
||||
// Registry represents a collection of repositories.
|
||||
type Registry interface {
|
||||
// Repositories lists the name of repositories available in the registry.
|
||||
// Since the returned repositories may be paginated by the underlying
|
||||
// implementation, a function should be passed in to process the paginated
|
||||
// repository list.
|
||||
// `last` argument is the `last` parameter when invoking the catalog API.
|
||||
// If `last` is NOT empty, the entries in the response start after the
|
||||
// repo specified by `last`. Otherwise, the response starts from the top
|
||||
// of the Repositories list.
|
||||
// Note: When implemented by a remote registry, the catalog API is called.
|
||||
// However, not all registries supports pagination or conforms the
|
||||
// specification.
|
||||
// Reference: https://distribution.github.io/distribution/spec/api/#catalog
|
||||
// See also `Repositories()` in this package.
|
||||
Repositories(ctx context.Context, last string, fn func(repos []string) error) error
|
||||
|
||||
// Repository returns a repository reference by the given name.
|
||||
Repository(ctx context.Context, name string) (Repository, error)
|
||||
}
|
||||
|
||||
// Repositories lists the name of repositories available in the registry.
|
||||
func Repositories(ctx context.Context, reg Registry) ([]string, error) {
|
||||
var res []string
|
||||
if err := reg.Repositories(ctx, "", func(repos []string) error {
|
||||
res = append(res, repos...)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
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 auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"oras.land/oras-go/v2/errdef"
|
||||
"oras.land/oras-go/v2/internal/syncutil"
|
||||
)
|
||||
|
||||
// DefaultCache is the sharable cache used by DefaultClient.
|
||||
var DefaultCache Cache = NewCache()
|
||||
|
||||
// Cache caches the auth-scheme and auth-token for the "Authorization" header in
|
||||
// accessing the remote registry.
|
||||
// Precisely, the header is `Authorization: auth-scheme auth-token`.
|
||||
// The `auth-token` is a generic term as `token68` in RFC 7235 section 2.1.
|
||||
type Cache interface {
|
||||
// GetScheme returns the auth-scheme part cached for the given registry.
|
||||
// A single registry is assumed to have a consistent scheme.
|
||||
// If a registry has different schemes per path, the auth client is still
|
||||
// workable. However, the cache may not be effective as the cache cannot
|
||||
// correctly guess the scheme.
|
||||
GetScheme(ctx context.Context, registry string) (Scheme, error)
|
||||
|
||||
// GetToken returns the auth-token part cached for the given registry of a
|
||||
// given scheme.
|
||||
// The underlying implementation MAY cache the token for all schemes for the
|
||||
// given registry.
|
||||
GetToken(ctx context.Context, registry string, scheme Scheme, key string) (string, error)
|
||||
|
||||
// Set fetches the token using the given fetch function and caches the token
|
||||
// for the given scheme with the given key for the given registry.
|
||||
// The return values of the fetch function is returned by this function.
|
||||
// The underlying implementation MAY combine the fetch operation if the Set
|
||||
// function is invoked multiple times at the same time.
|
||||
Set(ctx context.Context, registry string, scheme Scheme, key string, fetch func(context.Context) (string, error)) (string, error)
|
||||
}
|
||||
|
||||
// cacheEntry is a cache entry for a single registry.
|
||||
type cacheEntry struct {
|
||||
scheme Scheme
|
||||
tokens sync.Map // map[string]string
|
||||
}
|
||||
|
||||
// concurrentCache is a cache suitable for concurrent invocation.
|
||||
type concurrentCache struct {
|
||||
status sync.Map // map[string]*syncutil.Once
|
||||
cache sync.Map // map[string]*cacheEntry
|
||||
}
|
||||
|
||||
// NewCache creates a new go-routine safe cache instance.
|
||||
func NewCache() Cache {
|
||||
return &concurrentCache{}
|
||||
}
|
||||
|
||||
// GetScheme returns the auth-scheme part cached for the given registry.
|
||||
func (cc *concurrentCache) GetScheme(ctx context.Context, registry string) (Scheme, error) {
|
||||
entry, ok := cc.cache.Load(registry)
|
||||
if !ok {
|
||||
return SchemeUnknown, errdef.ErrNotFound
|
||||
}
|
||||
return entry.(*cacheEntry).scheme, nil
|
||||
}
|
||||
|
||||
// GetToken returns the auth-token part cached for the given registry of a given
|
||||
// scheme.
|
||||
func (cc *concurrentCache) GetToken(ctx context.Context, registry string, scheme Scheme, key string) (string, error) {
|
||||
entryValue, ok := cc.cache.Load(registry)
|
||||
if !ok {
|
||||
return "", errdef.ErrNotFound
|
||||
}
|
||||
entry := entryValue.(*cacheEntry)
|
||||
if entry.scheme != scheme {
|
||||
return "", errdef.ErrNotFound
|
||||
}
|
||||
if token, ok := entry.tokens.Load(key); ok {
|
||||
return token.(string), nil
|
||||
}
|
||||
return "", errdef.ErrNotFound
|
||||
}
|
||||
|
||||
// Set fetches the token using the given fetch function and caches the token
|
||||
// for the given scheme with the given key for the given registry.
|
||||
// Set combines the fetch operation if the Set is invoked multiple times at the
|
||||
// same time.
|
||||
func (cc *concurrentCache) Set(ctx context.Context, registry string, scheme Scheme, key string, fetch func(context.Context) (string, error)) (string, error) {
|
||||
// fetch token
|
||||
statusKey := strings.Join([]string{
|
||||
registry,
|
||||
scheme.String(),
|
||||
key,
|
||||
}, " ")
|
||||
statusValue, _ := cc.status.LoadOrStore(statusKey, syncutil.NewOnce())
|
||||
fetchOnce := statusValue.(*syncutil.Once)
|
||||
fetchedFirst, result, err := fetchOnce.Do(ctx, func() (interface{}, error) {
|
||||
return fetch(ctx)
|
||||
})
|
||||
if fetchedFirst {
|
||||
cc.status.Delete(statusKey)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := result.(string)
|
||||
if !fetchedFirst {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// cache token
|
||||
newEntry := &cacheEntry{
|
||||
scheme: scheme,
|
||||
}
|
||||
entryValue, exists := cc.cache.LoadOrStore(registry, newEntry)
|
||||
entry := entryValue.(*cacheEntry)
|
||||
if exists && entry.scheme != scheme {
|
||||
// there is a scheme change, which is not expected in most scenarios.
|
||||
// force invalidating all previous cache.
|
||||
entry = newEntry
|
||||
cc.cache.Store(registry, entry)
|
||||
}
|
||||
entry.tokens.Store(key, token)
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// noCache is a cache implementation that does not do cache at all.
|
||||
type noCache struct{}
|
||||
|
||||
// GetScheme always returns not found error as it has no cache.
|
||||
func (noCache) GetScheme(ctx context.Context, registry string) (Scheme, error) {
|
||||
return SchemeUnknown, errdef.ErrNotFound
|
||||
}
|
||||
|
||||
// GetToken always returns not found error as it has no cache.
|
||||
func (noCache) GetToken(ctx context.Context, registry string, scheme Scheme, key string) (string, error) {
|
||||
return "", errdef.ErrNotFound
|
||||
}
|
||||
|
||||
// Set calls fetch directly without caching.
|
||||
func (noCache) Set(ctx context.Context, registry string, scheme Scheme, key string, fetch func(context.Context) (string, error)) (string, error) {
|
||||
return fetch(ctx)
|
||||
}
|
||||
|
||||
// hostCache is an auth cache that ignores scopes. Uses only the registry's hostname to find a token.
|
||||
type hostCache struct {
|
||||
Cache
|
||||
}
|
||||
|
||||
// GetToken implements Cache.
|
||||
func (c *hostCache) GetToken(ctx context.Context, registry string, scheme Scheme, key string) (string, error) {
|
||||
return c.Cache.GetToken(ctx, registry, scheme, "")
|
||||
}
|
||||
|
||||
// Set implements Cache.
|
||||
func (c *hostCache) Set(ctx context.Context, registry string, scheme Scheme, key string, fetch func(context.Context) (string, error)) (string, error) {
|
||||
return c.Cache.Set(ctx, registry, scheme, "", fetch)
|
||||
}
|
||||
|
||||
// fallbackCache tries the primary cache then falls back to the secondary cache.
|
||||
type fallbackCache struct {
|
||||
primary Cache
|
||||
secondary Cache
|
||||
}
|
||||
|
||||
// GetScheme implements Cache.
|
||||
func (fc *fallbackCache) GetScheme(ctx context.Context, registry string) (Scheme, error) {
|
||||
scheme, err := fc.primary.GetScheme(ctx, registry)
|
||||
if err == nil {
|
||||
return scheme, nil
|
||||
}
|
||||
|
||||
// fallback
|
||||
return fc.secondary.GetScheme(ctx, registry)
|
||||
}
|
||||
|
||||
// GetToken implements Cache.
|
||||
func (fc *fallbackCache) GetToken(ctx context.Context, registry string, scheme Scheme, key string) (string, error) {
|
||||
token, err := fc.primary.GetToken(ctx, registry, scheme, key)
|
||||
if err == nil {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// fallback
|
||||
return fc.secondary.GetToken(ctx, registry, scheme, key)
|
||||
}
|
||||
|
||||
// Set implements Cache.
|
||||
func (fc *fallbackCache) Set(ctx context.Context, registry string, scheme Scheme, key string, fetch func(context.Context) (string, error)) (string, error) {
|
||||
token, err := fc.primary.Set(ctx, registry, scheme, key, fetch)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fc.secondary.Set(ctx, registry, scheme, key, func(ctx context.Context) (string, error) {
|
||||
return token, nil
|
||||
})
|
||||
}
|
||||
|
||||
// NewSingleContextCache creates a host-based cache for optimizing the auth flow for non-compliant registries.
|
||||
// It is intended to be used in a single context, such as pulling from a single repository.
|
||||
// This cache should not be shared.
|
||||
//
|
||||
// Note: [NewCache] should be used for compliant registries as it can be shared
|
||||
// across context and will generally make less re-authentication requests.
|
||||
func NewSingleContextCache() Cache {
|
||||
cache := NewCache()
|
||||
return &fallbackCache{
|
||||
primary: cache,
|
||||
// We can re-use the came concurrentCache here because the key space is different
|
||||
// (keys are always empty for the hostCache) so there is no collision.
|
||||
// Even if there is a collision it is not an issue.
|
||||
// Re-using saves a little memory.
|
||||
secondary: &hostCache{cache},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
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 auth
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Scheme define the authentication method.
|
||||
type Scheme byte
|
||||
|
||||
const (
|
||||
// SchemeUnknown represents unknown or unsupported schemes
|
||||
SchemeUnknown Scheme = iota
|
||||
|
||||
// SchemeBasic represents the "Basic" HTTP authentication scheme.
|
||||
// Reference: https://tools.ietf.org/html/rfc7617
|
||||
SchemeBasic
|
||||
|
||||
// SchemeBearer represents the Bearer token in OAuth 2.0.
|
||||
// Reference: https://tools.ietf.org/html/rfc6750
|
||||
SchemeBearer
|
||||
)
|
||||
|
||||
// parseScheme parse the authentication scheme from the given string
|
||||
// case-insensitively.
|
||||
func parseScheme(scheme string) Scheme {
|
||||
switch {
|
||||
case strings.EqualFold(scheme, "basic"):
|
||||
return SchemeBasic
|
||||
case strings.EqualFold(scheme, "bearer"):
|
||||
return SchemeBearer
|
||||
}
|
||||
return SchemeUnknown
|
||||
}
|
||||
|
||||
// String return the string for the scheme.
|
||||
func (s Scheme) String() string {
|
||||
switch s {
|
||||
case SchemeBasic:
|
||||
return "Basic"
|
||||
case SchemeBearer:
|
||||
return "Bearer"
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
// parseChallenge parses the "WWW-Authenticate" header returned by the remote
|
||||
// registry, and extracts parameters if scheme is Bearer.
|
||||
// References:
|
||||
// - https://distribution.github.io/distribution/spec/auth/token/#how-to-authenticate
|
||||
// - https://tools.ietf.org/html/rfc7235#section-2.1
|
||||
func parseChallenge(header string) (scheme Scheme, params map[string]string) {
|
||||
// as defined in RFC 7235 section 2.1, we have
|
||||
// challenge = auth-scheme [ 1*SP ( token68 / #auth-param ) ]
|
||||
// auth-scheme = token
|
||||
// auth-param = token BWS "=" BWS ( token / quoted-string )
|
||||
//
|
||||
// since we focus parameters only on Bearer, we have
|
||||
// challenge = auth-scheme [ 1*SP #auth-param ]
|
||||
schemeString, rest := parseToken(header)
|
||||
scheme = parseScheme(schemeString)
|
||||
|
||||
// fast path for non bearer challenge
|
||||
if scheme != SchemeBearer {
|
||||
return
|
||||
}
|
||||
|
||||
// parse params for bearer auth.
|
||||
// combining RFC 7235 section 2.1 with RFC 7230 section 7, we have
|
||||
// #auth-param => auth-param *( OWS "," OWS auth-param )
|
||||
var key, value string
|
||||
for {
|
||||
key, rest = parseToken(skipSpace(rest))
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
|
||||
rest = skipSpace(rest)
|
||||
if rest == "" || rest[0] != '=' {
|
||||
return
|
||||
}
|
||||
rest = skipSpace(rest[1:])
|
||||
if rest == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if rest[0] == '"' {
|
||||
prefix, err := strconv.QuotedPrefix(rest)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
value, err = strconv.Unquote(prefix)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
rest = rest[len(prefix):]
|
||||
} else {
|
||||
value, rest = parseToken(rest)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
}
|
||||
if params == nil {
|
||||
params = map[string]string{
|
||||
key: value,
|
||||
}
|
||||
} else {
|
||||
params[key] = value
|
||||
}
|
||||
|
||||
rest = skipSpace(rest)
|
||||
if rest == "" || rest[0] != ',' {
|
||||
return
|
||||
}
|
||||
rest = rest[1:]
|
||||
}
|
||||
}
|
||||
|
||||
// isNotTokenChar reports whether rune is not a `tchar` defined in RFC 7230
|
||||
// section 3.2.6.
|
||||
func isNotTokenChar(r rune) bool {
|
||||
// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*"
|
||||
// / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~"
|
||||
// / DIGIT / ALPHA
|
||||
// ; any VCHAR, except delimiters
|
||||
return (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') &&
|
||||
(r < '0' || r > '9') && !strings.ContainsRune("!#$%&'*+-.^_`|~", r)
|
||||
}
|
||||
|
||||
// parseToken finds the next token from the given string. If no token found,
|
||||
// an empty token is returned and the whole of the input is returned in rest.
|
||||
// Note: Since token = 1*tchar, empty string is not a valid token.
|
||||
func parseToken(s string) (token, rest string) {
|
||||
if i := strings.IndexFunc(s, isNotTokenChar); i != -1 {
|
||||
return s[:i], s[i:]
|
||||
}
|
||||
return s, ""
|
||||
}
|
||||
|
||||
// skipSpace skips "bad" whitespace (BWS) defined in RFC 7230 section 3.2.3.
|
||||
func skipSpace(s string) string {
|
||||
// OWS = *( SP / HTAB )
|
||||
// ; optional whitespace
|
||||
// BWS = OWS
|
||||
// ; "bad" whitespace
|
||||
if i := strings.IndexFunc(s, func(r rune) bool {
|
||||
return r != ' ' && r != '\t'
|
||||
}); i != -1 {
|
||||
return s[i:]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
/*
|
||||
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 auth provides authentication for a client to a remote registry.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"oras.land/oras-go/v2/registry/remote/internal/errutil"
|
||||
"oras.land/oras-go/v2/registry/remote/retry"
|
||||
)
|
||||
|
||||
// ErrBasicCredentialNotFound is returned when the credential is not found for
|
||||
// basic auth.
|
||||
var ErrBasicCredentialNotFound = errors.New("basic credential not found")
|
||||
|
||||
// DefaultClient is the default auth-decorated client.
|
||||
var DefaultClient = &Client{
|
||||
Client: retry.DefaultClient,
|
||||
Header: http.Header{
|
||||
"User-Agent": {"oras-go"},
|
||||
},
|
||||
Cache: DefaultCache,
|
||||
}
|
||||
|
||||
// maxResponseBytes specifies the default limit on how many response bytes are
|
||||
// allowed in the server's response from authorization service servers.
|
||||
// A typical response message from authorization service servers is around 1 to
|
||||
// 4 KiB. Since the size of a token must be smaller than the HTTP header size
|
||||
// limit, which is usually 16 KiB. As specified by the distribution, the
|
||||
// response may contain 2 identical tokens, that is, 16 x 2 = 32 KiB.
|
||||
// Hence, 128 KiB should be sufficient.
|
||||
// References: https://distribution.github.io/distribution/spec/auth/token/
|
||||
var maxResponseBytes int64 = 128 * 1024 // 128 KiB
|
||||
|
||||
// defaultClientID specifies the default client ID used in OAuth2.
|
||||
// See also ClientID.
|
||||
var defaultClientID = "oras-go"
|
||||
|
||||
// CredentialFunc represents a function that resolves the credential for the
|
||||
// given registry (i.e. host:port).
|
||||
//
|
||||
// [EmptyCredential] is a valid return value and should not be considered as
|
||||
// an error.
|
||||
type CredentialFunc func(ctx context.Context, hostport string) (Credential, error)
|
||||
|
||||
// StaticCredential specifies static credentials for the given host.
|
||||
func StaticCredential(registry string, cred Credential) CredentialFunc {
|
||||
if registry == "docker.io" {
|
||||
// it is expected that traffic targeting "docker.io" will be redirected
|
||||
// to "registry-1.docker.io"
|
||||
// reference: https://github.com/moby/moby/blob/v24.0.0-beta.2/registry/config.go#L25-L48
|
||||
registry = "registry-1.docker.io"
|
||||
}
|
||||
return func(_ context.Context, hostport string) (Credential, error) {
|
||||
if hostport == registry {
|
||||
return cred, nil
|
||||
}
|
||||
return EmptyCredential, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Client is an auth-decorated HTTP client.
|
||||
// Its zero value is a usable client that uses http.DefaultClient with no cache.
|
||||
type Client struct {
|
||||
// Client is the underlying HTTP client used to access the remote
|
||||
// server.
|
||||
// If nil, http.DefaultClient is used.
|
||||
// It is possible to use the default retry client from the package
|
||||
// `oras.land/oras-go/v2/registry/remote/retry`. That client is already available
|
||||
// in the DefaultClient.
|
||||
// It is also possible to use a custom client. For example, github.com/hashicorp/go-retryablehttp
|
||||
// is a popular HTTP client that supports retries.
|
||||
Client *http.Client
|
||||
|
||||
// Header contains the custom headers to be added to each request.
|
||||
Header http.Header
|
||||
|
||||
// Credential specifies the function for resolving the credential for the
|
||||
// given registry (i.e. host:port).
|
||||
// EmptyCredential is a valid return value and should not be considered as
|
||||
// an error.
|
||||
// If nil, the credential is always resolved to EmptyCredential.
|
||||
Credential CredentialFunc
|
||||
|
||||
// Cache caches credentials for direct accessing the remote registry.
|
||||
// If nil, no cache is used.
|
||||
Cache Cache
|
||||
|
||||
// ClientID used in fetching OAuth2 token as a required field.
|
||||
// If empty, a default client ID is used.
|
||||
// Reference: https://distribution.github.io/distribution/spec/auth/oauth/#getting-a-token
|
||||
ClientID string
|
||||
|
||||
// ForceAttemptOAuth2 controls whether to follow OAuth2 with password grant
|
||||
// instead the distribution spec when authenticating using username and
|
||||
// password.
|
||||
// References:
|
||||
// - https://distribution.github.io/distribution/spec/auth/jwt/
|
||||
// - https://distribution.github.io/distribution/spec/auth/oauth/
|
||||
ForceAttemptOAuth2 bool
|
||||
}
|
||||
|
||||
// client returns an HTTP client used to access the remote registry.
|
||||
// http.DefaultClient is return if the client is not configured.
|
||||
func (c *Client) client() *http.Client {
|
||||
if c.Client == nil {
|
||||
return http.DefaultClient
|
||||
}
|
||||
return c.Client
|
||||
}
|
||||
|
||||
// send adds headers to the request and sends the request to the remote server.
|
||||
func (c *Client) send(req *http.Request) (*http.Response, error) {
|
||||
for key, values := range c.Header {
|
||||
req.Header[key] = append(req.Header[key], values...)
|
||||
}
|
||||
return c.client().Do(req)
|
||||
}
|
||||
|
||||
// credential resolves the credential for the given registry.
|
||||
func (c *Client) credential(ctx context.Context, reg string) (Credential, error) {
|
||||
if c.Credential == nil {
|
||||
return EmptyCredential, nil
|
||||
}
|
||||
return c.Credential(ctx, reg)
|
||||
}
|
||||
|
||||
// cache resolves the cache.
|
||||
// noCache is return if the cache is not configured.
|
||||
func (c *Client) cache() Cache {
|
||||
if c.Cache == nil {
|
||||
return noCache{}
|
||||
}
|
||||
return c.Cache
|
||||
}
|
||||
|
||||
// SetUserAgent sets the user agent for all out-going requests.
|
||||
func (c *Client) SetUserAgent(userAgent string) {
|
||||
if c.Header == nil {
|
||||
c.Header = http.Header{}
|
||||
}
|
||||
c.Header.Set("User-Agent", userAgent)
|
||||
}
|
||||
|
||||
// Do sends the request to the remote server, attempting to resolve
|
||||
// authentication if 'Authorization' header is not set.
|
||||
//
|
||||
// On authentication failure due to bad credential,
|
||||
// - Do returns error if it fails to fetch token for bearer auth.
|
||||
// - Do returns the registry response without error for basic auth.
|
||||
func (c *Client) Do(originalReq *http.Request) (*http.Response, error) {
|
||||
if auth := originalReq.Header.Get("Authorization"); auth != "" {
|
||||
return c.send(originalReq)
|
||||
}
|
||||
|
||||
ctx := originalReq.Context()
|
||||
req := originalReq.Clone(ctx)
|
||||
|
||||
// attempt cached auth token
|
||||
var attemptedKey string
|
||||
cache := c.cache()
|
||||
host := originalReq.Host
|
||||
scheme, err := cache.GetScheme(ctx, host)
|
||||
if err == nil {
|
||||
switch scheme {
|
||||
case SchemeBasic:
|
||||
token, err := cache.GetToken(ctx, host, SchemeBasic, "")
|
||||
if err == nil {
|
||||
req.Header.Set("Authorization", "Basic "+token)
|
||||
}
|
||||
case SchemeBearer:
|
||||
scopes := GetAllScopesForHost(ctx, host)
|
||||
attemptedKey = strings.Join(scopes, " ")
|
||||
token, err := cache.GetToken(ctx, host, SchemeBearer, attemptedKey)
|
||||
if err == nil {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := c.send(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// attempt again with credentials for recognized schemes
|
||||
challenge := resp.Header.Get("Www-Authenticate")
|
||||
scheme, params := parseChallenge(challenge)
|
||||
switch scheme {
|
||||
case SchemeBasic:
|
||||
resp.Body.Close()
|
||||
|
||||
token, err := cache.Set(ctx, host, SchemeBasic, "", func(ctx context.Context) (string, error) {
|
||||
return c.fetchBasicAuth(ctx, host)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s %q: %w", resp.Request.Method, resp.Request.URL, err)
|
||||
}
|
||||
|
||||
req = originalReq.Clone(ctx)
|
||||
req.Header.Set("Authorization", "Basic "+token)
|
||||
case SchemeBearer:
|
||||
resp.Body.Close()
|
||||
|
||||
scopes := GetAllScopesForHost(ctx, host)
|
||||
if paramScope := params["scope"]; paramScope != "" {
|
||||
// merge hinted scopes with challenged scopes
|
||||
scopes = append(scopes, strings.Split(paramScope, " ")...)
|
||||
scopes = CleanScopes(scopes)
|
||||
}
|
||||
key := strings.Join(scopes, " ")
|
||||
|
||||
// attempt the cache again if there is a scope change
|
||||
if key != attemptedKey {
|
||||
if token, err := cache.GetToken(ctx, host, SchemeBearer, key); err == nil {
|
||||
req = originalReq.Clone(ctx)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if err := rewindRequestBody(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.send(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
return resp, nil
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// attempt with credentials
|
||||
realm := params["realm"]
|
||||
service := params["service"]
|
||||
token, err := cache.Set(ctx, host, SchemeBearer, key, func(ctx context.Context) (string, error) {
|
||||
return c.fetchBearerToken(ctx, host, realm, service, scopes)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s %q: %w", resp.Request.Method, resp.Request.URL, err)
|
||||
}
|
||||
|
||||
req = originalReq.Clone(ctx)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
default:
|
||||
return resp, nil
|
||||
}
|
||||
if err := rewindRequestBody(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c.send(req)
|
||||
}
|
||||
|
||||
// fetchBasicAuth fetches a basic auth token for the basic challenge.
|
||||
func (c *Client) fetchBasicAuth(ctx context.Context, registry string) (string, error) {
|
||||
cred, err := c.credential(ctx, registry)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve credential: %w", err)
|
||||
}
|
||||
if cred == EmptyCredential {
|
||||
return "", ErrBasicCredentialNotFound
|
||||
}
|
||||
if cred.Username == "" || cred.Password == "" {
|
||||
return "", errors.New("missing username or password for basic auth")
|
||||
}
|
||||
auth := cred.Username + ":" + cred.Password
|
||||
return base64.StdEncoding.EncodeToString([]byte(auth)), nil
|
||||
}
|
||||
|
||||
// fetchBearerToken fetches an access token for the bearer challenge.
|
||||
func (c *Client) fetchBearerToken(ctx context.Context, registry, realm, service string, scopes []string) (string, error) {
|
||||
cred, err := c.credential(ctx, registry)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if cred.AccessToken != "" {
|
||||
return cred.AccessToken, nil
|
||||
}
|
||||
if cred == EmptyCredential || (cred.RefreshToken == "" && !c.ForceAttemptOAuth2) {
|
||||
return c.fetchDistributionToken(ctx, realm, service, scopes, cred.Username, cred.Password)
|
||||
}
|
||||
return c.fetchOAuth2Token(ctx, realm, service, scopes, cred)
|
||||
}
|
||||
|
||||
// fetchDistributionToken fetches an access token as defined by the distribution
|
||||
// specification.
|
||||
// It fetches anonymous tokens if no credential is provided.
|
||||
// References:
|
||||
// - https://distribution.github.io/distribution/spec/auth/jwt/
|
||||
// - https://distribution.github.io/distribution/spec/auth/token/
|
||||
func (c *Client) fetchDistributionToken(ctx context.Context, realm, service string, scopes []string, username, password string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, realm, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if username != "" || password != "" {
|
||||
req.SetBasicAuth(username, password)
|
||||
}
|
||||
q := req.URL.Query()
|
||||
if service != "" {
|
||||
q.Set("service", service)
|
||||
}
|
||||
for _, scope := range scopes {
|
||||
q.Add("scope", scope)
|
||||
}
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
resp, err := c.send(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", errutil.ParseErrorResponse(resp)
|
||||
}
|
||||
|
||||
// As specified in https://distribution.github.io/distribution/spec/auth/token/ section
|
||||
// "Token Response Fields", the token is either in `token` or
|
||||
// `access_token`. If both present, they are identical.
|
||||
var result struct {
|
||||
Token string `json:"token"`
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
lr := io.LimitReader(resp.Body, maxResponseBytes)
|
||||
if err := json.NewDecoder(lr).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("%s %q: failed to decode response: %w", resp.Request.Method, resp.Request.URL, err)
|
||||
}
|
||||
if result.AccessToken != "" {
|
||||
return result.AccessToken, nil
|
||||
}
|
||||
if result.Token != "" {
|
||||
return result.Token, nil
|
||||
}
|
||||
return "", fmt.Errorf("%s %q: empty token returned", resp.Request.Method, resp.Request.URL)
|
||||
}
|
||||
|
||||
// fetchOAuth2Token fetches an OAuth2 access token.
|
||||
// Reference: https://distribution.github.io/distribution/spec/auth/oauth/
|
||||
func (c *Client) fetchOAuth2Token(ctx context.Context, realm, service string, scopes []string, cred Credential) (string, error) {
|
||||
form := url.Values{}
|
||||
if cred.RefreshToken != "" {
|
||||
form.Set("grant_type", "refresh_token")
|
||||
form.Set("refresh_token", cred.RefreshToken)
|
||||
} else if cred.Username != "" && cred.Password != "" {
|
||||
form.Set("grant_type", "password")
|
||||
form.Set("username", cred.Username)
|
||||
form.Set("password", cred.Password)
|
||||
} else {
|
||||
return "", errors.New("missing username or password for bearer auth")
|
||||
}
|
||||
form.Set("service", service)
|
||||
clientID := c.ClientID
|
||||
if clientID == "" {
|
||||
clientID = defaultClientID
|
||||
}
|
||||
form.Set("client_id", clientID)
|
||||
if len(scopes) != 0 {
|
||||
form.Set("scope", strings.Join(scopes, " "))
|
||||
}
|
||||
body := strings.NewReader(form.Encode())
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, realm, body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := c.send(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", errutil.ParseErrorResponse(resp)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
lr := io.LimitReader(resp.Body, maxResponseBytes)
|
||||
if err := json.NewDecoder(lr).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("%s %q: failed to decode response: %w", resp.Request.Method, resp.Request.URL, err)
|
||||
}
|
||||
if result.AccessToken != "" {
|
||||
return result.AccessToken, nil
|
||||
}
|
||||
return "", fmt.Errorf("%s %q: empty token returned", resp.Request.Method, resp.Request.URL)
|
||||
}
|
||||
|
||||
// rewindRequestBody tries to rewind the request body if exists.
|
||||
func rewindRequestBody(req *http.Request) error {
|
||||
if req.Body == nil || req.Body == http.NoBody {
|
||||
return nil
|
||||
}
|
||||
if req.GetBody == nil {
|
||||
return fmt.Errorf("%s %q: request body is not rewindable", req.Method, req.URL)
|
||||
}
|
||||
body, err := req.GetBody()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s %q: failed to get request body: %w", req.Method, req.URL, err)
|
||||
}
|
||||
req.Body = body
|
||||
return nil
|
||||
}
|
||||
@@ -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 auth
|
||||
|
||||
// EmptyCredential represents an empty credential.
|
||||
var EmptyCredential Credential
|
||||
|
||||
// Credential contains authentication credentials used to access remote
|
||||
// registries.
|
||||
type Credential struct {
|
||||
// Username is the name of the user for the remote registry.
|
||||
Username string
|
||||
|
||||
// Password is the secret associated with the username.
|
||||
Password string
|
||||
|
||||
// RefreshToken is a bearer token to be sent to the authorization service
|
||||
// for fetching access tokens.
|
||||
// A refresh token is often referred as an identity token.
|
||||
// Reference: https://distribution.github.io/distribution/spec/auth/oauth/
|
||||
RefreshToken string
|
||||
|
||||
// AccessToken is a bearer token to be sent to the registry.
|
||||
// An access token is often referred as a registry token.
|
||||
// Reference: https://distribution.github.io/distribution/spec/auth/token/
|
||||
AccessToken string
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
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 auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"oras.land/oras-go/v2/registry"
|
||||
)
|
||||
|
||||
// Actions used in scopes.
|
||||
// Reference: https://distribution.github.io/distribution/spec/auth/scope/
|
||||
const (
|
||||
// ActionPull represents generic read access for resources of the repository
|
||||
// type.
|
||||
ActionPull = "pull"
|
||||
|
||||
// ActionPush represents generic write access for resources of the
|
||||
// repository type.
|
||||
ActionPush = "push"
|
||||
|
||||
// ActionDelete represents the delete permission for resources of the
|
||||
// repository type.
|
||||
ActionDelete = "delete"
|
||||
)
|
||||
|
||||
// ScopeRegistryCatalog is the scope for registry catalog access.
|
||||
const ScopeRegistryCatalog = "registry:catalog:*"
|
||||
|
||||
// ScopeRepository returns a repository scope with given actions.
|
||||
// Reference: https://distribution.github.io/distribution/spec/auth/scope/
|
||||
func ScopeRepository(repository string, actions ...string) string {
|
||||
actions = cleanActions(actions)
|
||||
if repository == "" || len(actions) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join([]string{
|
||||
"repository",
|
||||
repository,
|
||||
strings.Join(actions, ","),
|
||||
}, ":")
|
||||
}
|
||||
|
||||
// AppendRepositoryScope returns a new context containing scope hints for the
|
||||
// auth client to fetch bearer tokens with the given actions on the repository.
|
||||
// If called multiple times, the new scopes will be appended to the existing
|
||||
// scopes. The resulted scopes are de-duplicated.
|
||||
//
|
||||
// For example, uploading blob to the repository "hello-world" does HEAD request
|
||||
// first then POST and PUT. The HEAD request will return a challenge for scope
|
||||
// `repository:hello-world:pull`, and the auth client will fetch a token for
|
||||
// that challenge. Later, the POST request will return a challenge for scope
|
||||
// `repository:hello-world:push`, and the auth client will fetch a token for
|
||||
// that challenge again. By invoking AppendRepositoryScope with the actions
|
||||
// [ActionPull] and [ActionPush] for the repository `hello-world`,
|
||||
// the auth client with cache is hinted to fetch a token via a single token
|
||||
// fetch request for all the HEAD, POST, PUT requests.
|
||||
func AppendRepositoryScope(ctx context.Context, ref registry.Reference, actions ...string) context.Context {
|
||||
if len(actions) == 0 {
|
||||
return ctx
|
||||
}
|
||||
scope := ScopeRepository(ref.Repository, actions...)
|
||||
return AppendScopesForHost(ctx, ref.Host(), scope)
|
||||
}
|
||||
|
||||
// scopesContextKey is the context key for scopes.
|
||||
type scopesContextKey struct{}
|
||||
|
||||
// WithScopes returns a context with scopes added. Scopes are de-duplicated.
|
||||
// Scopes are used as hints for the auth client to fetch bearer tokens with
|
||||
// larger scopes.
|
||||
//
|
||||
// For example, uploading blob to the repository "hello-world" does HEAD request
|
||||
// first then POST and PUT. The HEAD request will return a challenge for scope
|
||||
// `repository:hello-world:pull`, and the auth client will fetch a token for
|
||||
// that challenge. Later, the POST request will return a challenge for scope
|
||||
// `repository:hello-world:push`, and the auth client will fetch a token for
|
||||
// that challenge again. By invoking WithScopes with the scope
|
||||
// `repository:hello-world:pull,push`, the auth client with cache is hinted to
|
||||
// fetch a token via a single token fetch request for all the HEAD, POST, PUT
|
||||
// requests.
|
||||
//
|
||||
// Passing an empty list of scopes will virtually remove the scope hints in the
|
||||
// context.
|
||||
//
|
||||
// Reference: https://distribution.github.io/distribution/spec/auth/scope/
|
||||
func WithScopes(ctx context.Context, scopes ...string) context.Context {
|
||||
scopes = CleanScopes(scopes)
|
||||
return context.WithValue(ctx, scopesContextKey{}, scopes)
|
||||
}
|
||||
|
||||
// AppendScopes appends additional scopes to the existing scopes in the context
|
||||
// and returns a new context. The resulted scopes are de-duplicated.
|
||||
// The append operation does modify the existing scope in the context passed in.
|
||||
func AppendScopes(ctx context.Context, scopes ...string) context.Context {
|
||||
if len(scopes) == 0 {
|
||||
return ctx
|
||||
}
|
||||
return WithScopes(ctx, append(GetScopes(ctx), scopes...)...)
|
||||
}
|
||||
|
||||
// GetScopes returns the scopes in the context.
|
||||
func GetScopes(ctx context.Context) []string {
|
||||
if scopes, ok := ctx.Value(scopesContextKey{}).([]string); ok {
|
||||
return slices.Clone(scopes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scopesForHostContextKey is the context key for per-host scopes.
|
||||
type scopesForHostContextKey string
|
||||
|
||||
// WithScopesForHost returns a context with per-host scopes added.
|
||||
// Scopes are de-duplicated.
|
||||
// Scopes are used as hints for the auth client to fetch bearer tokens with
|
||||
// larger scopes.
|
||||
//
|
||||
// For example, uploading blob to the repository "hello-world" does HEAD request
|
||||
// first then POST and PUT. The HEAD request will return a challenge for scope
|
||||
// `repository:hello-world:pull`, and the auth client will fetch a token for
|
||||
// that challenge. Later, the POST request will return a challenge for scope
|
||||
// `repository:hello-world:push`, and the auth client will fetch a token for
|
||||
// that challenge again. By invoking WithScopesForHost with the scope
|
||||
// `repository:hello-world:pull,push`, the auth client with cache is hinted to
|
||||
// fetch a token via a single token fetch request for all the HEAD, POST, PUT
|
||||
// requests.
|
||||
//
|
||||
// Passing an empty list of scopes will virtually remove the scope hints in the
|
||||
// context for the given host.
|
||||
//
|
||||
// Reference: https://distribution.github.io/distribution/spec/auth/scope/
|
||||
func WithScopesForHost(ctx context.Context, host string, scopes ...string) context.Context {
|
||||
scopes = CleanScopes(scopes)
|
||||
return context.WithValue(ctx, scopesForHostContextKey(host), scopes)
|
||||
}
|
||||
|
||||
// AppendScopesForHost appends additional scopes to the existing scopes
|
||||
// in the context for the given host and returns a new context.
|
||||
// The resulted scopes are de-duplicated.
|
||||
// The append operation does modify the existing scope in the context passed in.
|
||||
func AppendScopesForHost(ctx context.Context, host string, scopes ...string) context.Context {
|
||||
if len(scopes) == 0 {
|
||||
return ctx
|
||||
}
|
||||
oldScopes := GetScopesForHost(ctx, host)
|
||||
return WithScopesForHost(ctx, host, append(oldScopes, scopes...)...)
|
||||
}
|
||||
|
||||
// GetScopesForHost returns the scopes in the context for the given host,
|
||||
// excluding global scopes added by [WithScopes] and [AppendScopes].
|
||||
func GetScopesForHost(ctx context.Context, host string) []string {
|
||||
if scopes, ok := ctx.Value(scopesForHostContextKey(host)).([]string); ok {
|
||||
return slices.Clone(scopes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllScopesForHost returns the scopes in the context for the given host,
|
||||
// including global scopes added by [WithScopes] and [AppendScopes].
|
||||
func GetAllScopesForHost(ctx context.Context, host string) []string {
|
||||
scopes := GetScopesForHost(ctx, host)
|
||||
globalScopes := GetScopes(ctx)
|
||||
|
||||
if len(scopes) == 0 {
|
||||
return globalScopes
|
||||
}
|
||||
if len(globalScopes) == 0 {
|
||||
return scopes
|
||||
}
|
||||
// re-clean the scopes
|
||||
allScopes := append(scopes, globalScopes...)
|
||||
return CleanScopes(allScopes)
|
||||
}
|
||||
|
||||
// CleanScopes merges and sort the actions in ascending order if the scopes have
|
||||
// the same resource type and name. The final scopes are sorted in ascending
|
||||
// order. In other words, the scopes passed in are de-duplicated and sorted.
|
||||
// Therefore, the output of this function is deterministic.
|
||||
//
|
||||
// If there is a wildcard `*` in the action, other actions in the same resource
|
||||
// type and name are ignored.
|
||||
func CleanScopes(scopes []string) []string {
|
||||
// fast paths
|
||||
switch len(scopes) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
scope := scopes[0]
|
||||
i := strings.LastIndex(scope, ":")
|
||||
if i == -1 {
|
||||
return []string{scope}
|
||||
}
|
||||
actionList := strings.Split(scope[i+1:], ",")
|
||||
actionList = cleanActions(actionList)
|
||||
if len(actionList) == 0 {
|
||||
return nil
|
||||
}
|
||||
actions := strings.Join(actionList, ",")
|
||||
scope = scope[:i+1] + actions
|
||||
return []string{scope}
|
||||
}
|
||||
|
||||
// slow path
|
||||
var result []string
|
||||
|
||||
// merge recognizable scopes
|
||||
resourceTypes := make(map[string]map[string]map[string]struct{})
|
||||
for _, scope := range scopes {
|
||||
// extract resource type
|
||||
i := strings.Index(scope, ":")
|
||||
if i == -1 {
|
||||
result = append(result, scope)
|
||||
continue
|
||||
}
|
||||
resourceType := scope[:i]
|
||||
|
||||
// extract resource name and actions
|
||||
rest := scope[i+1:]
|
||||
i = strings.LastIndex(rest, ":")
|
||||
if i == -1 {
|
||||
result = append(result, scope)
|
||||
continue
|
||||
}
|
||||
resourceName := rest[:i]
|
||||
actions := rest[i+1:]
|
||||
if actions == "" {
|
||||
// drop scope since no action found
|
||||
continue
|
||||
}
|
||||
|
||||
// add to the intermediate map for de-duplication
|
||||
namedActions := resourceTypes[resourceType]
|
||||
if namedActions == nil {
|
||||
namedActions = make(map[string]map[string]struct{})
|
||||
resourceTypes[resourceType] = namedActions
|
||||
}
|
||||
actionSet := namedActions[resourceName]
|
||||
if actionSet == nil {
|
||||
actionSet = make(map[string]struct{})
|
||||
namedActions[resourceName] = actionSet
|
||||
}
|
||||
for _, action := range strings.Split(actions, ",") {
|
||||
if action != "" {
|
||||
actionSet[action] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reconstruct scopes
|
||||
for resourceType, namedActions := range resourceTypes {
|
||||
for resourceName, actionSet := range namedActions {
|
||||
if len(actionSet) == 0 {
|
||||
continue
|
||||
}
|
||||
var actions []string
|
||||
for action := range actionSet {
|
||||
if action == "*" {
|
||||
actions = []string{"*"}
|
||||
break
|
||||
}
|
||||
actions = append(actions, action)
|
||||
}
|
||||
slices.Sort(actions)
|
||||
scope := resourceType + ":" + resourceName + ":" + strings.Join(actions, ",")
|
||||
result = append(result, scope)
|
||||
}
|
||||
}
|
||||
|
||||
// sort and return
|
||||
slices.Sort(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// cleanActions removes the duplicated actions and sort in ascending order.
|
||||
// If there is a wildcard `*` in the action, other actions are ignored.
|
||||
func cleanActions(actions []string) []string {
|
||||
// fast paths
|
||||
switch len(actions) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
if actions[0] == "" {
|
||||
return nil
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
// slow path
|
||||
slices.Sort(actions)
|
||||
n := 0
|
||||
for i := range len(actions) {
|
||||
if actions[i] == "*" {
|
||||
return []string{"*"}
|
||||
}
|
||||
if actions[i] != actions[n] {
|
||||
n++
|
||||
if n != i {
|
||||
actions[n] = actions[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
n++
|
||||
if actions[0] == "" {
|
||||
if n == 1 {
|
||||
return nil
|
||||
}
|
||||
return actions[1:n]
|
||||
}
|
||||
return actions[:n]
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
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 credentials
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"oras.land/oras-go/v2/registry/remote/auth"
|
||||
"oras.land/oras-go/v2/registry/remote/credentials/internal/config"
|
||||
)
|
||||
|
||||
// FileStore implements a credentials store using the docker configuration file
|
||||
// to keep the credentials in plain-text.
|
||||
//
|
||||
// Reference: https://docs.docker.com/engine/reference/commandline/cli/#docker-cli-configuration-file-configjson-properties
|
||||
type FileStore struct {
|
||||
// DisablePut disables putting credentials in plaintext.
|
||||
// If DisablePut is set to true, Put() will return ErrPlaintextPutDisabled.
|
||||
DisablePut bool
|
||||
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrPlaintextPutDisabled is returned by Put() when DisablePut is set
|
||||
// to true.
|
||||
ErrPlaintextPutDisabled = errors.New("putting plaintext credentials is disabled")
|
||||
// ErrBadCredentialFormat is returned by Put() when the credential format
|
||||
// is bad.
|
||||
ErrBadCredentialFormat = errors.New("bad credential format")
|
||||
)
|
||||
|
||||
// NewFileStore creates a new file credentials store.
|
||||
//
|
||||
// Reference: https://docs.docker.com/engine/reference/commandline/cli/#docker-cli-configuration-file-configjson-properties
|
||||
func NewFileStore(configPath string) (*FileStore, error) {
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newFileStore(cfg), nil
|
||||
}
|
||||
|
||||
// newFileStore creates a file credentials store based on the given config instance.
|
||||
func newFileStore(cfg *config.Config) *FileStore {
|
||||
return &FileStore{config: cfg}
|
||||
}
|
||||
|
||||
// Get retrieves credentials from the store for the given server address.
|
||||
func (fs *FileStore) Get(_ context.Context, serverAddress string) (auth.Credential, error) {
|
||||
return fs.config.GetCredential(serverAddress)
|
||||
}
|
||||
|
||||
// Put saves credentials into the store for the given server address.
|
||||
// Returns ErrPlaintextPutDisabled if fs.DisablePut is set to true.
|
||||
func (fs *FileStore) Put(_ context.Context, serverAddress string, cred auth.Credential) error {
|
||||
if fs.DisablePut {
|
||||
return ErrPlaintextPutDisabled
|
||||
}
|
||||
if err := validateCredentialFormat(cred); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return fs.config.PutCredential(serverAddress, cred)
|
||||
}
|
||||
|
||||
// Delete removes credentials from the store for the given server address.
|
||||
func (fs *FileStore) Delete(_ context.Context, serverAddress string) error {
|
||||
return fs.config.DeleteCredential(serverAddress)
|
||||
}
|
||||
|
||||
// validateCredentialFormat validates the format of cred.
|
||||
func validateCredentialFormat(cred auth.Credential) error {
|
||||
if strings.ContainsRune(cred.Username, ':') {
|
||||
// Username and password will be encoded in the base64(username:password)
|
||||
// format in the file. The decoded result will be wrong if username
|
||||
// contains colon(s).
|
||||
return fmt.Errorf("%w: colons(:) are not allowed in username", ErrBadCredentialFormat)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
/*
|
||||
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 config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"oras.land/oras-go/v2/registry/remote/auth"
|
||||
"oras.land/oras-go/v2/registry/remote/credentials/internal/ioutil"
|
||||
)
|
||||
|
||||
const (
|
||||
// configFieldAuths is the "auths" field in the config file.
|
||||
// Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L19
|
||||
configFieldAuths = "auths"
|
||||
// configFieldCredentialsStore is the "credsStore" field in the config file.
|
||||
configFieldCredentialsStore = "credsStore"
|
||||
// configFieldCredentialHelpers is the "credHelpers" field in the config file.
|
||||
configFieldCredentialHelpers = "credHelpers"
|
||||
)
|
||||
|
||||
// ErrInvalidConfigFormat is returned when the config format is invalid.
|
||||
var ErrInvalidConfigFormat = errors.New("invalid config format")
|
||||
|
||||
// AuthConfig contains authorization information for connecting to a Registry.
|
||||
// References:
|
||||
// - https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L17-L45
|
||||
// - https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/types/authconfig.go#L3-L22
|
||||
type AuthConfig struct {
|
||||
// Auth is a base64-encoded string of "{username}:{password}".
|
||||
Auth string `json:"auth,omitempty"`
|
||||
// IdentityToken is used to authenticate the user and get an access token
|
||||
// for the registry.
|
||||
IdentityToken string `json:"identitytoken,omitempty"`
|
||||
// RegistryToken is a bearer token to be sent to a registry.
|
||||
RegistryToken string `json:"registrytoken,omitempty"`
|
||||
|
||||
Username string `json:"username,omitempty"` // legacy field for compatibility
|
||||
Password string `json:"password,omitempty"` // legacy field for compatibility
|
||||
}
|
||||
|
||||
// NewAuthConfig creates an authConfig based on cred.
|
||||
func NewAuthConfig(cred auth.Credential) AuthConfig {
|
||||
return AuthConfig{
|
||||
Auth: encodeAuth(cred.Username, cred.Password),
|
||||
IdentityToken: cred.RefreshToken,
|
||||
RegistryToken: cred.AccessToken,
|
||||
}
|
||||
}
|
||||
|
||||
// Credential returns an auth.Credential based on ac.
|
||||
func (ac AuthConfig) Credential() (auth.Credential, error) {
|
||||
cred := auth.Credential{
|
||||
Username: ac.Username,
|
||||
Password: ac.Password,
|
||||
RefreshToken: ac.IdentityToken,
|
||||
AccessToken: ac.RegistryToken,
|
||||
}
|
||||
if ac.Auth != "" {
|
||||
var err error
|
||||
// override username and password
|
||||
cred.Username, cred.Password, err = decodeAuth(ac.Auth)
|
||||
if err != nil {
|
||||
return auth.EmptyCredential, fmt.Errorf("failed to decode auth field: %w: %v", ErrInvalidConfigFormat, err)
|
||||
}
|
||||
}
|
||||
return cred, nil
|
||||
}
|
||||
|
||||
// Config represents a docker configuration file.
|
||||
// References:
|
||||
// - https://docs.docker.com/engine/reference/commandline/cli/#docker-cli-configuration-file-configjson-properties
|
||||
// - https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L17-L44
|
||||
type Config struct {
|
||||
// path is the path to the config file.
|
||||
path string
|
||||
// rwLock is a read-write-lock for the file store.
|
||||
rwLock sync.RWMutex
|
||||
// content is the content of the config file.
|
||||
// Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L17-L44
|
||||
content map[string]json.RawMessage
|
||||
// authsCache is a cache of the auths field of the config.
|
||||
// Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L19
|
||||
authsCache map[string]json.RawMessage
|
||||
// credentialsStore is the credsStore field of the config.
|
||||
// Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L28
|
||||
credentialsStore string
|
||||
// credentialHelpers is the credHelpers field of the config.
|
||||
// Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L29
|
||||
credentialHelpers map[string]string
|
||||
}
|
||||
|
||||
// Load loads Config from the given config path.
|
||||
func Load(configPath string) (*Config, error) {
|
||||
cfg := &Config{path: configPath}
|
||||
configFile, err := os.Open(configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// init content and caches if the content file does not exist
|
||||
cfg.content = make(map[string]json.RawMessage)
|
||||
cfg.authsCache = make(map[string]json.RawMessage)
|
||||
return cfg, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to open config file at %s: %w", configPath, err)
|
||||
}
|
||||
defer configFile.Close()
|
||||
|
||||
// decode config content if the config file exists
|
||||
if err := json.NewDecoder(configFile).Decode(&cfg.content); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode config file at %s: %w: %v", configPath, ErrInvalidConfigFormat, err)
|
||||
}
|
||||
|
||||
if credsStoreBytes, ok := cfg.content[configFieldCredentialsStore]; ok {
|
||||
if err := json.Unmarshal(credsStoreBytes, &cfg.credentialsStore); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal creds store field: %w: %v", ErrInvalidConfigFormat, err)
|
||||
}
|
||||
}
|
||||
|
||||
if credHelpersBytes, ok := cfg.content[configFieldCredentialHelpers]; ok {
|
||||
if err := json.Unmarshal(credHelpersBytes, &cfg.credentialHelpers); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal cred helpers field: %w: %v", ErrInvalidConfigFormat, err)
|
||||
}
|
||||
}
|
||||
|
||||
if authsBytes, ok := cfg.content[configFieldAuths]; ok {
|
||||
if err := json.Unmarshal(authsBytes, &cfg.authsCache); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal auths field: %w: %v", ErrInvalidConfigFormat, err)
|
||||
}
|
||||
}
|
||||
if cfg.authsCache == nil {
|
||||
cfg.authsCache = make(map[string]json.RawMessage)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// GetAuthConfig returns an auth.Credential for serverAddress.
|
||||
func (cfg *Config) GetCredential(serverAddress string) (auth.Credential, error) {
|
||||
cfg.rwLock.RLock()
|
||||
defer cfg.rwLock.RUnlock()
|
||||
|
||||
authCfgBytes, ok := cfg.authsCache[serverAddress]
|
||||
if !ok {
|
||||
// NOTE: the auth key for the server address may have been stored with
|
||||
// a http/https prefix in legacy config files, e.g. "registry.example.com"
|
||||
// can be stored as "https://registry.example.com/".
|
||||
var matched bool
|
||||
for addr, auth := range cfg.authsCache {
|
||||
if ToHostname(addr) == serverAddress {
|
||||
matched = true
|
||||
authCfgBytes = auth
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return auth.EmptyCredential, nil
|
||||
}
|
||||
}
|
||||
var authCfg AuthConfig
|
||||
if err := json.Unmarshal(authCfgBytes, &authCfg); err != nil {
|
||||
return auth.EmptyCredential, fmt.Errorf("failed to unmarshal auth field: %w: %v", ErrInvalidConfigFormat, err)
|
||||
}
|
||||
return authCfg.Credential()
|
||||
}
|
||||
|
||||
// PutAuthConfig puts cred for serverAddress.
|
||||
func (cfg *Config) PutCredential(serverAddress string, cred auth.Credential) error {
|
||||
cfg.rwLock.Lock()
|
||||
defer cfg.rwLock.Unlock()
|
||||
|
||||
authCfg := NewAuthConfig(cred)
|
||||
authCfgBytes, err := json.Marshal(authCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal auth field: %w", err)
|
||||
}
|
||||
cfg.authsCache[serverAddress] = authCfgBytes
|
||||
return cfg.saveFile()
|
||||
}
|
||||
|
||||
// DeleteAuthConfig deletes the corresponding credential for serverAddress.
|
||||
func (cfg *Config) DeleteCredential(serverAddress string) error {
|
||||
cfg.rwLock.Lock()
|
||||
defer cfg.rwLock.Unlock()
|
||||
|
||||
if _, ok := cfg.authsCache[serverAddress]; !ok {
|
||||
// no ops
|
||||
return nil
|
||||
}
|
||||
delete(cfg.authsCache, serverAddress)
|
||||
return cfg.saveFile()
|
||||
}
|
||||
|
||||
// GetCredentialHelper returns the credential helpers for serverAddress.
|
||||
func (cfg *Config) GetCredentialHelper(serverAddress string) string {
|
||||
return cfg.credentialHelpers[serverAddress]
|
||||
}
|
||||
|
||||
// CredentialsStore returns the configured credentials store.
|
||||
func (cfg *Config) CredentialsStore() string {
|
||||
cfg.rwLock.RLock()
|
||||
defer cfg.rwLock.RUnlock()
|
||||
|
||||
return cfg.credentialsStore
|
||||
}
|
||||
|
||||
// Path returns the path to the config file.
|
||||
func (cfg *Config) Path() string {
|
||||
return cfg.path
|
||||
}
|
||||
|
||||
// SetCredentialsStore puts the configured credentials store.
|
||||
func (cfg *Config) SetCredentialsStore(credsStore string) error {
|
||||
cfg.rwLock.Lock()
|
||||
defer cfg.rwLock.Unlock()
|
||||
|
||||
cfg.credentialsStore = credsStore
|
||||
return cfg.saveFile()
|
||||
}
|
||||
|
||||
// IsAuthConfigured returns whether there is authentication configured in this
|
||||
// config file or not.
|
||||
func (cfg *Config) IsAuthConfigured() bool {
|
||||
return cfg.credentialsStore != "" ||
|
||||
len(cfg.credentialHelpers) > 0 ||
|
||||
len(cfg.authsCache) > 0
|
||||
}
|
||||
|
||||
// saveFile saves Config into the file.
|
||||
func (cfg *Config) saveFile() (returnErr error) {
|
||||
// marshal content
|
||||
// credentialHelpers is skipped as it's never set
|
||||
if cfg.credentialsStore != "" {
|
||||
credsStoreBytes, err := json.Marshal(cfg.credentialsStore)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal creds store: %w", err)
|
||||
}
|
||||
cfg.content[configFieldCredentialsStore] = credsStoreBytes
|
||||
} else {
|
||||
// omit empty
|
||||
delete(cfg.content, configFieldCredentialsStore)
|
||||
}
|
||||
authsBytes, err := json.Marshal(cfg.authsCache)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal credentials: %w", err)
|
||||
}
|
||||
cfg.content[configFieldAuths] = authsBytes
|
||||
jsonBytes, err := json.MarshalIndent(cfg.content, "", "\t")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal config: %w", err)
|
||||
}
|
||||
|
||||
// write the content to a ingest file for atomicity
|
||||
configDir := filepath.Dir(cfg.path)
|
||||
if err := os.MkdirAll(configDir, 0700); err != nil {
|
||||
return fmt.Errorf("failed to make directory %s: %w", configDir, err)
|
||||
}
|
||||
ingest, err := ioutil.Ingest(configDir, bytes.NewReader(jsonBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save config file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if returnErr != nil {
|
||||
// clean up the ingest file in case of error
|
||||
os.Remove(ingest)
|
||||
}
|
||||
}()
|
||||
|
||||
// overwrite the config file
|
||||
if err := os.Rename(ingest, cfg.path); err != nil {
|
||||
return fmt.Errorf("failed to save config file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// encodeAuth base64-encodes username and password into base64(username:password).
|
||||
func encodeAuth(username, password string) string {
|
||||
if username == "" && password == "" {
|
||||
return ""
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
|
||||
}
|
||||
|
||||
// decodeAuth decodes a base64 encoded string and returns username and password.
|
||||
func decodeAuth(authStr string) (username string, password string, err error) {
|
||||
if authStr == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(authStr)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
decodedStr := string(decoded)
|
||||
username, password, ok := strings.Cut(decodedStr, ":")
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("auth '%s' does not conform the base64(username:password) format", decodedStr)
|
||||
}
|
||||
return username, password, nil
|
||||
}
|
||||
|
||||
// ToHostname normalizes a server address to just its hostname, removing
|
||||
// the scheme and the path parts.
|
||||
// It is used to match keys in the auths map, which may be either stored as
|
||||
// hostname or as hostname including scheme (in legacy docker config files).
|
||||
// Reference: https://github.com/docker/cli/blob/v24.0.6/cli/config/credentials/file_store.go#L71
|
||||
func ToHostname(addr string) string {
|
||||
addr = strings.TrimPrefix(addr, "http://")
|
||||
addr = strings.TrimPrefix(addr, "https://")
|
||||
addr, _, _ = strings.Cut(addr, "/")
|
||||
return addr
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
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 executer is an abstraction for the docker credential helper protocol
|
||||
// binaries. It is used by nativeStore to interact with installed binaries.
|
||||
package executer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"oras.land/oras-go/v2/registry/remote/credentials/trace"
|
||||
)
|
||||
|
||||
// dockerDesktopHelperName is the name of the docker credentials helper
|
||||
// execuatable.
|
||||
const dockerDesktopHelperName = "docker-credential-desktop.exe"
|
||||
|
||||
// Executer is an interface that simulates an executable binary.
|
||||
type Executer interface {
|
||||
Execute(ctx context.Context, input io.Reader, action string) ([]byte, error)
|
||||
}
|
||||
|
||||
// executable implements the Executer interface.
|
||||
type executable struct {
|
||||
name string
|
||||
}
|
||||
|
||||
// New returns a new Executer instance.
|
||||
func New(name string) Executer {
|
||||
return &executable{
|
||||
name: name,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute operates on an executable binary and supports context.
|
||||
func (c *executable) Execute(ctx context.Context, input io.Reader, action string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, c.name, action)
|
||||
cmd.Stdin = input
|
||||
cmd.Stderr = os.Stderr
|
||||
trace := trace.ContextExecutableTrace(ctx)
|
||||
if trace != nil && trace.ExecuteStart != nil {
|
||||
trace.ExecuteStart(c.name, action)
|
||||
}
|
||||
output, err := cmd.Output()
|
||||
if trace != nil && trace.ExecuteDone != nil {
|
||||
trace.ExecuteDone(c.name, action, err)
|
||||
}
|
||||
if err != nil {
|
||||
switch execErr := err.(type) {
|
||||
case *exec.ExitError:
|
||||
if errMessage := string(bytes.TrimSpace(output)); errMessage != "" {
|
||||
return nil, errors.New(errMessage)
|
||||
}
|
||||
case *exec.Error:
|
||||
// check if the error is caused by Docker Desktop not running
|
||||
if execErr.Err == exec.ErrNotFound && c.name == dockerDesktopHelperName {
|
||||
return nil, errors.New("credentials store is configured to `desktop.exe` but Docker Desktop seems not running")
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
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 ioutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Ingest writes content into a temporary ingest file with the file name format
|
||||
// "oras_credstore_temp_{randomString}".
|
||||
func Ingest(dir string, content io.Reader) (path string, ingestErr error) {
|
||||
tempFile, err := os.CreateTemp(dir, "oras_credstore_temp_*")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create ingest file: %w", err)
|
||||
}
|
||||
path = tempFile.Name()
|
||||
defer func() {
|
||||
if err := tempFile.Close(); err != nil && ingestErr == nil {
|
||||
ingestErr = fmt.Errorf("failed to close ingest file: %w", err)
|
||||
}
|
||||
// remove the temp file in case of error.
|
||||
if ingestErr != nil {
|
||||
os.Remove(path)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := tempFile.Chmod(0600); err != nil {
|
||||
return "", fmt.Errorf("failed to ensure permission: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(tempFile, content); err != nil {
|
||||
return "", fmt.Errorf("failed to ingest: %w", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
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 credentials
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"oras.land/oras-go/v2/registry/remote/auth"
|
||||
"oras.land/oras-go/v2/registry/remote/credentials/internal/config"
|
||||
)
|
||||
|
||||
// memoryStore is a store that keeps credentials in memory.
|
||||
type memoryStore struct {
|
||||
store sync.Map
|
||||
}
|
||||
|
||||
// NewMemoryStore creates a new in-memory credentials store.
|
||||
func NewMemoryStore() Store {
|
||||
return &memoryStore{}
|
||||
}
|
||||
|
||||
// NewMemoryStoreFromDockerConfig creates a new in-memory credentials store from the given configuration.
|
||||
//
|
||||
// Reference: https://docs.docker.com/engine/reference/commandline/cli/#docker-cli-configuration-file-configjson-properties
|
||||
func NewMemoryStoreFromDockerConfig(c []byte) (Store, error) {
|
||||
cfg := struct {
|
||||
Auths map[string]config.AuthConfig `json:"auths"`
|
||||
}{}
|
||||
if err := json.Unmarshal(c, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal auth field: %w: %v", config.ErrInvalidConfigFormat, err)
|
||||
}
|
||||
|
||||
s := &memoryStore{}
|
||||
for addr, auth := range cfg.Auths {
|
||||
// Normalize the auth key to hostname.
|
||||
hostname := config.ToHostname(addr)
|
||||
cred, err := auth.Credential()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, _ = s.store.LoadOrStore(hostname, cred)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Get retrieves credentials from the store for the given server address.
|
||||
func (ms *memoryStore) Get(_ context.Context, serverAddress string) (auth.Credential, error) {
|
||||
cred, found := ms.store.Load(serverAddress)
|
||||
if !found {
|
||||
return auth.EmptyCredential, nil
|
||||
}
|
||||
return cred.(auth.Credential), nil
|
||||
}
|
||||
|
||||
// Put saves credentials into the store for the given server address.
|
||||
func (ms *memoryStore) Put(_ context.Context, serverAddress string, cred auth.Credential) error {
|
||||
ms.store.Store(serverAddress, cred)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes credentials from the store for the given server address.
|
||||
func (ms *memoryStore) Delete(_ context.Context, serverAddress string) error {
|
||||
ms.store.Delete(serverAddress)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
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 credentials
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"oras.land/oras-go/v2/registry/remote/auth"
|
||||
"oras.land/oras-go/v2/registry/remote/credentials/internal/executer"
|
||||
)
|
||||
|
||||
const (
|
||||
remoteCredentialsPrefix = "docker-credential-"
|
||||
emptyUsername = "<token>"
|
||||
errCredentialsNotFoundMessage = "credentials not found in native keychain"
|
||||
)
|
||||
|
||||
// dockerCredentials mimics how docker credential helper binaries store
|
||||
// credential information.
|
||||
// Reference:
|
||||
// - https://docs.docker.com/engine/reference/commandline/login/#credential-helper-protocol
|
||||
type dockerCredentials struct {
|
||||
ServerURL string `json:"ServerURL"`
|
||||
Username string `json:"Username"`
|
||||
Secret string `json:"Secret"`
|
||||
}
|
||||
|
||||
// nativeStore implements a credentials store using native keychain to keep
|
||||
// credentials secure.
|
||||
type nativeStore struct {
|
||||
exec executer.Executer
|
||||
}
|
||||
|
||||
// NewNativeStore creates a new native store that uses a remote helper program to
|
||||
// manage credentials.
|
||||
//
|
||||
// The argument of NewNativeStore can be the native keychains
|
||||
// ("wincred" for Windows, "pass" for linux and "osxkeychain" for macOS),
|
||||
// or any program that follows the docker-credentials-helper protocol.
|
||||
//
|
||||
// Reference:
|
||||
// - https://docs.docker.com/engine/reference/commandline/login#credentials-store
|
||||
func NewNativeStore(helperSuffix string) Store {
|
||||
return &nativeStore{
|
||||
exec: executer.New(remoteCredentialsPrefix + helperSuffix),
|
||||
}
|
||||
}
|
||||
|
||||
// NewDefaultNativeStore returns a native store based on the platform-default
|
||||
// docker credentials helper and a bool indicating if the native store is
|
||||
// available.
|
||||
// - Windows: "wincred"
|
||||
// - Linux: "pass" or "secretservice"
|
||||
// - macOS: "osxkeychain"
|
||||
//
|
||||
// Reference:
|
||||
// - https://docs.docker.com/engine/reference/commandline/login/#credentials-store
|
||||
func NewDefaultNativeStore() (Store, bool) {
|
||||
if helper := getDefaultHelperSuffix(); helper != "" {
|
||||
return NewNativeStore(helper), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Get retrieves credentials from the store for the given server.
|
||||
func (ns *nativeStore) Get(ctx context.Context, serverAddress string) (auth.Credential, error) {
|
||||
var cred auth.Credential
|
||||
out, err := ns.exec.Execute(ctx, strings.NewReader(serverAddress), "get")
|
||||
if err != nil {
|
||||
if err.Error() == errCredentialsNotFoundMessage {
|
||||
// do not return an error if the credentials are not in the keychain.
|
||||
return auth.EmptyCredential, nil
|
||||
}
|
||||
return auth.EmptyCredential, err
|
||||
}
|
||||
var dockerCred dockerCredentials
|
||||
if err := json.Unmarshal(out, &dockerCred); err != nil {
|
||||
return auth.EmptyCredential, err
|
||||
}
|
||||
// bearer auth is used if the username is "<token>"
|
||||
if dockerCred.Username == emptyUsername {
|
||||
cred.RefreshToken = dockerCred.Secret
|
||||
} else {
|
||||
cred.Username = dockerCred.Username
|
||||
cred.Password = dockerCred.Secret
|
||||
}
|
||||
return cred, nil
|
||||
}
|
||||
|
||||
// Put saves credentials into the store.
|
||||
func (ns *nativeStore) Put(ctx context.Context, serverAddress string, cred auth.Credential) error {
|
||||
dockerCred := &dockerCredentials{
|
||||
ServerURL: serverAddress,
|
||||
Username: cred.Username,
|
||||
Secret: cred.Password,
|
||||
}
|
||||
if cred.RefreshToken != "" {
|
||||
dockerCred.Username = emptyUsername
|
||||
dockerCred.Secret = cred.RefreshToken
|
||||
}
|
||||
credJSON, err := json.Marshal(dockerCred)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = ns.exec.Execute(ctx, bytes.NewReader(credJSON), "store")
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete removes credentials from the store for the given server.
|
||||
func (ns *nativeStore) Delete(ctx context.Context, serverAddress string) error {
|
||||
_, err := ns.exec.Execute(ctx, strings.NewReader(serverAddress), "erase")
|
||||
return err
|
||||
}
|
||||
|
||||
// getDefaultHelperSuffix returns the default credential helper suffix.
|
||||
func getDefaultHelperSuffix() string {
|
||||
platformDefault := getPlatformDefaultHelperSuffix()
|
||||
if _, err := exec.LookPath(remoteCredentialsPrefix + platformDefault); err == nil {
|
||||
return platformDefault
|
||||
}
|
||||
return ""
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
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 credentials
|
||||
|
||||
// getPlatformDefaultHelperSuffix returns the platform default credential
|
||||
// helper suffix.
|
||||
// Reference: https://docs.docker.com/engine/reference/commandline/login/#default-behavior
|
||||
func getPlatformDefaultHelperSuffix() string {
|
||||
return "osxkeychain"
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
//go:build !windows && !darwin && !linux
|
||||
|
||||
/*
|
||||
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 credentials
|
||||
|
||||
// getPlatformDefaultHelperSuffix returns the platform default credential
|
||||
// helper suffix.
|
||||
// Reference: https://docs.docker.com/engine/reference/commandline/login/#default-behavior
|
||||
func getPlatformDefaultHelperSuffix() string {
|
||||
return ""
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
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 credentials
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// getPlatformDefaultHelperSuffix returns the platform default credential
|
||||
// helper suffix.
|
||||
// Reference: https://docs.docker.com/engine/reference/commandline/login/#default-behavior
|
||||
func getPlatformDefaultHelperSuffix() string {
|
||||
if _, err := exec.LookPath("pass"); err == nil {
|
||||
return "pass"
|
||||
}
|
||||
|
||||
return "secretservice"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
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 credentials
|
||||
|
||||
// getPlatformDefaultHelperSuffix returns the platform default credential
|
||||
// helper suffix.
|
||||
// Reference: https://docs.docker.com/engine/reference/commandline/login/#default-behavior
|
||||
func getPlatformDefaultHelperSuffix() string {
|
||||
return "wincred"
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
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 credentials
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"oras.land/oras-go/v2/registry/remote"
|
||||
"oras.land/oras-go/v2/registry/remote/auth"
|
||||
)
|
||||
|
||||
// ErrClientTypeUnsupported is thrown by Login() when the registry's client type
|
||||
// is not supported.
|
||||
var ErrClientTypeUnsupported = errors.New("client type not supported")
|
||||
|
||||
// Login provides the login functionality with the given credentials. The target
|
||||
// registry's client should be nil or of type *auth.Client. Login uses
|
||||
// a client local to the function and will not modify the original client of
|
||||
// the registry.
|
||||
func Login(ctx context.Context, store Store, reg *remote.Registry, cred auth.Credential) error {
|
||||
// create a clone of the original registry for login purpose
|
||||
regClone := *reg
|
||||
// we use the original client if applicable, otherwise use a default client
|
||||
var authClient auth.Client
|
||||
if reg.Client == nil {
|
||||
authClient = *auth.DefaultClient
|
||||
authClient.Cache = nil // no cache
|
||||
} else if client, ok := reg.Client.(*auth.Client); ok {
|
||||
authClient = *client
|
||||
} else {
|
||||
return ErrClientTypeUnsupported
|
||||
}
|
||||
regClone.Client = &authClient
|
||||
// update credentials with the client
|
||||
authClient.Credential = auth.StaticCredential(reg.Reference.Registry, cred)
|
||||
// validate and store the credential
|
||||
if err := regClone.Ping(ctx); err != nil {
|
||||
return fmt.Errorf("failed to validate the credentials for %s: %w", regClone.Reference.Registry, err)
|
||||
}
|
||||
hostname := ServerAddressFromRegistry(regClone.Reference.Registry)
|
||||
if err := store.Put(ctx, hostname, cred); err != nil {
|
||||
return fmt.Errorf("failed to store the credentials for %s: %w", hostname, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Logout provides the logout functionality given the registry name.
|
||||
func Logout(ctx context.Context, store Store, registryName string) error {
|
||||
registryName = ServerAddressFromRegistry(registryName)
|
||||
if err := store.Delete(ctx, registryName); err != nil {
|
||||
return fmt.Errorf("failed to delete the credential for %s: %w", registryName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Credential returns a Credential() function that can be used by auth.Client.
|
||||
func Credential(store Store) auth.CredentialFunc {
|
||||
return func(ctx context.Context, hostport string) (auth.Credential, error) {
|
||||
hostport = ServerAddressFromHostname(hostport)
|
||||
if hostport == "" {
|
||||
return auth.EmptyCredential, nil
|
||||
}
|
||||
return store.Get(ctx, hostport)
|
||||
}
|
||||
}
|
||||
|
||||
// ServerAddressFromRegistry maps a registry to a server address, which is used as
|
||||
// a key for credentials store. The Docker CLI expects that the credentials of
|
||||
// the registry 'docker.io' will be added under the key "https://index.docker.io/v1/".
|
||||
// See: https://github.com/moby/moby/blob/v24.0.2/registry/config.go#L25-L48
|
||||
func ServerAddressFromRegistry(registry string) string {
|
||||
if registry == "docker.io" {
|
||||
return "https://index.docker.io/v1/"
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
// ServerAddressFromHostname maps a hostname to a server address, which is used as
|
||||
// a key for credentials store. It is expected that the traffic targetting the
|
||||
// host "registry-1.docker.io" will be redirected to "https://index.docker.io/v1/".
|
||||
// See: https://github.com/moby/moby/blob/v24.0.2/registry/config.go#L25-L48
|
||||
func ServerAddressFromHostname(hostname string) string {
|
||||
if hostname == "registry-1.docker.io" {
|
||||
return "https://index.docker.io/v1/"
|
||||
}
|
||||
return hostname
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
/*
|
||||
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 credentials supports reading, saving, and removing credentials from
|
||||
// Docker configuration files and external credential stores that follow
|
||||
// the Docker credential helper protocol.
|
||||
//
|
||||
// Reference: https://docs.docker.com/engine/reference/commandline/login/#credential-stores
|
||||
package credentials
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"oras.land/oras-go/v2/internal/syncutil"
|
||||
"oras.land/oras-go/v2/registry/remote/auth"
|
||||
"oras.land/oras-go/v2/registry/remote/credentials/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
dockerConfigDirEnv = "DOCKER_CONFIG"
|
||||
dockerConfigFileDir = ".docker"
|
||||
dockerConfigFileName = "config.json"
|
||||
)
|
||||
|
||||
// Store is the interface that any credentials store must implement.
|
||||
type Store interface {
|
||||
// Get retrieves credentials from the store for the given server address.
|
||||
Get(ctx context.Context, serverAddress string) (auth.Credential, error)
|
||||
// Put saves credentials into the store for the given server address.
|
||||
Put(ctx context.Context, serverAddress string, cred auth.Credential) error
|
||||
// Delete removes credentials from the store for the given server address.
|
||||
Delete(ctx context.Context, serverAddress string) error
|
||||
}
|
||||
|
||||
// DynamicStore dynamically determines which store to use based on the settings
|
||||
// in the config file.
|
||||
type DynamicStore struct {
|
||||
config *config.Config
|
||||
options StoreOptions
|
||||
detectedCredsStore string
|
||||
setCredsStoreOnce syncutil.OnceOrRetry
|
||||
}
|
||||
|
||||
// StoreOptions provides options for NewStore.
|
||||
type StoreOptions struct {
|
||||
// AllowPlaintextPut allows saving credentials in plaintext in the config
|
||||
// file.
|
||||
// - If AllowPlaintextPut is set to false (default value), Put() will
|
||||
// return an error when native store is not available.
|
||||
// - If AllowPlaintextPut is set to true, Put() will save credentials in
|
||||
// plaintext in the config file when native store is not available.
|
||||
AllowPlaintextPut bool
|
||||
|
||||
// DetectDefaultNativeStore enables detecting the platform-default native
|
||||
// credentials store when the config file has no authentication information.
|
||||
//
|
||||
// If DetectDefaultNativeStore is set to true, the store will detect and set
|
||||
// the default native credentials store in the "credsStore" field of the
|
||||
// config file.
|
||||
// - Windows: "wincred"
|
||||
// - Linux: "pass" or "secretservice"
|
||||
// - macOS: "osxkeychain"
|
||||
//
|
||||
// References:
|
||||
// - https://docs.docker.com/engine/reference/commandline/login/#credentials-store
|
||||
// - https://docs.docker.com/engine/reference/commandline/cli/#docker-cli-configuration-file-configjson-properties
|
||||
DetectDefaultNativeStore bool
|
||||
}
|
||||
|
||||
// NewStore returns a Store based on the given configuration file.
|
||||
//
|
||||
// For Get(), Put() and Delete(), the returned Store will dynamically determine
|
||||
// which underlying credentials store to use for the given server address.
|
||||
// The underlying credentials store is determined in the following order:
|
||||
// 1. Native server-specific credential helper
|
||||
// 2. Native credentials store
|
||||
// 3. The plain-text config file itself
|
||||
//
|
||||
// References:
|
||||
// - https://docs.docker.com/engine/reference/commandline/login/#credentials-store
|
||||
// - https://docs.docker.com/engine/reference/commandline/cli/#docker-cli-configuration-file-configjson-properties
|
||||
func NewStore(configPath string, opts StoreOptions) (*DynamicStore, error) {
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ds := &DynamicStore{
|
||||
config: cfg,
|
||||
options: opts,
|
||||
}
|
||||
if opts.DetectDefaultNativeStore && !cfg.IsAuthConfigured() {
|
||||
// no authentication configured, detect the default credentials store
|
||||
ds.detectedCredsStore = getDefaultHelperSuffix()
|
||||
}
|
||||
return ds, nil
|
||||
}
|
||||
|
||||
// NewStoreFromDocker returns a Store based on the default docker config file.
|
||||
// - If the $DOCKER_CONFIG environment variable is set,
|
||||
// $DOCKER_CONFIG/config.json will be used.
|
||||
// - Otherwise, the default location $HOME/.docker/config.json will be used.
|
||||
//
|
||||
// NewStoreFromDocker internally calls [NewStore].
|
||||
//
|
||||
// References:
|
||||
// - https://docs.docker.com/engine/reference/commandline/cli/#configuration-files
|
||||
// - https://docs.docker.com/engine/reference/commandline/cli/#change-the-docker-directory
|
||||
func NewStoreFromDocker(opt StoreOptions) (*DynamicStore, error) {
|
||||
configPath, err := getDockerConfigPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewStore(configPath, opt)
|
||||
}
|
||||
|
||||
// Get retrieves credentials from the store for the given server address.
|
||||
func (ds *DynamicStore) Get(ctx context.Context, serverAddress string) (auth.Credential, error) {
|
||||
return ds.getStore(serverAddress).Get(ctx, serverAddress)
|
||||
}
|
||||
|
||||
// Put saves credentials into the store for the given server address.
|
||||
// Put returns ErrPlaintextPutDisabled if native store is not available and
|
||||
// [StoreOptions].AllowPlaintextPut is set to false.
|
||||
func (ds *DynamicStore) Put(ctx context.Context, serverAddress string, cred auth.Credential) error {
|
||||
if err := ds.getStore(serverAddress).Put(ctx, serverAddress, cred); err != nil {
|
||||
return err
|
||||
}
|
||||
// save the detected creds store back to the config file on first put
|
||||
return ds.setCredsStoreOnce.Do(func() error {
|
||||
if ds.detectedCredsStore != "" {
|
||||
if err := ds.config.SetCredentialsStore(ds.detectedCredsStore); err != nil {
|
||||
return fmt.Errorf("failed to set credsStore: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Delete removes credentials from the store for the given server address.
|
||||
func (ds *DynamicStore) Delete(ctx context.Context, serverAddress string) error {
|
||||
return ds.getStore(serverAddress).Delete(ctx, serverAddress)
|
||||
}
|
||||
|
||||
// IsAuthConfigured returns whether there is authentication configured in the
|
||||
// config file or not.
|
||||
//
|
||||
// IsAuthConfigured returns true when:
|
||||
// - The "credsStore" field is not empty
|
||||
// - Or the "credHelpers" field is not empty
|
||||
// - Or there is any entry in the "auths" field
|
||||
func (ds *DynamicStore) IsAuthConfigured() bool {
|
||||
return ds.config.IsAuthConfigured()
|
||||
}
|
||||
|
||||
// ConfigPath returns the path to the config file.
|
||||
func (ds *DynamicStore) ConfigPath() string {
|
||||
return ds.config.Path()
|
||||
}
|
||||
|
||||
// getHelperSuffix returns the credential helper suffix for the given server
|
||||
// address.
|
||||
func (ds *DynamicStore) getHelperSuffix(serverAddress string) string {
|
||||
// 1. Look for a server-specific credential helper first
|
||||
if helper := ds.config.GetCredentialHelper(serverAddress); helper != "" {
|
||||
return helper
|
||||
}
|
||||
// 2. Then look for the configured native store
|
||||
if credsStore := ds.config.CredentialsStore(); credsStore != "" {
|
||||
return credsStore
|
||||
}
|
||||
// 3. Use the detected default store
|
||||
return ds.detectedCredsStore
|
||||
}
|
||||
|
||||
// getStore returns a store for the given server address.
|
||||
func (ds *DynamicStore) getStore(serverAddress string) Store {
|
||||
if helper := ds.getHelperSuffix(serverAddress); helper != "" {
|
||||
return NewNativeStore(helper)
|
||||
}
|
||||
|
||||
fs := newFileStore(ds.config)
|
||||
fs.DisablePut = !ds.options.AllowPlaintextPut
|
||||
return fs
|
||||
}
|
||||
|
||||
// getDockerConfigPath returns the path to the default docker config file.
|
||||
func getDockerConfigPath() (string, error) {
|
||||
// first try the environment variable
|
||||
configDir := os.Getenv(dockerConfigDirEnv)
|
||||
if configDir == "" {
|
||||
// then try home directory
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get user home directory: %w", err)
|
||||
}
|
||||
configDir = filepath.Join(homeDir, dockerConfigFileDir)
|
||||
}
|
||||
return filepath.Join(configDir, dockerConfigFileName), nil
|
||||
}
|
||||
|
||||
// storeWithFallbacks is a store that has multiple fallback stores.
|
||||
type storeWithFallbacks struct {
|
||||
stores []Store
|
||||
}
|
||||
|
||||
// NewStoreWithFallbacks returns a new store based on the given stores.
|
||||
// - Get() searches the primary and the fallback stores
|
||||
// for the credentials and returns when it finds the
|
||||
// credentials in any of the stores.
|
||||
// - Put() saves the credentials into the primary store.
|
||||
// - Delete() deletes the credentials from the primary store.
|
||||
func NewStoreWithFallbacks(primary Store, fallbacks ...Store) Store {
|
||||
if len(fallbacks) == 0 {
|
||||
return primary
|
||||
}
|
||||
return &storeWithFallbacks{
|
||||
stores: append([]Store{primary}, fallbacks...),
|
||||
}
|
||||
}
|
||||
|
||||
// Get retrieves credentials from the StoreWithFallbacks for the given server.
|
||||
// It searches the primary and the fallback stores for the credentials of serverAddress
|
||||
// and returns when it finds the credentials in any of the stores.
|
||||
func (sf *storeWithFallbacks) Get(ctx context.Context, serverAddress string) (auth.Credential, error) {
|
||||
for _, s := range sf.stores {
|
||||
cred, err := s.Get(ctx, serverAddress)
|
||||
if err != nil {
|
||||
return auth.EmptyCredential, err
|
||||
}
|
||||
if cred != auth.EmptyCredential {
|
||||
return cred, nil
|
||||
}
|
||||
}
|
||||
return auth.EmptyCredential, nil
|
||||
}
|
||||
|
||||
// Put saves credentials into the StoreWithFallbacks. It puts
|
||||
// the credentials into the primary store.
|
||||
func (sf *storeWithFallbacks) Put(ctx context.Context, serverAddress string, cred auth.Credential) error {
|
||||
return sf.stores[0].Put(ctx, serverAddress, cred)
|
||||
}
|
||||
|
||||
// Delete removes credentials from the StoreWithFallbacks for the given server.
|
||||
// It deletes the credentials from the primary store.
|
||||
func (sf *storeWithFallbacks) Delete(ctx context.Context, serverAddress string) error {
|
||||
return sf.stores[0].Delete(ctx, serverAddress)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
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 trace
|
||||
|
||||
import "context"
|
||||
|
||||
// executableTraceContextKey is a value key used to retrieve the ExecutableTrace
|
||||
// from Context.
|
||||
type executableTraceContextKey struct{}
|
||||
|
||||
// ExecutableTrace is a set of hooks used to trace the execution of binary
|
||||
// executables. Any particular hook may be nil.
|
||||
type ExecutableTrace struct {
|
||||
// ExecuteStart is called before the execution of the executable. The
|
||||
// executableName parameter is the name of the credential helper executable
|
||||
// used with NativeStore. The action parameter is one of "store", "get" and
|
||||
// "erase".
|
||||
//
|
||||
// Reference:
|
||||
// - https://docs.docker.com/engine/reference/commandline/login#credentials-store
|
||||
ExecuteStart func(executableName string, action string)
|
||||
|
||||
// ExecuteDone is called after the execution of an executable completes.
|
||||
// The executableName parameter is the name of the credential helper
|
||||
// executable used with NativeStore. The action parameter is one of "store",
|
||||
// "get" and "erase". The err parameter is the error (if any) returned from
|
||||
// the execution.
|
||||
//
|
||||
// Reference:
|
||||
// - https://docs.docker.com/engine/reference/commandline/login#credentials-store
|
||||
ExecuteDone func(executableName string, action string, err error)
|
||||
}
|
||||
|
||||
// ContextExecutableTrace returns the ExecutableTrace associated with the
|
||||
// context. If none, it returns nil.
|
||||
func ContextExecutableTrace(ctx context.Context) *ExecutableTrace {
|
||||
trace, _ := ctx.Value(executableTraceContextKey{}).(*ExecutableTrace)
|
||||
return trace
|
||||
}
|
||||
|
||||
// WithExecutableTrace takes a Context and an ExecutableTrace, and returns a
|
||||
// Context with the ExecutableTrace added as a Value. If the Context has a
|
||||
// previously added trace, the hooks defined in the new trace will be added
|
||||
// in addition to the previous ones. The recent hooks will be called first.
|
||||
func WithExecutableTrace(ctx context.Context, trace *ExecutableTrace) context.Context {
|
||||
if trace == nil {
|
||||
return ctx
|
||||
}
|
||||
if oldTrace := ContextExecutableTrace(ctx); oldTrace != nil {
|
||||
trace.compose(oldTrace)
|
||||
}
|
||||
return context.WithValue(ctx, executableTraceContextKey{}, trace)
|
||||
}
|
||||
|
||||
// compose takes an oldTrace and modifies the existing trace to include
|
||||
// the hooks defined in the oldTrace. The hooks in the existing trace will
|
||||
// be called first.
|
||||
func (trace *ExecutableTrace) compose(oldTrace *ExecutableTrace) {
|
||||
if oldStart := oldTrace.ExecuteStart; oldStart != nil {
|
||||
start := trace.ExecuteStart
|
||||
if start != nil {
|
||||
trace.ExecuteStart = func(executableName, action string) {
|
||||
start(executableName, action)
|
||||
oldStart(executableName, action)
|
||||
}
|
||||
} else {
|
||||
trace.ExecuteStart = oldStart
|
||||
}
|
||||
}
|
||||
if oldDone := oldTrace.ExecuteDone; oldDone != nil {
|
||||
done := trace.ExecuteDone
|
||||
if done != nil {
|
||||
trace.ExecuteDone = func(executableName, action string, err error) {
|
||||
done(executableName, action, err)
|
||||
oldDone(executableName, action, err)
|
||||
}
|
||||
} else {
|
||||
trace.ExecuteDone = oldDone
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
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 errcode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// References:
|
||||
// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#error-codes
|
||||
// - https://distribution.github.io/distribution/spec/api/#errors-2
|
||||
const (
|
||||
ErrorCodeBlobUnknown = "BLOB_UNKNOWN"
|
||||
ErrorCodeBlobUploadInvalid = "BLOB_UPLOAD_INVALID"
|
||||
ErrorCodeBlobUploadUnknown = "BLOB_UPLOAD_UNKNOWN"
|
||||
ErrorCodeDigestInvalid = "DIGEST_INVALID"
|
||||
ErrorCodeManifestBlobUnknown = "MANIFEST_BLOB_UNKNOWN"
|
||||
ErrorCodeManifestInvalid = "MANIFEST_INVALID"
|
||||
ErrorCodeManifestUnknown = "MANIFEST_UNKNOWN"
|
||||
ErrorCodeNameInvalid = "NAME_INVALID"
|
||||
ErrorCodeNameUnknown = "NAME_UNKNOWN"
|
||||
ErrorCodeSizeInvalid = "SIZE_INVALID"
|
||||
ErrorCodeUnauthorized = "UNAUTHORIZED"
|
||||
ErrorCodeDenied = "DENIED"
|
||||
ErrorCodeUnsupported = "UNSUPPORTED"
|
||||
)
|
||||
|
||||
// Error represents a response inner error returned by the remote
|
||||
// registry.
|
||||
// References:
|
||||
// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#error-codes
|
||||
// - https://distribution.github.io/distribution/spec/api/#errors-2
|
||||
type Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Detail any `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// Error returns a error string describing the error.
|
||||
func (e Error) Error() string {
|
||||
code := strings.Map(func(r rune) rune {
|
||||
if r == '_' {
|
||||
return ' '
|
||||
}
|
||||
return unicode.ToLower(r)
|
||||
}, e.Code)
|
||||
if e.Message == "" {
|
||||
return code
|
||||
}
|
||||
if e.Detail == nil {
|
||||
return fmt.Sprintf("%s: %s", code, e.Message)
|
||||
}
|
||||
return fmt.Sprintf("%s: %s: %v", code, e.Message, e.Detail)
|
||||
}
|
||||
|
||||
// Errors represents a list of response inner errors returned by the remote
|
||||
// server.
|
||||
// References:
|
||||
// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#error-codes
|
||||
// - https://distribution.github.io/distribution/spec/api/#errors-2
|
||||
type Errors []Error
|
||||
|
||||
// Error returns a error string describing the error.
|
||||
func (errs Errors) Error() string {
|
||||
switch len(errs) {
|
||||
case 0:
|
||||
return "<nil>"
|
||||
case 1:
|
||||
return errs[0].Error()
|
||||
}
|
||||
var errmsgs []string
|
||||
for _, err := range errs {
|
||||
errmsgs = append(errmsgs, err.Error())
|
||||
}
|
||||
return strings.Join(errmsgs, "; ")
|
||||
}
|
||||
|
||||
// Unwrap returns the inner error only when there is exactly one error.
|
||||
func (errs Errors) Unwrap() error {
|
||||
if len(errs) == 1 {
|
||||
return errs[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrorResponse represents an error response.
|
||||
type ErrorResponse struct {
|
||||
Method string
|
||||
URL *url.URL
|
||||
StatusCode int
|
||||
Errors Errors
|
||||
}
|
||||
|
||||
// Error returns a error string describing the error.
|
||||
func (err *ErrorResponse) Error() string {
|
||||
var errmsg string
|
||||
if len(err.Errors) > 0 {
|
||||
errmsg = err.Errors.Error()
|
||||
} else {
|
||||
errmsg = http.StatusText(err.StatusCode)
|
||||
}
|
||||
return fmt.Sprintf("%s %q: response status code %d: %s", err.Method, err.URL, err.StatusCode, errmsg)
|
||||
}
|
||||
|
||||
// Unwrap returns the internal errors of err if any.
|
||||
func (err *ErrorResponse) Unwrap() error {
|
||||
if len(err.Errors) == 0 {
|
||||
return nil
|
||||
}
|
||||
return err.Errors
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
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 errutil
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"oras.land/oras-go/v2/registry/remote/errcode"
|
||||
)
|
||||
|
||||
// maxErrorBytes specifies the default limit on how many response bytes are
|
||||
// allowed in the server's error response.
|
||||
// A typical error message is around 200 bytes. Hence, 8 KiB should be
|
||||
// sufficient.
|
||||
const maxErrorBytes int64 = 8 * 1024 // 8 KiB
|
||||
|
||||
// ParseErrorResponse parses the error returned by the remote registry.
|
||||
func ParseErrorResponse(resp *http.Response) error {
|
||||
resultErr := &errcode.ErrorResponse{
|
||||
Method: resp.Request.Method,
|
||||
URL: resp.Request.URL,
|
||||
StatusCode: resp.StatusCode,
|
||||
}
|
||||
var body struct {
|
||||
Errors errcode.Errors `json:"errors"`
|
||||
}
|
||||
lr := io.LimitReader(resp.Body, maxErrorBytes)
|
||||
if err := json.NewDecoder(lr).Decode(&body); err == nil {
|
||||
resultErr.Errors = body.Errors
|
||||
}
|
||||
return resultErr
|
||||
}
|
||||
|
||||
// IsErrorCode returns true if err is an Error and its Code equals to code.
|
||||
func IsErrorCode(err error, code string) bool {
|
||||
var ec errcode.Error
|
||||
return errors.As(err, &ec) && ec.Code == code
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
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 remote
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"oras.land/oras-go/v2/internal/docker"
|
||||
"oras.land/oras-go/v2/internal/spec"
|
||||
)
|
||||
|
||||
// defaultManifestMediaTypes contains the default set of manifests media types.
|
||||
var defaultManifestMediaTypes = []string{
|
||||
docker.MediaTypeManifest,
|
||||
docker.MediaTypeManifestList,
|
||||
ocispec.MediaTypeImageManifest,
|
||||
ocispec.MediaTypeImageIndex,
|
||||
spec.MediaTypeArtifactManifest,
|
||||
}
|
||||
|
||||
// defaultManifestAcceptHeader is the default set in the `Accept` header for
|
||||
// resolving manifests from tags.
|
||||
var defaultManifestAcceptHeader = strings.Join(defaultManifestMediaTypes, ", ")
|
||||
|
||||
// isManifest determines if the given descriptor points to a manifest.
|
||||
func isManifest(manifestMediaTypes []string, desc ocispec.Descriptor) bool {
|
||||
if len(manifestMediaTypes) == 0 {
|
||||
manifestMediaTypes = defaultManifestMediaTypes
|
||||
}
|
||||
for _, mediaType := range manifestMediaTypes {
|
||||
if desc.MediaType == mediaType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// manifestAcceptHeader generates the set in the `Accept` header for resolving
|
||||
// manifests from tags.
|
||||
func manifestAcceptHeader(manifestMediaTypes []string) string {
|
||||
if len(manifestMediaTypes) == 0 {
|
||||
return defaultManifestAcceptHeader
|
||||
}
|
||||
return strings.Join(manifestMediaTypes, ", ")
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
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 remote
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"oras.land/oras-go/v2/content"
|
||||
"oras.land/oras-go/v2/internal/descriptor"
|
||||
)
|
||||
|
||||
// zeroDigest represents a digest that consists of zeros. zeroDigest is used
|
||||
// for pinging Referrers API.
|
||||
const zeroDigest = "sha256:0000000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
// referrersState represents the state of Referrers API.
|
||||
type referrersState = int32
|
||||
|
||||
const (
|
||||
// referrersStateUnknown represents an unknown state of Referrers API.
|
||||
referrersStateUnknown referrersState = iota
|
||||
// referrersStateSupported represents that the repository is known to
|
||||
// support Referrers API.
|
||||
referrersStateSupported
|
||||
// referrersStateUnsupported represents that the repository is known to
|
||||
// not support Referrers API.
|
||||
referrersStateUnsupported
|
||||
)
|
||||
|
||||
// referrerOperation represents an operation on a referrer.
|
||||
type referrerOperation = int32
|
||||
|
||||
const (
|
||||
// referrerOperationAdd represents an addition operation on a referrer.
|
||||
referrerOperationAdd referrerOperation = iota
|
||||
// referrerOperationRemove represents a removal operation on a referrer.
|
||||
referrerOperationRemove
|
||||
)
|
||||
|
||||
// referrerChange represents a change on a referrer.
|
||||
type referrerChange struct {
|
||||
referrer ocispec.Descriptor
|
||||
operation referrerOperation
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrReferrersCapabilityAlreadySet is returned by SetReferrersCapability()
|
||||
// when the Referrers API capability has been already set.
|
||||
ErrReferrersCapabilityAlreadySet = errors.New("referrers capability cannot be changed once set")
|
||||
|
||||
// errNoReferrerUpdate is returned by applyReferrerChanges() when there
|
||||
// is no any referrer update.
|
||||
errNoReferrerUpdate = errors.New("no referrer update")
|
||||
)
|
||||
|
||||
const (
|
||||
// opDeleteReferrersIndex represents the operation for deleting a
|
||||
// referrers index.
|
||||
opDeleteReferrersIndex = "DeleteReferrersIndex"
|
||||
)
|
||||
|
||||
// ReferrersError records an error and the operation and the subject descriptor.
|
||||
type ReferrersError struct {
|
||||
// Op represents the failing operation.
|
||||
Op string
|
||||
// Subject is the descriptor of referenced artifact.
|
||||
Subject ocispec.Descriptor
|
||||
// Err is the entity of referrers error.
|
||||
Err error
|
||||
}
|
||||
|
||||
// Error returns error msg of IgnorableError.
|
||||
func (e *ReferrersError) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
// Unwrap returns the inner error of IgnorableError.
|
||||
func (e *ReferrersError) Unwrap() error {
|
||||
return errors.Unwrap(e.Err)
|
||||
}
|
||||
|
||||
// IsIndexDelete tells if e is kind of error related to referrers
|
||||
// index deletion.
|
||||
func (e *ReferrersError) IsReferrersIndexDelete() bool {
|
||||
return e.Op == opDeleteReferrersIndex
|
||||
}
|
||||
|
||||
// buildReferrersTag builds the referrers tag for the given manifest descriptor.
|
||||
// Format: <algorithm>-<digest>
|
||||
// Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#unavailable-referrers-api
|
||||
func buildReferrersTag(desc ocispec.Descriptor) (string, error) {
|
||||
if err := desc.Digest.Validate(); err != nil {
|
||||
return "", fmt.Errorf("failed to build referrers tag for %s: %w", desc.Digest, err)
|
||||
}
|
||||
alg := desc.Digest.Algorithm().String()
|
||||
encoded := desc.Digest.Encoded()
|
||||
return alg + "-" + encoded, nil
|
||||
}
|
||||
|
||||
// isReferrersFilterApplied checks if requsted is in the applied filter list.
|
||||
func isReferrersFilterApplied(applied, requested string) bool {
|
||||
if applied == "" || requested == "" {
|
||||
return false
|
||||
}
|
||||
filters := strings.Split(applied, ",")
|
||||
for _, f := range filters {
|
||||
if f == requested {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// filterReferrers filters a slice of referrers by artifactType in place.
|
||||
// The returned slice contains matching referrers.
|
||||
func filterReferrers(refs []ocispec.Descriptor, artifactType string) []ocispec.Descriptor {
|
||||
if artifactType == "" {
|
||||
return refs
|
||||
}
|
||||
var j int
|
||||
for i, ref := range refs {
|
||||
if ref.ArtifactType == artifactType {
|
||||
if i != j {
|
||||
refs[j] = ref
|
||||
}
|
||||
j++
|
||||
}
|
||||
}
|
||||
return refs[:j]
|
||||
}
|
||||
|
||||
// applyReferrerChanges applies referrerChanges on referrers and returns the
|
||||
// updated referrers.
|
||||
// Returns errNoReferrerUpdate if there is no any referrers updates.
|
||||
func applyReferrerChanges(referrers []ocispec.Descriptor, referrerChanges []referrerChange) ([]ocispec.Descriptor, error) {
|
||||
referrersMap := make(map[descriptor.Descriptor]int, len(referrers)+len(referrerChanges))
|
||||
updatedReferrers := make([]ocispec.Descriptor, 0, len(referrers)+len(referrerChanges))
|
||||
var updateRequired bool
|
||||
for _, r := range referrers {
|
||||
if content.Equal(r, ocispec.Descriptor{}) {
|
||||
// skip bad entry
|
||||
updateRequired = true
|
||||
continue
|
||||
}
|
||||
key := descriptor.FromOCI(r)
|
||||
if _, ok := referrersMap[key]; ok {
|
||||
// skip duplicates
|
||||
updateRequired = true
|
||||
continue
|
||||
}
|
||||
updatedReferrers = append(updatedReferrers, r)
|
||||
referrersMap[key] = len(updatedReferrers) - 1
|
||||
}
|
||||
|
||||
// apply changes
|
||||
for _, change := range referrerChanges {
|
||||
key := descriptor.FromOCI(change.referrer)
|
||||
switch change.operation {
|
||||
case referrerOperationAdd:
|
||||
if _, ok := referrersMap[key]; !ok {
|
||||
// add distinct referrers
|
||||
updatedReferrers = append(updatedReferrers, change.referrer)
|
||||
referrersMap[key] = len(updatedReferrers) - 1
|
||||
}
|
||||
case referrerOperationRemove:
|
||||
if pos, ok := referrersMap[key]; ok {
|
||||
// remove referrers that are already in the map
|
||||
updatedReferrers[pos] = ocispec.Descriptor{}
|
||||
delete(referrersMap, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// skip unnecessary update
|
||||
if !updateRequired && len(referrersMap) == len(referrers) {
|
||||
// if the result referrer map contains the same content as the
|
||||
// original referrers, consider that there is no update on the
|
||||
// referrers.
|
||||
for _, r := range referrers {
|
||||
key := descriptor.FromOCI(r)
|
||||
if _, ok := referrersMap[key]; !ok {
|
||||
updateRequired = true
|
||||
}
|
||||
}
|
||||
if !updateRequired {
|
||||
return nil, errNoReferrerUpdate
|
||||
}
|
||||
}
|
||||
|
||||
return removeEmptyDescriptors(updatedReferrers, len(referrersMap)), nil
|
||||
}
|
||||
|
||||
// removeEmptyDescriptors in-place removes empty items from descs, given a hint
|
||||
// of the number of non-empty descriptors.
|
||||
func removeEmptyDescriptors(descs []ocispec.Descriptor, hint int) []ocispec.Descriptor {
|
||||
j := 0
|
||||
for i, r := range descs {
|
||||
if !content.Equal(r, ocispec.Descriptor{}) {
|
||||
if i > j {
|
||||
descs[j] = r
|
||||
}
|
||||
j++
|
||||
}
|
||||
if j == hint {
|
||||
break
|
||||
}
|
||||
}
|
||||
return descs[:j]
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
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 remote provides a client to the remote registry.
|
||||
// Reference: https://github.com/distribution/distribution
|
||||
package remote
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"oras.land/oras-go/v2/errdef"
|
||||
"oras.land/oras-go/v2/registry"
|
||||
"oras.land/oras-go/v2/registry/remote/auth"
|
||||
"oras.land/oras-go/v2/registry/remote/internal/errutil"
|
||||
)
|
||||
|
||||
// RepositoryOptions is an alias of Repository to avoid name conflicts.
|
||||
// It also hides all methods associated with Repository.
|
||||
type RepositoryOptions Repository
|
||||
|
||||
// Registry is an HTTP client to a remote registry.
|
||||
type Registry struct {
|
||||
// RepositoryOptions contains common options for Registry and Repository.
|
||||
// It is also used as a template for derived repositories.
|
||||
RepositoryOptions
|
||||
|
||||
// RepositoryListPageSize specifies the page size when invoking the catalog
|
||||
// API.
|
||||
// If zero, the page size is determined by the remote registry.
|
||||
// Reference: https://distribution.github.io/distribution/spec/api/#catalog
|
||||
RepositoryListPageSize int
|
||||
}
|
||||
|
||||
// NewRegistry creates a client to the remote registry with the specified domain
|
||||
// name.
|
||||
// Example: localhost:5000
|
||||
func NewRegistry(name string) (*Registry, error) {
|
||||
ref := registry.Reference{
|
||||
Registry: name,
|
||||
}
|
||||
if err := ref.ValidateRegistry(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Registry{
|
||||
RepositoryOptions: RepositoryOptions{
|
||||
Reference: ref,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// client returns an HTTP client used to access the remote registry.
|
||||
// A default HTTP client is return if the client is not configured.
|
||||
func (r *Registry) client() Client {
|
||||
if r.Client == nil {
|
||||
return auth.DefaultClient
|
||||
}
|
||||
return r.Client
|
||||
}
|
||||
|
||||
// do sends an HTTP request and returns an HTTP response using the HTTP client
|
||||
// returned by r.client().
|
||||
func (r *Registry) do(req *http.Request) (*http.Response, error) {
|
||||
if r.HandleWarning == nil {
|
||||
return r.client().Do(req)
|
||||
}
|
||||
|
||||
resp, err := r.client().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handleWarningHeaders(resp.Header.Values(headerWarning), r.HandleWarning)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Ping checks whether or not the registry implement Docker Registry API V2 or
|
||||
// OCI Distribution Specification.
|
||||
// Ping can be used to check authentication when an auth client is configured.
|
||||
//
|
||||
// References:
|
||||
// - https://distribution.github.io/distribution/spec/api/#base
|
||||
// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#api
|
||||
func (r *Registry) Ping(ctx context.Context) error {
|
||||
url := buildRegistryBaseURL(r.PlainHTTP, r.Reference)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := r.do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
return nil
|
||||
case http.StatusNotFound:
|
||||
return errdef.ErrNotFound
|
||||
default:
|
||||
return errutil.ParseErrorResponse(resp)
|
||||
}
|
||||
}
|
||||
|
||||
// Repositories lists the name of repositories available in the registry.
|
||||
// See also `RepositoryListPageSize`.
|
||||
//
|
||||
// If `last` is NOT empty, the entries in the response start after the
|
||||
// repo specified by `last`. Otherwise, the response starts from the top
|
||||
// of the Repositories list.
|
||||
//
|
||||
// Reference: https://distribution.github.io/distribution/spec/api/#catalog
|
||||
func (r *Registry) Repositories(ctx context.Context, last string, fn func(repos []string) error) error {
|
||||
ctx = auth.AppendScopesForHost(ctx, r.Reference.Host(), auth.ScopeRegistryCatalog)
|
||||
url := buildRegistryCatalogURL(r.PlainHTTP, r.Reference)
|
||||
var err error
|
||||
for err == nil {
|
||||
url, err = r.repositories(ctx, last, fn, url)
|
||||
// clear `last` for subsequent pages
|
||||
last = ""
|
||||
}
|
||||
if err != errNoLink {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// repositories returns a single page of repository list with the next link.
|
||||
func (r *Registry) repositories(ctx context.Context, last string, fn func(repos []string) error, url string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if r.RepositoryListPageSize > 0 || last != "" {
|
||||
q := req.URL.Query()
|
||||
if r.RepositoryListPageSize > 0 {
|
||||
q.Set("n", strconv.Itoa(r.RepositoryListPageSize))
|
||||
}
|
||||
if last != "" {
|
||||
q.Set("last", last)
|
||||
}
|
||||
req.URL.RawQuery = q.Encode()
|
||||
}
|
||||
resp, err := r.do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", errutil.ParseErrorResponse(resp)
|
||||
}
|
||||
var page struct {
|
||||
Repositories []string `json:"repositories"`
|
||||
}
|
||||
lr := limitReader(resp.Body, r.MaxMetadataBytes)
|
||||
if err := json.NewDecoder(lr).Decode(&page); err != nil {
|
||||
return "", fmt.Errorf("%s %q: failed to decode response: %w", resp.Request.Method, resp.Request.URL, err)
|
||||
}
|
||||
if err := fn(page.Repositories); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return parseLink(resp)
|
||||
}
|
||||
|
||||
// Repository returns a repository reference by the given name.
|
||||
func (r *Registry) Repository(ctx context.Context, name string) (registry.Repository, error) {
|
||||
ref := registry.Reference{
|
||||
Registry: r.Reference.Registry,
|
||||
Repository: name,
|
||||
}
|
||||
return newRepositoryWithOptions(ref, &r.RepositoryOptions)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
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 retry
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultClient is a client with the default retry policy.
|
||||
var DefaultClient = NewClient()
|
||||
|
||||
// NewClient creates an HTTP client with the default retry policy.
|
||||
func NewClient() *http.Client {
|
||||
return &http.Client{
|
||||
Transport: NewTransport(nil),
|
||||
}
|
||||
}
|
||||
|
||||
// Transport is an HTTP transport with retry policy.
|
||||
type Transport struct {
|
||||
// Base is the underlying HTTP transport to use.
|
||||
// If nil, http.DefaultTransport is used for round trips.
|
||||
Base http.RoundTripper
|
||||
|
||||
// Policy returns a retry Policy to use for the request.
|
||||
// If nil, DefaultPolicy is used to determine if the request should be retried.
|
||||
Policy func() Policy
|
||||
}
|
||||
|
||||
// NewTransport creates an HTTP Transport with the default retry policy.
|
||||
func NewTransport(base http.RoundTripper) *Transport {
|
||||
return &Transport{
|
||||
Base: base,
|
||||
}
|
||||
}
|
||||
|
||||
// RoundTrip executes a single HTTP transaction, returning a Response for the
|
||||
// provided Request.
|
||||
// It relies on the configured Policy to determine if the request should be
|
||||
// retried and to backoff.
|
||||
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
ctx := req.Context()
|
||||
policy := t.policy()
|
||||
attempt := 0
|
||||
for {
|
||||
resp, respErr := t.roundTrip(req)
|
||||
duration, err := policy.Retry(attempt, resp, respErr)
|
||||
if err != nil {
|
||||
if respErr == nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if duration < 0 {
|
||||
return resp, respErr
|
||||
}
|
||||
|
||||
// rewind the body if possible
|
||||
if req.Body != nil {
|
||||
if req.GetBody == nil {
|
||||
// body can't be rewound, so we can't retry
|
||||
return resp, respErr
|
||||
}
|
||||
body, err := req.GetBody()
|
||||
if err != nil {
|
||||
// failed to rewind the body, so we can't retry
|
||||
return resp, respErr
|
||||
}
|
||||
req.Body = body
|
||||
}
|
||||
|
||||
// close the response body if needed
|
||||
if respErr == nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
timer := time.NewTimer(duration)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return nil, ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
attempt++
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Transport) roundTrip(req *http.Request) (*http.Response, error) {
|
||||
if t.Base == nil {
|
||||
return http.DefaultTransport.RoundTrip(req)
|
||||
}
|
||||
return t.Base.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (t *Transport) policy() Policy {
|
||||
if t.Policy == nil {
|
||||
return DefaultPolicy
|
||||
}
|
||||
return t.Policy()
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
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 retry
|
||||
|
||||
import (
|
||||
"hash/maphash"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// headerRetryAfter is the header key for Retry-After.
|
||||
const headerRetryAfter = "Retry-After"
|
||||
|
||||
// DefaultPolicy is a policy with fine-tuned retry parameters.
|
||||
// It uses an exponential backoff with jitter.
|
||||
var DefaultPolicy Policy = &GenericPolicy{
|
||||
Retryable: DefaultPredicate,
|
||||
Backoff: DefaultBackoff,
|
||||
MinWait: 200 * time.Millisecond,
|
||||
MaxWait: 3 * time.Second,
|
||||
MaxRetry: 5,
|
||||
}
|
||||
|
||||
// DefaultPredicate is a predicate that retries on 5xx errors, 429 Too Many
|
||||
// Requests, 408 Request Timeout and on network dial timeout.
|
||||
var DefaultPredicate Predicate = func(resp *http.Response, err error) (bool, error) {
|
||||
if err != nil {
|
||||
// retry on Dial timeout
|
||||
if err, ok := err.(net.Error); ok && err.Timeout() {
|
||||
return true, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusTooManyRequests {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if resp.StatusCode == 0 || resp.StatusCode >= 500 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// DefaultBackoff is a backoff that uses an exponential backoff with jitter.
|
||||
// It uses a base of 250ms, a factor of 2 and a jitter of 10%.
|
||||
var DefaultBackoff Backoff = ExponentialBackoff(250*time.Millisecond, 2, 0.1)
|
||||
|
||||
// Policy is a retry policy.
|
||||
type Policy interface {
|
||||
// Retry returns the duration to wait before retrying the request.
|
||||
// It returns a negative value if the request should not be retried.
|
||||
// The attempt is used to:
|
||||
// - calculate the backoff duration, the default backoff is an exponential backoff.
|
||||
// - determine if the request should be retried.
|
||||
// The attempt starts at 0 and should be less than MaxRetry for the request to
|
||||
// be retried.
|
||||
Retry(attempt int, resp *http.Response, err error) (time.Duration, error)
|
||||
}
|
||||
|
||||
// Predicate is a function that returns true if the request should be retried.
|
||||
type Predicate func(resp *http.Response, err error) (bool, error)
|
||||
|
||||
// Backoff is a function that returns the duration to wait before retrying the
|
||||
// request. The attempt, is the next attempt number. The response is the
|
||||
// response from the previous request.
|
||||
type Backoff func(attempt int, resp *http.Response) time.Duration
|
||||
|
||||
// ExponentialBackoff returns a Backoff that uses an exponential backoff with
|
||||
// jitter. The backoff is calculated as:
|
||||
//
|
||||
// temp = backoff * factor ^ attempt
|
||||
// interval = temp * (1 - jitter) + rand.Int64N(2 * jitter * temp)
|
||||
//
|
||||
// The HTTP response is checked for a Retry-After header. If it is present, the
|
||||
// value is used as the backoff duration.
|
||||
func ExponentialBackoff(backoff time.Duration, factor, jitter float64) Backoff {
|
||||
return func(attempt int, resp *http.Response) time.Duration {
|
||||
var h maphash.Hash
|
||||
h.SetSeed(maphash.MakeSeed())
|
||||
rand := rand.New(rand.NewPCG(0, h.Sum64()))
|
||||
|
||||
// check Retry-After
|
||||
if resp != nil && resp.StatusCode == http.StatusTooManyRequests {
|
||||
if v := resp.Header.Get(headerRetryAfter); v != "" {
|
||||
if retryAfter, _ := strconv.ParseInt(v, 10, 64); retryAfter > 0 {
|
||||
return time.Duration(retryAfter) * time.Second
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// do exponential backoff with jitter
|
||||
temp := float64(backoff) * math.Pow(factor, float64(attempt))
|
||||
return time.Duration(temp*(1-jitter)) + time.Duration(rand.Int64N(int64(2*jitter*temp)))
|
||||
}
|
||||
}
|
||||
|
||||
// GenericPolicy is a generic retry policy.
|
||||
type GenericPolicy struct {
|
||||
// Retryable is a predicate that returns true if the request should be
|
||||
// retried.
|
||||
Retryable Predicate
|
||||
|
||||
// Backoff is a function that returns the duration to wait before retrying.
|
||||
Backoff Backoff
|
||||
|
||||
// MinWait is the minimum duration to wait before retrying.
|
||||
MinWait time.Duration
|
||||
|
||||
// MaxWait is the maximum duration to wait before retrying.
|
||||
MaxWait time.Duration
|
||||
|
||||
// MaxRetry is the maximum number of retries.
|
||||
MaxRetry int
|
||||
}
|
||||
|
||||
// Retry returns the duration to wait before retrying the request.
|
||||
// It returns -1 if the request should not be retried.
|
||||
func (p *GenericPolicy) Retry(attempt int, resp *http.Response, err error) (time.Duration, error) {
|
||||
if attempt >= p.MaxRetry {
|
||||
return -1, nil
|
||||
}
|
||||
if ok, err := p.Retryable(resp, err); err != nil {
|
||||
return -1, err
|
||||
} else if !ok {
|
||||
return -1, nil
|
||||
}
|
||||
backoff := p.Backoff(attempt, resp)
|
||||
if backoff < p.MinWait {
|
||||
backoff = p.MinWait
|
||||
}
|
||||
if backoff > p.MaxWait {
|
||||
backoff = p.MaxWait
|
||||
}
|
||||
return backoff, nil
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
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 remote
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/opencontainers/go-digest"
|
||||
"oras.land/oras-go/v2/registry"
|
||||
)
|
||||
|
||||
// buildScheme returns HTTP scheme used to access the remote registry.
|
||||
func buildScheme(plainHTTP bool) string {
|
||||
if plainHTTP {
|
||||
return "http"
|
||||
}
|
||||
return "https"
|
||||
}
|
||||
|
||||
// buildRegistryBaseURL builds the URL for accessing the base API.
|
||||
// Format: <scheme>://<registry>/v2/
|
||||
// Reference: https://distribution.github.io/distribution/spec/api/#base
|
||||
func buildRegistryBaseURL(plainHTTP bool, ref registry.Reference) string {
|
||||
return fmt.Sprintf("%s://%s/v2/", buildScheme(plainHTTP), ref.Host())
|
||||
}
|
||||
|
||||
// buildRegistryCatalogURL builds the URL for accessing the catalog API.
|
||||
// Format: <scheme>://<registry>/v2/_catalog
|
||||
// Reference: https://distribution.github.io/distribution/spec/api/#catalog
|
||||
func buildRegistryCatalogURL(plainHTTP bool, ref registry.Reference) string {
|
||||
return fmt.Sprintf("%s://%s/v2/_catalog", buildScheme(plainHTTP), ref.Host())
|
||||
}
|
||||
|
||||
// buildRepositoryBaseURL builds the base endpoint of the remote repository.
|
||||
// Format: <scheme>://<registry>/v2/<repository>
|
||||
func buildRepositoryBaseURL(plainHTTP bool, ref registry.Reference) string {
|
||||
return fmt.Sprintf("%s://%s/v2/%s", buildScheme(plainHTTP), ref.Host(), ref.Repository)
|
||||
}
|
||||
|
||||
// buildRepositoryTagListURL builds the URL for accessing the tag list API.
|
||||
// Format: <scheme>://<registry>/v2/<repository>/tags/list
|
||||
// Reference: https://distribution.github.io/distribution/spec/api/#tags
|
||||
func buildRepositoryTagListURL(plainHTTP bool, ref registry.Reference) string {
|
||||
return buildRepositoryBaseURL(plainHTTP, ref) + "/tags/list"
|
||||
}
|
||||
|
||||
// buildRepositoryManifestURL builds the URL for accessing the manifest API.
|
||||
// Format: <scheme>://<registry>/v2/<repository>/manifests/<digest_or_tag>
|
||||
// Reference: https://distribution.github.io/distribution/spec/api/#manifest
|
||||
func buildRepositoryManifestURL(plainHTTP bool, ref registry.Reference) string {
|
||||
return strings.Join([]string{
|
||||
buildRepositoryBaseURL(plainHTTP, ref),
|
||||
"manifests",
|
||||
ref.Reference,
|
||||
}, "/")
|
||||
}
|
||||
|
||||
// buildRepositoryBlobURL builds the URL for accessing the blob API.
|
||||
// Format: <scheme>://<registry>/v2/<repository>/blobs/<digest>
|
||||
// Reference: https://distribution.github.io/distribution/spec/api/#blob
|
||||
func buildRepositoryBlobURL(plainHTTP bool, ref registry.Reference) string {
|
||||
return strings.Join([]string{
|
||||
buildRepositoryBaseURL(plainHTTP, ref),
|
||||
"blobs",
|
||||
ref.Reference,
|
||||
}, "/")
|
||||
}
|
||||
|
||||
// buildRepositoryBlobUploadURL builds the URL for blob uploading.
|
||||
// Format: <scheme>://<registry>/v2/<repository>/blobs/uploads/
|
||||
// Reference: https://distribution.github.io/distribution/spec/api/#initiate-blob-upload
|
||||
func buildRepositoryBlobUploadURL(plainHTTP bool, ref registry.Reference) string {
|
||||
return buildRepositoryBaseURL(plainHTTP, ref) + "/blobs/uploads/"
|
||||
}
|
||||
|
||||
// buildRepositoryBlobMountURLbuilds the URL for cross-repository mounting.
|
||||
// Format: <scheme>://<registry>/v2/<repository>/blobs/uploads/?mount=<digest>&from=<other_repository>
|
||||
// Reference: https://distribution.github.io/distribution/spec/api/#blob
|
||||
func buildRepositoryBlobMountURL(plainHTTP bool, ref registry.Reference, d digest.Digest, fromRepo string) string {
|
||||
return fmt.Sprintf("%s?mount=%s&from=%s",
|
||||
buildRepositoryBlobUploadURL(plainHTTP, ref),
|
||||
d,
|
||||
fromRepo,
|
||||
)
|
||||
}
|
||||
|
||||
// buildReferrersURL builds the URL for querying the Referrers API.
|
||||
// Format: <scheme>://<registry>/v2/<repository>/referrers/<digest>?artifactType=<artifactType>
|
||||
// Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#listing-referrers
|
||||
func buildReferrersURL(plainHTTP bool, ref registry.Reference, artifactType string) string {
|
||||
var query string
|
||||
if artifactType != "" {
|
||||
v := url.Values{}
|
||||
v.Set("artifactType", artifactType)
|
||||
query = "?" + v.Encode()
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"%s/referrers/%s%s",
|
||||
buildRepositoryBaseURL(plainHTTP, ref),
|
||||
ref.Reference,
|
||||
query,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
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 remote
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"oras.land/oras-go/v2/content"
|
||||
"oras.land/oras-go/v2/errdef"
|
||||
)
|
||||
|
||||
// defaultMaxMetadataBytes specifies the default limit on how many response
|
||||
// bytes are allowed in the server's response to the metadata APIs.
|
||||
// See also: Repository.MaxMetadataBytes
|
||||
var defaultMaxMetadataBytes int64 = 4 * 1024 * 1024 // 4 MiB
|
||||
|
||||
// errNoLink is returned by parseLink() when no Link header is present.
|
||||
var errNoLink = errors.New("no Link header in response")
|
||||
|
||||
// parseLink returns the URL of the response's "Link" header, if present.
|
||||
func parseLink(resp *http.Response) (string, error) {
|
||||
link := resp.Header.Get("Link")
|
||||
if link == "" {
|
||||
return "", errNoLink
|
||||
}
|
||||
if link[0] != '<' {
|
||||
return "", fmt.Errorf("invalid next link %q: missing '<'", link)
|
||||
}
|
||||
if i := strings.IndexByte(link, '>'); i == -1 {
|
||||
return "", fmt.Errorf("invalid next link %q: missing '>'", link)
|
||||
} else {
|
||||
link = link[1:i]
|
||||
}
|
||||
|
||||
linkURL, err := resp.Request.URL.Parse(link)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return linkURL.String(), nil
|
||||
}
|
||||
|
||||
// limitReader returns a Reader that reads from r but stops with EOF after n
|
||||
// bytes. If n is less than or equal to zero, defaultMaxMetadataBytes is used.
|
||||
func limitReader(r io.Reader, n int64) io.Reader {
|
||||
if n <= 0 {
|
||||
n = defaultMaxMetadataBytes
|
||||
}
|
||||
return io.LimitReader(r, n)
|
||||
}
|
||||
|
||||
// limitSize returns ErrSizeExceedsLimit if the size of desc exceeds the limit n.
|
||||
// If n is less than or equal to zero, defaultMaxMetadataBytes is used.
|
||||
func limitSize(desc ocispec.Descriptor, n int64) error {
|
||||
if n <= 0 {
|
||||
n = defaultMaxMetadataBytes
|
||||
}
|
||||
if desc.Size > n {
|
||||
return fmt.Errorf(
|
||||
"content size %v exceeds MaxMetadataBytes %v: %w",
|
||||
desc.Size,
|
||||
n,
|
||||
errdef.ErrSizeExceedsLimit)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeJSON safely reads the JSON content described by desc, and
|
||||
// decodes it into v.
|
||||
func decodeJSON(r io.Reader, desc ocispec.Descriptor, v any) error {
|
||||
jsonBytes, err := content.ReadAll(r, desc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(jsonBytes, v)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
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 remote
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// headerWarning is the "Warning" header.
|
||||
// Reference: https://www.rfc-editor.org/rfc/rfc7234#section-5.5
|
||||
headerWarning = "Warning"
|
||||
|
||||
// warnCode299 is the 299 warn-code.
|
||||
// Reference: https://www.rfc-editor.org/rfc/rfc7234#section-5.5
|
||||
warnCode299 = 299
|
||||
|
||||
// warnAgentUnknown represents an unknown warn-agent.
|
||||
// Reference: https://www.rfc-editor.org/rfc/rfc7234#section-5.5
|
||||
warnAgentUnknown = "-"
|
||||
)
|
||||
|
||||
// errUnexpectedWarningFormat is returned by parseWarningHeader when
|
||||
// an unexpected warning format is encountered.
|
||||
var errUnexpectedWarningFormat = errors.New("unexpected warning format")
|
||||
|
||||
// WarningValue represents the value of the Warning header.
|
||||
//
|
||||
// References:
|
||||
// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#warnings
|
||||
// - https://www.rfc-editor.org/rfc/rfc7234#section-5.5
|
||||
type WarningValue struct {
|
||||
// Code is the warn-code.
|
||||
Code int
|
||||
// Agent is the warn-agent.
|
||||
Agent string
|
||||
// Text is the warn-text.
|
||||
Text string
|
||||
}
|
||||
|
||||
// Warning contains the value of the warning header and may contain
|
||||
// other information related to the warning.
|
||||
//
|
||||
// References:
|
||||
// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#warnings
|
||||
// - https://www.rfc-editor.org/rfc/rfc7234#section-5.5
|
||||
type Warning struct {
|
||||
// WarningValue is the value of the warning header.
|
||||
WarningValue
|
||||
}
|
||||
|
||||
// parseWarningHeader parses the warning header into WarningValue.
|
||||
func parseWarningHeader(header string) (WarningValue, error) {
|
||||
if len(header) < 9 || !strings.HasPrefix(header, `299 - "`) || !strings.HasSuffix(header, `"`) {
|
||||
// minimum header value: `299 - "x"`
|
||||
return WarningValue{}, fmt.Errorf("%s: %w", header, errUnexpectedWarningFormat)
|
||||
}
|
||||
|
||||
// validate text only as code and agent are fixed
|
||||
quotedText := header[6:] // behind `299 - `, quoted by "
|
||||
text, err := strconv.Unquote(quotedText)
|
||||
if err != nil {
|
||||
return WarningValue{}, fmt.Errorf("%s: unexpected text: %w: %v", header, errUnexpectedWarningFormat, err)
|
||||
}
|
||||
|
||||
return WarningValue{
|
||||
Code: warnCode299,
|
||||
Agent: warnAgentUnknown,
|
||||
Text: text,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// handleWarningHeaders parses the warning headers and handles the parsed
|
||||
// warnings using handleWarning.
|
||||
func handleWarningHeaders(headers []string, handleWarning func(Warning)) {
|
||||
for _, h := range headers {
|
||||
if value, err := parseWarningHeader(h); err == nil {
|
||||
// ignore warnings in unexpected formats
|
||||
handleWarning(Warning{
|
||||
WarningValue: value,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
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 registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"oras.land/oras-go/v2/content"
|
||||
"oras.land/oras-go/v2/errdef"
|
||||
"oras.land/oras-go/v2/internal/descriptor"
|
||||
"oras.land/oras-go/v2/internal/spec"
|
||||
)
|
||||
|
||||
// Repository is an ORAS target and an union of the blob and the manifest CASs.
|
||||
//
|
||||
// As specified by https://distribution.github.io/distribution/spec/api/, it is natural to
|
||||
// assume that content.Resolver interface only works for manifests. Tagging a
|
||||
// blob may be resulted in an `ErrUnsupported` error. However, this interface
|
||||
// does not restrict tagging blobs.
|
||||
//
|
||||
// Since a repository is an union of the blob and the manifest CASs, all
|
||||
// operations defined in the `BlobStore` are executed depending on the media
|
||||
// type of the given descriptor accordingly.
|
||||
//
|
||||
// Furthermore, this interface also provides the ability to enforce the
|
||||
// separation of the blob and the manifests CASs.
|
||||
type Repository interface {
|
||||
content.Storage
|
||||
content.Deleter
|
||||
content.TagResolver
|
||||
ReferenceFetcher
|
||||
ReferencePusher
|
||||
ReferrerLister
|
||||
TagLister
|
||||
|
||||
// Blobs provides access to the blob CAS only, which contains config blobs,
|
||||
// layers, and other generic blobs.
|
||||
Blobs() BlobStore
|
||||
|
||||
// Manifests provides access to the manifest CAS only.
|
||||
Manifests() ManifestStore
|
||||
}
|
||||
|
||||
// BlobStore is a CAS with the ability to stat and delete its content.
|
||||
type BlobStore interface {
|
||||
content.Storage
|
||||
content.Deleter
|
||||
content.Resolver
|
||||
ReferenceFetcher
|
||||
}
|
||||
|
||||
// ManifestStore is a CAS with the ability to stat and delete its content.
|
||||
// Besides, ManifestStore provides reference tagging.
|
||||
type ManifestStore interface {
|
||||
BlobStore
|
||||
content.Tagger
|
||||
ReferencePusher
|
||||
}
|
||||
|
||||
// ReferencePusher provides advanced push with the tag service.
|
||||
type ReferencePusher interface {
|
||||
// PushReference pushes the manifest with a reference tag.
|
||||
PushReference(ctx context.Context, expected ocispec.Descriptor, content io.Reader, reference string) error
|
||||
}
|
||||
|
||||
// ReferenceFetcher provides advanced fetch with the tag service.
|
||||
type ReferenceFetcher interface {
|
||||
// FetchReference fetches the content identified by the reference.
|
||||
FetchReference(ctx context.Context, reference string) (ocispec.Descriptor, io.ReadCloser, error)
|
||||
}
|
||||
|
||||
// ReferrerLister provides the Referrers API.
|
||||
// Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#listing-referrers
|
||||
type ReferrerLister interface {
|
||||
Referrers(ctx context.Context, desc ocispec.Descriptor, artifactType string, fn func(referrers []ocispec.Descriptor) error) error
|
||||
}
|
||||
|
||||
// TagLister lists tags by the tag service.
|
||||
type TagLister interface {
|
||||
// Tags lists the tags available in the repository.
|
||||
// Since the returned tag list may be paginated by the underlying
|
||||
// implementation, a function should be passed in to process the paginated
|
||||
// tag list.
|
||||
//
|
||||
// `last` argument is the `last` parameter when invoking the tags API.
|
||||
// If `last` is NOT empty, the entries in the response start after the
|
||||
// tag specified by `last`. Otherwise, the response starts from the top
|
||||
// of the Tags list.
|
||||
//
|
||||
// Note: When implemented by a remote registry, the tags API is called.
|
||||
// However, not all registries supports pagination or conforms the
|
||||
// specification.
|
||||
//
|
||||
// References:
|
||||
// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#content-discovery
|
||||
// - https://distribution.github.io/distribution/spec/api/#tags
|
||||
// See also `Tags()` in this package.
|
||||
Tags(ctx context.Context, last string, fn func(tags []string) error) error
|
||||
}
|
||||
|
||||
// Mounter allows cross-repository blob mounts.
|
||||
// For backward compatibility reasons, this is not implemented by
|
||||
// BlobStore: use a type assertion to check availability.
|
||||
type Mounter interface {
|
||||
// Mount makes the blob with the given descriptor in fromRepo
|
||||
// available in the repository signified by the receiver.
|
||||
Mount(ctx context.Context,
|
||||
desc ocispec.Descriptor,
|
||||
fromRepo string,
|
||||
getContent func() (io.ReadCloser, error),
|
||||
) error
|
||||
}
|
||||
|
||||
// Tags lists the tags available in the repository.
|
||||
func Tags(ctx context.Context, repo TagLister) ([]string, error) {
|
||||
var res []string
|
||||
if err := repo.Tags(ctx, "", func(tags []string) error {
|
||||
res = append(res, tags...)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Referrers lists the descriptors of image or artifact manifests directly
|
||||
// referencing the given manifest descriptor.
|
||||
//
|
||||
// Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#listing-referrers
|
||||
func Referrers(ctx context.Context, store content.ReadOnlyGraphStorage, desc ocispec.Descriptor, artifactType string) ([]ocispec.Descriptor, error) {
|
||||
if !descriptor.IsManifest(desc) {
|
||||
return nil, fmt.Errorf("the descriptor %v is not a manifest: %w", desc, errdef.ErrUnsupported)
|
||||
}
|
||||
|
||||
var results []ocispec.Descriptor
|
||||
|
||||
// use the Referrer API if it is available
|
||||
if rf, ok := store.(ReferrerLister); ok {
|
||||
if err := rf.Referrers(ctx, desc, artifactType, func(referrers []ocispec.Descriptor) error {
|
||||
results = append(results, referrers...)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
predecessors, err := store.Predecessors(ctx, desc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, node := range predecessors {
|
||||
switch node.MediaType {
|
||||
case ocispec.MediaTypeImageManifest:
|
||||
fetched, err := content.FetchAll(ctx, store, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var manifest ocispec.Manifest
|
||||
if err := json.Unmarshal(fetched, &manifest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if manifest.Subject == nil || !content.Equal(*manifest.Subject, desc) {
|
||||
continue
|
||||
}
|
||||
node.ArtifactType = manifest.ArtifactType
|
||||
if node.ArtifactType == "" {
|
||||
node.ArtifactType = manifest.Config.MediaType
|
||||
}
|
||||
node.Annotations = manifest.Annotations
|
||||
case ocispec.MediaTypeImageIndex:
|
||||
fetched, err := content.FetchAll(ctx, store, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var index ocispec.Index
|
||||
if err := json.Unmarshal(fetched, &index); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if index.Subject == nil || !content.Equal(*index.Subject, desc) {
|
||||
continue
|
||||
}
|
||||
node.ArtifactType = index.ArtifactType
|
||||
node.Annotations = index.Annotations
|
||||
case spec.MediaTypeArtifactManifest:
|
||||
fetched, err := content.FetchAll(ctx, store, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var artifact spec.Artifact
|
||||
if err := json.Unmarshal(fetched, &artifact); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if artifact.Subject == nil || !content.Equal(*artifact.Subject, desc) {
|
||||
continue
|
||||
}
|
||||
node.ArtifactType = artifact.ArtifactType
|
||||
node.Annotations = artifact.Annotations
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if artifactType == "" || artifactType == node.ArtifactType {
|
||||
// the field artifactType in referrers descriptor is allowed to be empty
|
||||
// https://github.com/opencontainers/distribution-spec/issues/458
|
||||
results = append(results, node)
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
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 oras
|
||||
|
||||
import "oras.land/oras-go/v2/content"
|
||||
|
||||
// Target is a CAS with generic tags.
|
||||
type Target interface {
|
||||
content.Storage
|
||||
content.TagResolver
|
||||
}
|
||||
|
||||
// GraphTarget is a CAS with generic tags that supports direct predecessor node
|
||||
// finding.
|
||||
type GraphTarget interface {
|
||||
content.GraphStorage
|
||||
content.TagResolver
|
||||
}
|
||||
|
||||
// ReadOnlyTarget represents a read-only Target.
|
||||
type ReadOnlyTarget interface {
|
||||
content.ReadOnlyStorage
|
||||
content.Resolver
|
||||
}
|
||||
|
||||
// ReadOnlyGraphTarget represents a read-only GraphTarget.
|
||||
type ReadOnlyGraphTarget interface {
|
||||
content.ReadOnlyGraphStorage
|
||||
content.Resolver
|
||||
}
|
||||
Reference in New Issue
Block a user