2016-07-20 09:46:01 +00:00
|
|
|
|
package docker
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"io/ioutil"
|
|
|
|
|
"net/http"
|
2016-09-17 13:50:35 +00:00
|
|
|
|
"net/url"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
2016-07-20 09:46:01 +00:00
|
|
|
|
|
|
|
|
|
"github.com/Sirupsen/logrus"
|
|
|
|
|
"github.com/containers/image/manifest"
|
|
|
|
|
"github.com/containers/image/types"
|
2016-10-17 13:53:40 +00:00
|
|
|
|
"github.com/opencontainers/go-digest"
|
|
|
|
|
"github.com/pkg/errors"
|
2016-07-20 09:46:01 +00:00
|
|
|
|
)
|
|
|
|
|
|
2017-02-01 00:45:59 +00:00
|
|
|
|
var manifestMIMETypes = []string{
|
|
|
|
|
// TODO(runcom): we'll add OCI as part of another PR here
|
|
|
|
|
manifest.DockerV2Schema2MediaType,
|
|
|
|
|
manifest.DockerV2Schema1SignedMediaType,
|
|
|
|
|
manifest.DockerV2Schema1MediaType,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func supportedManifestMIMETypesMap() map[string]bool {
|
|
|
|
|
m := make(map[string]bool, len(manifestMIMETypes))
|
|
|
|
|
for _, mt := range manifestMIMETypes {
|
|
|
|
|
m[mt] = true
|
|
|
|
|
}
|
|
|
|
|
return m
|
|
|
|
|
}
|
|
|
|
|
|
2016-07-20 09:46:01 +00:00
|
|
|
|
type dockerImageDestination struct {
|
|
|
|
|
ref dockerReference
|
|
|
|
|
c *dockerClient
|
2016-09-17 13:50:35 +00:00
|
|
|
|
// State
|
2016-11-22 19:32:10 +00:00
|
|
|
|
manifestDigest digest.Digest // or "" if not yet known.
|
2016-07-20 09:46:01 +00:00
|
|
|
|
}
|
|
|
|
|
|
2016-09-17 13:50:35 +00:00
|
|
|
|
// newImageDestination creates a new ImageDestination for the specified image reference.
|
|
|
|
|
func newImageDestination(ctx *types.SystemContext, ref dockerReference) (types.ImageDestination, error) {
|
2017-02-01 00:45:59 +00:00
|
|
|
|
c, err := newDockerClient(ctx, ref, true, "push")
|
2016-07-20 09:46:01 +00:00
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return &dockerImageDestination{
|
|
|
|
|
ref: ref,
|
|
|
|
|
c: c,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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 (d *dockerImageDestination) Reference() types.ImageReference {
|
|
|
|
|
return d.ref
|
|
|
|
|
}
|
|
|
|
|
|
2016-09-17 13:50:35 +00:00
|
|
|
|
// Close removes resources associated with an initialized ImageDestination, if any.
|
|
|
|
|
func (d *dockerImageDestination) Close() {
|
|
|
|
|
}
|
|
|
|
|
|
2016-07-20 09:46:01 +00:00
|
|
|
|
func (d *dockerImageDestination) SupportedManifestMIMETypes() []string {
|
2017-02-01 00:45:59 +00:00
|
|
|
|
return manifestMIMETypes
|
2016-09-17 13:50:35 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SupportsSignatures returns an error (to be displayed to the user) if the destination certainly can't store signatures.
|
|
|
|
|
// Note: It is still possible for PutSignatures to fail if SupportsSignatures returns nil.
|
|
|
|
|
func (d *dockerImageDestination) SupportsSignatures() error {
|
2016-10-17 13:53:40 +00:00
|
|
|
|
return errors.Errorf("Pushing signatures to a Docker Registry is not supported")
|
2016-09-17 13:50:35 +00:00
|
|
|
|
}
|
|
|
|
|
|
2016-11-22 19:32:10 +00:00
|
|
|
|
// ShouldCompressLayers returns true iff it is desirable to compress layer blobs written to this destination.
|
|
|
|
|
func (d *dockerImageDestination) ShouldCompressLayers() bool {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// AcceptsForeignLayerURLs returns false iff foreign layers in manifest should be actually
|
|
|
|
|
// uploaded to the image destination, true otherwise.
|
|
|
|
|
func (d *dockerImageDestination) AcceptsForeignLayerURLs() bool {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// sizeCounter is an io.Writer which only counts the total size of its input.
|
|
|
|
|
type sizeCounter struct{ size int64 }
|
|
|
|
|
|
|
|
|
|
func (c *sizeCounter) Write(p []byte) (n int, err error) {
|
|
|
|
|
c.size += int64(len(p))
|
|
|
|
|
return len(p), 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.
|
2016-09-17 13:50:35 +00:00
|
|
|
|
// WARNING: The contents of stream are being verified on the fly. Until stream.Read() returns io.EOF, the contents of the data SHOULD NOT be available
|
|
|
|
|
// to any other readers for download using the supplied digest.
|
|
|
|
|
// If stream.Read() at any time, ESPECIALLY at end of input, returns an error, PutBlob MUST 1) fail, and 2) delete any data stored so far.
|
2016-11-22 19:32:10 +00:00
|
|
|
|
func (d *dockerImageDestination) PutBlob(stream io.Reader, inputInfo types.BlobInfo) (types.BlobInfo, error) {
|
|
|
|
|
if inputInfo.Digest.String() != "" {
|
|
|
|
|
checkURL := fmt.Sprintf(blobsURL, d.ref.ref.RemoteName(), inputInfo.Digest.String())
|
2016-09-17 13:50:35 +00:00
|
|
|
|
|
|
|
|
|
logrus.Debugf("Checking %s", checkURL)
|
|
|
|
|
res, err := d.c.makeRequest("HEAD", checkURL, nil, nil)
|
|
|
|
|
if err != nil {
|
2016-11-22 19:32:10 +00:00
|
|
|
|
return types.BlobInfo{}, err
|
2016-09-17 13:50:35 +00:00
|
|
|
|
}
|
|
|
|
|
defer res.Body.Close()
|
2016-11-22 19:32:10 +00:00
|
|
|
|
switch res.StatusCode {
|
|
|
|
|
case http.StatusOK:
|
2016-09-17 13:50:35 +00:00
|
|
|
|
logrus.Debugf("... already exists, not uploading")
|
2016-11-22 19:32:10 +00:00
|
|
|
|
return types.BlobInfo{Digest: inputInfo.Digest, Size: getBlobSize(res)}, nil
|
|
|
|
|
case http.StatusUnauthorized:
|
|
|
|
|
logrus.Debugf("... not authorized")
|
2016-10-17 13:53:40 +00:00
|
|
|
|
return types.BlobInfo{}, errors.Errorf("not authorized to read from destination repository %s", d.ref.ref.RemoteName())
|
2016-11-22 19:32:10 +00:00
|
|
|
|
case http.StatusNotFound:
|
|
|
|
|
// noop
|
|
|
|
|
default:
|
2016-10-17 13:53:40 +00:00
|
|
|
|
return types.BlobInfo{}, errors.Errorf("failed to read from destination repository %s: %v", d.ref.ref.RemoteName(), http.StatusText(res.StatusCode))
|
2016-09-17 13:50:35 +00:00
|
|
|
|
}
|
|
|
|
|
logrus.Debugf("... failed, status %d", res.StatusCode)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// FIXME? Chunked upload, progress reporting, etc.
|
|
|
|
|
uploadURL := fmt.Sprintf(blobUploadURL, d.ref.ref.RemoteName())
|
|
|
|
|
logrus.Debugf("Uploading %s", uploadURL)
|
|
|
|
|
res, err := d.c.makeRequest("POST", uploadURL, nil, nil)
|
|
|
|
|
if err != nil {
|
2016-11-22 19:32:10 +00:00
|
|
|
|
return types.BlobInfo{}, err
|
2016-09-17 13:50:35 +00:00
|
|
|
|
}
|
|
|
|
|
defer res.Body.Close()
|
|
|
|
|
if res.StatusCode != http.StatusAccepted {
|
|
|
|
|
logrus.Debugf("Error initiating layer upload, response %#v", *res)
|
2016-10-17 13:53:40 +00:00
|
|
|
|
return types.BlobInfo{}, errors.Errorf("Error initiating layer upload to %s, status %d", uploadURL, res.StatusCode)
|
2016-09-17 13:50:35 +00:00
|
|
|
|
}
|
|
|
|
|
uploadLocation, err := res.Location()
|
|
|
|
|
if err != nil {
|
2016-10-17 13:53:40 +00:00
|
|
|
|
return types.BlobInfo{}, errors.Wrap(err, "Error determining upload URL")
|
2016-09-17 13:50:35 +00:00
|
|
|
|
}
|
|
|
|
|
|
2016-10-17 13:53:40 +00:00
|
|
|
|
digester := digest.Canonical.Digester()
|
2016-11-22 19:32:10 +00:00
|
|
|
|
sizeCounter := &sizeCounter{}
|
|
|
|
|
tee := io.TeeReader(stream, io.MultiWriter(digester.Hash(), sizeCounter))
|
|
|
|
|
res, err = d.c.makeRequestToResolvedURL("PATCH", uploadLocation.String(), map[string][]string{"Content-Type": {"application/octet-stream"}}, tee, inputInfo.Size, true)
|
2016-09-17 13:50:35 +00:00
|
|
|
|
if err != nil {
|
|
|
|
|
logrus.Debugf("Error uploading layer chunked, response %#v", *res)
|
2016-11-22 19:32:10 +00:00
|
|
|
|
return types.BlobInfo{}, err
|
2016-09-17 13:50:35 +00:00
|
|
|
|
}
|
|
|
|
|
defer res.Body.Close()
|
2016-11-22 19:32:10 +00:00
|
|
|
|
computedDigest := digester.Digest()
|
2016-09-17 13:50:35 +00:00
|
|
|
|
|
|
|
|
|
uploadLocation, err = res.Location()
|
|
|
|
|
if err != nil {
|
2016-10-17 13:53:40 +00:00
|
|
|
|
return types.BlobInfo{}, errors.Wrap(err, "Error determining upload URL")
|
2016-09-17 13:50:35 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// FIXME: DELETE uploadLocation on failure
|
|
|
|
|
|
|
|
|
|
locationQuery := uploadLocation.Query()
|
2016-11-22 19:32:10 +00:00
|
|
|
|
// TODO: check inputInfo.Digest == computedDigest https://github.com/containers/image/pull/70#discussion_r77646717
|
|
|
|
|
locationQuery.Set("digest", computedDigest.String())
|
2016-09-17 13:50:35 +00:00
|
|
|
|
uploadLocation.RawQuery = locationQuery.Encode()
|
2016-11-22 19:32:10 +00:00
|
|
|
|
res, err = d.c.makeRequestToResolvedURL("PUT", uploadLocation.String(), map[string][]string{"Content-Type": {"application/octet-stream"}}, nil, -1, true)
|
2016-09-17 13:50:35 +00:00
|
|
|
|
if err != nil {
|
2016-11-22 19:32:10 +00:00
|
|
|
|
return types.BlobInfo{}, err
|
2016-07-20 09:46:01 +00:00
|
|
|
|
}
|
2016-09-17 13:50:35 +00:00
|
|
|
|
defer res.Body.Close()
|
|
|
|
|
if res.StatusCode != http.StatusCreated {
|
|
|
|
|
logrus.Debugf("Error uploading layer, response %#v", *res)
|
2016-10-17 13:53:40 +00:00
|
|
|
|
return types.BlobInfo{}, errors.Errorf("Error uploading layer to %s, status %d", uploadLocation, res.StatusCode)
|
2016-11-22 19:32:10 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logrus.Debugf("Upload of layer %s complete", computedDigest)
|
|
|
|
|
return types.BlobInfo{Digest: computedDigest, Size: sizeCounter.size}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (d *dockerImageDestination) HasBlob(info types.BlobInfo) (bool, int64, error) {
|
|
|
|
|
if info.Digest == "" {
|
2016-10-17 13:53:40 +00:00
|
|
|
|
return false, -1, errors.Errorf(`"Can not check for a blob with unknown digest`)
|
2016-09-17 13:50:35 +00:00
|
|
|
|
}
|
2016-11-22 19:32:10 +00:00
|
|
|
|
checkURL := fmt.Sprintf(blobsURL, d.ref.ref.RemoteName(), info.Digest.String())
|
2016-09-17 13:50:35 +00:00
|
|
|
|
|
2016-11-22 19:32:10 +00:00
|
|
|
|
logrus.Debugf("Checking %s", checkURL)
|
|
|
|
|
res, err := d.c.makeRequest("HEAD", checkURL, nil, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false, -1, err
|
|
|
|
|
}
|
|
|
|
|
defer res.Body.Close()
|
|
|
|
|
switch res.StatusCode {
|
|
|
|
|
case http.StatusOK:
|
|
|
|
|
logrus.Debugf("... already exists")
|
|
|
|
|
return true, getBlobSize(res), nil
|
|
|
|
|
case http.StatusUnauthorized:
|
|
|
|
|
logrus.Debugf("... not authorized")
|
2016-10-17 13:53:40 +00:00
|
|
|
|
return false, -1, errors.Errorf("not authorized to read from destination repository %s", d.ref.ref.RemoteName())
|
2016-11-22 19:32:10 +00:00
|
|
|
|
case http.StatusNotFound:
|
|
|
|
|
logrus.Debugf("... not present")
|
|
|
|
|
return false, -1, types.ErrBlobNotFound
|
|
|
|
|
default:
|
|
|
|
|
logrus.Errorf("failed to read from destination repository %s: %v", d.ref.ref.RemoteName(), http.StatusText(res.StatusCode))
|
|
|
|
|
}
|
|
|
|
|
logrus.Debugf("... failed, status %d, ignoring", res.StatusCode)
|
|
|
|
|
return false, -1, types.ErrBlobNotFound
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (d *dockerImageDestination) ReapplyBlob(info types.BlobInfo) (types.BlobInfo, error) {
|
|
|
|
|
return info, nil
|
2016-07-20 09:46:01 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (d *dockerImageDestination) PutManifest(m []byte) error {
|
|
|
|
|
digest, err := manifest.Digest(m)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2016-09-17 13:50:35 +00:00
|
|
|
|
d.manifestDigest = digest
|
2016-11-22 19:32:10 +00:00
|
|
|
|
|
|
|
|
|
reference, err := d.ref.tagOrDigest()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
url := fmt.Sprintf(manifestURL, d.ref.ref.RemoteName(), reference)
|
2016-07-20 09:46:01 +00:00
|
|
|
|
|
|
|
|
|
headers := map[string][]string{}
|
|
|
|
|
mimeType := manifest.GuessMIMEType(m)
|
|
|
|
|
if mimeType != "" {
|
|
|
|
|
headers["Content-Type"] = []string{mimeType}
|
|
|
|
|
}
|
|
|
|
|
res, err := d.c.makeRequest("PUT", url, headers, bytes.NewReader(m))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
defer res.Body.Close()
|
|
|
|
|
if res.StatusCode != http.StatusCreated {
|
|
|
|
|
body, err := ioutil.ReadAll(res.Body)
|
|
|
|
|
if err == nil {
|
|
|
|
|
logrus.Debugf("Error body %s", string(body))
|
|
|
|
|
}
|
|
|
|
|
logrus.Debugf("Error uploading manifest, status %d, %#v", res.StatusCode, res)
|
2016-10-17 13:53:40 +00:00
|
|
|
|
return errors.Errorf("Error uploading manifest to %s, status %d", url, res.StatusCode)
|
2016-07-20 09:46:01 +00:00
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2016-09-17 13:50:35 +00:00
|
|
|
|
func (d *dockerImageDestination) PutSignatures(signatures [][]byte) error {
|
|
|
|
|
// FIXME? This overwrites files one at a time, definitely not atomic.
|
|
|
|
|
// A failure when updating signatures with a reordered copy could lose some of them.
|
2016-07-20 09:46:01 +00:00
|
|
|
|
|
2016-09-17 13:50:35 +00:00
|
|
|
|
// Skip dealing with the manifest digest if not necessary.
|
|
|
|
|
if len(signatures) == 0 {
|
2016-07-20 09:46:01 +00:00
|
|
|
|
return nil
|
|
|
|
|
}
|
2016-09-17 13:50:35 +00:00
|
|
|
|
if d.c.signatureBase == nil {
|
2016-10-17 13:53:40 +00:00
|
|
|
|
return errors.Errorf("Pushing signatures to a Docker Registry is not supported, and there is no applicable signature storage configured")
|
2016-07-20 09:46:01 +00:00
|
|
|
|
}
|
|
|
|
|
|
2016-11-22 19:32:10 +00:00
|
|
|
|
if d.manifestDigest.String() == "" {
|
|
|
|
|
// This shouldn’t happen, ImageDestination users are required to call PutManifest before PutSignatures
|
2016-10-17 13:53:40 +00:00
|
|
|
|
return errors.Errorf("Unknown manifest digest, can't add signatures")
|
2016-09-17 13:50:35 +00:00
|
|
|
|
}
|
2016-07-20 09:46:01 +00:00
|
|
|
|
|
2016-09-17 13:50:35 +00:00
|
|
|
|
for i, signature := range signatures {
|
|
|
|
|
url := signatureStorageURL(d.c.signatureBase, d.manifestDigest, i)
|
|
|
|
|
if url == nil {
|
2016-10-17 13:53:40 +00:00
|
|
|
|
return errors.Errorf("Internal error: signatureStorageURL with non-nil base returned nil")
|
2016-09-17 13:50:35 +00:00
|
|
|
|
}
|
|
|
|
|
err := d.putOneSignature(url, signature)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2016-07-20 09:46:01 +00:00
|
|
|
|
}
|
2016-09-17 13:50:35 +00:00
|
|
|
|
// Remove any other signatures, if present.
|
|
|
|
|
// We stop at the first missing signature; if a previous deleting loop aborted
|
|
|
|
|
// prematurely, this may not clean up all of them, but one missing signature
|
|
|
|
|
// is enough for dockerImageSource to stop looking for other signatures, so that
|
|
|
|
|
// is sufficient.
|
|
|
|
|
for i := len(signatures); ; i++ {
|
|
|
|
|
url := signatureStorageURL(d.c.signatureBase, d.manifestDigest, i)
|
|
|
|
|
if url == nil {
|
2016-10-17 13:53:40 +00:00
|
|
|
|
return errors.Errorf("Internal error: signatureStorageURL with non-nil base returned nil")
|
2016-09-17 13:50:35 +00:00
|
|
|
|
}
|
|
|
|
|
missing, err := d.c.deleteOneSignature(url)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if missing {
|
|
|
|
|
break
|
|
|
|
|
}
|
2016-07-20 09:46:01 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2016-09-17 13:50:35 +00:00
|
|
|
|
// putOneSignature stores one signature to url.
|
|
|
|
|
func (d *dockerImageDestination) putOneSignature(url *url.URL, signature []byte) error {
|
|
|
|
|
switch url.Scheme {
|
|
|
|
|
case "file":
|
|
|
|
|
logrus.Debugf("Writing to %s", url.Path)
|
|
|
|
|
err := os.MkdirAll(filepath.Dir(url.Path), 0755)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
err = ioutil.WriteFile(url.Path, signature, 0644)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
|
2016-11-22 19:32:10 +00:00
|
|
|
|
case "http", "https":
|
2016-10-17 13:53:40 +00:00
|
|
|
|
return errors.Errorf("Writing directly to a %s sigstore %s is not supported. Configure a sigstore-staging: location", url.Scheme, url.String())
|
2016-09-17 13:50:35 +00:00
|
|
|
|
default:
|
2016-10-17 13:53:40 +00:00
|
|
|
|
return errors.Errorf("Unsupported scheme when writing signature to %s", url.String())
|
2016-07-20 09:46:01 +00:00
|
|
|
|
}
|
2016-09-17 13:50:35 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// deleteOneSignature deletes a signature from url, if it exists.
|
|
|
|
|
// If it successfully determines that the signature does not exist, returns (true, nil)
|
|
|
|
|
func (c *dockerClient) deleteOneSignature(url *url.URL) (missing bool, err error) {
|
|
|
|
|
switch url.Scheme {
|
|
|
|
|
case "file":
|
|
|
|
|
logrus.Debugf("Deleting %s", url.Path)
|
|
|
|
|
err := os.Remove(url.Path)
|
|
|
|
|
if err != nil && os.IsNotExist(err) {
|
|
|
|
|
return true, nil
|
|
|
|
|
}
|
|
|
|
|
return false, err
|
|
|
|
|
|
2016-11-22 19:32:10 +00:00
|
|
|
|
case "http", "https":
|
2016-10-17 13:53:40 +00:00
|
|
|
|
return false, errors.Errorf("Writing directly to a %s sigstore %s is not supported. Configure a sigstore-staging: location", url.Scheme, url.String())
|
2016-09-17 13:50:35 +00:00
|
|
|
|
default:
|
2016-10-17 13:53:40 +00:00
|
|
|
|
return false, errors.Errorf("Unsupported scheme when deleting signature from %s", url.String())
|
2016-09-17 13:50:35 +00:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Commit marks the process of storing the image as successful and asks for the image to be persisted.
|
|
|
|
|
// WARNING: This does not have any transactional semantics:
|
|
|
|
|
// - Uploaded data MAY be visible to others before Commit() is called
|
|
|
|
|
// - Uploaded data MAY be removed or MAY remain around if Close() is called without Commit() (i.e. rollback is allowed but not guaranteed)
|
|
|
|
|
func (d *dockerImageDestination) Commit() error {
|
2016-07-20 09:46:01 +00:00
|
|
|
|
return nil
|
|
|
|
|
}
|