blob: 596cfd2d9749d7338d1010f991f02b7f7e970fe1 [file]
#!/usr/bin/env python
#
# Copyright 2007 Google Inc.
#
# 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.
#
"""Stub for Google storage."""
import calendar
import datetime
import hashlib
import StringIO
from google.appengine.api import datastore
from google.appengine.api import namespace_manager
from google.appengine.api.blobstore import blobstore_stub
from google.appengine.ext import db
from google.appengine.ext.cloudstorage import common
class _AE_GCSFileInfo_(db.Model):
"""Store GCS specific info.
GCS allows user to define arbitrary metadata via header x-goog-meta-foo: bar.
These headers are returned when user does a GET or HEAD on the object.
Key name is blobkey.
"""
filename = db.StringProperty(required=True)
finalized = db.BooleanProperty(required=True)
raw_options = db.StringListProperty()
size = db.IntegerProperty()
creation = db.DateTimeProperty()
content_type = db.StringProperty()
etag = db.ByteStringProperty()
def get_options(self):
return dict(o.split(':', 1) for o in self.raw_options)
def set_options(self, options_dict):
self.raw_options = [
'%s:%s' % (k.lower(), v) for k, v in options_dict.iteritems()]
if 'content-type' in options_dict:
self.content_type = options_dict['content-type']
options = property(get_options, set_options)
@classmethod
def kind(cls):
return blobstore_stub._GS_INFO_KIND
class _AE_GCSPartialFile_(db.Model):
"""Store partial content for uploading files."""
end = db.IntegerProperty(required=True)
partial_content = db.TextProperty(required=True)
class CloudStorageStub(object):
"""Google Cloud Storage stub implementation.
We use blobstore stub to store files. All metadata are stored
in _AE_GCSFileInfo_.
Note: this Google Cloud Storage stub is designed to work with
apphosting.ext.cloudstorage.storage_api.py.
It only implements the part of GCS storage_api.py uses, and its interface
maps to GCS XML APIs.
"""
def __init__(self, blob_storage):
"""Initialize.
Args:
blob_storage:
apphosting.api.blobstore.blobstore_stub.BlobStorage instance
"""
self.blob_storage = blob_storage
def _filename_to_blobkey(self, filename):
"""Get blobkey for filename.
Args:
filename: gcs filename of form /bucket/filename.
Returns:
blobinfo's datastore's key name, aka, blobkey.
"""
common.validate_file_path(filename)
return blobstore_stub.BlobstoreServiceStub.CreateEncodedGoogleStorageKey(
filename[1:])
def post_start_creation(self, filename, options):
"""Start object creation with a POST.
This implements the resumable upload XML API.
Args:
filename: gcs filename of form /bucket/filename.
options: a dict containing all user specified request headers.
e.g. {'content-type': 'foo', 'x-goog-meta-bar': 'bar'}.
Returns:
a token (blobkey) used for continuing upload.
"""
ns = namespace_manager.get_namespace()
try:
namespace_manager.set_namespace('')
common.validate_file_path(filename)
token = self._filename_to_blobkey(filename)
gcs_file = _AE_GCSFileInfo_.get_by_key_name(token)
self._cleanup_old_file(gcs_file)
new_file = _AE_GCSFileInfo_(key_name=token,
filename=filename,
finalized=False)
new_file.options = options
new_file.put()
return token
finally:
namespace_manager.set_namespace(ns)
def _cleanup_old_file(self, gcs_file):
"""Clean up the old version of a file.
The old version may or may not be finalized yet. Either way,
when user tries to create a file that already exists, we delete the
old version first.
Args:
gcs_file: an instance of _AE_GCSFileInfo_.
"""
if gcs_file:
if gcs_file.finalized:
blobkey = gcs_file.key().name()
self.blob_storage.DeleteBlob(blobkey)
else:
db.delete(_AE_GCSPartialFile_.all().ancestor(gcs_file))
gcs_file.delete()
def put_continue_creation(self, token, content, content_range,
last=False,
_upload_filename=None):
"""Continue object upload with PUTs.
This implements the resumable upload XML API.
Args:
token: upload token returned by post_start_creation.
content: object content.
content_range: a (start, end) tuple specifying the content range of this
chunk. Both are inclusive according to XML API.
last: True if this is the last chunk of file content.
_upload_filename: internal use. Might be removed any time! This is
used by blobstore to pass in the upload filename from user.
Returns:
_AE_GCSFileInfo entity for this file if the file is finalized.
Raises:
ValueError: if token is invalid.
"""
ns = namespace_manager.get_namespace()
try:
namespace_manager.set_namespace('')
gcs_file = _AE_GCSFileInfo_.get_by_key_name(token)
if not gcs_file:
raise ValueError('Invalid token')
if content:
start, end = content_range
if len(content) != (end - start + 1):
raise ValueError('Invalid content range %d-%d' % content_range)
blobkey = '%s-%d-%d' % (token, content_range[0], content_range[1])
self.blob_storage.StoreBlob(blobkey, StringIO.StringIO(content))
new_content = _AE_GCSPartialFile_(parent=gcs_file,
key_name='{:020}'.format(start),
partial_content=blobkey,
start=start,
end=end + 1)
new_content.put()
if last:
return self._end_creation(token, _upload_filename)
finally:
namespace_manager.set_namespace(ns)
def _end_creation(self, token, _upload_filename):
"""End object upload.
Args:
token: upload token returned by post_start_creation.
Returns:
_AE_GCSFileInfo Entity for this file.
Raises:
ValueError: if token is invalid. Or file is corrupted during upload.
Save file content to blobstore. Save blobinfo and _AE_GCSFileInfo.
"""
gcs_file = _AE_GCSFileInfo_.get_by_key_name(token)
if not gcs_file:
raise ValueError('Invalid token')
error_msg, content = self._get_content(gcs_file)
if error_msg:
raise ValueError(error_msg)
gcs_file.etag = hashlib.md5(content).hexdigest()
gcs_file.creation = datetime.datetime.utcnow()
gcs_file.size = len(content)
blob_info = datastore.Entity('__BlobInfo__', name=str(token), namespace='')
blob_info['content_type'] = gcs_file.content_type
blob_info['creation'] = gcs_file.creation
blob_info['filename'] = _upload_filename
blob_info['md5_hash'] = gcs_file.etag
blob_info['size'] = gcs_file.size
datastore.Put(blob_info)
self.blob_storage.StoreBlob(token, StringIO.StringIO(content))
gcs_file.finalized = True
gcs_file.put()
return gcs_file
@db.transactional
def _get_content(self, gcs_file):
"""Aggregate all partial content of the gcs_file.
Args:
gcs_file: an instance of _AE_GCSFileInfo_.
Returns:
(error_msg, content) tuple. error_msg is set if the file is
corrupted during upload. Otherwise content is set to the
aggregation of all partial contents.
"""
content = ''
previous_end = 0
error_msg = ''
for partial in (_AE_GCSPartialFile_.all(namespace='').ancestor(gcs_file).
order('__key__')):
start = int(partial.key().name())
if not error_msg:
if start < previous_end:
error_msg = 'File is corrupted due to missing chunks.'
elif start > previous_end:
error_msg = 'File is corrupted due to overlapping chunks'
previous_end = partial.end
content += self.blob_storage.OpenBlob(partial.partial_content).read()
self.blob_storage.DeleteBlob(partial.partial_content)
partial.delete()
if error_msg:
gcs_file.delete()
content = ''
return error_msg, content
def get_bucket(self,
bucketpath,
prefix,
marker,
max_keys):
"""Get bucket listing with a GET.
Args:
bucketpath: gcs bucket path of form '/bucket'
prefix: prefix to limit listing.
marker: a str after which to start listing.
max_keys: max size of listing.
See https://developers.google.com/storage/docs/reference-methods#getbucket
for details.
Returns:
A list of GCSFileStat sorted by filename.
"""
common.validate_bucket_path(bucketpath)
q = _AE_GCSFileInfo_.all(namespace='')
fully_qualified_prefix = '/'.join([bucketpath, prefix])
if marker:
q.filter('filename >', '/'.join([bucketpath, marker]))
else:
q.filter('filename >=', fully_qualified_prefix)
result = []
for info in q.run(limit=max_keys):
if not info.filename.startswith(fully_qualified_prefix):
break
info = db.get(info.key())
if info:
result.append(common.GCSFileStat(
filename=info.filename,
st_size=info.size,
st_ctime=calendar.timegm(info.creation.utctimetuple()),
etag=info.etag))
return result
def get_object(self, filename, start=0, end=None):
"""Get file content with a GET.
Args:
filename: gcs filename of form '/bucket/filename'.
start: start offset to request. Inclusive.
end: end offset to request. Inclusive.
Returns:
The segment of file content requested.
Raises:
ValueError: if file doesn't exist.
"""
common.validate_file_path(filename)
blobkey = self._filename_to_blobkey(filename)
key = blobstore_stub.BlobstoreServiceStub.ToDatastoreBlobKey(blobkey)
gcsfileinfo = db.get(key)
if not gcsfileinfo or not gcsfileinfo.finalized:
raise ValueError('File does not exist.')
local_file = self.blob_storage.OpenBlob(blobkey)
local_file.seek(start)
if end:
return local_file.read(end - start + 1)
else:
return local_file.read()
def head_object(self, filename):
"""Get file stat with a HEAD.
Args:
filename: gcs filename of form '/bucket/filename'
Returns:
A GCSFileStat object containing file stat. None if file doesn't exist.
"""
common.validate_file_path(filename)
blobkey = self._filename_to_blobkey(filename)
key = blobstore_stub.BlobstoreServiceStub.ToDatastoreBlobKey(blobkey)
info = db.get(key)
if info and info.finalized:
metadata = common.get_metadata(info.options)
filestat = common.GCSFileStat(
filename=info.filename,
st_size=info.size,
etag=info.etag,
st_ctime=calendar.timegm(info.creation.utctimetuple()),
content_type=info.content_type,
metadata=metadata)
return filestat
return None
def delete_object(self, filename):
"""Delete file with a DELETE.
Args:
filename: gcs filename of form '/bucket/filename'
Returns:
True if file is deleted. False if file doesn't exist.
"""
common.validate_file_path(filename)
blobkey = self._filename_to_blobkey(filename)
key = blobstore_stub.BlobstoreServiceStub.ToDatastoreBlobKey(blobkey)
gcsfileinfo = db.get(key)
if not gcsfileinfo:
return False
blobstore_stub.BlobstoreServiceStub.DeleteBlob(blobkey, self.blob_storage)
return True