Implements the S3 store to the level of the swift store.
This branch is Chris' work with a merge of trunk, fix of merge conflicts from trunk, and moving the import of boto into a conditional block so tests can run with the fakes when boto is not installed on the local machine.
This commit is contained in:
@@ -53,13 +53,17 @@ def get_backend_class(backend):
|
|||||||
"""
|
"""
|
||||||
# NOTE(sirp): avoiding circular import
|
# NOTE(sirp): avoiding circular import
|
||||||
from glance.store.http import HTTPBackend
|
from glance.store.http import HTTPBackend
|
||||||
|
from glance.store.s3 import S3Backend
|
||||||
from glance.store.swift import SwiftBackend
|
from glance.store.swift import SwiftBackend
|
||||||
from glance.store.filesystem import FilesystemBackend
|
from glance.store.filesystem import FilesystemBackend
|
||||||
|
|
||||||
BACKENDS = {"file": FilesystemBackend,
|
BACKENDS = {
|
||||||
"http": HTTPBackend,
|
"file": FilesystemBackend,
|
||||||
"https": HTTPBackend,
|
"http": HTTPBackend,
|
||||||
"swift": SwiftBackend}
|
"https": HTTPBackend,
|
||||||
|
"swift": SwiftBackend,
|
||||||
|
"s3": S3Backend
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return BACKENDS[backend]
|
return BACKENDS[backend]
|
||||||
@@ -99,3 +103,41 @@ def get_store_from_location(location):
|
|||||||
"""
|
"""
|
||||||
loc_pieces = urlparse.urlparse(location)
|
loc_pieces = urlparse.urlparse(location)
|
||||||
return loc_pieces.scheme
|
return loc_pieces.scheme
|
||||||
|
|
||||||
|
|
||||||
|
def parse_uri_tokens(parsed_uri, example_url):
|
||||||
|
"""
|
||||||
|
Given a URI and an example_url, attempt to parse the uri to assemble an
|
||||||
|
authurl. This method returns the user, key, authurl, referenced container,
|
||||||
|
and the object we're looking for in that container.
|
||||||
|
|
||||||
|
Parsing the uri is three phases:
|
||||||
|
1) urlparse to split the tokens
|
||||||
|
2) use RE to split on @ and /
|
||||||
|
3) reassemble authurl
|
||||||
|
|
||||||
|
"""
|
||||||
|
path = parsed_uri.path.lstrip('//')
|
||||||
|
netloc = parsed_uri.netloc
|
||||||
|
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
creds, netloc = netloc.split('@')
|
||||||
|
except ValueError:
|
||||||
|
# Python 2.6.1 compat
|
||||||
|
# see lp659445 and Python issue7904
|
||||||
|
creds, path = path.split('@')
|
||||||
|
user, key = creds.split(':')
|
||||||
|
path_parts = path.split('/')
|
||||||
|
obj = path_parts.pop()
|
||||||
|
container = path_parts.pop()
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
raise BackendException(
|
||||||
|
"Expected four values to unpack in: %s:%s. "
|
||||||
|
"Should have received something like: %s."
|
||||||
|
% (parsed_uri.scheme, parsed_uri.path, example_url))
|
||||||
|
|
||||||
|
authurl = "https://%s" % '/'.join(path_parts)
|
||||||
|
|
||||||
|
return user, key, authurl, container, obj
|
||||||
|
|
||||||
|
@@ -14,107 +14,3 @@
|
|||||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
# License for the specific language governing permissions and limitations
|
# License for the specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
import os
|
|
||||||
import urlparse
|
|
||||||
|
|
||||||
from glance.common import exception
|
|
||||||
|
|
||||||
|
|
||||||
# TODO(sirp): should this be moved out to common/utils.py ?
|
|
||||||
def _file_iter(f, size):
|
|
||||||
"""
|
|
||||||
Return an iterator for a file-like object
|
|
||||||
"""
|
|
||||||
chunk = f.read(size)
|
|
||||||
while chunk:
|
|
||||||
yield chunk
|
|
||||||
chunk = f.read(size)
|
|
||||||
|
|
||||||
|
|
||||||
class BackendException(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class UnsupportedBackend(BackendException):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class Backend(object):
|
|
||||||
CHUNKSIZE = 4096
|
|
||||||
|
|
||||||
|
|
||||||
class FilesystemBackend(Backend):
|
|
||||||
@classmethod
|
|
||||||
def get(cls, parsed_uri, expected_size, opener=lambda p: open(p, "rb")):
|
|
||||||
""" Filesystem-based backend
|
|
||||||
|
|
||||||
file:///path/to/file.tar.gz.0
|
|
||||||
"""
|
|
||||||
#FIXME: must prevent attacks using ".." and "." paths
|
|
||||||
with opener(parsed_uri.path) as f:
|
|
||||||
return _file_iter(f, cls.CHUNKSIZE)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def delete(cls, parsed_uri):
|
|
||||||
"""
|
|
||||||
Removes a file from the filesystem backend.
|
|
||||||
|
|
||||||
:param parsed_uri: Parsed pieces of URI in form of::
|
|
||||||
file:///path/to/filename.ext
|
|
||||||
|
|
||||||
:raises NotFound if file does not exist
|
|
||||||
:raises NotAuthorized if cannot delete because of permissions
|
|
||||||
"""
|
|
||||||
fn = parsed_uri.path
|
|
||||||
if os.path.exists(fn):
|
|
||||||
try:
|
|
||||||
os.unlink(fn)
|
|
||||||
except OSError:
|
|
||||||
raise exception.NotAuthorized("You cannot delete file %s" % fn)
|
|
||||||
else:
|
|
||||||
raise exception.NotFound("File %s does not exist" % fn)
|
|
||||||
|
|
||||||
|
|
||||||
def get_backend_class(backend):
|
|
||||||
"""
|
|
||||||
Returns the backend class as designated in the
|
|
||||||
backend name
|
|
||||||
|
|
||||||
:param backend: Name of backend to create
|
|
||||||
"""
|
|
||||||
# NOTE(sirp): avoiding circular import
|
|
||||||
from glance.store.backends.http import HTTPBackend
|
|
||||||
from glance.store.backends.swift import SwiftBackend
|
|
||||||
|
|
||||||
BACKENDS = {"file": FilesystemBackend,
|
|
||||||
"http": HTTPBackend,
|
|
||||||
"https": HTTPBackend,
|
|
||||||
"swift": SwiftBackend}
|
|
||||||
|
|
||||||
try:
|
|
||||||
return BACKENDS[backend]
|
|
||||||
except KeyError:
|
|
||||||
raise UnsupportedBackend("No backend found for '%s'" % scheme)
|
|
||||||
|
|
||||||
|
|
||||||
def get_from_backend(uri, **kwargs):
|
|
||||||
"""Yields chunks of data from backend specified by uri"""
|
|
||||||
|
|
||||||
parsed_uri = urlparse.urlparse(uri)
|
|
||||||
scheme = parsed_uri.scheme
|
|
||||||
|
|
||||||
backend_class = get_backend_class(scheme)
|
|
||||||
|
|
||||||
return backend_class.get(parsed_uri, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def delete_from_backend(uri, **kwargs):
|
|
||||||
"""Removes chunks of data from backend specified by uri"""
|
|
||||||
|
|
||||||
parsed_uri = urlparse.urlparse(uri)
|
|
||||||
scheme = parsed_uri.scheme
|
|
||||||
|
|
||||||
backend_class = get_backend_class(scheme)
|
|
||||||
|
|
||||||
return backend_class.delete(parsed_uri, **kwargs)
|
|
||||||
|
109
glance/store/s3.py
Normal file
109
glance/store/s3.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
|
||||||
|
# Copyright 2010 OpenStack, LLC
|
||||||
|
# All Rights Reserved.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
"""The s3 backend adapter"""
|
||||||
|
|
||||||
|
import glance.store
|
||||||
|
|
||||||
|
|
||||||
|
class S3Backend(glance.store.Backend):
|
||||||
|
"""An implementation of the s3 adapter."""
|
||||||
|
|
||||||
|
EXAMPLE_URL = "s3://ACCESS_KEY:SECRET_KEY@s3_url/bucket/file.gz.0"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls, parsed_uri, expected_size, conn_class=None):
|
||||||
|
"""
|
||||||
|
Takes a parsed_uri in the format of:
|
||||||
|
s3://access_key:secret_key@s3.amazonaws.com/bucket/file.gz.0, connects
|
||||||
|
to s3 and downloads the file. Returns the generator resp_body provided
|
||||||
|
by get_object.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if conn_class:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
import boto.s3.connection
|
||||||
|
conn_class = boto.s3.connection.S3Connection
|
||||||
|
|
||||||
|
(access_key, secret_key, host, bucket, obj) = \
|
||||||
|
cls._parse_s3_tokens(parsed_uri)
|
||||||
|
|
||||||
|
# Close the connection when we're through.
|
||||||
|
with conn_class(access_key, secret_key, host=host) as s3_conn:
|
||||||
|
bucket = cls._get_bucket(s3_conn, bucket)
|
||||||
|
|
||||||
|
# Close the key when we're through.
|
||||||
|
with cls._get_key(bucket, obj) as key:
|
||||||
|
if not key.size == expected_size:
|
||||||
|
raise glance.store.BackendException(
|
||||||
|
"Expected %s bytes, got %s" %
|
||||||
|
(expected_size, key.size))
|
||||||
|
|
||||||
|
key.BufferSize = cls.CHUNKSIZE
|
||||||
|
for chunk in key:
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def delete(cls, parsed_uri, conn_class=None):
|
||||||
|
"""
|
||||||
|
Takes a parsed_uri in the format of:
|
||||||
|
s3://access_key:secret_key@s3.amazonaws.com/bucket/file.gz.0, connects
|
||||||
|
to s3 and deletes the file. Returns whatever boto.s3.key.Key.delete()
|
||||||
|
returns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if conn_class:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
conn_class = boto.s3.connection.S3Connection
|
||||||
|
|
||||||
|
(access_key, secret_key, host, bucket, obj) = \
|
||||||
|
cls._parse_s3_tokens(parsed_uri)
|
||||||
|
|
||||||
|
# Close the connection when we're through.
|
||||||
|
with conn_class(access_key, secret_key, host=host) as s3_conn:
|
||||||
|
bucket = cls._get_bucket(s3_conn, bucket)
|
||||||
|
|
||||||
|
# Close the key when we're through.
|
||||||
|
with cls._get_key(bucket, obj) as key:
|
||||||
|
return key.delete()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_bucket(cls, conn, bucket_id):
|
||||||
|
"""Get a bucket from an s3 connection"""
|
||||||
|
|
||||||
|
bucket = conn.get_bucket(bucket_id)
|
||||||
|
if not bucket:
|
||||||
|
raise glance.store.BackendException("Could not find bucket: %s" %
|
||||||
|
bucket_id)
|
||||||
|
|
||||||
|
return bucket
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_key(cls, bucket, obj):
|
||||||
|
"""Get a key from a bucket"""
|
||||||
|
|
||||||
|
key = bucket.get_key(obj)
|
||||||
|
if not key:
|
||||||
|
raise glance.store.BackendException("Could not get key: %s" % key)
|
||||||
|
return key
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse_s3_tokens(cls, parsed_uri):
|
||||||
|
"""Parse tokens from the parsed_uri"""
|
||||||
|
return glance.store.parse_uri_tokens(parsed_uri, cls.EXAMPLE_URL)
|
@@ -15,6 +15,7 @@
|
|||||||
# License for the specific language governing permissions and limitations
|
# License for the specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
|
from __future__ import absolute_import
|
||||||
import glance.store
|
import glance.store
|
||||||
|
|
||||||
|
|
||||||
@@ -114,21 +115,7 @@ class SwiftBackend(glance.store.Backend):
|
|||||||
|
|
||||||
|
|
||||||
def get_connection_class(conn_class):
|
def get_connection_class(conn_class):
|
||||||
if conn_class:
|
if not conn_class:
|
||||||
pass # Use the provided conn_class
|
import swift.common.client
|
||||||
else:
|
|
||||||
# NOTE(sirp): A standard import statement won't work here because
|
|
||||||
# this file ('swift.py') is shadowing the swift module, and since
|
|
||||||
# the import statement searches locally before globally, we'd end
|
|
||||||
# up importing ourselves.
|
|
||||||
#
|
|
||||||
# NOTE(jaypipes): This can be resolved by putting this code in
|
|
||||||
# /glance/store/swift/__init__.py
|
|
||||||
#
|
|
||||||
# see http://docs.python.org/library/functions.html#__import__
|
|
||||||
PERFORM_ABSOLUTE_IMPORTS = 0
|
|
||||||
swift = __import__('swift.common.client', globals(), locals(), [],
|
|
||||||
PERFORM_ABSOLUTE_IMPORTS)
|
|
||||||
|
|
||||||
conn_class = swift.common.client.Connection
|
conn_class = swift.common.client.Connection
|
||||||
return conn_class
|
return conn_class
|
||||||
|
@@ -105,6 +105,44 @@ def stub_out_filesystem_backend():
|
|||||||
f.close()
|
f.close()
|
||||||
|
|
||||||
|
|
||||||
|
def stub_out_s3_backend(stubs):
|
||||||
|
""" Stubs out the S3 Backend with fake data and calls.
|
||||||
|
|
||||||
|
The stubbed swift backend provides back an iterator over
|
||||||
|
the data ""
|
||||||
|
|
||||||
|
:param stubs: Set of stubout stubs
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
class FakeSwiftAuth(object):
|
||||||
|
pass
|
||||||
|
class FakeS3Connection(object):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class FakeS3Backend(object):
|
||||||
|
CHUNK_SIZE = 2
|
||||||
|
DATA = 'I am a teapot, short and stout\n'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls, parsed_uri, expected_size, conn_class=None):
|
||||||
|
S3Backend = glance.store.s3.S3Backend
|
||||||
|
|
||||||
|
# raise BackendException if URI is bad.
|
||||||
|
(user, key, authurl, container, obj) = \
|
||||||
|
S3Backend._parse_s3_tokens(parsed_uri)
|
||||||
|
|
||||||
|
def chunk_it():
|
||||||
|
for i in xrange(0, len(cls.DATA), cls.CHUNK_SIZE):
|
||||||
|
yield cls.DATA[i:i+cls.CHUNK_SIZE]
|
||||||
|
|
||||||
|
return chunk_it()
|
||||||
|
|
||||||
|
fake_swift_backend = FakeS3Backend()
|
||||||
|
stubs.Set(glance.store.s3.S3Backend, 'get',
|
||||||
|
fake_swift_backend.get)
|
||||||
|
|
||||||
|
|
||||||
def stub_out_swift_backend(stubs):
|
def stub_out_swift_backend(stubs):
|
||||||
"""Stubs out the Swift Glance backend with fake data
|
"""Stubs out the Swift Glance backend with fake data
|
||||||
and calls.
|
and calls.
|
||||||
|
@@ -21,6 +21,7 @@ import stubout
|
|||||||
import unittest
|
import unittest
|
||||||
import urlparse
|
import urlparse
|
||||||
|
|
||||||
|
from glance.store.s3 import S3Backend
|
||||||
from glance.store.swift import SwiftBackend
|
from glance.store.swift import SwiftBackend
|
||||||
from glance.store import Backend, BackendException, get_from_backend
|
from glance.store import Backend, BackendException, get_from_backend
|
||||||
from tests import stubs
|
from tests import stubs
|
||||||
@@ -87,6 +88,25 @@ class TestHTTPBackend(TestBackend):
|
|||||||
self.assertEqual(chunks, expected_returns)
|
self.assertEqual(chunks, expected_returns)
|
||||||
|
|
||||||
|
|
||||||
|
class TestS3Backend(TestBackend):
|
||||||
|
def setUp(self):
|
||||||
|
super(TestS3Backend, self).setUp()
|
||||||
|
stubs.stub_out_s3_backend(self.stubs)
|
||||||
|
|
||||||
|
def test_get(self):
|
||||||
|
s3_uri = "s3://user:password@localhost/bucket1/file.tar.gz"
|
||||||
|
|
||||||
|
expected_returns = ['I ', 'am', ' a', ' t', 'ea', 'po', 't,', ' s',
|
||||||
|
'ho', 'rt', ' a', 'nd', ' s', 'to', 'ut', '\n']
|
||||||
|
fetcher = get_from_backend(s3_uri,
|
||||||
|
expected_size=8,
|
||||||
|
conn_class=S3Backend)
|
||||||
|
|
||||||
|
chunks = [c for c in fetcher]
|
||||||
|
self.assertEqual(chunks, expected_returns)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class TestSwiftBackend(TestBackend):
|
class TestSwiftBackend(TestBackend):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
Reference in New Issue
Block a user