distribution/registry/storage/driver/gcs/gcs.go

897 lines
24 KiB
Go
Raw Normal View History

// Package gcs provides a storagedriver.StorageDriver implementation to
// store blobs in Google cloud storage.
//
// This package leverages the google.golang.org/cloud/storage client library
// for interfacing with gcs.
//
// Because gcs is a key, value store the Stat call does not support last modification
// time for directories (directories are an abstraction for key, value stores)
//
// Note that the contents of incomplete uploads are not accessible even though
// Stat returns their length
package gcs
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"os"
"reflect"
"regexp"
"strconv"
"strings"
"time"
"cloud.google.com/go/storage"
storagedriver "github.com/distribution/distribution/v3/registry/storage/driver"
"github.com/distribution/distribution/v3/registry/storage/driver/base"
"github.com/distribution/distribution/v3/registry/storage/driver/factory"
"github.com/sirupsen/logrus"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"golang.org/x/oauth2/jwt"
"google.golang.org/api/googleapi"
"google.golang.org/api/iterator"
"google.golang.org/api/option"
)
const (
driverName = "gcs"
dummyProjectID = "<unknown>"
minChunkSize = 256 * 1024
defaultChunkSize = 16 * 1024 * 1024
defaultMaxConcurrency = 50
minConcurrency = 25
uploadSessionContentType = "application/x-docker-upload-session"
blobContentType = "application/octet-stream"
maxTries = 5
)
var rangeHeader = regexp.MustCompile(`^bytes=([0-9])+-([0-9]+)$`)
var _ storagedriver.FileWriter = &writer{}
// driverParameters is a struct that encapsulates all of the driver parameters after all values have been set
type driverParameters struct {
bucket string
email string
privateKey []byte
client *http.Client
rootDirectory string
chunkSize int
gcs *storage.Client
// maxConcurrency limits the number of concurrent driver operations
// to GCS, which ultimately increases reliability of many simultaneous
// pushes by ensuring we aren't DoSing our own server with many
// connections.
maxConcurrency uint64
}
func init() {
factory.Register(driverName, &gcsDriverFactory{})
}
// gcsDriverFactory implements the factory.StorageDriverFactory interface
type gcsDriverFactory struct{}
// Create StorageDriver from parameters
func (factory *gcsDriverFactory) Create(ctx context.Context, parameters map[string]interface{}) (storagedriver.StorageDriver, error) {
return FromParameters(ctx, parameters)
}
var _ storagedriver.StorageDriver = &driver{}
// driver is a storagedriver.StorageDriver implementation backed by GCS
// Objects are stored at absolute keys in the provided bucket.
type driver struct {
client *http.Client
bucket *storage.BucketHandle
email string
privateKey []byte
rootDirectory string
chunkSize int
}
// Wrapper wraps `driver` with a throttler, ensuring that no more than N
// GCS actions can occur concurrently. The default limit is 75.
type Wrapper struct {
baseEmbed
}
type baseEmbed struct {
base.Base
}
// FromParameters constructs a new Driver with a given parameters map
// Required parameters:
// - bucket
func FromParameters(ctx context.Context, parameters map[string]interface{}) (storagedriver.StorageDriver, error) {
bucket, ok := parameters["bucket"]
if !ok || fmt.Sprint(bucket) == "" {
return nil, fmt.Errorf("No bucket parameter provided")
}
rootDirectory, ok := parameters["rootdirectory"]
if !ok {
rootDirectory = ""
}
chunkSize := defaultChunkSize
chunkSizeParam, ok := parameters["chunksize"]
if ok {
switch v := chunkSizeParam.(type) {
case string:
vv, err := strconv.Atoi(v)
if err != nil {
return nil, fmt.Errorf("chunksize must be an integer, %v invalid", chunkSizeParam)
}
chunkSize = vv
case int, uint, int32, uint32, uint64, int64:
chunkSize = int(reflect.ValueOf(v).Convert(reflect.TypeOf(chunkSize)).Int())
default:
return nil, fmt.Errorf("invalid valud for chunksize: %#v", chunkSizeParam)
}
if chunkSize < minChunkSize {
return nil, fmt.Errorf("chunksize %#v must be larger than or equal to %d", chunkSize, minChunkSize)
}
if chunkSize%minChunkSize != 0 {
return nil, fmt.Errorf("chunksize should be a multiple of %d", minChunkSize)
}
}
var ts oauth2.TokenSource
jwtConf := new(jwt.Config)
var err error
var gcs *storage.Client
var options []option.ClientOption
if keyfile, ok := parameters["keyfile"]; ok {
jsonKey, err := os.ReadFile(fmt.Sprint(keyfile))
if err != nil {
return nil, err
}
jwtConf, err = google.JWTConfigFromJSON(jsonKey, storage.ScopeFullControl)
if err != nil {
return nil, err
}
ts = jwtConf.TokenSource(ctx)
options = append(options, option.WithCredentialsFile(fmt.Sprint(keyfile)))
} else if credentials, ok := parameters["credentials"]; ok {
credentialMap, ok := credentials.(map[interface{}]interface{})
if !ok {
return nil, fmt.Errorf("The credentials were not specified in the correct format")
}
stringMap := map[string]interface{}{}
for k, v := range credentialMap {
key, ok := k.(string)
if !ok {
return nil, fmt.Errorf("One of the credential keys was not a string: %s", fmt.Sprint(k))
}
stringMap[key] = v
}
data, err := json.Marshal(stringMap)
if err != nil {
return nil, fmt.Errorf("Failed to marshal gcs credentials to json")
}
jwtConf, err = google.JWTConfigFromJSON(data, storage.ScopeFullControl)
if err != nil {
return nil, err
}
ts = jwtConf.TokenSource(ctx)
options = append(options, option.WithCredentialsJSON(data))
} else {
var err error
// DefaultTokenSource is a convenience method. It first calls FindDefaultCredentials,
// then uses the credentials to construct an http.Client or an oauth2.TokenSource.
// https://pkg.go.dev/golang.org/x/oauth2/google#hdr-Credentials
ts, err = google.DefaultTokenSource(ctx, storage.ScopeFullControl)
if err != nil {
return nil, err
}
}
if userAgent, ok := parameters["useragent"]; ok {
if ua, ok := userAgent.(string); ok && ua != "" {
options = append(options, option.WithUserAgent(ua))
}
}
gcs, err = storage.NewClient(ctx, options...)
if err != nil {
return nil, err
}
maxConcurrency, err := base.GetLimitFromParameter(parameters["maxconcurrency"], minConcurrency, defaultMaxConcurrency)
if err != nil {
return nil, fmt.Errorf("maxconcurrency config error: %s", err)
}
params := driverParameters{
bucket: fmt.Sprint(bucket),
rootDirectory: fmt.Sprint(rootDirectory),
email: jwtConf.Email,
privateKey: jwtConf.PrivateKey,
client: oauth2.NewClient(ctx, ts),
chunkSize: chunkSize,
maxConcurrency: maxConcurrency,
gcs: gcs,
}
return New(ctx, params)
}
// New constructs a new driver
func New(ctx context.Context, params driverParameters) (storagedriver.StorageDriver, error) {
rootDirectory := strings.Trim(params.rootDirectory, "/")
if rootDirectory != "" {
rootDirectory += "/"
}
if params.chunkSize <= 0 || params.chunkSize%minChunkSize != 0 {
return nil, fmt.Errorf("Invalid chunksize: %d is not a positive multiple of %d", params.chunkSize, minChunkSize)
}
d := &driver{
bucket: params.gcs.Bucket(params.bucket),
rootDirectory: rootDirectory,
email: params.email,
privateKey: params.privateKey,
client: params.client,
chunkSize: params.chunkSize,
}
return &Wrapper{
baseEmbed: baseEmbed{
Base: base.Base{
StorageDriver: base.NewRegulator(d, params.maxConcurrency),
},
},
}, nil
}
// Implement the storagedriver.StorageDriver interface
func (d *driver) Name() string {
return driverName
}
// GetContent retrieves the content stored at "path" as a []byte.
// This should primarily be used for small objects.
func (d *driver) GetContent(ctx context.Context, path string) ([]byte, error) {
r, err := d.bucket.Object(d.pathToKey(path)).NewReader(ctx)
if err != nil {
if err == storage.ErrObjectNotExist {
return nil, storagedriver.PathNotFoundError{Path: path}
}
return nil, err
}
defer r.Close()
return io.ReadAll(r)
}
// PutContent stores the []byte content at a location designated by "path".
// This should primarily be used for small objects.
func (d *driver) PutContent(ctx context.Context, path string, contents []byte) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
object := d.bucket.Object(d.pathToKey(path))
err := d.putContent(ctx, object, contents, blobContentType, nil)
if err != nil {
return err
}
return nil
}
// Reader retrieves an io.ReadCloser for the content stored at "path"
// with a given byte offset.
// May be used to resume reading a stream by providing a nonzero offset.
func (d *driver) Reader(ctx context.Context, path string, offset int64) (io.ReadCloser, error) {
obj := d.bucket.Object(d.pathToKey(path))
// NOTE(milosgajdos): If length is negative, the object is read until the end
// See: https://pkg.go.dev/cloud.google.com/go/storage#ObjectHandle.NewRangeReader
r, err := obj.NewRangeReader(ctx, offset, -1)
if err != nil {
if err == storage.ErrObjectNotExist {
return nil, storagedriver.PathNotFoundError{Path: path}
}
var status *googleapi.Error
if errors.As(err, &status) {
switch status.Code {
case http.StatusNotFound:
return nil, storagedriver.PathNotFoundError{Path: path}
case http.StatusRequestedRangeNotSatisfiable:
attrs, err := obj.Attrs(ctx)
if err != nil {
return nil, err
}
if offset == attrs.Size {
return io.NopCloser(bytes.NewReader([]byte{})), nil
}
return nil, storagedriver.InvalidOffsetError{Path: path, Offset: offset}
}
}
return nil, err
}
if r.Attrs.ContentType == uploadSessionContentType {
r.Close()
return nil, storagedriver.PathNotFoundError{Path: path}
}
return r, nil
}
// Writer returns a FileWriter which will store the content written to it
// at the location designated by "path" after the call to Commit.
func (d *driver) Writer(ctx context.Context, path string, appendMode bool) (storagedriver.FileWriter, error) {
w := &writer{
ctx: ctx,
driver: d,
object: d.bucket.Object(d.pathToKey(path)),
buffer: make([]byte, d.chunkSize),
}
if appendMode {
err := w.init(ctx)
if err != nil {
return nil, err
}
}
return w, nil
}
type writer struct {
ctx context.Context
object *storage.ObjectHandle
driver *driver
size int64
offset int64
closed bool
cancelled bool
committed bool
sessionURI string
buffer []byte
buffSize int
}
// Cancel removes any written content from this FileWriter.
func (w *writer) Cancel(ctx context.Context) error {
w.closed = true
w.cancelled = true
err := w.object.Delete(ctx)
if err != nil {
if err == storage.ErrObjectNotExist {
err = nil
}
}
return err
}
func (w *writer) Close() error {
if w.closed {
return nil
}
w.closed = true
err := w.writeChunk(w.ctx)
if err != nil {
return err
}
// Copy the remaining bytes from the buffer to the upload session
// Normally buffSize will be smaller than minChunkSize. However, in the
// unlikely event that the upload session failed to start, this number could be higher.
// In this case we can safely clip the remaining bytes to the minChunkSize
if w.buffSize > minChunkSize {
w.buffSize = minChunkSize
}
// commit the writes by updating the upload session
metadata := map[string]string{
"Session-URI": w.sessionURI,
"Offset": strconv.FormatInt(w.offset, 10),
}
err = retry(func() error {
err := w.driver.putContent(w.ctx, w.object, w.buffer[0:w.buffSize], uploadSessionContentType, metadata)
if err != nil {
return err
}
w.size = w.offset + int64(w.buffSize)
w.buffSize = 0
return nil
})
return err
}
func (d *driver) putContent(ctx context.Context, obj *storage.ObjectHandle, content []byte, contentType string, metadata map[string]string) error {
wc := obj.NewWriter(ctx)
wc.Metadata = metadata
wc.ContentType = contentType
wc.ChunkSize = d.chunkSize
if _, err := bytes.NewReader(content).WriteTo(wc); err != nil {
return err
}
return wc.Close()
}
// Commit flushes all content written to this FileWriter and makes it
// available for future calls to StorageDriver.GetContent and
// StorageDriver.Reader.
func (w *writer) Commit(ctx context.Context) error {
if w.closed {
return fmt.Errorf("already closed")
}
w.closed = true
// no session started yet just perform a simple upload
if w.sessionURI == "" {
err := retry(func() error {
err := w.driver.putContent(ctx, w.object, w.buffer[0:w.buffSize], blobContentType, nil)
if err != nil {
return err
}
w.committed = true
w.size = w.offset + int64(w.buffSize)
w.buffSize = 0
return nil
})
return err
}
size := w.offset + int64(w.buffSize)
var written int
// loop must be performed at least once to ensure the file is committed even when
// the buffer is empty
for {
n, err := w.putChunk(ctx, w.sessionURI, w.buffer[written:w.buffSize], w.offset, size)
written += int(n)
w.offset += n
w.size = w.offset
if err != nil {
w.buffSize = copy(w.buffer, w.buffer[written:w.buffSize])
return err
}
if written == w.buffSize {
break
}
}
w.committed = true
w.buffSize = 0
return nil
}
func (w *writer) writeChunk(ctx context.Context) error {
var err error
// chunks can be uploaded only in multiples of minChunkSize
// chunkSize is a multiple of minChunkSize less than or equal to buffSize
chunkSize := w.buffSize - (w.buffSize % minChunkSize)
if chunkSize == 0 {
return nil
}
// if their is no sessionURI yet, obtain one by starting the session
if w.sessionURI == "" {
w.sessionURI, err = w.newSession()
}
if err != nil {
return err
}
n, err := w.putChunk(ctx, w.sessionURI, w.buffer[0:chunkSize], w.offset, -1)
w.offset += n
if w.offset > w.size {
w.size = w.offset
}
// shift the remaining bytes to the start of the buffer
w.buffSize = copy(w.buffer, w.buffer[int(n):w.buffSize])
return err
}
func (w *writer) Write(p []byte) (int, error) {
if w.closed {
return 0, fmt.Errorf("already closed")
} else if w.cancelled {
return 0, fmt.Errorf("already cancelled")
}
var (
written int
err error
)
for written < len(p) {
n := copy(w.buffer[w.buffSize:], p[written:])
w.buffSize += n
if w.buffSize == cap(w.buffer) {
err = w.writeChunk(w.ctx)
if err != nil {
break
}
}
written += n
}
w.size = w.offset + int64(w.buffSize)
return written, err
}
// Size returns the number of bytes written to this FileWriter.
func (w *writer) Size() int64 {
return w.size
}
func (w *writer) init(ctx context.Context) error {
attrs, err := w.object.Attrs(ctx)
if err != nil {
return err
}
// NOTE(milosgajdos): when PUSH abruptly finishes by
// calling a single commit and then closes the stream
// attrs.ContentType ends up being set to application/octet-stream
// We must handle this case so the upload can resume.
if attrs.ContentType != uploadSessionContentType &&
attrs.ContentType != blobContentType {
return storagedriver.PathNotFoundError{Path: w.object.ObjectName()}
}
offset := int64(0)
// NOTE(milosgajdos): if a client creates an empty blob, then
// closes the stream and then attempts to append to it, the offset
// will be empty, in which case strconv.ParseInt will return error
// See: https://pkg.go.dev/strconv#ParseInt
if attrs.Metadata["Offset"] != "" {
offset, err = strconv.ParseInt(attrs.Metadata["Offset"], 10, 64)
if err != nil {
return err
}
}
r, err := w.object.NewReader(ctx)
if err != nil {
return err
}
defer r.Close()
for err == nil && w.buffSize < len(w.buffer) {
var n int
n, err = r.Read(w.buffer[w.buffSize:])
w.buffSize += n
}
if err != nil && err != io.EOF {
return err
}
// NOTE(milosgajdos): if a client closes an existing session and then attempts
// to append to an existing blob, the session will be empty; recreate it
if w.sessionURI = attrs.Metadata["Session-URI"]; w.sessionURI == "" {
w.sessionURI, err = w.newSession()
if err != nil {
return err
}
}
w.offset = offset
w.size = offset + int64(w.buffSize)
return nil
}
type request func() error
func retry(req request) error {
backoff := time.Second
var err error
for i := 0; i < maxTries; i++ {
err = req()
if err == nil {
return nil
}
status, ok := err.(*googleapi.Error)
if !ok || (status.Code != http.StatusTooManyRequests && status.Code < http.StatusInternalServerError) {
return err
}
time.Sleep(backoff - time.Second + (time.Duration(rand.Int31n(1000)) * time.Millisecond))
if i <= 4 {
backoff = backoff * 2
}
}
return err
}
// Stat retrieves the FileInfo for the given path, including the current
// size in bytes and the creation time.
func (d *driver) Stat(ctx context.Context, path string) (storagedriver.FileInfo, error) {
var fi storagedriver.FileInfoFields
// try to get as file
obj, err := d.bucket.Object(d.pathToKey(path)).Attrs(ctx)
if err == nil {
if obj.ContentType == uploadSessionContentType {
return nil, storagedriver.PathNotFoundError{Path: path}
}
fi = storagedriver.FileInfoFields{
Path: path,
Size: obj.Size,
ModTime: obj.Updated,
IsDir: false,
}
return storagedriver.FileInfoInternal{FileInfoFields: fi}, nil
}
// try to get as folder
dirpath := d.pathToDirKey(path)
query := &storage.Query{
Prefix: dirpath,
}
obj, err = d.bucket.Objects(ctx, query).Next()
if err != nil {
if err == iterator.Done {
return nil, storagedriver.PathNotFoundError{Path: path}
}
return nil, err
}
fi = storagedriver.FileInfoFields{
Path: path,
IsDir: true,
}
if obj.Name == dirpath {
fi.Size = obj.Size
fi.ModTime = obj.Updated
}
return storagedriver.FileInfoInternal{FileInfoFields: fi}, nil
}
// List returns a list of the objects that are direct descendants of the
// given path.
func (d *driver) List(ctx context.Context, path string) ([]string, error) {
query := &storage.Query{
Delimiter: "/",
Prefix: d.pathToDirKey(path),
}
objects := d.bucket.Objects(ctx, query)
list := make([]string, 0, 64)
for {
object, err := objects.Next()
if err != nil {
if err == iterator.Done {
break
}
return nil, err
}
// GCS does not guarantee strong consistency between
// DELETE and LIST operations. Check that the object is not deleted,
// and filter out any objects with a non-zero time-deleted
if object.Deleted.IsZero() && object.ContentType != uploadSessionContentType && object.Name != "" {
list = append(list, d.keyToPath(object.Name))
}
if object.Name == "" && object.Prefix != "" {
subpath := d.keyToPath(object.Prefix)
list = append(list, subpath)
}
}
if path != "/" && len(list) == 0 {
// Treat empty response as missing directory, since we don't actually
// have directories in Google Cloud Storage.
return nil, storagedriver.PathNotFoundError{Path: path}
}
return list, nil
}
// Move moves an object stored at sourcePath to destPath, removing the
// original object.
func (d *driver) Move(ctx context.Context, sourcePath string, destPath string) error {
srcKey, dstKey := d.pathToKey(sourcePath), d.pathToKey(destPath)
src := d.bucket.Object(srcKey)
_, err := d.bucket.Object(dstKey).CopierFrom(src).Run(ctx)
if err != nil {
var status *googleapi.Error
if errors.As(err, &status) {
if status.Code == http.StatusNotFound {
return storagedriver.PathNotFoundError{Path: srcKey}
}
}
return fmt.Errorf("move %q to %q: %v", srcKey, dstKey, err)
}
err = src.Delete(ctx)
// if deleting the file fails, log the error, but do not fail; the file was successfully copied,
// and the original should eventually be cleaned when purging the uploads folder.
if err != nil {
logrus.Infof("error deleting %v: %v", sourcePath, err)
}
return nil
}
// listAll recursively lists all names of objects stored at "prefix" and its subpaths.
func (d *driver) listAll(ctx context.Context, prefix string) ([]string, error) {
objects := d.bucket.Objects(ctx, &storage.Query{
Prefix: prefix,
Versions: false,
})
list := make([]string, 0, 64)
for {
object, err := objects.Next()
if err != nil {
if err == iterator.Done {
break
}
return nil, err
}
// GCS does not guarantee strong consistency between
// DELETE and LIST operations. Check that the object is not deleted,
// and filter out any objects with a non-zero time-deleted
if object.Deleted.IsZero() {
list = append(list, object.Name)
}
}
return list, nil
}
// Delete recursively deletes all objects stored at "path" and its subpaths.
func (d *driver) Delete(ctx context.Context, path string) error {
prefix := d.pathToDirKey(path)
keys, err := d.listAll(ctx, prefix)
if err != nil {
return err
}
if len(keys) > 0 {
// NOTE(milosgajdos): d.listAll calls (BucketHandle).Objects
// See: https://pkg.go.dev/cloud.google.com/go/storage#BucketHandle.Objects
// docs: Objects will be iterated over lexicographically by name.
// This means we don't have to reverse order the slice; we can
// range over the keys slice in reverse order
for i := len(keys) - 1; i >= 0; i-- {
key := keys[i]
err := d.bucket.Object(key).Delete(ctx)
// GCS only guarantees eventual consistency, so listAll might return
// paths that no longer exist. If this happens, just ignore any not
// found error
if status, ok := err.(*googleapi.Error); ok {
if status.Code == http.StatusNotFound {
err = nil
}
}
if err != nil {
return err
}
}
return nil
}
err = d.bucket.Object(d.pathToKey(path)).Delete(ctx)
if err == storage.ErrObjectNotExist {
return storagedriver.PathNotFoundError{Path: path}
}
return err
}
// RedirectURL returns a URL which may be used to retrieve the content stored at
// the given path, possibly using the given options.
func (d *driver) RedirectURL(r *http.Request, path string) (string, error) {
if d.privateKey == nil {
return "", nil
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
return "", nil
}
opts := &storage.SignedURLOptions{
GoogleAccessID: d.email,
PrivateKey: d.privateKey,
Method: r.Method,
Expires: time.Now().Add(20 * time.Minute),
}
return d.bucket.SignedURL(d.pathToKey(path), opts)
}
// Walk traverses a filesystem defined within driver, starting
// from the given path, calling f on each file
func (d *driver) Walk(ctx context.Context, path string, f storagedriver.WalkFn, options ...func(*storagedriver.WalkOptions)) error {
return storagedriver.WalkFallback(ctx, d, path, f, options...)
}
func (w *writer) newSession() (uri string, err error) {
u := &url.URL{
Scheme: "https",
Host: "www.googleapis.com",
Path: fmt.Sprintf("/upload/storage/v1/b/%v/o", w.object.BucketName()),
RawQuery: fmt.Sprintf("uploadType=resumable&name=%v", w.object.ObjectName()),
}
req, err := http.NewRequestWithContext(w.ctx, http.MethodPost, u.String(), nil)
if err != nil {
return "", err
}
req.Header.Set("X-Upload-Content-Type", blobContentType)
req.Header.Set("Content-Length", "0")
err = retry(func() error {
resp, err := w.driver.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
err = googleapi.CheckMediaResponse(resp)
if err != nil {
return err
}
uri = resp.Header.Get("Location")
return nil
})
return uri, err
}
func (w *writer) putChunk(ctx context.Context, sessionURI string, chunk []byte, from int64, totalSize int64) (int64, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPut, sessionURI, bytes.NewReader(chunk))
if err != nil {
return 0, err
}
length := int64(len(chunk))
to := from + length - 1
size := "*"
if totalSize >= 0 {
size = strconv.FormatInt(totalSize, 10)
}
req.Header.Set("Content-Type", blobContentType)
if from == to+1 {
req.Header.Set("Content-Range", fmt.Sprintf("bytes */%s", size))
} else {
req.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%s", from, to, size))
}
req.Header.Set("Content-Length", strconv.FormatInt(length, 10))
bytesPut := int64(0)
err = retry(func() error {
resp, err := w.driver.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if totalSize < 0 && resp.StatusCode == http.StatusPermanentRedirect {
groups := rangeHeader.FindStringSubmatch(resp.Header.Get("Range"))
end, err := strconv.ParseInt(groups[2], 10, 64)
if err != nil {
return err
}
bytesPut = end - from + 1
return nil
}
err = googleapi.CheckMediaResponse(resp)
if err != nil {
return err
}
bytesPut = to - from + 1
return nil
})
return bytesPut, err
}
func (d *driver) pathToKey(path string) string {
return strings.TrimSpace(strings.TrimRight(d.rootDirectory+strings.TrimLeft(path, "/"), "/"))
}
func (d *driver) pathToDirKey(path string) string {
return d.pathToKey(path) + "/"
}
func (d *driver) keyToPath(key string) string {
return "/" + strings.Trim(strings.TrimPrefix(key, d.rootDirectory), "/")
}