Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#305 cleanup 2: Reference handling #426

Merged
merged 43 commits into from
Jun 5, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
c395f15
Make TestStorageReferenceDockerDeference table-driven
mtrmac Mar 9, 2018
cce3881
Add more test cases, along with a few FIXMEs
mtrmac Feb 12, 2018
225e91a
Re-add the :tag or @digest form at start of PolicyConfigurationNamesp…
mtrmac Mar 9, 2018
b8969b7
Rewrite TestTransportValidatePolicyConfigurationScope
mtrmac Mar 9, 2018
04e1018
Fix ValidatePolicyConfigurationScope
mtrmac Mar 9, 2018
88567cf
Add a test for verboseName
mtrmac Mar 9, 2018
5cb0e19
Make verboseName accept only a non-nil reference.Named
mtrmac Mar 9, 2018
a758135
Simplify verboseName now that isNamed is always true
mtrmac Mar 9, 2018
cbb7c07
Don't use reference.TrimNamed().String()
mtrmac Mar 9, 2018
e75d430
Rewrite verboseName() as reference.Named.String()
mtrmac Mar 9, 2018
a0db144
Eliminate verboseName
mtrmac Mar 9, 2018
50232c6
RFC: Refuse un-named @digest reference input
mtrmac Mar 9, 2018
63f2cf8
UNTESTED: Fix name@digest@IDprefix parsing
mtrmac Mar 9, 2018
52e9a63
Refuse name@digest@digest@ID inputs when parsing references
mtrmac Mar 9, 2018
c0bc991
UNTESTED: Match repositories using .Name() instead of FamiliarName()
mtrmac Mar 9, 2018
4406711
UNTESTED: Extract duplicate code from resolveImage
mtrmac Mar 9, 2018
5b62b7d
Introduce storageReference.completeReference
mtrmac Mar 9, 2018
acf13f4
Fix storageReference.tag for name:tag@digest
mtrmac Mar 9, 2018
5ad5ea2
Remove the only user of storageReference.tag
mtrmac Mar 9, 2018
91508be
Remove storageReference.tag
mtrmac Mar 9, 2018
d801ec6
Remove all users of storageReference.digest
mtrmac Mar 9, 2018
70afa3b
Remove storageReference.digest
mtrmac Mar 9, 2018
5bc7884
Remove all users of storageReference.name
mtrmac Mar 9, 2018
059c0fc
Simplify storageReference.DockerReference
mtrmac Mar 9, 2018
841cbfe
Remove storageReference.name
mtrmac Mar 9, 2018
446237b
Eliminate some references to "name" in ParseStoreReference
mtrmac Mar 9, 2018
1faf4fa
Eliminate short-lived reference.* variables in ParseStoreReference
mtrmac Mar 9, 2018
e78daf9
Consolidate the value of "refname" in ParseStoreReference
mtrmac Mar 9, 2018
682fd27
Simplify ParseStoreReference a bit more
mtrmac Mar 9, 2018
3ebfd20
Eliminate all users of storageReference.reference
mtrmac Mar 9, 2018
46174f3
Remove storageReference.reference
mtrmac Mar 9, 2018
416ef90
Use .StringWithinTransport for the debug log in ParseStoreReference
mtrmac Mar 9, 2018
e643db3
Simplify the named/unnamed paths in ParseStoreReference a bit
mtrmac Mar 9, 2018
c5e28a6
Rename storageReference.completeReference to storageReference.named
mtrmac Mar 9, 2018
9d0ef1b
Enforce that a reference has at least one of "named" and "id"
mtrmac Mar 9, 2018
896003d
Simplify StringWithinTransport and PolicyConfigurationIdentity
mtrmac Mar 9, 2018
e17ff8f
RFC: Simplify parsing of image IDs/digests
mtrmac Mar 9, 2018
03e1320
Move the ID-only case before checking for a digest
mtrmac Mar 9, 2018
034fb34
Don't explicitly parse a digest.
mtrmac Mar 9, 2018
b69da33
Add a name:tag parent policy namespace for name:tag@digest images
mtrmac Mar 9, 2018
9fd3ee4
RFC UNTESTED: Only load an image once in resolveImage
mtrmac Mar 9, 2018
9b4590b
Add types.ImageDestination.IgnoresEmbeddedDockerReference
mtrmac Mar 9, 2018
62ed2a8
Drop storageReference.breakDockerReference and storageImageDestinatio…
mtrmac Mar 9, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions copy/copy.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,9 @@ func checkImageDestinationForCurrentRuntimeOS(ctx context.Context, sys *types.Sy

// updateEmbeddedDockerReference handles the Docker reference embedded in Docker schema1 manifests.
func (ic *imageCopier) updateEmbeddedDockerReference() error {
if ic.c.dest.IgnoresEmbeddedDockerReference() {
return nil // Destination would prefer us not to update the embedded reference.
}
destRef := ic.c.dest.Reference().DockerReference()
if destRef == nil {
return nil // Destination does not care about Docker references
Expand Down
7 changes: 7 additions & 0 deletions directory/directory_dest.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,13 @@ func (d *dirImageDestination) MustMatchRuntimeOS() bool {
return false
}

// IgnoresEmbeddedDockerReference returns true iff the destination does not care about Image.EmbeddedDockerReferenceConflicts(),
// and would prefer to receive an unmodified manifest instead of one modified for the destination.
// Does not make a difference if Reference().DockerReference() is nil.
func (d *dirImageDestination) IgnoresEmbeddedDockerReference() bool {
return false // N/A, DockerReference() returns nil.
}

// PutBlob writes contents of stream and returns data representing the result (with all data filled in).
// inputInfo.Digest can be optionally provided if known; it is not mandatory for the implementation to verify it.
// inputInfo.Size is the expected length of stream, if known.
Expand Down
7 changes: 7 additions & 0 deletions docker/docker_image_dest.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ func (d *dockerImageDestination) MustMatchRuntimeOS() bool {
return false
}

// IgnoresEmbeddedDockerReference returns true iff the destination does not care about Image.EmbeddedDockerReferenceConflicts(),
// and would prefer to receive an unmodified manifest instead of one modified for the destination.
// Does not make a difference if Reference().DockerReference() is nil.
func (d *dockerImageDestination) IgnoresEmbeddedDockerReference() bool {
return false // We do want the manifest updated; older registry versions refuse manifests if the embedded reference does not match.
}

// sizeCounter is an io.Writer which only counts the total size of its input.
type sizeCounter struct{ size int64 }

Expand Down
7 changes: 7 additions & 0 deletions docker/tarfile/dest.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ func (d *Destination) MustMatchRuntimeOS() bool {
return false
}

// IgnoresEmbeddedDockerReference returns true iff the destination does not care about Image.EmbeddedDockerReferenceConflicts(),
// and would prefer to receive an unmodified manifest instead of one modified for the destination.
// Does not make a difference if Reference().DockerReference() is nil.
func (d *Destination) IgnoresEmbeddedDockerReference() bool {
return false // N/A, we only accept schema2 images where EmbeddedDockerReferenceConflicts() is always false.
}

// PutBlob writes contents of stream and returns data representing the result (with all data filled in).
// inputInfo.Digest can be optionally provided if known; it is not mandatory for the implementation to verify it.
// inputInfo.Size is the expected length of stream, if known.
Expand Down
3 changes: 3 additions & 0 deletions image/docker_schema2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,9 @@ func (d *memoryImageDest) AcceptsForeignLayerURLs() bool {
func (d *memoryImageDest) MustMatchRuntimeOS() bool {
panic("Unexpected call to a mock function")
}
func (d *memoryImageDest) IgnoresEmbeddedDockerReference() bool {
panic("Unexpected call to a mock function")
}
func (d *memoryImageDest) PutBlob(ctx context.Context, stream io.Reader, inputInfo types.BlobInfo, isConfig bool) (types.BlobInfo, error) {
if d.storedBlobs == nil {
d.storedBlobs = make(map[digest.Digest][]byte)
Expand Down
7 changes: 7 additions & 0 deletions oci/archive/oci_dest.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ func (d *ociArchiveImageDestination) MustMatchRuntimeOS() bool {
return d.unpackedDest.MustMatchRuntimeOS()
}

// IgnoresEmbeddedDockerReference returns true iff the destination does not care about Image.EmbeddedDockerReferenceConflicts(),
// and would prefer to receive an unmodified manifest instead of one modified for the destination.
// Does not make a difference if Reference().DockerReference() is nil.
func (d *ociArchiveImageDestination) IgnoresEmbeddedDockerReference() bool {
return d.unpackedDest.IgnoresEmbeddedDockerReference()
}

// PutBlob writes contents of stream and returns data representing the result (with all data filled in).
// inputInfo.Digest can be optionally provided if known; it is not mandatory for the implementation to verify it.
// inputInfo.Size is the expected length of stream, if known.
Expand Down
7 changes: 7 additions & 0 deletions oci/layout/oci_dest.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ func (d *ociImageDestination) MustMatchRuntimeOS() bool {
return false
}

// IgnoresEmbeddedDockerReference returns true iff the destination does not care about Image.EmbeddedDockerReferenceConflicts(),
// and would prefer to receive an unmodified manifest instead of one modified for the destination.
// Does not make a difference if Reference().DockerReference() is nil.
func (d *ociImageDestination) IgnoresEmbeddedDockerReference() bool {
return false // N/A, DockerReference() returns nil.
}

// PutBlob writes contents of stream and returns data representing the result (with all data filled in).
// inputInfo.Digest can be optionally provided if known; it is not mandatory for the implementation to verify it.
// inputInfo.Size is the expected length of stream, if known.
Expand Down
7 changes: 7 additions & 0 deletions openshift/openshift.go
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,13 @@ func (d *openshiftImageDestination) MustMatchRuntimeOS() bool {
return false
}

// IgnoresEmbeddedDockerReference returns true iff the destination does not care about Image.EmbeddedDockerReferenceConflicts(),
// and would prefer to receive an unmodified manifest instead of one modified for the destination.
// Does not make a difference if Reference().DockerReference() is nil.
func (d *openshiftImageDestination) IgnoresEmbeddedDockerReference() bool {
return d.docker.IgnoresEmbeddedDockerReference()
}

// PutBlob writes contents of stream and returns data representing the result (with all data filled in).
// inputInfo.Digest can be optionally provided if known; it is not mandatory for the implementation to verify it.
// inputInfo.Size is the expected length of stream, if known.
Expand Down
7 changes: 7 additions & 0 deletions ostree/ostree_dest.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,13 @@ func (d *ostreeImageDestination) MustMatchRuntimeOS() bool {
return true
}

// IgnoresEmbeddedDockerReference returns true iff the destination does not care about Image.EmbeddedDockerReferenceConflicts(),
// and would prefer to receive an unmodified manifest instead of one modified for the destination.
// Does not make a difference if Reference().DockerReference() is nil.
func (d *ostreeImageDestination) IgnoresEmbeddedDockerReference() bool {
return false // N/A, DockerReference() returns nil.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when it is not used, should it return true so that updateEmbeddedDockerReference exits early?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@giuseppe I’m not too worried about saving a few cycles during an image copy which writes gigabytes of data to the disk.

If anything, it’s more a matter of semantics: does the ostree backend prefer a manifest tailored for it, or an unmodified one? Looking at how ostreeImageDestination.Commit records the manifest digest in the ostree metadata, maybe it really would prefer the unmodified one, and this should return true for that reason.

If it does not matter at all, I’d rather leave it at the default false; we may eventually want to to have defaults for many of the ImageDestination flags without copies in every transport, and that will result in shorter code if more transports use the default values.

}

func (d *ostreeImageDestination) PutBlob(ctx context.Context, stream io.Reader, inputInfo types.BlobInfo, isConfig bool) (types.BlobInfo, error) {
tmpDir, err := ioutil.TempDir(d.tmpDirPath, "blob")
if err != nil {
Expand Down
26 changes: 12 additions & 14 deletions storage/storage_image.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,7 @@ type storageImageSource struct {
}

type storageImageDestination struct {
imageRef storageReference // The reference we'll use to name the image
publicRef storageReference // The reference we return when asked about the name we'll give to the image
imageRef storageReference
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Do we need a comment here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

“A reference to the image this destination writes to” should be clear enough without a comment; the previous two values needed comments (even more comments, arguably) to explain when to use which value.

directory string // Temporary directory where we store blobs until Commit() time
nextTempFileID int32 // A counter that we use for computing filenames to assign to blobs
manifest []byte // Manifest contents, temporary
Expand Down Expand Up @@ -243,15 +242,8 @@ func newImageDestination(imageRef storageReference) (*storageImageDestination, e
if err != nil {
return nil, errors.Wrapf(err, "error creating a temporary directory")
}
// Break reading of the reference we're writing, so that copy.Image() won't try to rewrite
// schema1 image manifests to remove embedded references, since that changes the manifest's
// digest, and that makes the image unusable if we subsequently try to access it using a
// reference that mentions the no-longer-correct digest.
publicRef := imageRef
publicRef.name = nil
image := &storageImageDestination{
imageRef: imageRef,
publicRef: publicRef,
directory: directory,
blobDiffIDs: make(map[digest.Digest]digest.Digest),
fileSizes: make(map[digest.Digest]int64),
Expand All @@ -261,11 +253,10 @@ func newImageDestination(imageRef storageReference) (*storageImageDestination, e
return image, nil
}

// Reference returns a mostly-usable image reference that can't return a DockerReference, to
// avoid triggering logic in copy.Image() that rewrites schema 1 image manifests in order to
// remove image names that they contain which don't match the value we're using.
// Reference returns the reference used to set up this destination. Note that this should directly correspond to user's intent,
// e.g. it should use the public hostname instead of the result of resolving CNAMEs or following redirects.
func (s storageImageDestination) Reference() types.ImageReference {
return s.publicRef
return s.imageRef
}

// Close cleans up the temporary directory.
Expand Down Expand Up @@ -613,7 +604,7 @@ func (s *storageImageDestination) Commit(ctx context.Context) error {
if name := s.imageRef.DockerReference(); len(oldNames) > 0 || name != nil {
names := []string{}
if name != nil {
names = append(names, verboseName(name))
names = append(names, name.String())
}
if len(oldNames) > 0 {
names = append(names, oldNames...)
Expand Down Expand Up @@ -703,6 +694,13 @@ func (s *storageImageDestination) MustMatchRuntimeOS() bool {
return true
}

// IgnoresEmbeddedDockerReference returns true iff the destination does not care about Image.EmbeddedDockerReferenceConflicts(),
// and would prefer to receive an unmodified manifest instead of one modified for the destination.
// Does not make a difference if Reference().DockerReference() is nil.
func (s *storageImageDestination) IgnoresEmbeddedDockerReference() bool {
return true // Yes, we want the unmodified manifest
}

// PutSignatures records the image's signatures for committing as a single data blob.
func (s *storageImageDestination) PutSignatures(ctx context.Context, signatures [][]byte) error {
sizes := []int{}
Expand Down
139 changes: 68 additions & 71 deletions storage/storage_reference.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,63 +9,72 @@ import (
"github.com/containers/image/docker/reference"
"github.com/containers/image/types"
"github.com/containers/storage"
digest "github.com/opencontainers/go-digest"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)

// A storageReference holds an arbitrary name and/or an ID, which is a 32-byte
// value hex-encoded into a 64-character string, and a reference to a Store
// where an image is, or would be, kept.
// Either "named" or "id" must be set.
type storageReference struct {
transport storageTransport
reference string
named reference.Named // may include a tag and/or a digest
id string
name reference.Named
tag string
digest digest.Digest
}

func newReference(transport storageTransport, reference, id string, name reference.Named, tag string, digest digest.Digest) *storageReference {
func newReference(transport storageTransport, named reference.Named, id string) (*storageReference, error) {
if named == nil && id == "" {
return nil, ErrInvalidReference
}
// We take a copy of the transport, which contains a pointer to the
// store that it used for resolving this reference, so that the
// transport that we'll return from Transport() won't be affected by
// further calls to the original transport's SetStore() method.
return &storageReference{
transport: transport,
reference: reference,
named: named,
id: id,
name: name,
tag: tag,
digest: digest,
}, nil
}

// imageMatchesRepo returns true iff image.Names contains an element with the same repo as ref
func imageMatchesRepo(image *storage.Image, ref reference.Named) bool {
repo := ref.Name()
for _, name := range image.Names {
if named, err := reference.ParseNormalizedNamed(name); err == nil {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it ok to ignore the err? Should we logrus.Debugf this or is this expected.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

imageMatchesRepo is clearly(?) false for values which can’t be parsed. I don’t know whether such values can happen, or how likely that is. @nalind ?

(This code is not new, it’s only moved out of the calling function (commit “UNTESTED: Extract duplicate code from resolveImage”).)

if named.Name() == repo {
return true
}
}
}
return false
}

// Resolve the reference's name to an image ID in the store, if there's already
// one present with the same name or ID, and return the image.
func (s *storageReference) resolveImage() (*storage.Image, error) {
var loadedImage *storage.Image
if s.id == "" {
// Look for an image that has the expanded reference name as an explicit Name value.
image, err := s.transport.store.Image(s.reference)
image, err := s.transport.store.Image(s.named.String())
if image != nil && err == nil {
loadedImage = image
s.id = image.ID
}
}
if s.id == "" && s.name != nil && s.digest != "" {
// Look for an image with the specified digest that has the same name,
// though possibly with a different tag or digest, as a Name value, so
// that the canonical reference can be implicitly resolved to the image.
images, err := s.transport.store.ImagesByDigest(s.digest)
if images != nil && err == nil {
repo := reference.FamiliarName(reference.TrimNamed(s.name))
search:
for _, image := range images {
for _, name := range image.Names {
if named, err := reference.ParseNormalizedNamed(name); err == nil {
if reference.FamiliarName(reference.TrimNamed(named)) == repo {
s.id = image.ID
break search
}
if s.id == "" && s.named != nil {
if digested, ok := s.named.(reference.Digested); ok {
// Look for an image with the specified digest that has the same name,
// though possibly with a different tag or digest, as a Name value, so
// that the canonical reference can be implicitly resolved to the image.
images, err := s.transport.store.ImagesByDigest(digested.Digest())
if images != nil && err == nil {
for _, image := range images {
if imageMatchesRepo(image, s.named) {
loadedImage = image
s.id = image.ID
break
}
}
}
Expand All @@ -75,27 +84,20 @@ func (s *storageReference) resolveImage() (*storage.Image, error) {
logrus.Debugf("reference %q does not resolve to an image ID", s.StringWithinTransport())
return nil, errors.Wrapf(ErrNoSuchImage, "reference %q does not resolve to an image ID", s.StringWithinTransport())
}
img, err := s.transport.store.Image(s.id)
if err != nil {
return nil, errors.Wrapf(err, "error reading image %q", s.id)
}
if s.name != nil {
repo := reference.FamiliarName(reference.TrimNamed(s.name))
nameMatch := false
for _, name := range img.Names {
if named, err := reference.ParseNormalizedNamed(name); err == nil {
if reference.FamiliarName(reference.TrimNamed(named)) == repo {
nameMatch = true
break
}
}
if loadedImage == nil {
img, err := s.transport.store.Image(s.id)
if err != nil {
return nil, errors.Wrapf(err, "error reading image %q", s.id)
}
if !nameMatch {
loadedImage = img
}
if s.named != nil {
if !imageMatchesRepo(loadedImage, s.named) {
logrus.Errorf("no image matching reference %q found", s.StringWithinTransport())
return nil, ErrNoSuchImage
}
}
return img, nil
return loadedImage, nil
}

// Return a Transport object that defaults to using the same store that we used
Expand All @@ -110,20 +112,7 @@ func (s storageReference) Transport() types.ImageTransport {

// Return a name with a tag or digest, if we have either, else return it bare.
func (s storageReference) DockerReference() reference.Named {
if s.name == nil {
return nil
}
if s.tag != "" {
if namedTagged, err := reference.WithTag(s.name, s.tag); err == nil {
return namedTagged
}
}
if s.digest != "" {
if canonical, err := reference.WithDigest(s.name, s.digest); err == nil {
return canonical
}
}
return s.name
return s.named
}

// Return a name with a tag, prefixed with the graph root and driver name, to
Expand All @@ -135,25 +124,25 @@ func (s storageReference) StringWithinTransport() string {
if len(options) > 0 {
optionsList = ":" + strings.Join(options, ",")
}
storeSpec := "[" + s.transport.store.GraphDriverName() + "@" + s.transport.store.GraphRoot() + "+" + s.transport.store.RunRoot() + optionsList + "]"
if s.reference == "" {
return storeSpec + "@" + s.id
res := "[" + s.transport.store.GraphDriverName() + "@" + s.transport.store.GraphRoot() + "+" + s.transport.store.RunRoot() + optionsList + "]"
if s.named != nil {
res = res + s.named.String()
}
if s.id == "" {
return storeSpec + s.reference
if s.id != "" {
res = res + "@" + s.id
}
return storeSpec + s.reference + "@" + s.id
return res
}

func (s storageReference) PolicyConfigurationIdentity() string {
storeSpec := "[" + s.transport.store.GraphDriverName() + "@" + s.transport.store.GraphRoot() + "]"
if s.name == nil {
return storeSpec + "@" + s.id
res := "[" + s.transport.store.GraphDriverName() + "@" + s.transport.store.GraphRoot() + "]"
if s.named != nil {
res = res + s.named.String()
}
if s.id == "" {
return storeSpec + s.reference
if s.id != "" {
res = res + "@" + s.id
}
return storeSpec + s.reference + "@" + s.id
return res
}

// Also accept policy that's tied to the combination of the graph root and
Expand All @@ -164,9 +153,17 @@ func (s storageReference) PolicyConfigurationNamespaces() []string {
storeSpec := "[" + s.transport.store.GraphDriverName() + "@" + s.transport.store.GraphRoot() + "]"
driverlessStoreSpec := "[" + s.transport.store.GraphRoot() + "]"
namespaces := []string{}
if s.name != nil {
name := reference.TrimNamed(s.name)
components := strings.Split(name.String(), "/")
if s.named != nil {
if s.id != "" {
// The reference without the ID is also a valid namespace.
namespaces = append(namespaces, storeSpec+s.named.String())
}
tagged, isTagged := s.named.(reference.Tagged)
_, isDigested := s.named.(reference.Digested)
if isTagged && isDigested { // s.named is "name:tag@digest"; add a "name:tag" parent namespace.
namespaces = append(namespaces, storeSpec+s.named.Name()+":"+tagged.Tag())
}
components := strings.Split(s.named.Name(), "/")
for len(components) > 0 {
namespaces = append(namespaces, storeSpec+strings.Join(components, "/"))
components = components[:len(components)-1]
Expand Down
Loading