diff --git a/conf/nginx-nossl.conf b/conf/nginx-nossl.conf index 73a9c7605..fbcce63c0 100644 --- a/conf/nginx-nossl.conf +++ b/conf/nginx-nossl.conf @@ -13,10 +13,5 @@ http { include server-base.conf; listen 80 default; - - location /static/ { - # checks for static file, if not found proxy to app - alias /static/; - } } } diff --git a/conf/nginx.conf b/conf/nginx.conf index 43c21b6ca..e208d30e0 100644 --- a/conf/nginx.conf +++ b/conf/nginx.conf @@ -23,10 +23,5 @@ http { ssl_protocols SSLv3 TLSv1; ssl_ciphers ALL:!ADH:!EXPORT56:RC4+RSA:+HIGH:+MEDIUM:+LOW:+SSLv3:+EXP; ssl_prefer_server_ciphers on; - - location /static/ { - # checks for static file, if not found proxy to app - alias /static/; - } } } diff --git a/conf/server-base.conf b/conf/server-base.conf index a13cf1424..4636afdde 100644 --- a/conf/server-base.conf +++ b/conf/server-base.conf @@ -24,4 +24,16 @@ location / { proxy_pass http://app_server; proxy_read_timeout 2000; proxy_temp_path /var/log/nginx/proxy_temp 1 2; +} + +location /static/ { + # checks for static file, if not found proxy to app + alias /static/; +} + +location /v1/_ping { + add_header Content-Type text/plain; + add_header X-Docker-Registry-Version 0.6.0; + add_header X-Docker-Registry-Standalone 0; + return 200 'okay'; } \ No newline at end of file diff --git a/config.py b/config.py index 4fe5b2cd5..6742d1a43 100644 --- a/config.py +++ b/config.py @@ -165,6 +165,9 @@ class DefaultConfig(object): # Feature Flag: Whether emails are enabled. FEATURE_MAILING = True + # Feature Flag: Whether users can be created (by non-super users). + FEATURE_USER_CREATION = True + DISTRIBUTED_STORAGE_CONFIG = { 'local_eu': ['LocalStorage', {'storage_path': 'test/data/registry/eu'}], 'local_us': ['LocalStorage', {'storage_path': 'test/data/registry/us'}], diff --git a/data/migrations/versions/2fb36d4be80d_translate_the_queue_names_to_reference_.py b/data/migrations/versions/2fb36d4be80d_translate_the_queue_names_to_reference_.py index ae4c5d274..53b43a4ec 100644 --- a/data/migrations/versions/2fb36d4be80d_translate_the_queue_names_to_reference_.py +++ b/data/migrations/versions/2fb36d4be80d_translate_the_queue_names_to_reference_.py @@ -1,14 +1,14 @@ """Translate the queue names to reference namespace by id, remove the namespace column. Revision ID: 2fb36d4be80d -Revises: 3f4fe1194671 +Revises: 9a1087b007d Create Date: 2014-09-30 17:31:33.308490 """ # revision identifiers, used by Alembic. revision = '2fb36d4be80d' -down_revision = '3f4fe1194671' +down_revision = '9a1087b007d' from alembic import op import sqlalchemy as sa diff --git a/data/migrations/versions/3f4fe1194671_backfill_the_namespace_user_fields.py b/data/migrations/versions/3f4fe1194671_backfill_the_namespace_user_fields.py index cf0b90199..6f40f4fc0 100644 --- a/data/migrations/versions/3f4fe1194671_backfill_the_namespace_user_fields.py +++ b/data/migrations/versions/3f4fe1194671_backfill_the_namespace_user_fields.py @@ -22,4 +22,5 @@ def upgrade(tables): def downgrade(tables): + op.drop_constraint('fk_repository_namespace_user_id_user', table_name='repository', type_='foreignkey') op.drop_index('repository_namespace_user_id_name', table_name='repository') diff --git a/data/migrations/versions/51d04d0e7e6f_email_invites_for_joining_a_team.py b/data/migrations/versions/51d04d0e7e6f_email_invites_for_joining_a_team.py index 2e4242d8a..c18335adb 100644 --- a/data/migrations/versions/51d04d0e7e6f_email_invites_for_joining_a_team.py +++ b/data/migrations/versions/51d04d0e7e6f_email_invites_for_joining_a_team.py @@ -74,8 +74,5 @@ def downgrade(tables): .where(tables.notificationkind.c.name == op.inline_literal('org_team_invite'))) ) - op.drop_index('teammemberinvite_user_id', table_name='teammemberinvite') - op.drop_index('teammemberinvite_team_id', table_name='teammemberinvite') - op.drop_index('teammemberinvite_inviter_id', table_name='teammemberinvite') op.drop_table('teammemberinvite') ### end Alembic commands ### diff --git a/data/migrations/versions/9a1087b007d_allow_the_namespace_column_to_be_.py b/data/migrations/versions/9a1087b007d_allow_the_namespace_column_to_be_.py new file mode 100644 index 000000000..9b63ae190 --- /dev/null +++ b/data/migrations/versions/9a1087b007d_allow_the_namespace_column_to_be_.py @@ -0,0 +1,28 @@ +"""Allow the namespace column to be nullable. + +Revision ID: 9a1087b007d +Revises: 3f4fe1194671 +Create Date: 2014-10-01 16:11:21.277226 + +""" + +# revision identifiers, used by Alembic. +revision = '9a1087b007d' +down_revision = '3f4fe1194671' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(tables): + op.drop_index('repository_namespace_name', table_name='repository') + op.alter_column('repository', 'namespace', nullable=True, existing_type=sa.String(length=255), + server_default=sa.text('NULL')) + + +def downgrade(tables): + conn = op.get_bind() + conn.execute('update repository set namespace = (select username from user where user.id = repository.namespace_user_id) where namespace is NULL') + + op.create_index('repository_namespace_name', 'repository', ['namespace', 'name'], unique=True) + op.alter_column('repository', 'namespace', nullable=False, existing_type=sa.String(length=255)) diff --git a/data/model/legacy.py b/data/model/legacy.py index effa2c8ef..ece14a4c2 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -1239,8 +1239,7 @@ def get_storage_by_uuid(storage_uuid): return found -def set_image_size(docker_image_id, namespace_name, repository_name, - image_size): +def set_image_size(docker_image_id, namespace_name, repository_name, image_size, uncompressed_size): try: image = (Image .select(Image, ImageStorage) @@ -1249,18 +1248,15 @@ def set_image_size(docker_image_id, namespace_name, repository_name, .switch(Image) .join(ImageStorage, JOIN_LEFT_OUTER) .where(Repository.name == repository_name, Namespace.username == namespace_name, - Image.docker_image_id == docker_image_id) + Image.docker_image_id == docker_image_id) .get()) except Image.DoesNotExist: raise DataModelException('No image with specified id and repository') - if image.storage and image.storage.id: - image.storage.image_size = image_size - image.storage.save() - else: - image.image_size = image_size - image.save() + image.storage.image_size = image_size + image.storage.uncompressed_size = uncompressed_size + image.storage.save() return image diff --git a/data/model/sqlalchemybridge.py b/data/model/sqlalchemybridge.py index 46809fb21..8b7d8b664 100644 --- a/data/model/sqlalchemybridge.py +++ b/data/model/sqlalchemybridge.py @@ -17,7 +17,12 @@ OPTION_TRANSLATIONS = { def gen_sqlalchemy_metadata(peewee_model_list): - metadata = MetaData() + metadata = MetaData(naming_convention={ + "ix": 'ix_%(column_0_label)s', + "uq": "uq_%(table_name)s_%(column_0_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s" + }) for model in peewee_model_list: meta = model._meta diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py index 2f5e2045e..1943051e0 100644 --- a/endpoints/api/__init__.py +++ b/endpoints/api/__init__.py @@ -27,8 +27,8 @@ api_bp = Blueprint('api', __name__) api = Api() api.init_app(api_bp) api.decorators = [csrf_protect, - process_oauth, - crossdomain(origin='*', headers=['Authorization', 'Content-Type'])] + crossdomain(origin='*', headers=['Authorization', 'Content-Type']), + process_oauth] class ApiException(Exception): @@ -90,6 +90,7 @@ def handle_api_error(error): if error.error_type is not None: response.headers['WWW-Authenticate'] = ('Bearer error="%s" error_description="%s"' % (error.error_type, error.error_description)) + return response @@ -191,6 +192,7 @@ def query_param(name, help_str, type=reqparse.text_type, default=None, 'default': default, 'choices': choices, 'required': required, + 'location': ('args') }) return func return add_param diff --git a/endpoints/api/user.py b/endpoints/api/user.py index b842bb466..cb0602010 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -120,6 +120,10 @@ class User(ApiResource): 'type': 'string', 'description': 'The user\'s email address', }, + 'invite_code': { + 'type': 'string', + 'description': 'The optional invite code' + } } }, 'UpdateUser': { @@ -207,16 +211,14 @@ class User(ApiResource): return user_view(user) + @show_if(features.USER_CREATION) @nickname('createNewUser') - @parse_args - @query_param('inviteCode', 'Invitation code given for creating the user.', type=str, - default='') @internal_only @validate_json_request('NewUser') - def post(self, args): + def post(self): """ Create a new user. """ user_data = request.get_json() - invite_code = args['inviteCode'] + invite_code = user_data.get('invite_code', '') existing_user = model.get_user(user_data['username']) if existing_user: diff --git a/endpoints/callbacks.py b/endpoints/callbacks.py index 1cbd46192..637033ab6 100644 --- a/endpoints/callbacks.py +++ b/endpoints/callbacks.py @@ -26,7 +26,8 @@ def render_ologin_error(service_name, error_message='Could not load user data. The token may have expired.'): return render_page_template('ologinerror.html', service_name=service_name, error_message=error_message, - service_url=get_app_url()) + service_url=get_app_url(), + user_creation=features.USER_CREATION) def exchange_code_for_token(code, service_name='GITHUB', for_login=True, form_encode=False, redirect_suffix=''): @@ -85,7 +86,12 @@ def get_google_user(token): def conduct_oauth_login(service_name, user_id, username, email, metadata={}): to_login = model.verify_federated_login(service_name.lower(), user_id) if not to_login: - # try to create the user + # See if we can create a new user. + if not features.USER_CREATION: + error_message = 'User creation is disabled. Please contact your administrator' + return render_ologin_error(service_name, error_message) + + # Try to create the user try: valid = next(generate_valid_usernames(username)) to_login = model.create_federated_user(valid, email, service_name.lower(), @@ -147,7 +153,7 @@ def github_oauth_callback(): token = exchange_code_for_token(request.args.get('code'), service_name='GITHUB') user_data = get_github_user(token) - if not user_data: + if not user_data or not 'login' in user_data: return render_ologin_error('GitHub') username = user_data['login'] diff --git a/endpoints/index.py b/endpoints/index.py index 46c5b9771..eb52971cf 100644 --- a/endpoints/index.py +++ b/endpoints/index.py @@ -19,6 +19,7 @@ from auth.permissions import (ModifyRepositoryPermission, UserAdminPermission, from util.http import abort from endpoints.notificationhelper import spawn_notification +import features logger = logging.getLogger(__name__) profile = logging.getLogger('application.profiler') @@ -65,6 +66,9 @@ def generate_headers(role='read'): @index.route('/users', methods=['POST']) @index.route('/users/', methods=['POST']) def create_user(): + if not features.USER_CREATION: + abort(400, 'User creation is disabled. Please speak to your administrator.') + user_data = request.get_json() if not 'username' in user_data: abort(400, 'Missing username') @@ -454,6 +458,7 @@ def get_search(): @index.route('/_ping') @index.route('/_ping') def ping(): + # NOTE: any changes made here must also be reflected in the nginx config response = make_response('true', 200) response.headers['X-Docker-Registry-Version'] = '0.6.0' response.headers['X-Docker-Registry-Standalone'] = '0' diff --git a/endpoints/registry.py b/endpoints/registry.py index 741601d0b..751c2a0ff 100644 --- a/endpoints/registry.py +++ b/endpoints/registry.py @@ -220,7 +220,7 @@ def put_image_layer(namespace, repository, image_id): image_size = tmp.tell() # Save the size of the image. - model.set_image_size(image_id, namespace, repository, image_size) + model.set_image_size(image_id, namespace, repository, image_size, uncompressed_size_info.size) tmp.seek(0) csums.append(checksums.compute_tarsum(tmp, json_data)) @@ -229,12 +229,6 @@ def put_image_layer(namespace, repository, image_id): logger.debug('put_image_layer: Error when computing tarsum ' '{0}'.format(e)) - # Write the uncompressed image size, if any. - if uncompressed_size_info['size'] > 0: - profile.debug('Storing uncompressed layer size: %s' % uncompressed_size_info['size']) - repo_image.storage.uncompressed_size = uncompressed_size_info['size'] - repo_image.storage.save() - if repo_image.storage.checksum is None: # We don't have a checksum stored yet, that's fine skipping the check. # Not removing the mark though, image is not downloadable yet. diff --git a/endpoints/trigger.py b/endpoints/trigger.py index 4a10485ae..c7c47db79 100644 --- a/endpoints/trigger.py +++ b/endpoints/trigger.py @@ -377,7 +377,7 @@ class GithubBuildTrigger(BuildTrigger): gh_client = self._get_client(auth_token) source = config['build_source'] repo = gh_client.get_repo(source) - branches = [branch['name'] for branch in repo.get_branches()] + branches = [branch.name for branch in repo.get_branches()] if not repo.default_branch in branches: branches.insert(0, repo.default_branch) diff --git a/initdb.py b/initdb.py index 6be72f6ff..6fa8efe98 100644 --- a/initdb.py +++ b/initdb.py @@ -82,8 +82,9 @@ def __create_subtree(repo, structure, creator_username, parent): new_image = model.set_image_metadata(docker_image_id, repo.namespace_user.username, repo.name, str(creation_time), 'no comment', command, parent) - model.set_image_size(docker_image_id, repo.namespace_user.username, repo.name, - random.randrange(1, 1024 * 1024 * 1024)) + compressed_size = random.randrange(1, 1024 * 1024 * 1024) + model.set_image_size(docker_image_id, repo.namespace_user.username, repo.name, compressed_size, + int(compressed_size * 1.4)) # Populate the diff file diff_path = store.image_file_diffs_path(new_image.storage.uuid) diff --git a/static/directives/signup-form.html b/static/directives/signup-form.html index ba4efe287..9117e880a 100644 --- a/static/directives/signup-form.html +++ b/static/directives/signup-form.html @@ -1,4 +1,4 @@ -
+
-
+
diff --git a/static/js/app.js b/static/js/app.js index adb11d6a9..ba3145116 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -2814,7 +2814,7 @@ quayApp.directive('signupForm', function () { $scope.registering = true; if ($scope.inviteCode) { - $scope.newUser['inviteCode'] = $scope.inviteCode; + $scope.newUser['invite_code'] = $scope.inviteCode; } ApiService.createNewUser($scope.newUser).then(function(resp) { diff --git a/templates/ologinerror.html b/templates/ologinerror.html index 304b7f554..74f51987e 100644 --- a/templates/ologinerror.html +++ b/templates/ologinerror.html @@ -8,17 +8,19 @@
diff --git a/test/data/test.db b/test/data/test.db index 88e3bd7a3..fdbb5b861 100644 Binary files a/test/data/test.db and b/test/data/test.db differ diff --git a/test/test_api_usage.py b/test/test_api_usage.py index 2d73008f0..05fe525be 100644 --- a/test/test_api_usage.py +++ b/test/test_api_usage.py @@ -173,7 +173,7 @@ class ApiTestCase(unittest.TestCase): if memberData['name'] == membername: return - self.fail(membername + ' not found in team: ' + json.dumps(data)) + self.fail(membername + ' not found in team: ' + py_json.dumps(data)) def login(self, username, password='password'): return self.postJsonResponse(Signin, data=dict(username=username, password=password)) @@ -405,7 +405,7 @@ class TestCreateNewUser(ApiTestCase): invite = model.add_or_invite_to_team(inviter, team, None, 'foo@example.com') details = { - 'inviteCode': invite.invite_token + 'invite_code': invite.invite_token } details.update(NEW_USER_DETAILS); diff --git a/tools/uncompressedsize.py b/tools/uncompressedsize.py index 56a22756b..1b6c0e468 100644 --- a/tools/uncompressedsize.py +++ b/tools/uncompressedsize.py @@ -1,101 +1,60 @@ -import json import logging +import zlib from data import model from data.database import ImageStorage from app import app, storage as store from data.database import db -from gzip import GzipFile -from tempfile import SpooledTemporaryFile +from util.gzipstream import ZLIB_GZIP_WINDOW logger = logging.getLogger(__name__) -def backfill_sizes_from_json(): - query = (ImageStorage - .select() - .where(ImageStorage.uncompressed_size == None, ImageStorage.uploading == False) - .limit(100)) - - total = 0 - missing = 0 - batch_processed = 1 - - while batch_processed > 0: - batch_processed = 0 - with app.config['DB_TRANSACTION_FACTORY'](db): - for image_storage in query.clone(): - total += 1 - batch_processed += 1 - - if (total - 1) % 100 == 0: - logger.debug('Storing entry: %s', total) - - # Lookup the JSON for the image. - uuid = image_storage.uuid - with_locations = model.get_storage_by_uuid(uuid) - - try: - json_string = store.get_content(with_locations.locations, store.image_json_path(uuid)) - json_data = json.loads(json_string) - size = json_data.get('Size', json_data.get('size', -1)) - except IOError: - logger.debug('Image storage with no json %s', uuid) - size = -1 - - if size == -1: - missing += 1 - - logger.debug('Missing entry %s (%s/%s)', uuid, missing, total) - - image_storage.uncompressed_size = size - image_storage.save() +CHUNK_SIZE = 512 * 1024 * 1024 def backfill_sizes_from_data(): - storage_ids = list(ImageStorage - .select(ImageStorage.uuid) - .where(ImageStorage.uncompressed_size == -1, ImageStorage.uploading == False)) - - counter = 0 - for uuid in [s.uuid for s in storage_ids]: - counter += 1 - - # Load the storage with locations. - logger.debug('Loading entry: %s (%s/%s)', uuid, counter, len(storage_ids)) - with_locations = model.get_storage_by_uuid(uuid) - layer_size = -2 - - # Read the layer from backing storage and calculate the uncompressed size. + while True: + # Load the record from the DB. try: - logger.debug('Loading data: %s (%s bytes)', uuid, with_locations.image_size) - CHUNK_SIZE = 512 * 1024 - with SpooledTemporaryFile(CHUNK_SIZE) as tarball: - layer_data = store.get_content(with_locations.locations, store.image_layer_path(uuid)) - tarball.write(layer_data) - tarball.seek(0) + record = (ImageStorage + .select(ImageStorage.uuid) + .where(ImageStorage.uncompressed_size >> None, ImageStorage.uploading == False) + .get()) + except ImageStorage.DoesNotExist: + # We're done! + return - with GzipFile(fileobj=tarball, mode='rb') as gzip_file: - gzip_file.read() - layer_size = gzip_file.size + uuid = record.uuid + + with_locations = model.get_storage_by_uuid(uuid) + + # Read the layer from backing storage and calculate the uncompressed size. + logger.debug('Loading data: %s (%s bytes)', uuid, with_locations.image_size) + decompressor = zlib.decompressobj(ZLIB_GZIP_WINDOW) + + uncompressed_size = 0 + with store.stream_read_file(with_locations.locations, store.image_layer_path(uuid)) as stream: + while True: + current_data = stream.read(CHUNK_SIZE) + if len(current_data) == 0: + break + + uncompressed_size += len(decompressor.decompress(current_data)) - except Exception as ex: - logger.debug('Could not gunzip entry: %s. Reason: %s', uuid, ex) - continue - # Write the size to the image storage. We do so under a transaction AFTER checking to # make sure the image storage still exists and has not changed. - logger.debug('Writing entry: %s. Size: %s', uuid, layer_size) + logger.debug('Writing entry: %s. Size: %s', uuid, uncompressed_size) with app.config['DB_TRANSACTION_FACTORY'](db): - try: + try: current_record = model.get_storage_by_uuid(uuid) - except: - # Record no longer exists. + except model.InvalidImageException: + logger.warning('Storage with uuid no longer exists: %s', uuid) continue - if not current_record.uploading and current_record.uncompressed_size == -1: - current_record.uncompressed_size = layer_size + if not current_record.uploading and current_record.uncompressed_size == None: + current_record.uncompressed_size = uncompressed_size current_record.save() @@ -103,5 +62,4 @@ if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) logging.getLogger('boto').setLevel(logging.CRITICAL) - backfill_sizes_from_json() backfill_sizes_from_data() diff --git a/util/gzipstream.py b/util/gzipstream.py index 7dbb2fc3e..8f5aa9f6d 100644 --- a/util/gzipstream.py +++ b/util/gzipstream.py @@ -9,17 +9,20 @@ import zlib # http://stackoverflow.com/questions/3122145/zlib-error-error-3-while-decompressing-incorrect-header-check/22310760#22310760 ZLIB_GZIP_WINDOW = zlib.MAX_WBITS | 32 +class SizeInfo(object): + def __init__(self): + self.size = 0 + def calculate_size_handler(): """ Returns an object and a SocketReader handler. The handler will gunzip the data it receives, adding the size found to the object. """ - uncompressed_size_info = { - 'size': 0 - } - + + size_info = SizeInfo() + decompressor = zlib.decompressobj(ZLIB_GZIP_WINDOW) def fn(buf): - uncompressed_size_info['size'] += len(decompressor.decompress(buf)) + size_info.size += len(decompressor.decompress(buf)) - return uncompressed_size_info, fn + return size_info, fn diff --git a/workers/dockerfilebuild.py b/workers/dockerfilebuild.py index 870eeff95..dc4699858 100644 --- a/workers/dockerfilebuild.py +++ b/workers/dockerfilebuild.py @@ -223,7 +223,6 @@ class DockerfileBuildContext(object): raise RuntimeError(message) def pull(self): - # Pull the image, in case it was updated since the last build image_and_tag_tuple = self._parsed_dockerfile.get_image_and_tag() if image_and_tag_tuple is None or image_and_tag_tuple[0] is None: self._build_logger('Missing FROM command in Dockerfile', build_logs.ERROR)