Merge master into laffa

This commit is contained in:
Joseph Schorr 2014-10-07 14:03:17 -04:00
commit f38ce51943
94 changed files with 3132 additions and 871 deletions

View file

@ -47,7 +47,7 @@ RUN venv/bin/python -m external_libraries
# Run the tests # Run the tests
RUN TEST=true venv/bin/python -m unittest discover RUN TEST=true venv/bin/python -m unittest discover
VOLUME ["/conf/stack", "/var/log", "/datastorage"] VOLUME ["/conf/stack", "/var/log", "/datastorage", "/tmp"]
EXPOSE 443 80 EXPOSE 443 80

View file

@ -7,7 +7,7 @@ from peewee import Proxy
from app import app as application from app import app as application
from flask import request, Request from flask import request, Request
from util.names import urn_generator from util.names import urn_generator
from data.model import db as model_db, read_slave from data.database import db as model_db, read_slave
# Turn off debug logging for boto # Turn off debug logging for boto
logging.getLogger('boto').setLevel(logging.CRITICAL) logging.getLogger('boto').setLevel(logging.CRITICAL)

View file

@ -25,7 +25,7 @@ def _load_user_from_cookie():
if not current_user.is_anonymous(): if not current_user.is_anonymous():
logger.debug('Loading user from cookie: %s', current_user.get_id()) logger.debug('Loading user from cookie: %s', current_user.get_id())
set_authenticated_user_deferred(current_user.get_id()) set_authenticated_user_deferred(current_user.get_id())
loaded = QuayDeferredPermissionUser(current_user.get_id(), 'username', {scopes.DIRECT_LOGIN}) loaded = QuayDeferredPermissionUser(current_user.get_id(), 'user_db_id', {scopes.DIRECT_LOGIN})
identity_changed.send(app, identity=loaded) identity_changed.send(app, identity=loaded)
return current_user.db_user() return current_user.db_user()
return None return None
@ -58,12 +58,10 @@ def _validate_and_apply_oauth_token(token):
set_authenticated_user(validated.authorized_user) set_authenticated_user(validated.authorized_user)
set_validated_oauth_token(validated) set_validated_oauth_token(validated)
new_identity = QuayDeferredPermissionUser(validated.authorized_user.username, 'username', new_identity = QuayDeferredPermissionUser(validated.authorized_user.id, 'user_db_id', scope_set)
scope_set)
identity_changed.send(app, identity=new_identity) identity_changed.send(app, identity=new_identity)
def process_basic_auth(auth): def process_basic_auth(auth):
normalized = [part.strip() for part in auth.split(' ') if part] normalized = [part.strip() for part in auth.split(' ') if part]
if normalized[0].lower() != 'basic' or len(normalized) != 2: if normalized[0].lower() != 'basic' or len(normalized) != 2:
@ -100,8 +98,7 @@ def process_basic_auth(auth):
logger.debug('Successfully validated robot: %s' % credentials[0]) logger.debug('Successfully validated robot: %s' % credentials[0])
set_authenticated_user(robot) set_authenticated_user(robot)
deferred_robot = QuayDeferredPermissionUser(robot.username, 'username', deferred_robot = QuayDeferredPermissionUser(robot.id, 'user_db_id', {scopes.DIRECT_LOGIN})
{scopes.DIRECT_LOGIN})
identity_changed.send(app, identity=deferred_robot) identity_changed.send(app, identity=deferred_robot)
return return
except model.InvalidRobotException: except model.InvalidRobotException:
@ -114,7 +111,7 @@ def process_basic_auth(auth):
logger.debug('Successfully validated user: %s' % authenticated.username) logger.debug('Successfully validated user: %s' % authenticated.username)
set_authenticated_user(authenticated) set_authenticated_user(authenticated)
new_identity = QuayDeferredPermissionUser(authenticated.username, 'username', new_identity = QuayDeferredPermissionUser(authenticated.id, 'user_db_id',
{scopes.DIRECT_LOGIN}) {scopes.DIRECT_LOGIN})
identity_changed.send(app, identity=new_identity) identity_changed.send(app, identity=new_identity)
return return

View file

@ -10,13 +10,13 @@ logger = logging.getLogger(__name__)
def get_authenticated_user(): def get_authenticated_user():
user = getattr(_request_ctx_stack.top, 'authenticated_user', None) user = getattr(_request_ctx_stack.top, 'authenticated_user', None)
if not user: if not user:
username = getattr(_request_ctx_stack.top, 'authenticated_username', None) db_id = getattr(_request_ctx_stack.top, 'authenticated_db_id', None)
if not username: if not db_id:
logger.debug('No authenticated user or deferred username.') logger.debug('No authenticated user or deferred database id.')
return None return None
logger.debug('Loading deferred authenticated user.') logger.debug('Loading deferred authenticated user.')
loaded = model.get_user(username) loaded = model.get_user_by_id(db_id)
set_authenticated_user(loaded) set_authenticated_user(loaded)
user = loaded user = loaded
@ -30,10 +30,10 @@ def set_authenticated_user(user_or_robot):
ctx.authenticated_user = user_or_robot ctx.authenticated_user = user_or_robot
def set_authenticated_user_deferred(username_or_robotname): def set_authenticated_user_deferred(user_or_robot_db_id):
logger.debug('Deferring loading of authenticated user object: %s', username_or_robotname) logger.debug('Deferring loading of authenticated user object: %s', user_or_robot_db_id)
ctx = _request_ctx_stack.top ctx = _request_ctx_stack.top
ctx.authenticated_username = username_or_robotname ctx.authenticated_db_id = user_or_robot_db_id
def get_validated_oauth_token(): def get_validated_oauth_token():

View file

@ -58,8 +58,8 @@ SCOPE_MAX_USER_ROLES.update({
class QuayDeferredPermissionUser(Identity): class QuayDeferredPermissionUser(Identity):
def __init__(self, id, auth_type, scopes): def __init__(self, db_id, auth_type, scopes):
super(QuayDeferredPermissionUser, self).__init__(id, auth_type) super(QuayDeferredPermissionUser, self).__init__(db_id, auth_type)
self._permissions_loaded = False self._permissions_loaded = False
self._scope_set = scopes self._scope_set = scopes
@ -88,7 +88,7 @@ class QuayDeferredPermissionUser(Identity):
def can(self, permission): def can(self, permission):
if not self._permissions_loaded: if not self._permissions_loaded:
logger.debug('Loading user permissions after deferring.') logger.debug('Loading user permissions after deferring.')
user_object = model.get_user(self.id) user_object = model.get_user_by_id(self.id)
# Add the superuser need, if applicable. # Add the superuser need, if applicable.
if (user_object.username is not None and if (user_object.username is not None and
@ -112,7 +112,7 @@ class QuayDeferredPermissionUser(Identity):
# Add repository permissions # Add repository permissions
for perm in model.get_all_user_permissions(user_object): for perm in model.get_all_user_permissions(user_object):
repo_grant = _RepositoryNeed(perm.repository.namespace, perm.repository.name, repo_grant = _RepositoryNeed(perm.repository.namespace_user.username, perm.repository.name,
self._repo_role_for_scopes(perm.role.name)) self._repo_role_for_scopes(perm.role.name))
logger.debug('User added permission: {0}'.format(repo_grant)) logger.debug('User added permission: {0}'.format(repo_grant))
self.provides.add(repo_grant) self.provides.add(repo_grant)
@ -230,16 +230,16 @@ def on_identity_loaded(sender, identity):
if isinstance(identity, QuayDeferredPermissionUser): if isinstance(identity, QuayDeferredPermissionUser):
logger.debug('Deferring permissions for user: %s', identity.id) logger.debug('Deferring permissions for user: %s', identity.id)
elif identity.auth_type == 'username': elif identity.auth_type == 'user_db_id':
logger.debug('Switching username permission to deferred object: %s', identity.id) logger.debug('Switching username permission to deferred object: %s', identity.id)
switch_to_deferred = QuayDeferredPermissionUser(identity.id, 'username', {scopes.DIRECT_LOGIN}) switch_to_deferred = QuayDeferredPermissionUser(identity.id, 'user_db_id', {scopes.DIRECT_LOGIN})
identity_changed.send(app, identity=switch_to_deferred) identity_changed.send(app, identity=switch_to_deferred)
elif identity.auth_type == 'token': elif identity.auth_type == 'token':
logger.debug('Loading permissions for token: %s', identity.id) logger.debug('Loading permissions for token: %s', identity.id)
token_data = model.load_token_data(identity.id) token_data = model.load_token_data(identity.id)
repo_grant = _RepositoryNeed(token_data.repository.namespace, repo_grant = _RepositoryNeed(token_data.repository.namespace_user.username,
token_data.repository.name, token_data.repository.name,
token_data.role.name) token_data.role.name)
logger.debug('Delegate token added permission: {0}'.format(repo_grant)) logger.debug('Delegate token added permission: {0}'.format(repo_grant))

View file

@ -13,10 +13,5 @@ http {
include server-base.conf; include server-base.conf;
listen 80 default; listen 80 default;
location /static/ {
# checks for static file, if not found proxy to app
alias /static/;
}
} }
} }

View file

@ -23,10 +23,5 @@ http {
ssl_protocols SSLv3 TLSv1; ssl_protocols SSLv3 TLSv1;
ssl_ciphers ALL:!ADH:!EXPORT56:RC4+RSA:+HIGH:+MEDIUM:+LOW:+SSLv3:+EXP; ssl_ciphers ALL:!ADH:!EXPORT56:RC4+RSA:+HIGH:+MEDIUM:+LOW:+SSLv3:+EXP;
ssl_prefer_server_ciphers on; ssl_prefer_server_ciphers on;
location /static/ {
# checks for static file, if not found proxy to app
alias /static/;
}
} }
} }

View file

@ -24,4 +24,16 @@ location / {
proxy_pass http://app_server; proxy_pass http://app_server;
proxy_read_timeout 2000; proxy_read_timeout 2000;
proxy_temp_path /var/log/nginx/proxy_temp 1 2; 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';
} }

View file

@ -80,11 +80,11 @@ class DefaultConfig(object):
AUTHENTICATION_TYPE = 'Database' AUTHENTICATION_TYPE = 'Database'
# Build logs # Build logs
BUILDLOGS_REDIS_HOSTNAME = 'logs.quay.io' BUILDLOGS_REDIS = {'host': 'logs.quay.io'}
BUILDLOGS_OPTIONS = [] BUILDLOGS_OPTIONS = []
# Real-time user events # Real-time user events
USER_EVENTS_REDIS_HOSTNAME = 'logs.quay.io' USER_EVENTS_REDIS = {'host': 'logs.quay.io'}
# Stripe config # Stripe config
BILLING_TYPE = 'FakeStripe' BILLING_TYPE = 'FakeStripe'
@ -162,6 +162,12 @@ class DefaultConfig(object):
# Feature Flag: Dockerfile build support. # Feature Flag: Dockerfile build support.
FEATURE_BUILD_SUPPORT = True FEATURE_BUILD_SUPPORT = True
# 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 = { DISTRIBUTED_STORAGE_CONFIG = {
'local_eu': ['LocalStorage', {'storage_path': 'test/data/registry/eu'}], 'local_eu': ['LocalStorage', {'storage_path': 'test/data/registry/eu'}],
'local_us': ['LocalStorage', {'storage_path': 'test/data/registry/us'}], 'local_us': ['LocalStorage', {'storage_path': 'test/data/registry/us'}],

View file

@ -16,8 +16,8 @@ class RedisBuildLogs(object):
COMMAND = 'command' COMMAND = 'command'
PHASE = 'phase' PHASE = 'phase'
def __init__(self, redis_host): def __init__(self, redis_config):
self._redis = redis.StrictRedis(host=redis_host) self._redis = redis.StrictRedis(socket_connect_timeout=5, **redis_config)
@staticmethod @staticmethod
def _logs_key(build_id): def _logs_key(build_id):
@ -104,7 +104,13 @@ class BuildLogs(object):
self.state = None self.state = None
def init_app(self, app): def init_app(self, app):
buildlogs_hostname = app.config.get('BUILDLOGS_REDIS_HOSTNAME') buildlogs_config = app.config.get('BUILDLOGS_REDIS')
if not buildlogs_config:
# This is the old key name.
buildlogs_config = {
'host': app.config.get('BUILDLOGS_REDIS_HOSTNAME')
}
buildlogs_options = app.config.get('BUILDLOGS_OPTIONS', []) buildlogs_options = app.config.get('BUILDLOGS_OPTIONS', [])
buildlogs_import = app.config.get('BUILDLOGS_MODULE_AND_CLASS', None) buildlogs_import = app.config.get('BUILDLOGS_MODULE_AND_CLASS', None)
@ -113,7 +119,7 @@ class BuildLogs(object):
else: else:
klass = import_class(buildlogs_import[0], buildlogs_import[1]) klass = import_class(buildlogs_import[0], buildlogs_import[1])
buildlogs = klass(buildlogs_hostname, *buildlogs_options) buildlogs = klass(buildlogs_config, *buildlogs_options)
# register extension with app # register extension with app
app.extensions = getattr(app, 'extensions', {}) app.extensions = getattr(app, 'extensions', {})

View file

@ -8,7 +8,7 @@ from peewee import *
from data.read_slave import ReadSlaveModel from data.read_slave import ReadSlaveModel
from sqlalchemy.engine.url import make_url from sqlalchemy.engine.url import make_url
from urlparse import urlparse from urlparse import urlparse
from util.names import urn_generator
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -21,8 +21,24 @@ SCHEME_DRIVERS = {
'postgresql+psycopg2': PostgresqlDatabase, 'postgresql+psycopg2': PostgresqlDatabase,
} }
SCHEME_RANDOM_FUNCTION = {
'mysql': fn.Rand,
'mysql+pymysql': fn.Rand,
'sqlite': fn.Random,
'postgresql': fn.Random,
'postgresql+psycopg2': fn.Random,
}
class CallableProxy(Proxy):
def __call__(self, *args, **kwargs):
if self.obj is None:
raise AttributeError('Cannot use uninitialized Proxy.')
return self.obj(*args, **kwargs)
db = Proxy() db = Proxy()
read_slave = Proxy() read_slave = Proxy()
db_random_func = CallableProxy()
def _db_from_url(url, db_kwargs): def _db_from_url(url, db_kwargs):
parsed_url = make_url(url) parsed_url = make_url(url)
@ -38,11 +54,15 @@ def _db_from_url(url, db_kwargs):
return SCHEME_DRIVERS[parsed_url.drivername](parsed_url.database, **db_kwargs) return SCHEME_DRIVERS[parsed_url.drivername](parsed_url.database, **db_kwargs)
def configure(config_object): def configure(config_object):
db_kwargs = dict(config_object['DB_CONNECTION_ARGS']) db_kwargs = dict(config_object['DB_CONNECTION_ARGS'])
write_db_uri = config_object['DB_URI'] write_db_uri = config_object['DB_URI']
db.initialize(_db_from_url(write_db_uri, db_kwargs)) db.initialize(_db_from_url(write_db_uri, db_kwargs))
parsed_write_uri = make_url(write_db_uri)
db_random_func.initialize(SCHEME_RANDOM_FUNCTION[parsed_write_uri.drivername])
read_slave_uri = config_object.get('DB_READ_SLAVE_URI', None) read_slave_uri = config_object.get('DB_READ_SLAVE_URI', None)
if read_slave_uri is not None: if read_slave_uri is not None:
read_slave.initialize(_db_from_url(read_slave_uri, db_kwargs)) read_slave.initialize(_db_from_url(read_slave_uri, db_kwargs))
@ -112,6 +132,15 @@ class TeamMember(BaseModel):
) )
class TeamMemberInvite(BaseModel):
# Note: Either user OR email will be filled in, but not both.
user = ForeignKeyField(User, index=True, null=True)
email = CharField(null=True)
team = ForeignKeyField(Team, index=True)
inviter = ForeignKeyField(User, related_name='inviter')
invite_token = CharField(default=urn_generator(['teaminvite']))
class LoginService(BaseModel): class LoginService(BaseModel):
name = CharField(unique=True, index=True) name = CharField(unique=True, index=True)
@ -139,7 +168,7 @@ class Visibility(BaseModel):
class Repository(BaseModel): class Repository(BaseModel):
namespace = CharField() namespace_user = ForeignKeyField(User)
name = CharField() name = CharField()
visibility = ForeignKeyField(Visibility) visibility = ForeignKeyField(Visibility)
description = TextField(null=True) description = TextField(null=True)
@ -150,7 +179,7 @@ class Repository(BaseModel):
read_slaves = (read_slave,) read_slaves = (read_slave,)
indexes = ( indexes = (
# create a unique index on namespace and name # create a unique index on namespace and name
(('namespace', 'name'), True), (('namespace_user', 'name'), True),
) )
@ -227,7 +256,7 @@ class EmailConfirmation(BaseModel):
class ImageStorage(BaseModel): class ImageStorage(BaseModel):
uuid = CharField(default=uuid_generator) uuid = CharField(default=uuid_generator, index=True)
checksum = CharField(null=True) checksum = CharField(null=True)
created = DateTimeField(null=True) created = DateTimeField(null=True)
comment = TextField(null=True) comment = TextField(null=True)
@ -333,7 +362,7 @@ class RepositoryBuild(BaseModel):
class QueueItem(BaseModel): class QueueItem(BaseModel):
queue_name = CharField(index=True, max_length=1024) queue_name = CharField(index=True, max_length=1024)
body = TextField() body = TextField()
available_after = DateTimeField(default=datetime.now, index=True) available_after = DateTimeField(default=datetime.utcnow, index=True)
available = BooleanField(default=True, index=True) available = BooleanField(default=True, index=True)
processing_expires = DateTimeField(null=True, index=True) processing_expires = DateTimeField(null=True, index=True)
retries_remaining = IntegerField(default=5) retries_remaining = IntegerField(default=5)
@ -438,4 +467,5 @@ all_models = [User, Repository, Image, AccessToken, Role, RepositoryPermission,
OAuthApplication, OAuthAuthorizationCode, OAuthAccessToken, NotificationKind, OAuthApplication, OAuthAuthorizationCode, OAuthAccessToken, NotificationKind,
Notification, ImageStorageLocation, ImageStoragePlacement, Notification, ImageStorageLocation, ImageStoragePlacement,
ExternalNotificationEvent, ExternalNotificationMethod, RepositoryNotification, ExternalNotificationEvent, ExternalNotificationMethod, RepositoryNotification,
RepositoryAuthorizedEmail, ImageStorageTransformation, DerivedImageStorage] RepositoryAuthorizedEmail, ImageStorageTransformation, DerivedImageStorage,
TeamMemberInvite]

View file

@ -0,0 +1,24 @@
"""Migrate registry namespaces to reference a user.
Revision ID: 13da56878560
Revises: 51d04d0e7e6f
Create Date: 2014-09-18 13:56:45.130455
"""
# revision identifiers, used by Alembic.
revision = '13da56878560'
down_revision = '51d04d0e7e6f'
from alembic import op
import sqlalchemy as sa
from data.database import Repository, User
def upgrade(tables):
# Add the namespace_user column, allowing it to be nullable
op.add_column('repository', sa.Column('namespace_user_id', sa.Integer(), sa.ForeignKey('user.id')))
def downgrade(tables):
op.drop_column('repository', 'namespace_user_id')

View file

@ -44,11 +44,11 @@ def downgrade(tables):
op.create_index('notificationkind_name', 'notificationkind', ['name'], unique=False) op.create_index('notificationkind_name', 'notificationkind', ['name'], unique=False)
op.drop_index('logentrykind_name', table_name='logentrykind') op.drop_index('logentrykind_name', table_name='logentrykind')
op.create_index('logentrykind_name', 'logentrykind', ['name'], unique=False) op.create_index('logentrykind_name', 'logentrykind', ['name'], unique=False)
op.add_column('image', sa.Column('created', mysql.DATETIME(), nullable=True)) op.add_column('image', sa.Column('created', sa.DateTime(), nullable=True))
op.add_column('image', sa.Column('command', mysql.LONGTEXT(), nullable=True)) op.add_column('image', sa.Column('command', sa.Text(), nullable=True))
op.add_column('image', sa.Column('image_size', mysql.BIGINT(display_width=20), nullable=True)) op.add_column('image', sa.Column('image_size', sa.BigInteger(), nullable=True))
op.add_column('image', sa.Column('checksum', mysql.VARCHAR(length=255), nullable=True)) op.add_column('image', sa.Column('checksum', sa.String(length=255), nullable=True))
op.add_column('image', sa.Column('comment', mysql.LONGTEXT(), nullable=True)) op.add_column('image', sa.Column('comment', sa.Text(), nullable=True))
op.drop_index('buildtriggerservice_name', table_name='buildtriggerservice') op.drop_index('buildtriggerservice_name', table_name='buildtriggerservice')
op.create_index('buildtriggerservice_name', 'buildtriggerservice', ['name'], unique=False) op.create_index('buildtriggerservice_name', 'buildtriggerservice', ['name'], unique=False)
### end Alembic commands ### ### end Alembic commands ###

View file

@ -0,0 +1,26 @@
"""Backfill the namespace_user fields.
Revision ID: 3f4fe1194671
Revises: 6f2ecf5afcf
Create Date: 2014-09-24 14:29:45.192179
"""
# revision identifiers, used by Alembic.
revision = '3f4fe1194671'
down_revision = '6f2ecf5afcf'
from alembic import op
import sqlalchemy as sa
def upgrade(tables):
conn = op.get_bind()
user_table_name_escaped = conn.dialect.identifier_preparer.format_table(tables['user'])
conn.execute('update repository set namespace_user_id = (select id from {0} where {0}.username = repository.namespace) where namespace_user_id is NULL'.format(user_table_name_escaped))
op.create_index('repository_namespace_user_id_name', 'repository', ['namespace_user_id', 'name'], unique=True)
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')

View file

@ -0,0 +1,78 @@
"""Email invites for joining a team.
Revision ID: 51d04d0e7e6f
Revises: 34fd69f63809
Create Date: 2014-09-15 23:51:35.478232
"""
# revision identifiers, used by Alembic.
revision = '51d04d0e7e6f'
down_revision = '34fd69f63809'
from alembic import op
import sqlalchemy as sa
def upgrade(tables):
### commands auto generated by Alembic - please adjust! ###
op.create_table('teammemberinvite',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('email', sa.String(length=255), nullable=True),
sa.Column('team_id', sa.Integer(), nullable=False),
sa.Column('inviter_id', sa.Integer(), nullable=False),
sa.Column('invite_token', sa.String(length=255), nullable=False),
sa.ForeignKeyConstraint(['inviter_id'], ['user.id'], ),
sa.ForeignKeyConstraint(['team_id'], ['team.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('teammemberinvite_inviter_id', 'teammemberinvite', ['inviter_id'], unique=False)
op.create_index('teammemberinvite_team_id', 'teammemberinvite', ['team_id'], unique=False)
op.create_index('teammemberinvite_user_id', 'teammemberinvite', ['user_id'], unique=False)
### end Alembic commands ###
# Manually add the new logentrykind types
op.bulk_insert(tables.logentrykind,
[
{'id':42, 'name':'org_invite_team_member'},
{'id':43, 'name':'org_team_member_invite_accepted'},
{'id':44, 'name':'org_team_member_invite_declined'},
{'id':45, 'name':'org_delete_team_member_invite'},
])
op.bulk_insert(tables.notificationkind,
[
{'id':10, 'name':'org_team_invite'},
])
def downgrade(tables):
### commands auto generated by Alembic - please adjust! ###
op.execute(
(tables.logentrykind.delete()
.where(tables.logentrykind.c.name == op.inline_literal('org_invite_team_member')))
)
op.execute(
(tables.logentrykind.delete()
.where(tables.logentrykind.c.name == op.inline_literal('org_team_member_invite_accepted')))
)
op.execute(
(tables.logentrykind.delete()
.where(tables.logentrykind.c.name == op.inline_literal('org_team_member_invite_declined')))
)
op.execute(
(tables.logentrykind.delete()
.where(tables.logentrykind.c.name == op.inline_literal('org_delete_team_member_invite')))
)
op.execute(
(tables.notificationkind.delete()
.where(tables.notificationkind.c.name == op.inline_literal('org_team_invite')))
)
op.drop_table('teammemberinvite')
### end Alembic commands ###

View file

@ -1,17 +1,16 @@
"""add the uncompressed size to image storage """add the uncompressed size to image storage
Revision ID: 6f2ecf5afcf Revision ID: 6f2ecf5afcf
Revises: 3f6d26399bd2 Revises: 13da56878560
Create Date: 2014-09-22 14:39:13.470566 Create Date: 2014-09-22 14:39:13.470566
""" """
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = '6f2ecf5afcf' revision = '6f2ecf5afcf'
down_revision = '3f6d26399bd2' down_revision = '13da56878560'
from alembic import op from alembic import op
from tools.uncompressedsize import backfill_sizes
import sqlalchemy as sa import sqlalchemy as sa
@ -20,9 +19,6 @@ def upgrade(tables):
op.add_column('imagestorage', sa.Column('uncompressed_size', sa.BigInteger(), nullable=True)) op.add_column('imagestorage', sa.Column('uncompressed_size', sa.BigInteger(), nullable=True))
### end Alembic commands ### ### end Alembic commands ###
# Backfill the uncompressed size to the image storage table.
backfill_sizes()
def downgrade(tables): def downgrade(tables):
### commands auto generated by Alembic - please adjust! ### ### commands auto generated by Alembic - please adjust! ###
op.drop_column('imagestorage', 'uncompressed_size') op.drop_column('imagestorage', 'uncompressed_size')

View file

@ -0,0 +1,29 @@
"""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()
user_table_name_escaped = conn.dialect.identifier_preparer.format_table(tables['user'])
conn.execute('update repository set namespace = (select username from {0} where {0}.id = repository.namespace_user_id) where namespace is NULL'.format(user_table_name_escaped))
op.create_index('repository_namespace_name', 'repository', ['namespace', 'name'], unique=True)
op.alter_column('repository', 'namespace', nullable=False, existing_type=sa.String(length=255))

View file

@ -0,0 +1,22 @@
"""Add an index to the uuid in the image storage table.
Revision ID: b1d41e2071b
Revises: 9a1087b007d
Create Date: 2014-10-06 18:42:10.021235
"""
# revision identifiers, used by Alembic.
revision = 'b1d41e2071b'
down_revision = '9a1087b007d'
from alembic import op
import sqlalchemy as sa
def upgrade(tables):
op.create_index('imagestorage_uuid', 'imagestorage', ['uuid'], unique=True)
def downgrade(tables):
op.drop_index('imagestorage_uuid', table_name='imagestorage')

View file

@ -23,13 +23,11 @@ def upgrade(tables):
def downgrade(tables): def downgrade(tables):
### commands auto generated by Alembic - please adjust! ### ### commands auto generated by Alembic - please adjust! ###
op.create_table('webhook', op.create_table('webhook',
sa.Column('id', mysql.INTEGER(display_width=11), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('public_id', mysql.VARCHAR(length=255), nullable=False), sa.Column('public_id', sa.String(length=255), nullable=False),
sa.Column('repository_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False), sa.Column('repository_id', sa.Integer(), nullable=False),
sa.Column('parameters', mysql.LONGTEXT(), nullable=False), sa.Column('parameters', sa.Text(), nullable=False),
sa.ForeignKeyConstraint(['repository_id'], [u'repository.id'], name=u'fk_webhook_repository_repository_id'), sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], ),
sa.PrimaryKeyConstraint('id'), sa.PrimaryKeyConstraint('id')
mysql_default_charset=u'latin1',
mysql_engine=u'InnoDB'
) )
### end Alembic commands ### ### end Alembic commands ###

File diff suppressed because it is too large Load diff

View file

@ -17,7 +17,12 @@ OPTION_TRANSLATIONS = {
def gen_sqlalchemy_metadata(peewee_model_list): 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: for model in peewee_model_list:
meta = model._meta meta = model._meta

View file

@ -68,9 +68,8 @@ class WorkQueue(object):
'retries_remaining': retries_remaining, 'retries_remaining': retries_remaining,
} }
if available_after: available_date = datetime.utcnow() + timedelta(seconds=available_after or 0)
available_date = datetime.utcnow() + timedelta(seconds=available_after) params['available_after'] = available_date
params['available_after'] = available_date
with self._transaction_factory(db): with self._transaction_factory(db):
QueueItem.create(**params) QueueItem.create(**params)

View file

@ -7,14 +7,14 @@ class UserEventBuilder(object):
Defines a helper class for constructing UserEvent and UserEventListener Defines a helper class for constructing UserEvent and UserEventListener
instances. instances.
""" """
def __init__(self, redis_host): def __init__(self, redis_config):
self._redis_host = redis_host self._redis_config = redis_config
def get_event(self, username): def get_event(self, username):
return UserEvent(self._redis_host, username) return UserEvent(self._redis_config, username)
def get_listener(self, username, events): def get_listener(self, username, events):
return UserEventListener(self._redis_host, username, events) return UserEventListener(self._redis_config, username, events)
class UserEventsBuilderModule(object): class UserEventsBuilderModule(object):
@ -26,8 +26,14 @@ class UserEventsBuilderModule(object):
self.state = None self.state = None
def init_app(self, app): def init_app(self, app):
redis_hostname = app.config.get('USER_EVENTS_REDIS_HOSTNAME') redis_config = app.config.get('USER_EVENTS_REDIS')
user_events = UserEventBuilder(redis_hostname) if not redis_config:
# This is the old key name.
redis_config = {
'host': app.config.get('USER_EVENTS_REDIS_HOSTNAME')
}
user_events = UserEventBuilder(redis_config)
# register extension with app # register extension with app
app.extensions = getattr(app, 'extensions', {}) app.extensions = getattr(app, 'extensions', {})
@ -43,8 +49,8 @@ class UserEvent(object):
Defines a helper class for publishing to realtime user events Defines a helper class for publishing to realtime user events
as backed by Redis. as backed by Redis.
""" """
def __init__(self, redis_host, username): def __init__(self, redis_config, username):
self._redis = redis.StrictRedis(host=redis_host) self._redis = redis.StrictRedis(socket_connect_timeout=5, **redis_config)
self._username = username self._username = username
@staticmethod @staticmethod
@ -74,10 +80,10 @@ class UserEventListener(object):
Defines a helper class for subscribing to realtime user events as Defines a helper class for subscribing to realtime user events as
backed by Redis. backed by Redis.
""" """
def __init__(self, redis_host, username, events=set([])): def __init__(self, redis_config, username, events=set([])):
channels = [self._user_event_key(username, e) for e in events] channels = [self._user_event_key(username, e) for e in events]
self._redis = redis.StrictRedis(host=redis_host) self._redis = redis.StrictRedis(socket_connect_timeout=5, **redis_config)
self._pubsub = self._redis.pubsub() self._pubsub = self._redis.pubsub()
self._pubsub.subscribe(channels) self._pubsub.subscribe(channels)

45
emails/base.html Normal file
View file

@ -0,0 +1,45 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; margin: 0; padding: 0;">
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>{{ subject }}</title>
</head>
<body bgcolor="#FFFFFF" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; width: 100% !important; height: 100%; margin: 0; padding: 0;"><style type="text/css">
@media only screen and (max-width: 600px) {
a[class="btn"] {
display: block !important; margin-bottom: 10px !important; background-image: none !important; margin-right: 0 !important;
}
div[class="column"] {
width: auto !important; float: none !important;
}
table.social div[class="column"] {
width: auto !important;
}
}
</style>
<!-- HEADER -->
<table class="head-wrap" bgcolor="#FFFFFF" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; width: 100%; margin: 0; padding: 0;"><tr style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; margin: 0; padding: 0;"><td style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; margin: 0; padding: 0;"></td>
<td class="header container" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; display: block !important; max-width: 100% !important; clear: both !important; margin: 0; padding: 0;">
<div class="content" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; max-width: 100%; display: block; margin: 0; padding: 15px;">
<table bgcolor="#FFFFFF" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; width: 100%; margin: 0; padding: 0;"><tr style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; margin: 0; padding: 0;"><td style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; margin: 0; padding: 0;"><img src="{{ app_logo }}" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; max-width: 100%; margin: 0; padding: 0;" alt="{{ app_title }}" title="{{ app_title }}"/></td>
</tr></table></div>
</td>
<td style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; margin: 0; padding: 0;"></td>
</tr></table><!-- /HEADER --><!-- BODY --><table class="body-wrap" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; width: 100%; margin: 0; padding: 0;"><tr style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; margin: 0; padding: 0;"><td style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; margin: 0; padding: 0;"></td>
<td class="container" bgcolor="#FFFFFF" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; display: block !important; max-width: 100% !important; clear: both !important; margin: 0; padding: 0;">
<div class="content" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; max-width: 100%; display: block; margin: 0; padding: 15px;">
<table style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; width: 100%; margin: 0; padding: 0;"><tr style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; margin: 0; padding: 0;"><td style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; margin: 0; padding: 0;">
{% block content %}{% endblock %}
</td>
</tr></table></div><!-- /content -->
</td>
<td style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; margin: 0; padding: 0;"></td>
</tr></table><!-- /BODY -->
</body>
</html>

13
emails/changeemail.html Normal file
View file

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block content %}
<h3>E-mail Address Change Requested</h3>
This email address was recently asked to become the new e-mail address for user {{ username | user_reference }}.
<br>
<br>
To confirm this change, please click the following link:<br>
{{ app_link('confirm?code=' + token) }}
{% endblock %}

13
emails/confirmemail.html Normal file
View file

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block content %}
<h3>Please Confirm E-mail Address</h3>
This email address was recently used to register user {{ username | user_reference }}.
<br>
<br>
To confirm this email address, please click the following link:<br>
{{ app_link('confirm?code=' + token) }}
{% endblock %}

12
emails/emailchanged.html Normal file
View file

@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block content %}
<h3>Account E-mail Address Changed</h3>
The email address for user {{ username | user_reference }} has been changed from this e-mail address to {{ new_email }}.
<br>
<br>
If this change was not expected, please immediately log into your {{ username | admin_reference }} and reset your email address.
{% endblock %}

View file

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block content %}
<h3>Account Password Changed</h3>
The password for user {{ username | user_reference }} has been updated.
<br>
<br>
If this change was not expected, please immediately log into your account settings and reset your email address,
or <a href="https://quay.io/contact">contact support</a>.
{% endblock %}

View file

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block content %}
<h3>Subscription Payment Failure</h3>
Your recent payment for account {{ username | user_reference }} failed, which usually results in our payments processor canceling
your subscription automatically. If you would like to continue to use {{ app_title }} without interruption,
please add a new card to {{ app_title }} and re-subscribe to your plan.<br>
<br>
You can find the card and subscription management features under your {{ username | admin_reference }}<br>
{% endblock %}

18
emails/recovery.html Normal file
View file

@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block content %}
<h3>Account recovery</h3>
A user at {{ app_link() }} has attempted to recover their account
using this email address.
<br>
<br>
If you made this request, please click the following link to recover your account and
change your password:
{{ app_link('recovery?code=' + token) }}
<br><br>
If you did not make this request, your account has not been compromised and the user was
not given access. Please disregard this email.
{% endblock %}

View file

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block content %}
<h3>Verify e-mail to receive repository notifications</h3>
A request has been made to send <a href="http://docs.quay.io/guides/notifications.html">notifications</a> to this email address for repository {{ (namespace, repository) | repository_reference }}
<br><br>
To verify this email address, please click the following link:<br>
{{ app_link('authrepoemail?code=' + token) }}
{% endblock %}

17
emails/teaminvite.html Normal file
View file

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block content %}
<h3>Invitation to join team: {{ teamname }}</h3>
{{ inviter | user_reference }} has invited you to join the team <b>{{ teamname }}</b> under organization {{ organization | user_reference }}.
<br><br>
To join the team, please click the following link:<br>
{{ app_link('confirminvite?code=' + token) }}
<br><br>
If you were not expecting this invitation, you can ignore this email.
{% endblock %}

View file

@ -27,8 +27,8 @@ api_bp = Blueprint('api', __name__)
api = Api() api = Api()
api.init_app(api_bp) api.init_app(api_bp)
api.decorators = [csrf_protect, api.decorators = [csrf_protect,
process_oauth, crossdomain(origin='*', headers=['Authorization', 'Content-Type']),
crossdomain(origin='*', headers=['Authorization', 'Content-Type'])] process_oauth]
class ApiException(Exception): class ApiException(Exception):
@ -90,6 +90,7 @@ def handle_api_error(error):
if error.error_type is not None: if error.error_type is not None:
response.headers['WWW-Authenticate'] = ('Bearer error="%s" error_description="%s"' % response.headers['WWW-Authenticate'] = ('Bearer error="%s" error_description="%s"' %
(error.error_type, error.error_description)) (error.error_type, error.error_description))
return response return response
@ -191,6 +192,7 @@ def query_param(name, help_str, type=reqparse.text_type, default=None,
'default': default, 'default': default,
'choices': choices, 'choices': choices,
'required': required, 'required': required,
'location': ('args')
}) })
return func return func
return add_param return add_param

View file

@ -169,7 +169,7 @@ class RepositoryBuildList(RepositoryParamResource):
# was used. # was used.
associated_repository = model.get_repository_for_resource(dockerfile_id) associated_repository = model.get_repository_for_resource(dockerfile_id)
if associated_repository: if associated_repository:
if not ModifyRepositoryPermission(associated_repository.namespace, if not ModifyRepositoryPermission(associated_repository.namespace_user.username,
associated_repository.name): associated_repository.name):
raise Unauthorized() raise Unauthorized()

View file

@ -125,7 +125,11 @@ def swagger_route_data(include_internal=False, compact=False):
new_operation['requires_fresh_login'] = True new_operation['requires_fresh_login'] = True
if not internal or (internal and include_internal): if not internal or (internal and include_internal):
operations.append(new_operation) # Swagger requires valid nicknames on all operations.
if new_operation.get('nickname'):
operations.append(new_operation)
else:
logger.debug('Operation missing nickname: %s' % method)
swagger_path = PARAM_REGEX.sub(r'{\2}', rule.rule) swagger_path = PARAM_REGEX.sub(r'{\2}', rule.rule)
new_resource = { new_resource = {

View file

@ -9,22 +9,33 @@ from data import model
from util.cache import cache_control_flask_restful from util.cache import cache_control_flask_restful
def image_view(image): def image_view(image, image_map):
extended_props = image extended_props = image
if image.storage and image.storage.id: if image.storage and image.storage.id:
extended_props = image.storage extended_props = image.storage
command = extended_props.command command = extended_props.command
def docker_id(aid):
if not aid:
return ''
return image_map[aid]
# Calculate the ancestors string, with the DBID's replaced with the docker IDs.
ancestors = [docker_id(a) for a in image.ancestors.split('/')]
ancestors_string = '/'.join(ancestors)
return { return {
'id': image.docker_image_id, 'id': image.docker_image_id,
'created': format_date(extended_props.created), 'created': format_date(extended_props.created),
'comment': extended_props.comment, 'comment': extended_props.comment,
'command': json.loads(command) if command else None, 'command': json.loads(command) if command else None,
'ancestors': image.ancestors,
'dbid': image.id,
'size': extended_props.image_size, 'size': extended_props.image_size,
'locations': list(image.storage.locations), 'locations': list(image.storage.locations),
'uploading': image.storage.uploading, 'uploading': image.storage.uploading,
'ancestors': ancestors_string,
'sort_index': len(image.ancestors)
} }
@ -42,14 +53,16 @@ class RepositoryImageList(RepositoryParamResource):
for tag in all_tags: for tag in all_tags:
tags_by_image_id[tag.image.docker_image_id].append(tag.name) tags_by_image_id[tag.image.docker_image_id].append(tag.name)
image_map = {}
for image in all_images:
image_map[str(image.id)] = image.docker_image_id
def add_tags(image_json): def add_tags(image_json):
image_json['tags'] = tags_by_image_id[image_json['id']] image_json['tags'] = tags_by_image_id[image_json['id']]
return image_json return image_json
return { return {
'images': [add_tags(image_view(image)) for image in all_images] 'images': [add_tags(image_view(image, image_map)) for image in all_images]
} }
@ -64,7 +77,12 @@ class RepositoryImage(RepositoryParamResource):
if not image: if not image:
raise NotFound() raise NotFound()
return image_view(image) # Lookup all the ancestor images for the image.
image_map = {}
for current_image in model.get_parent_images(namespace, repository, image):
image_map[str(current_image.id)] = image.docker_image_id
return image_view(image, image_map)
@resource('/v1/repository/<repopath:repository>/image/<image_id>/changes') @resource('/v1/repository/<repopath:repository>/image/<image_id>/changes')

View file

@ -3,7 +3,8 @@ import logging
from flask import request, abort from flask import request, abort
from endpoints.api import (resource, nickname, require_repo_admin, RepositoryParamResource, from endpoints.api import (resource, nickname, require_repo_admin, RepositoryParamResource,
log_action, validate_json_request, NotFound, internal_only) log_action, validate_json_request, NotFound, internal_only,
show_if)
from app import tf from app import tf
from data import model from data import model
@ -19,12 +20,13 @@ def record_view(record):
return { return {
'email': record.email, 'email': record.email,
'repository': record.repository.name, 'repository': record.repository.name,
'namespace': record.repository.namespace, 'namespace': record.repository.namespace_user.username,
'confirmed': record.confirmed 'confirmed': record.confirmed
} }
@internal_only @internal_only
@show_if(features.MAILING)
@resource('/v1/repository/<repopath:repository>/authorizedemail/<email>') @resource('/v1/repository/<repopath:repository>/authorizedemail/<email>')
class RepositoryAuthorizedEmail(RepositoryParamResource): class RepositoryAuthorizedEmail(RepositoryParamResource):
""" Resource for checking and authorizing e-mail addresses to receive repo notifications. """ """ Resource for checking and authorizing e-mail addresses to receive repo notifications. """

View file

@ -80,8 +80,7 @@ class RepositoryList(ApiResource):
visibility = req['visibility'] visibility = req['visibility']
repo = model.create_repository(namespace_name, repository_name, owner, repo = model.create_repository(namespace_name, repository_name, owner, visibility)
visibility)
repo.description = req['description'] repo.description = req['description']
repo.save() repo.save()
@ -110,7 +109,7 @@ class RepositoryList(ApiResource):
"""Fetch the list of repositories under a variety of situations.""" """Fetch the list of repositories under a variety of situations."""
def repo_view(repo_obj): def repo_view(repo_obj):
return { return {
'namespace': repo_obj.namespace, 'namespace': repo_obj.namespace_user.username,
'name': repo_obj.name, 'name': repo_obj.name,
'description': repo_obj.description, 'description': repo_obj.description,
'is_public': repo_obj.visibility.name == 'public', 'is_public': repo_obj.visibility.name == 'public',
@ -134,7 +133,8 @@ class RepositoryList(ApiResource):
response['repositories'] = [repo_view(repo) for repo in repo_query response['repositories'] = [repo_view(repo) for repo in repo_query
if (repo.visibility.name == 'public' or if (repo.visibility.name == 'public' or
ReadRepositoryPermission(repo.namespace, repo.name).can())] ReadRepositoryPermission(repo.namespace_user.username,
repo.name).can())]
return response return response
@ -168,8 +168,7 @@ class Repository(RepositoryParamResource):
def tag_view(tag): def tag_view(tag):
return { return {
'name': tag.name, 'name': tag.name,
'image_id': tag.image.docker_image_id, 'image_id': tag.image.docker_image_id
'dbid': tag.image.id
} }
organization = None organization = None

View file

@ -111,7 +111,7 @@ class FindRepositories(ApiResource):
def repo_view(repo): def repo_view(repo):
return { return {
'namespace': repo.namespace, 'namespace': repo.namespace_user.username,
'name': repo.name, 'name': repo.name,
'description': repo.description 'description': repo.description
} }
@ -125,5 +125,5 @@ class FindRepositories(ApiResource):
return { return {
'repositories': [repo_view(repo) for repo in matching 'repositories': [repo_view(repo) for repo in matching
if (repo.visibility.name == 'public' or if (repo.visibility.name == 'public' or
ReadRepositoryPermission(repo.namespace, repo.name).can())] ReadRepositoryPermission(repo.namespace_user.username, repo.name).can())]
} }

View file

@ -1,20 +1,22 @@
import string
import logging import logging
import json import json
from random import SystemRandom
from app import app from app import app
from flask import request from flask import request
from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error, from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error,
log_action, internal_only, NotFound, require_user_admin, format_date, log_action, internal_only, NotFound, require_user_admin, format_date,
InvalidToken, require_scope, format_date, hide_if, show_if, parse_args, InvalidToken, require_scope, format_date, hide_if, show_if, parse_args,
query_param, abort) query_param, abort, require_fresh_login)
from endpoints.api.logs import get_logs from endpoints.api.logs import get_logs
from data import model from data import model
from auth.permissions import SuperUserPermission from auth.permissions import SuperUserPermission
from auth.auth_context import get_authenticated_user from auth.auth_context import get_authenticated_user
from util.useremails import send_confirmation_email, send_recovery_email
import features import features
@ -55,6 +57,26 @@ def user_view(user):
@show_if(features.SUPER_USERS) @show_if(features.SUPER_USERS)
class SuperUserList(ApiResource): class SuperUserList(ApiResource):
""" Resource for listing users in the system. """ """ Resource for listing users in the system. """
schemas = {
'CreateInstallUser': {
'id': 'CreateInstallUser',
'description': 'Data for creating a user',
'required': ['username', 'email'],
'properties': {
'username': {
'type': 'string',
'description': 'The username of the user being created'
},
'email': {
'type': 'string',
'description': 'The email address of the user being created'
}
}
}
}
@require_fresh_login
@nickname('listAllUsers') @nickname('listAllUsers')
def get(self): def get(self):
""" Returns a list of all users in the system. """ """ Returns a list of all users in the system. """
@ -67,6 +89,63 @@ class SuperUserList(ApiResource):
abort(403) abort(403)
@require_fresh_login
@nickname('createInstallUser')
@validate_json_request('CreateInstallUser')
def post(self):
""" Creates a new user. """
user_information = request.get_json()
if SuperUserPermission().can():
username = user_information['username']
email = user_information['email']
# Generate a temporary password for the user.
random = SystemRandom()
password = ''.join([random.choice(string.ascii_uppercase + string.digits) for _ in range(32)])
# Create the user.
user = model.create_user(username, password, email, auto_verify=not features.MAILING)
# If mailing is turned on, send the user a verification email.
if features.MAILING:
confirmation = model.create_confirm_email_code(user, new_email=user.email)
send_confirmation_email(user.username, user.email, confirmation.code)
return {
'username': username,
'email': email,
'password': password
}
abort(403)
@resource('/v1/superusers/users/<username>/sendrecovery')
@internal_only
@show_if(features.SUPER_USERS)
@show_if(features.MAILING)
class SuperUserSendRecoveryEmail(ApiResource):
""" Resource for sending a recovery user on behalf of a user. """
@require_fresh_login
@nickname('sendInstallUserRecoveryEmail')
def post(self, username):
if SuperUserPermission().can():
user = model.get_user(username)
if not user or user.organization or user.robot:
abort(404)
if username in app.config['SUPER_USERS']:
abort(403)
code = model.create_reset_password_email_code(user.email)
send_recovery_email(user.email, code.code)
return {
'email': user.email
}
abort(403)
@resource('/v1/superuser/users/<username>') @resource('/v1/superuser/users/<username>')
@internal_only @internal_only
@show_if(features.SUPER_USERS) @show_if(features.SUPER_USERS)
@ -90,18 +169,20 @@ class SuperUserManagement(ApiResource):
}, },
} }
@require_fresh_login
@nickname('getInstallUser') @nickname('getInstallUser')
def get(self, username): def get(self, username):
""" Returns information about the specified user. """ """ Returns information about the specified user. """
if SuperUserPermission().can(): if SuperUserPermission().can():
user = model.get_user(username) user = model.get_user(username)
if not user or user.organization or user.robot: if not user or user.organization or user.robot:
abort(404) abort(404)
return user_view(user) return user_view(user)
abort(403) abort(403)
@require_fresh_login
@nickname('deleteInstallUser') @nickname('deleteInstallUser')
def delete(self, username): def delete(self, username):
""" Deletes the specified user. """ """ Deletes the specified user. """
@ -118,6 +199,7 @@ class SuperUserManagement(ApiResource):
abort(403) abort(403)
@require_fresh_login
@nickname('changeInstallUser') @nickname('changeInstallUser')
@validate_json_request('UpdateUser') @validate_json_request('UpdateUser')
def put(self, username): def put(self, username):

View file

@ -85,11 +85,14 @@ class RepositoryTagImages(RepositoryParamResource):
raise NotFound() raise NotFound()
parent_images = model.get_parent_images(namespace, repository, tag_image) parent_images = model.get_parent_images(namespace, repository, tag_image)
image_map = {}
for image in parent_images:
image_map[str(image.id)] = image.docker_image_id
parents = list(parent_images) parents = list(parent_images)
parents.reverse() parents.reverse()
all_images = [tag_image] + parents all_images = [tag_image] + parents
return { return {
'images': [image_view(image) for image in all_images] 'images': [image_view(image, image_map) for image in all_images]
} }

View file

@ -1,12 +1,51 @@
from flask import request from flask import request
from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error, from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error,
log_action, Unauthorized, NotFound, internal_only, require_scope) log_action, Unauthorized, NotFound, internal_only, require_scope,
query_param, truthy_bool, parse_args, require_user_admin, show_if)
from auth.permissions import AdministerOrganizationPermission, ViewTeamPermission from auth.permissions import AdministerOrganizationPermission, ViewTeamPermission
from auth.auth_context import get_authenticated_user from auth.auth_context import get_authenticated_user
from auth import scopes from auth import scopes
from data import model from data import model
from util.useremails import send_org_invite_email
from util.gravatar import compute_hash
import features
def try_accept_invite(code, user):
(team, inviter) = model.confirm_team_invite(code, user)
model.delete_matching_notifications(user, 'org_team_invite', code=code)
orgname = team.organization.username
log_action('org_team_member_invite_accepted', orgname, {
'member': user.username,
'team': team.name,
'inviter': inviter.username
})
return team
def handle_addinvite_team(inviter, team, user=None, email=None):
invite = model.add_or_invite_to_team(inviter, team, user, email,
requires_invite = features.MAILING)
if not invite:
# User was added to the team directly.
return
orgname = team.organization.username
if user:
model.create_notification('org_team_invite', user, metadata = {
'code': invite.invite_token,
'inviter': inviter.username,
'org': orgname,
'team': team.name
})
send_org_invite_email(user.username if user else email, user.email if user else email,
orgname, team.name, inviter.username, invite.invite_token)
return invite
def team_view(orgname, team): def team_view(orgname, team):
view_permission = ViewTeamPermission(orgname, team.name) view_permission = ViewTeamPermission(orgname, team.name)
@ -19,14 +58,28 @@ def team_view(orgname, team):
'role': role 'role': role
} }
def member_view(member): def member_view(member, invited=False):
return { return {
'name': member.username, 'name': member.username,
'kind': 'user', 'kind': 'user',
'is_robot': member.robot, 'is_robot': member.robot,
'gravatar': compute_hash(member.email) if not member.robot else None,
'invited': invited,
} }
def invite_view(invite):
if invite.user:
return member_view(invite.user, invited=True)
else:
return {
'email': invite.email,
'kind': 'invite',
'gravatar': compute_hash(invite.email),
'invited': True
}
@resource('/v1/organization/<orgname>/team/<teamname>') @resource('/v1/organization/<orgname>/team/<teamname>')
@internal_only @internal_only
class OrganizationTeam(ApiResource): class OrganizationTeam(ApiResource):
@ -114,8 +167,10 @@ class OrganizationTeam(ApiResource):
@internal_only @internal_only
class TeamMemberList(ApiResource): class TeamMemberList(ApiResource):
""" Resource for managing the list of members for a team. """ """ Resource for managing the list of members for a team. """
@parse_args
@query_param('includePending', 'Whether to include pending members', type=truthy_bool, default=False)
@nickname('getOrganizationTeamMembers') @nickname('getOrganizationTeamMembers')
def get(self, orgname, teamname): def get(self, args, orgname, teamname):
""" Retrieve the list of members for the specified team. """ """ Retrieve the list of members for the specified team. """
view_permission = ViewTeamPermission(orgname, teamname) view_permission = ViewTeamPermission(orgname, teamname)
edit_permission = AdministerOrganizationPermission(orgname) edit_permission = AdministerOrganizationPermission(orgname)
@ -128,11 +183,18 @@ class TeamMemberList(ApiResource):
raise NotFound() raise NotFound()
members = model.get_organization_team_members(team.id) members = model.get_organization_team_members(team.id)
return { invites = []
'members': {m.username : member_view(m) for m in members},
if args['includePending'] and edit_permission.can():
invites = model.get_organization_team_member_invites(team.id)
data = {
'members': [member_view(m) for m in members] + [invite_view(i) for i in invites],
'can_edit': edit_permission.can() 'can_edit': edit_permission.can()
} }
return data
raise Unauthorized() raise Unauthorized()
@ -142,7 +204,7 @@ class TeamMember(ApiResource):
@require_scope(scopes.ORG_ADMIN) @require_scope(scopes.ORG_ADMIN)
@nickname('updateOrganizationTeamMember') @nickname('updateOrganizationTeamMember')
def put(self, orgname, teamname, membername): def put(self, orgname, teamname, membername):
""" Add a member to an existing team. """ """ Adds or invites a member to an existing team. """
permission = AdministerOrganizationPermission(orgname) permission = AdministerOrganizationPermission(orgname)
if permission.can(): if permission.can():
team = None team = None
@ -159,23 +221,151 @@ class TeamMember(ApiResource):
if not user: if not user:
raise request_error(message='Unknown user') raise request_error(message='Unknown user')
# Add the user to the team. # Add or invite the user to the team.
model.add_user_to_team(user, team) inviter = get_authenticated_user()
log_action('org_add_team_member', orgname, {'member': membername, 'team': teamname}) invite = handle_addinvite_team(inviter, team, user=user)
return member_view(user) if not invite:
log_action('org_add_team_member', orgname, {'member': membername, 'team': teamname})
return member_view(user, invited=False)
# User was invited.
log_action('org_invite_team_member', orgname, {
'user': membername,
'member': membername,
'team': teamname
})
return member_view(user, invited=True)
raise Unauthorized() raise Unauthorized()
@require_scope(scopes.ORG_ADMIN) @require_scope(scopes.ORG_ADMIN)
@nickname('deleteOrganizationTeamMember') @nickname('deleteOrganizationTeamMember')
def delete(self, orgname, teamname, membername): def delete(self, orgname, teamname, membername):
""" Delete an existing member of a team. """ """ Delete a member of a team. If the user is merely invited to join
the team, then the invite is removed instead.
"""
permission = AdministerOrganizationPermission(orgname) permission = AdministerOrganizationPermission(orgname)
if permission.can(): if permission.can():
# Remote the user from the team. # Remote the user from the team.
invoking_user = get_authenticated_user().username invoking_user = get_authenticated_user().username
# Find the team.
try:
team = model.get_organization_team(orgname, teamname)
except model.InvalidTeamException:
raise NotFound()
# Find the member.
member = model.get_user(membername)
if not member:
raise NotFound()
# First attempt to delete an invite for the user to this team. If none found,
# then we try to remove the user directly.
if model.delete_team_user_invite(team, member):
log_action('org_delete_team_member_invite', orgname, {
'user': membername,
'team': teamname,
'member': membername
})
return 'Deleted', 204
model.remove_user_from_team(orgname, teamname, membername, invoking_user) model.remove_user_from_team(orgname, teamname, membername, invoking_user)
log_action('org_remove_team_member', orgname, {'member': membername, 'team': teamname}) log_action('org_remove_team_member', orgname, {'member': membername, 'team': teamname})
return 'Deleted', 204 return 'Deleted', 204
raise Unauthorized() raise Unauthorized()
@resource('/v1/organization/<orgname>/team/<teamname>/invite/<email>')
@show_if(features.MAILING)
class InviteTeamMember(ApiResource):
""" Resource for inviting a team member via email address. """
@require_scope(scopes.ORG_ADMIN)
@nickname('inviteTeamMemberEmail')
def put(self, orgname, teamname, email):
""" Invites an email address to an existing team. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
team = None
# Find the team.
try:
team = model.get_organization_team(orgname, teamname)
except model.InvalidTeamException:
raise NotFound()
# Invite the email to the team.
inviter = get_authenticated_user()
invite = handle_addinvite_team(inviter, team, email=email)
log_action('org_invite_team_member', orgname, {
'email': email,
'team': teamname,
'member': email
})
return invite_view(invite)
raise Unauthorized()
@require_scope(scopes.ORG_ADMIN)
@nickname('deleteTeamMemberEmailInvite')
def delete(self, orgname, teamname, email):
""" Delete an invite of an email address to join a team. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
team = None
# Find the team.
try:
team = model.get_organization_team(orgname, teamname)
except model.InvalidTeamException:
raise NotFound()
# Delete the invite.
model.delete_team_email_invite(team, email)
log_action('org_delete_team_member_invite', orgname, {
'email': email,
'team': teamname,
'member': email
})
return 'Deleted', 204
raise Unauthorized()
@resource('/v1/teaminvite/<code>')
@internal_only
@show_if(features.MAILING)
class TeamMemberInvite(ApiResource):
""" Resource for managing invites to jon a team. """
@require_user_admin
@nickname('acceptOrganizationTeamInvite')
def put(self, code):
""" Accepts an invite to join a team in an organization. """
# Accept the invite for the current user.
team = try_accept_invite(code, get_authenticated_user())
if not team:
raise NotFound()
orgname = team.organization.username
return {
'org': orgname,
'team': team.name
}
@nickname('declineOrganizationTeamInvite')
@require_user_admin
def delete(self, code):
""" Delete an existing member of a team. """
(team, inviter) = model.delete_team_invite(code, get_authenticated_user())
model.delete_matching_notifications(get_authenticated_user(), 'org_team_invite', code=code)
orgname = team.organization.username
log_action('org_team_member_invite_declined', orgname, {
'member': get_authenticated_user().username,
'team': team.name,
'inviter': inviter.username
})
return 'Deleted', 204

View file

@ -14,7 +14,7 @@ from endpoints.api.build import (build_status_view, trigger_view, RepositoryBuil
from endpoints.common import start_build from endpoints.common import start_build
from endpoints.trigger import (BuildTrigger as BuildTriggerBase, TriggerDeactivationException, from endpoints.trigger import (BuildTrigger as BuildTriggerBase, TriggerDeactivationException,
TriggerActivationException, EmptyRepositoryException, TriggerActivationException, EmptyRepositoryException,
RepositoryReadException) RepositoryReadException, TriggerStartException)
from data import model from data import model
from auth.permissions import UserAdminPermission, AdministerOrganizationPermission, ReadRepositoryPermission from auth.permissions import UserAdminPermission, AdministerOrganizationPermission, ReadRepositoryPermission
from util.names import parse_robot_username from util.names import parse_robot_username
@ -205,7 +205,7 @@ class BuildTriggerActivate(RepositoryParamResource):
'write') 'write')
try: try:
repository_path = '%s/%s' % (trigger.repository.namespace, repository_path = '%s/%s' % (trigger.repository.namespace_user.username,
trigger.repository.name) trigger.repository.name)
path = url_for('webhooks.build_trigger_webhook', path = url_for('webhooks.build_trigger_webhook',
repository=repository_path, trigger_uuid=trigger.uuid) repository=repository_path, trigger_uuid=trigger.uuid)
@ -374,9 +374,24 @@ class BuildTriggerAnalyze(RepositoryParamResource):
@resource('/v1/repository/<repopath:repository>/trigger/<trigger_uuid>/start') @resource('/v1/repository/<repopath:repository>/trigger/<trigger_uuid>/start')
class ActivateBuildTrigger(RepositoryParamResource): class ActivateBuildTrigger(RepositoryParamResource):
""" Custom verb to manually activate a build trigger. """ """ Custom verb to manually activate a build trigger. """
schemas = {
'RunParameters': {
'id': 'RunParameters',
'type': 'object',
'description': 'Optional run parameters for activating the build trigger',
'additional_properties': False,
'properties': {
'branch_name': {
'type': 'string',
'description': '(GitHub Only) If specified, the name of the GitHub branch to build.'
}
}
}
}
@require_repo_admin @require_repo_admin
@nickname('manuallyStartBuildTrigger') @nickname('manuallyStartBuildTrigger')
@validate_json_request('RunParameters')
def post(self, namespace, repository, trigger_uuid): def post(self, namespace, repository, trigger_uuid):
""" Manually start a build from the specified trigger. """ """ Manually start a build from the specified trigger. """
try: try:
@ -389,14 +404,18 @@ class ActivateBuildTrigger(RepositoryParamResource):
if not handler.is_active(config_dict): if not handler.is_active(config_dict):
raise InvalidRequest('Trigger is not active.') raise InvalidRequest('Trigger is not active.')
specs = handler.manual_start(trigger.auth_token, config_dict) try:
dockerfile_id, tags, name, subdir = specs run_parameters = request.get_json()
specs = handler.manual_start(trigger.auth_token, config_dict, run_parameters=run_parameters)
dockerfile_id, tags, name, subdir = specs
repo = model.get_repository(namespace, repository) repo = model.get_repository(namespace, repository)
pull_robot_name = model.get_pull_robot_name(trigger) pull_robot_name = model.get_pull_robot_name(trigger)
build_request = start_build(repo, dockerfile_id, tags, name, subdir, True, build_request = start_build(repo, dockerfile_id, tags, name, subdir, True,
pull_robot_name=pull_robot_name) pull_robot_name=pull_robot_name)
except TriggerStartException as tse:
raise InvalidRequest(tse.message)
resp = build_status_view(build_request, True) resp = build_status_view(build_request, True)
repo_string = '%s/%s' % (namespace, repository) repo_string = '%s/%s' % (namespace, repository)
@ -424,6 +443,36 @@ class TriggerBuildList(RepositoryParamResource):
} }
@resource('/v1/repository/<repopath:repository>/trigger/<trigger_uuid>/fields/<field_name>')
@internal_only
class BuildTriggerFieldValues(RepositoryParamResource):
""" Custom verb to fetch a values list for a particular field name. """
@require_repo_admin
@nickname('listTriggerFieldValues')
def get(self, namespace, repository, trigger_uuid, field_name):
""" List the field values for a custom run field. """
try:
trigger = model.get_build_trigger(namespace, repository, trigger_uuid)
except model.InvalidBuildTriggerException:
raise NotFound()
user_permission = UserAdminPermission(trigger.connected_user.username)
if user_permission.can():
trigger_handler = BuildTriggerBase.get_trigger_for_service(trigger.service.name)
values = trigger_handler.list_field_values(trigger.auth_token, json.loads(trigger.config),
field_name)
if values is None:
raise NotFound()
return {
'values': values
}
else:
raise Unauthorized()
@resource('/v1/repository/<repopath:repository>/trigger/<trigger_uuid>/sources') @resource('/v1/repository/<repopath:repository>/trigger/<trigger_uuid>/sources')
@internal_only @internal_only
class BuildTriggerSources(RepositoryParamResource): class BuildTriggerSources(RepositoryParamResource):

View file

@ -12,6 +12,8 @@ from endpoints.api import (ApiResource, nickname, resource, validate_json_reques
license_error, require_fresh_login) license_error, require_fresh_login)
from endpoints.api.subscribe import subscribe from endpoints.api.subscribe import subscribe
from endpoints.common import common_login from endpoints.common import common_login
from endpoints.api.team import try_accept_invite
from data import model from data import model
from data.billing import get_plan from data.billing import get_plan
from auth.permissions import (AdministerOrganizationPermission, CreateRepositoryPermission, from auth.permissions import (AdministerOrganizationPermission, CreateRepositoryPermission,
@ -19,7 +21,8 @@ from auth.permissions import (AdministerOrganizationPermission, CreateRepository
from auth.auth_context import get_authenticated_user from auth.auth_context import get_authenticated_user
from auth import scopes from auth import scopes
from util.gravatar import compute_hash from util.gravatar import compute_hash
from util.useremails import (send_confirmation_email, send_recovery_email, send_change_email) from util.useremails import (send_confirmation_email, send_recovery_email, send_change_email, send_password_changed)
from util.names import parse_single_urn
import features import features
@ -117,6 +120,10 @@ class User(ApiResource):
'type': 'string', 'type': 'string',
'description': 'The user\'s email address', 'description': 'The user\'s email address',
}, },
'invite_code': {
'type': 'string',
'description': 'The optional invite code'
}
} }
}, },
'UpdateUser': { 'UpdateUser': {
@ -166,6 +173,9 @@ class User(ApiResource):
log_action('account_change_password', user.username) log_action('account_change_password', user.username)
model.change_password(user, user_data['password']) model.change_password(user, user_data['password'])
if features.MAILING:
send_password_changed(user.username, user.email)
if 'invoice_email' in user_data: if 'invoice_email' in user_data:
logger.debug('Changing invoice_email for user: %s', user.username) logger.debug('Changing invoice_email for user: %s', user.username)
model.change_invoice_email(user, user_data['invoice_email']) model.change_invoice_email(user, user_data['invoice_email'])
@ -176,22 +186,27 @@ class User(ApiResource):
# Email already used. # Email already used.
raise request_error(message='E-mail address already used') raise request_error(message='E-mail address already used')
logger.debug('Sending email to change email address for user: %s', if features.MAILING:
user.username) logger.debug('Sending email to change email address for user: %s',
code = model.create_confirm_email_code(user, new_email=new_email) user.username)
send_change_email(user.username, user_data['email'], code.code) code = model.create_confirm_email_code(user, new_email=new_email)
send_change_email(user.username, user_data['email'], code.code)
else:
model.update_email(user, new_email, auto_verify=not features.MAILING)
except model.InvalidPasswordException, ex: except model.InvalidPasswordException, ex:
raise request_error(exception=ex) raise request_error(exception=ex)
return user_view(user) return user_view(user)
@show_if(features.USER_CREATION)
@nickname('createNewUser') @nickname('createNewUser')
@internal_only @internal_only
@validate_json_request('NewUser') @validate_json_request('NewUser')
def post(self): def post(self):
""" Create a new user. """ """ Create a new user. """
user_data = request.get_json() user_data = request.get_json()
invite_code = user_data.get('invite_code', '')
existing_user = model.get_user(user_data['username']) existing_user = model.get_user(user_data['username'])
if existing_user: if existing_user:
@ -199,10 +214,29 @@ class User(ApiResource):
try: try:
new_user = model.create_user(user_data['username'], user_data['password'], new_user = model.create_user(user_data['username'], user_data['password'],
user_data['email']) user_data['email'], auto_verify=not features.MAILING)
code = model.create_confirm_email_code(new_user)
send_confirmation_email(new_user.username, new_user.email, code.code) # Handle any invite codes.
return 'Created', 201 parsed_invite = parse_single_urn(invite_code)
if parsed_invite is not None:
if parsed_invite[0] == 'teaminvite':
# Add the user to the team.
try:
try_accept_invite(invite_code, new_user)
except model.DataModelException:
pass
if features.MAILING:
code = model.create_confirm_email_code(new_user)
send_confirmation_email(new_user.username, new_user.email, code.code)
return {
'awaiting_verification': True
}
else:
common_login(new_user)
return user_view(new_user)
except model.TooManyUsersException as ex: except model.TooManyUsersException as ex:
raise license_error(exception=ex) raise license_error(exception=ex)
except model.DataModelException as ex: except model.DataModelException as ex:
@ -422,6 +456,7 @@ class DetachExternal(ApiResource):
@resource("/v1/recovery") @resource("/v1/recovery")
@show_if(features.MAILING)
@internal_only @internal_only
class Recovery(ApiResource): class Recovery(ApiResource):
""" Resource for requesting a password recovery email. """ """ Resource for requesting a password recovery email. """

View file

@ -26,7 +26,8 @@ def render_ologin_error(service_name,
error_message='Could not load user data. The token may have expired.'): error_message='Could not load user data. The token may have expired.'):
return render_page_template('ologinerror.html', service_name=service_name, return render_page_template('ologinerror.html', service_name=service_name,
error_message=error_message, 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, def exchange_code_for_token(code, service_name='GITHUB', for_login=True, form_encode=False,
redirect_suffix=''): redirect_suffix=''):
@ -85,7 +86,12 @@ def get_google_user(token):
def conduct_oauth_login(service_name, user_id, username, email, metadata={}): def conduct_oauth_login(service_name, user_id, username, email, metadata={}):
to_login = model.verify_federated_login(service_name.lower(), user_id) to_login = model.verify_federated_login(service_name.lower(), user_id)
if not to_login: 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: try:
valid = next(generate_valid_usernames(username)) valid = next(generate_valid_usernames(username))
to_login = model.create_federated_user(valid, email, service_name.lower(), 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') token = exchange_code_for_token(request.args.get('code'), service_name='GITHUB')
user_data = get_github_user(token) 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') return render_ologin_error('GitHub')
username = user_data['login'] username = user_data['login']

View file

@ -82,20 +82,23 @@ def param_required(param_name):
@login_manager.user_loader @login_manager.user_loader
def load_user(username): def load_user(user_db_id):
logger.debug('User loader loading deferred user: %s' % username) logger.debug('User loader loading deferred user id: %s' % user_db_id)
return _LoginWrappedDBUser(username) try:
user_db_id_int = int(user_db_id)
return _LoginWrappedDBUser(user_db_id_int)
except ValueError:
return None
class _LoginWrappedDBUser(UserMixin): class _LoginWrappedDBUser(UserMixin):
def __init__(self, db_username, db_user=None): def __init__(self, user_db_id, db_user=None):
self._db_id = user_db_id
self._db_username = db_username
self._db_user = db_user self._db_user = db_user
def db_user(self): def db_user(self):
if not self._db_user: if not self._db_user:
self._db_user = model.get_user(self._db_username) self._db_user = model.get_user_by_id(self._db_id)
return self._db_user return self._db_user
def is_authenticated(self): def is_authenticated(self):
@ -105,13 +108,13 @@ class _LoginWrappedDBUser(UserMixin):
return self.db_user().verified return self.db_user().verified
def get_id(self): def get_id(self):
return unicode(self._db_username) return unicode(self._db_id)
def common_login(db_user): def common_login(db_user):
if login_user(_LoginWrappedDBUser(db_user.username, db_user)): if login_user(_LoginWrappedDBUser(db_user.id, db_user)):
logger.debug('Successfully signed in as: %s' % db_user.username) logger.debug('Successfully signed in as: %s' % db_user.username)
new_identity = QuayDeferredPermissionUser(db_user.username, 'username', {scopes.DIRECT_LOGIN}) new_identity = QuayDeferredPermissionUser(db_user.id, 'user_db_id', {scopes.DIRECT_LOGIN})
identity_changed.send(app, identity=new_identity) identity_changed.send(app, identity=new_identity)
session['login_time'] = datetime.datetime.now() session['login_time'] = datetime.datetime.now()
return True return True
@ -202,7 +205,7 @@ def check_repository_usage(user_or_org, plan_found):
def start_build(repository, dockerfile_id, tags, build_name, subdir, manual, def start_build(repository, dockerfile_id, tags, build_name, subdir, manual,
trigger=None, pull_robot_name=None): trigger=None, pull_robot_name=None):
host = urlparse.urlparse(request.url).netloc host = urlparse.urlparse(request.url).netloc
repo_path = '%s/%s/%s' % (host, repository.namespace, repository.name) repo_path = '%s/%s/%s' % (host, repository.namespace_user.username, repository.name)
token = model.create_access_token(repository, 'write') token = model.create_access_token(repository, 'write')
logger.debug('Creating build %s with repo %s tags %s and dockerfile_id %s', logger.debug('Creating build %s with repo %s tags %s and dockerfile_id %s',
@ -218,9 +221,9 @@ def start_build(repository, dockerfile_id, tags, build_name, subdir, manual,
dockerfile_id, build_name, dockerfile_id, build_name,
trigger, pull_robot_name=pull_robot_name) trigger, pull_robot_name=pull_robot_name)
dockerfile_build_queue.put([repository.namespace, repository.name], json.dumps({ dockerfile_build_queue.put([repository.namespace_user.username, repository.name], json.dumps({
'build_uuid': build_request.uuid, 'build_uuid': build_request.uuid,
'namespace': repository.namespace, 'namespace': repository.namespace_user.username,
'repository': repository.name, 'repository': repository.name,
'pull_credentials': model.get_pull_credentials(pull_robot_name) if pull_robot_name else None 'pull_credentials': model.get_pull_credentials(pull_robot_name) if pull_robot_name else None
}), retries_remaining=1) }), retries_remaining=1)
@ -228,7 +231,7 @@ def start_build(repository, dockerfile_id, tags, build_name, subdir, manual,
# Add the build to the repo's log. # Add the build to the repo's log.
metadata = { metadata = {
'repo': repository.name, 'repo': repository.name,
'namespace': repository.namespace, 'namespace': repository.namespace_user.username,
'fileid': dockerfile_id, 'fileid': dockerfile_id,
'manual': manual, 'manual': manual,
} }
@ -238,9 +241,8 @@ def start_build(repository, dockerfile_id, tags, build_name, subdir, manual,
metadata['config'] = json.loads(trigger.config) metadata['config'] = json.loads(trigger.config)
metadata['service'] = trigger.service.name metadata['service'] = trigger.service.name
model.log_action('build_dockerfile', repository.namespace, model.log_action('build_dockerfile', repository.namespace_user.username, ip=request.remote_addr,
ip=request.remote_addr, metadata=metadata, metadata=metadata, repository=repository)
repository=repository)
# Add notifications for the build queue. # Add notifications for the build queue.
profile.debug('Adding notifications for repository') profile.debug('Adding notifications for repository')

View file

@ -19,6 +19,7 @@ from auth.permissions import (ModifyRepositoryPermission, UserAdminPermission,
from util.http import abort from util.http import abort
from endpoints.notificationhelper import spawn_notification from endpoints.notificationhelper import spawn_notification
import features
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
profile = logging.getLogger('application.profiler') profile = logging.getLogger('application.profiler')
@ -65,6 +66,9 @@ def generate_headers(role='read'):
@index.route('/users', methods=['POST']) @index.route('/users', methods=['POST'])
@index.route('/users/', methods=['POST']) @index.route('/users/', methods=['POST'])
def create_user(): def create_user():
if not features.USER_CREATION:
abort(400, 'User creation is disabled. Please speak to your administrator.')
user_data = request.get_json() user_data = request.get_json()
if not 'username' in user_data: if not 'username' in user_data:
abort(400, 'Missing username') abort(400, 'Missing username')
@ -420,7 +424,7 @@ def put_repository_auth(namespace, repository):
def get_search(): def get_search():
def result_view(repo): def result_view(repo):
return { return {
"name": repo.namespace + '/' + repo.name, "name": repo.namespace_user.username + '/' + repo.name,
"description": repo.description "description": repo.description
} }
@ -438,7 +442,7 @@ def get_search():
results = [result_view(repo) for repo in matching results = [result_view(repo) for repo in matching
if (repo.visibility.name == 'public' or if (repo.visibility.name == 'public' or
ReadRepositoryPermission(repo.namespace, repo.name).can())] ReadRepositoryPermission(repo.namespace_user.username, repo.name).can())]
data = { data = {
"query": query, "query": query,
@ -454,6 +458,7 @@ def get_search():
@index.route('/_ping') @index.route('/_ping')
@index.route('/_ping') @index.route('/_ping')
def ping(): def ping():
# NOTE: any changes made here must also be reflected in the nginx config
response = make_response('true', 200) response = make_response('true', 200)
response.headers['X-Docker-Registry-Version'] = '0.6.0' response.headers['X-Docker-Registry-Version'] = '0.6.0'
response.headers['X-Docker-Registry-Standalone'] = '0' response.headers['X-Docker-Registry-Standalone'] = '0'

View file

@ -1,8 +1,4 @@
import logging import logging
import io
import os.path
import tarfile
import base64
from notificationhelper import build_event_data from notificationhelper import build_event_data

View file

@ -4,7 +4,7 @@ from data import model
import json import json
def build_event_data(repo, extra_data={}, subpage=None): def build_event_data(repo, extra_data={}, subpage=None):
repo_string = '%s/%s' % (repo.namespace, repo.name) repo_string = '%s/%s' % (repo.namespace_user.username, repo.name)
homepage = '%s://%s/repository/%s' % (app.config['PREFERRED_URL_SCHEME'], homepage = '%s://%s/repository/%s' % (app.config['PREFERRED_URL_SCHEME'],
app.config['SERVER_HOSTNAME'], app.config['SERVER_HOSTNAME'],
repo_string) repo_string)
@ -17,7 +17,7 @@ def build_event_data(repo, extra_data={}, subpage=None):
event_data = { event_data = {
'repository': repo_string, 'repository': repo_string,
'namespace': repo.namespace, 'namespace': repo.namespace_user.username,
'name': repo.name, 'name': repo.name,
'docker_url': '%s/%s' % (app.config['SERVER_HOSTNAME'], repo_string), 'docker_url': '%s/%s' % (app.config['SERVER_HOSTNAME'], repo_string),
'homepage': homepage, 'homepage': homepage,
@ -30,7 +30,7 @@ def build_event_data(repo, extra_data={}, subpage=None):
def build_notification_data(notification, event_data): def build_notification_data(notification, event_data):
return { return {
'notification_uuid': notification.uuid, 'notification_uuid': notification.uuid,
'repository_namespace': notification.repository.namespace, 'repository_namespace': notification.repository.namespace_user.username,
'repository_name': notification.repository.name, 'repository_name': notification.repository.name,
'event_data': event_data 'event_data': event_data
} }
@ -39,8 +39,9 @@ def build_notification_data(notification, event_data):
def spawn_notification(repo, event_name, extra_data={}, subpage=None, pathargs=[]): def spawn_notification(repo, event_name, extra_data={}, subpage=None, pathargs=[]):
event_data = build_event_data(repo, extra_data=extra_data, subpage=subpage) event_data = build_event_data(repo, extra_data=extra_data, subpage=subpage)
notifications = model.list_repo_notifications(repo.namespace, repo.name, event_name=event_name) notifications = model.list_repo_notifications(repo.namespace_user.username, repo.name,
event_name=event_name)
for notification in notifications: for notification in notifications:
notification_data = build_notification_data(notification, event_data) notification_data = build_notification_data(notification, event_data)
path = [repo.namespace, repo.name, event_name] + pathargs path = [repo.namespace_user.username, repo.name, event_name] + pathargs
notification_queue.put(path, json.dumps(notification_data)) notification_queue.put(path, json.dumps(notification_data))

View file

@ -10,6 +10,7 @@ import re
from flask.ext.mail import Message from flask.ext.mail import Message
from app import mail, app, get_app_url from app import mail, app, get_app_url
from data import model from data import model
from workers.worker import JobException
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -19,6 +20,9 @@ class InvalidNotificationMethodException(Exception):
class CannotValidateNotificationMethodException(Exception): class CannotValidateNotificationMethodException(Exception):
pass pass
class NotificationMethodPerformException(JobException):
pass
class NotificationMethod(object): class NotificationMethod(object):
def __init__(self): def __init__(self):
@ -84,7 +88,7 @@ class QuayNotificationMethod(NotificationMethod):
return (True, 'Unknown organization %s' % target_info['name'], None) return (True, 'Unknown organization %s' % target_info['name'], None)
# Only repositories under the organization can cause notifications to that org. # Only repositories under the organization can cause notifications to that org.
if target_info['name'] != repository.namespace: if target_info['name'] != repository.namespace_user.username:
return (False, 'Organization name must match repository namespace') return (False, 'Organization name must match repository namespace')
return (True, None, [target]) return (True, None, [target])
@ -92,7 +96,7 @@ class QuayNotificationMethod(NotificationMethod):
# Lookup the team. # Lookup the team.
team = None team = None
try: try:
team = model.get_organization_team(repository.namespace, target_info['name']) team = model.get_organization_team(repository.namespace_user.username, target_info['name'])
except model.InvalidTeamException: except model.InvalidTeamException:
# Probably deleted. # Probably deleted.
return (True, 'Unknown team %s' % target_info['name'], None) return (True, 'Unknown team %s' % target_info['name'], None)
@ -105,19 +109,18 @@ class QuayNotificationMethod(NotificationMethod):
repository = notification.repository repository = notification.repository
if not repository: if not repository:
# Probably deleted. # Probably deleted.
return True return
# Lookup the target user or team to which we'll send the notification. # Lookup the target user or team to which we'll send the notification.
config_data = json.loads(notification.config_json) config_data = json.loads(notification.config_json)
status, err_message, target_users = self.find_targets(repository, config_data) status, err_message, target_users = self.find_targets(repository, config_data)
if not status: if not status:
return False raise NotificationMethodPerformException(err_message)
# For each of the target users, create a notification. # For each of the target users, create a notification.
for target_user in set(target_users or []): for target_user in set(target_users or []):
model.create_notification(event_handler.event_name(), target_user, model.create_notification(event_handler.event_name(), target_user,
metadata=notification_data['event_data']) metadata=notification_data['event_data'])
return True
class EmailMethod(NotificationMethod): class EmailMethod(NotificationMethod):
@ -130,7 +133,8 @@ class EmailMethod(NotificationMethod):
if not email: if not email:
raise CannotValidateNotificationMethodException('Missing e-mail address') raise CannotValidateNotificationMethodException('Missing e-mail address')
record = model.get_email_authorized_for_repo(repository.namespace, repository.name, email) record = model.get_email_authorized_for_repo(repository.namespace_user.username,
repository.name, email)
if not record or not record.confirmed: if not record or not record.confirmed:
raise CannotValidateNotificationMethodException('The specified e-mail address ' raise CannotValidateNotificationMethodException('The specified e-mail address '
'is not authorized to receive ' 'is not authorized to receive '
@ -141,7 +145,7 @@ class EmailMethod(NotificationMethod):
config_data = json.loads(notification.config_json) config_data = json.loads(notification.config_json)
email = config_data.get('email', '') email = config_data.get('email', '')
if not email: if not email:
return False return
msg = Message(event_handler.get_summary(notification_data['event_data'], notification_data), msg = Message(event_handler.get_summary(notification_data['event_data'], notification_data),
sender='support@quay.io', sender='support@quay.io',
@ -153,9 +157,7 @@ class EmailMethod(NotificationMethod):
mail.send(msg) mail.send(msg)
except Exception as ex: except Exception as ex:
logger.exception('Email was unable to be sent: %s' % ex.message) logger.exception('Email was unable to be sent: %s' % ex.message)
return False raise NotificationMethodPerformException(ex.message)
return True
class WebhookMethod(NotificationMethod): class WebhookMethod(NotificationMethod):
@ -172,7 +174,7 @@ class WebhookMethod(NotificationMethod):
config_data = json.loads(notification.config_json) config_data = json.loads(notification.config_json)
url = config_data.get('url', '') url = config_data.get('url', '')
if not url: if not url:
return False return
payload = notification_data['event_data'] payload = notification_data['event_data']
headers = {'Content-type': 'application/json'} headers = {'Content-type': 'application/json'}
@ -180,15 +182,14 @@ class WebhookMethod(NotificationMethod):
try: try:
resp = requests.post(url, data=json.dumps(payload), headers=headers) resp = requests.post(url, data=json.dumps(payload), headers=headers)
if resp.status_code/100 != 2: if resp.status_code/100 != 2:
logger.error('%s response for webhook to url: %s' % (resp.status_code, error_message = '%s response for webhook to url: %s' % (resp.status_code, url)
url)) logger.error(error_message)
return False logger.error(resp.content)
raise NotificationMethodPerformException(error_message)
except requests.exceptions.RequestException as ex: except requests.exceptions.RequestException as ex:
logger.exception('Webhook was unable to be sent: %s' % ex.message) logger.exception('Webhook was unable to be sent: %s' % ex.message)
return False raise NotificationMethodPerformException(ex.message)
return True
class FlowdockMethod(NotificationMethod): class FlowdockMethod(NotificationMethod):
@ -208,12 +209,12 @@ class FlowdockMethod(NotificationMethod):
config_data = json.loads(notification.config_json) config_data = json.loads(notification.config_json)
token = config_data.get('flow_api_token', '') token = config_data.get('flow_api_token', '')
if not token: if not token:
return False return
owner = model.get_user(notification.repository.namespace) owner = model.get_user(notification.repository.namespace_user.username)
if not owner: if not owner:
# Something went wrong. # Something went wrong.
return False return
url = 'https://api.flowdock.com/v1/messages/team_inbox/%s' % token url = 'https://api.flowdock.com/v1/messages/team_inbox/%s' % token
headers = {'Content-type': 'application/json'} headers = {'Content-type': 'application/json'}
@ -223,7 +224,8 @@ class FlowdockMethod(NotificationMethod):
'subject': event_handler.get_summary(notification_data['event_data'], notification_data), 'subject': event_handler.get_summary(notification_data['event_data'], notification_data),
'content': event_handler.get_message(notification_data['event_data'], notification_data), 'content': event_handler.get_message(notification_data['event_data'], notification_data),
'from_name': owner.username, 'from_name': owner.username,
'project': notification.repository.namespace + ' ' + notification.repository.name, 'project': (notification.repository.namespace_user.username + ' ' +
notification.repository.name),
'tags': ['#' + event_handler.event_name()], 'tags': ['#' + event_handler.event_name()],
'link': notification_data['event_data']['homepage'] 'link': notification_data['event_data']['homepage']
} }
@ -231,16 +233,14 @@ class FlowdockMethod(NotificationMethod):
try: try:
resp = requests.post(url, data=json.dumps(payload), headers=headers) resp = requests.post(url, data=json.dumps(payload), headers=headers)
if resp.status_code/100 != 2: if resp.status_code/100 != 2:
logger.error('%s response for flowdock to url: %s' % (resp.status_code, error_message = '%s response for flowdock to url: %s' % (resp.status_code, url)
url)) logger.error(error_message)
logger.error(resp.content) logger.error(resp.content)
return False raise NotificationMethodPerformException(error_message)
except requests.exceptions.RequestException as ex: except requests.exceptions.RequestException as ex:
logger.exception('Flowdock method was unable to be sent: %s' % ex.message) logger.exception('Flowdock method was unable to be sent: %s' % ex.message)
return False raise NotificationMethodPerformException(ex.message)
return True
class HipchatMethod(NotificationMethod): class HipchatMethod(NotificationMethod):
@ -265,12 +265,12 @@ class HipchatMethod(NotificationMethod):
room_id = config_data.get('room_id', '') room_id = config_data.get('room_id', '')
if not token or not room_id: if not token or not room_id:
return False return
owner = model.get_user(notification.repository.namespace) owner = model.get_user(notification.repository.namespace_user.username)
if not owner: if not owner:
# Something went wrong. # Something went wrong.
return False return
url = 'https://api.hipchat.com/v2/room/%s/notification?auth_token=%s' % (room_id, token) url = 'https://api.hipchat.com/v2/room/%s/notification?auth_token=%s' % (room_id, token)
@ -293,16 +293,14 @@ class HipchatMethod(NotificationMethod):
try: try:
resp = requests.post(url, data=json.dumps(payload), headers=headers) resp = requests.post(url, data=json.dumps(payload), headers=headers)
if resp.status_code/100 != 2: if resp.status_code/100 != 2:
logger.error('%s response for hipchat to url: %s' % (resp.status_code, error_message = '%s response for hipchat to url: %s' % (resp.status_code, url)
url)) logger.error(error_message)
logger.error(resp.content) logger.error(resp.content)
return False raise NotificationMethodPerformException(error_message)
except requests.exceptions.RequestException as ex: except requests.exceptions.RequestException as ex:
logger.exception('Hipchat method was unable to be sent: %s' % ex.message) logger.exception('Hipchat method was unable to be sent: %s' % ex.message)
return False raise NotificationMethodPerformException(ex.message)
return True
class SlackMethod(NotificationMethod): class SlackMethod(NotificationMethod):
@ -334,12 +332,12 @@ class SlackMethod(NotificationMethod):
subdomain = config_data.get('subdomain', '') subdomain = config_data.get('subdomain', '')
if not token or not subdomain: if not token or not subdomain:
return False return
owner = model.get_user(notification.repository.namespace) owner = model.get_user(notification.repository.namespace_user.username)
if not owner: if not owner:
# Something went wrong. # Something went wrong.
return False return
url = 'https://%s.slack.com/services/hooks/incoming-webhook?token=%s' % (subdomain, token) url = 'https://%s.slack.com/services/hooks/incoming-webhook?token=%s' % (subdomain, token)
@ -370,13 +368,11 @@ class SlackMethod(NotificationMethod):
try: try:
resp = requests.post(url, data=json.dumps(payload), headers=headers) resp = requests.post(url, data=json.dumps(payload), headers=headers)
if resp.status_code/100 != 2: if resp.status_code/100 != 2:
logger.error('%s response for Slack to url: %s' % (resp.status_code, error_message = '%s response for Slack to url: %s' % (resp.status_code, url)
url)) logger.error(error_message)
logger.error(resp.content) logger.error(resp.content)
return False raise NotificationMethodPerformException(error_message)
except requests.exceptions.RequestException as ex: except requests.exceptions.RequestException as ex:
logger.exception('Slack method was unable to be sent: %s' % ex.message) logger.exception('Slack method was unable to be sent: %s' % ex.message)
return False raise NotificationMethodPerformException(ex.message)
return True

View file

@ -14,6 +14,7 @@ from util.http import abort, exact_abort
from auth.permissions import (ReadRepositoryPermission, from auth.permissions import (ReadRepositoryPermission,
ModifyRepositoryPermission) ModifyRepositoryPermission)
from data import model from data import model
from util import gzipstream
registry = Blueprint('registry', __name__) registry = Blueprint('registry', __name__)
@ -193,21 +194,33 @@ def put_image_layer(namespace, repository, image_id):
# encoding (Gunicorn) # encoding (Gunicorn)
input_stream = request.environ['wsgi.input'] input_stream = request.environ['wsgi.input']
# compute checksums # Create a socket reader to read the input stream containing the layer data.
csums = []
sr = SocketReader(input_stream) sr = SocketReader(input_stream)
# Add a handler that store the data in storage.
tmp, store_hndlr = store.temp_store_handler() tmp, store_hndlr = store.temp_store_handler()
sr.add_handler(store_hndlr) sr.add_handler(store_hndlr)
# Add a handler to compute the uncompressed size of the layer.
uncompressed_size_info, size_hndlr = gzipstream.calculate_size_handler()
sr.add_handler(size_hndlr)
# Add a handler which computes the checksum.
h, sum_hndlr = checksums.simple_checksum_handler(json_data) h, sum_hndlr = checksums.simple_checksum_handler(json_data)
sr.add_handler(sum_hndlr) sr.add_handler(sum_hndlr)
# Stream write the data to storage.
store.stream_write(repo_image.storage.locations, layer_path, sr) store.stream_write(repo_image.storage.locations, layer_path, sr)
# Append the computed checksum.
csums = []
csums.append('sha256:{0}'.format(h.hexdigest())) csums.append('sha256:{0}'.format(h.hexdigest()))
try: try:
image_size = tmp.tell() image_size = tmp.tell()
# Save the size of the image. # 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) tmp.seek(0)
csums.append(checksums.compute_tarsum(tmp, json_data)) csums.append(checksums.compute_tarsum(tmp, json_data))
@ -451,12 +464,6 @@ def put_image_json(namespace, repository, image_id):
set_uploading_flag(repo_image, True) set_uploading_flag(repo_image, True)
# We cleanup any old checksum in case it's a retry after a fail
profile.debug('Cleanup old checksum')
repo_image.storage.uncompressed_size = data.get('Size')
repo_image.storage.checksum = None
repo_image.storage.save()
# If we reach that point, it means that this is a new image or a retry # If we reach that point, it means that this is a new image or a retry
# on a failed push # on a failed push
# save the metadata # save the metadata

View file

@ -36,6 +36,9 @@ class TriggerActivationException(Exception):
class TriggerDeactivationException(Exception): class TriggerDeactivationException(Exception):
pass pass
class TriggerStartException(Exception):
pass
class ValidationRequestException(Exception): class ValidationRequestException(Exception):
pass pass
@ -109,12 +112,19 @@ class BuildTrigger(object):
""" """
raise NotImplementedError raise NotImplementedError
def manual_start(self, auth_token, config): def manual_start(self, auth_token, config, run_parameters = None):
""" """
Manually creates a repository build for this trigger. Manually creates a repository build for this trigger.
""" """
raise NotImplementedError raise NotImplementedError
def list_field_values(self, auth_token, config, field_name):
"""
Lists all values for the given custom trigger field. For example, a trigger might have a
field named "branches", and this method would return all branches.
"""
raise NotImplementedError
@classmethod @classmethod
def service_name(cls): def service_name(cls):
""" """
@ -345,14 +355,37 @@ class GithubBuildTrigger(BuildTrigger):
return GithubBuildTrigger._prepare_build(config, repo, commit_sha, return GithubBuildTrigger._prepare_build(config, repo, commit_sha,
short_sha, ref) short_sha, ref)
def manual_start(self, auth_token, config): def manual_start(self, auth_token, config, run_parameters = None):
source = config['build_source'] try:
source = config['build_source']
run_parameters = run_parameters or {}
gh_client = self._get_client(auth_token) gh_client = self._get_client(auth_token)
repo = gh_client.get_repo(source) repo = gh_client.get_repo(source)
master = repo.get_branch(repo.default_branch) master = repo.get_branch(repo.default_branch)
master_sha = master.commit.sha master_sha = master.commit.sha
short_sha = GithubBuildTrigger.get_display_name(master_sha) short_sha = GithubBuildTrigger.get_display_name(master_sha)
ref = 'refs/heads/%s' % repo.default_branch ref = 'refs/heads/%s' % (run_parameters.get('branch_name') or repo.default_branch)
return self._prepare_build(config, repo, master_sha, short_sha, ref) return self._prepare_build(config, repo, master_sha, short_sha, ref)
except GithubException as ghe:
raise TriggerStartException(ghe.data['message'])
def list_field_values(self, auth_token, config, field_name):
if field_name == 'branch_name':
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()]
if not repo.default_branch in branches:
branches.insert(0, repo.default_branch)
if branches[0] != repo.default_branch:
branches.remove(repo.default_branch)
branches.insert(0, repo.default_branch)
return branches
return None

View file

@ -18,6 +18,7 @@ from endpoints.common import common_login, render_page_template, route_show_if,
from endpoints.csrf import csrf_protect, generate_csrf_token from endpoints.csrf import csrf_protect, generate_csrf_token
from util.names import parse_repository_name from util.names import parse_repository_name
from util.gravatar import compute_hash from util.gravatar import compute_hash
from util.useremails import send_email_changed
from auth import scopes from auth import scopes
import features import features
@ -32,8 +33,8 @@ STATUS_TAGS = app.config['STATUS_TAGS']
@web.route('/', methods=['GET'], defaults={'path': ''}) @web.route('/', methods=['GET'], defaults={'path': ''})
@web.route('/organization/<path:path>', methods=['GET']) @web.route('/organization/<path:path>', methods=['GET'])
@no_cache @no_cache
def index(path): def index(path, **kwargs):
return render_page_template('index.html') return render_page_template('index.html', **kwargs)
@web.route('/500', methods=['GET']) @web.route('/500', methods=['GET'])
@ -101,7 +102,7 @@ def superuser():
@web.route('/signin/') @web.route('/signin/')
@no_cache @no_cache
def signin(): def signin(redirect=None):
return index('') return index('')
@ -123,6 +124,13 @@ def new():
return index('') return index('')
@web.route('/confirminvite')
@no_cache
def confirm_invite():
code = request.values['code']
return index('', code=code)
@web.route('/repository/', defaults={'path': ''}) @web.route('/repository/', defaults={'path': ''})
@web.route('/repository/<path:path>', methods=['GET']) @web.route('/repository/<path:path>', methods=['GET'])
@no_cache @no_cache
@ -215,6 +223,7 @@ def receipt():
@web.route('/authrepoemail', methods=['GET']) @web.route('/authrepoemail', methods=['GET'])
@route_show_if(features.MAILING)
def confirm_repo_email(): def confirm_repo_email():
code = request.values['code'] code = request.values['code']
record = None record = None
@ -228,23 +237,27 @@ def confirm_repo_email():
Your E-mail address has been authorized to receive notifications for repository Your E-mail address has been authorized to receive notifications for repository
<a href="%s://%s/repository/%s/%s">%s/%s</a>. <a href="%s://%s/repository/%s/%s">%s/%s</a>.
""" % (app.config['PREFERRED_URL_SCHEME'], app.config['SERVER_HOSTNAME'], """ % (app.config['PREFERRED_URL_SCHEME'], app.config['SERVER_HOSTNAME'],
record.repository.namespace, record.repository.name, record.repository.namespace_user.username, record.repository.name,
record.repository.namespace, record.repository.name) record.repository.namespace_user.username, record.repository.name)
return render_page_template('message.html', message=message) return render_page_template('message.html', message=message)
@web.route('/confirm', methods=['GET']) @web.route('/confirm', methods=['GET'])
@route_show_if(features.MAILING)
def confirm_email(): def confirm_email():
code = request.values['code'] code = request.values['code']
user = None user = None
new_email = None new_email = None
try: try:
user, new_email = model.confirm_user_email(code) user, new_email, old_email = model.confirm_user_email(code)
except model.DataModelException as ex: except model.DataModelException as ex:
return render_page_template('confirmerror.html', error_message=ex.message) return render_page_template('confirmerror.html', error_message=ex.message)
if new_email:
send_email_changed(user.username, old_email, new_email)
common_login(user) common_login(user)
return redirect(url_for('web.user', tab='email') return redirect(url_for('web.user', tab='email')

View file

@ -51,7 +51,7 @@ def __gen_checksum(image_id):
def __gen_image_id(repo, image_num): def __gen_image_id(repo, image_num):
str_to_hash = "%s/%s/%s" % (repo.namespace, repo.name, image_num) str_to_hash = "%s/%s/%s" % (repo.namespace_user.username, repo.name, image_num)
h = hashlib.md5(str_to_hash) h = hashlib.md5(str_to_hash)
return h.hexdigest() + h.hexdigest() return h.hexdigest() + h.hexdigest()
@ -79,12 +79,12 @@ def __create_subtree(repo, structure, creator_username, parent):
creation_time = REFERENCE_DATE + timedelta(days=image_num) creation_time = REFERENCE_DATE + timedelta(days=image_num)
command_list = SAMPLE_CMDS[image_num % len(SAMPLE_CMDS)] command_list = SAMPLE_CMDS[image_num % len(SAMPLE_CMDS)]
command = json.dumps(command_list) if command_list else None command = json.dumps(command_list) if command_list else None
new_image = model.set_image_metadata(docker_image_id, repo.namespace, new_image = model.set_image_metadata(docker_image_id, repo.namespace_user.username, repo.name,
repo.name, str(creation_time), str(creation_time), 'no comment', command, parent)
'no comment', command, parent)
model.set_image_size(docker_image_id, repo.namespace, repo.name, compressed_size = random.randrange(1, 1024 * 1024 * 1024)
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 # Populate the diff file
diff_path = store.image_file_diffs_path(new_image.storage.uuid) diff_path = store.image_file_diffs_path(new_image.storage.uuid)
@ -100,7 +100,7 @@ def __create_subtree(repo, structure, creator_username, parent):
last_node_tags = [last_node_tags] last_node_tags = [last_node_tags]
for tag_name in last_node_tags: for tag_name in last_node_tags:
model.create_or_update_tag(repo.namespace, repo.name, tag_name, model.create_or_update_tag(repo.namespace_user.username, repo.name, tag_name,
new_image.docker_image_id) new_image.docker_image_id)
for subtree in subtrees: for subtree in subtrees:
@ -214,7 +214,11 @@ def initialize_database():
LogEntryKind.create(name='org_create_team') LogEntryKind.create(name='org_create_team')
LogEntryKind.create(name='org_delete_team') LogEntryKind.create(name='org_delete_team')
LogEntryKind.create(name='org_invite_team_member')
LogEntryKind.create(name='org_delete_team_member_invite')
LogEntryKind.create(name='org_add_team_member') LogEntryKind.create(name='org_add_team_member')
LogEntryKind.create(name='org_team_member_invite_accepted')
LogEntryKind.create(name='org_team_member_invite_declined')
LogEntryKind.create(name='org_remove_team_member') LogEntryKind.create(name='org_remove_team_member')
LogEntryKind.create(name='org_set_team_description') LogEntryKind.create(name='org_set_team_description')
LogEntryKind.create(name='org_set_team_role') LogEntryKind.create(name='org_set_team_role')
@ -271,6 +275,7 @@ def initialize_database():
NotificationKind.create(name='over_private_usage') NotificationKind.create(name='over_private_usage')
NotificationKind.create(name='expiring_license') NotificationKind.create(name='expiring_license')
NotificationKind.create(name='maintenance') NotificationKind.create(name='maintenance')
NotificationKind.create(name='org_team_invite')
NotificationKind.create(name='test_notification') NotificationKind.create(name='test_notification')
@ -302,7 +307,7 @@ def populate_database():
new_user_2.verified = True new_user_2.verified = True
new_user_2.save() new_user_2.save()
new_user_3 = model.create_user('freshuser', 'password', 'no@thanks.com') new_user_3 = model.create_user('freshuser', 'password', 'jschorr+test@devtable.com')
new_user_3.verified = True new_user_3.verified = True
new_user_3.save() new_user_3.save()
@ -323,7 +328,8 @@ def populate_database():
outside_org.verified = True outside_org.verified = True
outside_org.save() outside_org.save()
model.create_notification('test_notification', new_user_1, metadata={'some': 'value', 'arr': [1,2,3], 'obj': {'a': 1, 'b': 2}}) model.create_notification('test_notification', new_user_1,
metadata={'some':'value', 'arr':[1, 2, 3], 'obj':{'a':1, 'b':2}})
from_date = datetime.utcnow() from_date = datetime.utcnow()
to_date = from_date + timedelta(hours=1) to_date = from_date + timedelta(hours=1)
@ -387,18 +393,20 @@ def populate_database():
}) })
trigger.save() trigger.save()
repo = 'ci.devtable.com:5000/%s/%s' % (building.namespace, building.name) repo = 'ci.devtable.com:5000/%s/%s' % (building.namespace_user.username, building.name)
job_config = { job_config = {
'repository': repo, 'repository': repo,
'docker_tags': ['latest'], 'docker_tags': ['latest'],
'build_subdir': '', 'build_subdir': '',
} }
record = model.create_email_authorization_for_repo(new_user_1.username, 'simple', 'jschorr@devtable.com') record = model.create_email_authorization_for_repo(new_user_1.username, 'simple',
'jschorr@devtable.com')
record.confirmed = True record.confirmed = True
record.save() record.save()
model.create_email_authorization_for_repo(new_user_1.username, 'simple', 'jschorr+other@devtable.com') model.create_email_authorization_for_repo(new_user_1.username, 'simple',
'jschorr+other@devtable.com')
build2 = model.create_repository_build(building, token, job_config, build2 = model.create_repository_build(building, token, job_config,
'68daeebd-a5b9-457f-80a0-4363b882f8ea', '68daeebd-a5b9-457f-80a0-4363b882f8ea',
@ -425,12 +433,12 @@ def populate_database():
model.create_robot('coolrobot', org) model.create_robot('coolrobot', org)
oauth.create_application(org, 'Some Test App', 'http://localhost:8000', 'http://localhost:8000/o2c.html', oauth.create_application(org, 'Some Test App', 'http://localhost:8000',
client_id='deadbeef') 'http://localhost:8000/o2c.html', client_id='deadbeef')
oauth.create_application(org, 'Some Other Test App', 'http://quay.io', 'http://localhost:8000/o2c.html', oauth.create_application(org, 'Some Other Test App', 'http://quay.io',
client_id='deadpork', 'http://localhost:8000/o2c.html', client_id='deadpork',
description = 'This is another test application') description='This is another test application')
model.oauth.create_access_token_for_testing(new_user_1, 'deadbeef', 'repo:admin') model.oauth.create_access_token_for_testing(new_user_1, 'deadbeef', 'repo:admin')
@ -452,8 +460,8 @@ def populate_database():
reader_team = model.create_team('readers', org, 'member', reader_team = model.create_team('readers', org, 'member',
'Readers of orgrepo.') 'Readers of orgrepo.')
model.set_team_repo_permission(reader_team.name, org_repo.namespace, model.set_team_repo_permission(reader_team.name, org_repo.namespace_user.username, org_repo.name,
org_repo.name, 'read') 'read')
model.add_user_to_team(new_user_2, reader_team) model.add_user_to_team(new_user_2, reader_team)
model.add_user_to_team(reader, reader_team) model.add_user_to_team(reader, reader_team)
@ -475,12 +483,9 @@ def populate_database():
(2, [], 'latest17'), (2, [], 'latest17'),
(2, [], 'latest18'),]) (2, [], 'latest18'),])
model.add_prototype_permission(org, 'read', activating_user=new_user_1, model.add_prototype_permission(org, 'read', activating_user=new_user_1, delegate_user=new_user_2)
delegate_user=new_user_2) model.add_prototype_permission(org, 'read', activating_user=new_user_1, delegate_team=reader_team)
model.add_prototype_permission(org, 'read', activating_user=new_user_1, model.add_prototype_permission(org, 'write', activating_user=new_user_2, delegate_user=new_user_1)
delegate_team=reader_team)
model.add_prototype_permission(org, 'write', activating_user=new_user_2,
delegate_user=new_user_1)
today = datetime.today() today = datetime.today()
week_ago = today - timedelta(6) week_ago = today - timedelta(6)

View file

@ -144,6 +144,15 @@ nav.navbar-default .navbar-nav>li>a.active {
max-width: 320px; max-width: 320px;
} }
.notification-view-element .right-controls button {
margin-left: 10px;
}
.notification-view-element .message i.fa {
margin-right: 6px;
}
.notification-view-element .orginfo { .notification-view-element .orginfo {
margin-top: 8px; margin-top: 8px;
float: left; float: left;
@ -3593,6 +3602,12 @@ p.editable:hover i {
white-space: nowrap; white-space: nowrap;
} }
.tt-message {
padding: 10px;
font-size: 12px;
white-space: nowrap;
}
.tt-suggestion p { .tt-suggestion p {
margin: 0; margin: 0;
} }
@ -4284,7 +4299,7 @@ pre.command:before {
} }
.user-row.super-user td { .user-row.super-user td {
background-color: #d9edf7; background-color: #eeeeee;
} }
.user-row .user-class { .user-row .user-class {
@ -4672,4 +4687,68 @@ i.slack-icon {
.external-notification-view-element:hover .side-controls button { .external-notification-view-element:hover .side-controls button {
border: 1px solid #eee; border: 1px solid #eee;
}
.member-listing {
width: 100%;
}
.member-listing .section-header {
color: #ccc;
margin-top: 20px;
margin-bottom: 10px;
}
.member-listing .gravatar {
vertical-align: middle;
margin-right: 10px;
}
.member-listing .entity-reference {
margin-bottom: 10px;
display: inline-block;
}
.member-listing .invite-listing {
margin-bottom: 10px;
display: inline-block;
}
.team-view .organization-header .popover {
max-width: none !important;
}
.team-view .organization-header .popover.bottom-right .arrow:after {
border-bottom-color: #f7f7f7;
top: 2px;
}
.team-view .organization-header .popover-content {
font-size: 14px;
padding-top: 6px;
}
.team-view .organization-header .popover-content input {
background: white;
}
.team-view .team-view-add-element .help-text {
font-size: 13px;
color: #ccc;
margin-top: 10px;
}
.team-view .organization-header .popover-content {
min-width: 500px;
}
#startTriggerDialog .trigger-description {
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid #eee;
}
#startTriggerDialog #runForm .field-title {
width: 120px;
padding-right: 10px;
} }

View file

@ -7,15 +7,19 @@
</span> </span>
</span> </span>
<span ng-if="entity.kind == 'org'"> <span ng-if="entity.kind == 'org'">
<img src="//www.gravatar.com/avatar/{{ entity.gravatar }}?s=16&amp;d=identicon"> <img ng-src="//www.gravatar.com/avatar/{{ entity.gravatar }}?s={{ gravatarSize || '16' }}&amp;d=identicon">
<span class="entity-name"> <span class="entity-name">
<span ng-if="!getIsAdmin(entity.name)">{{entity.name}}</span> <span ng-if="!getIsAdmin(entity.name)">{{entity.name}}</span>
<span ng-if="getIsAdmin(entity.name)"><a href="/organization/{{ entity.name }}">{{entity.name}}</a></span> <span ng-if="getIsAdmin(entity.name)"><a href="/organization/{{ entity.name }}">{{entity.name}}</a></span>
</span> </span>
</span> </span>
<span ng-if="entity.kind != 'team' && entity.kind != 'org'"> <span ng-if="entity.kind != 'team' && entity.kind != 'org'">
<i class="fa fa-user" ng-show="!entity.is_robot" data-title="User" bs-tooltip="tooltip.title" data-container="body"></i> <img class="gravatar" ng-if="showGravatar == 'true' && entity.gravatar" ng-src="//www.gravatar.com/avatar/{{ entity.gravatar }}?s={{ gravatarSize || '16' }}&amp;d=identicon">
<i class="fa fa-wrench" ng-show="entity.is_robot" data-title="Robot Account" bs-tooltip="tooltip.title" data-container="body"></i> <span ng-if="showGravatar != 'true' || !entity.gravatar">
<i class="fa fa-user" ng-show="!entity.is_robot" data-title="User" bs-tooltip="tooltip.title" data-container="body"></i>
<i class="fa fa-wrench" ng-show="entity.is_robot" data-title="Robot Account" bs-tooltip="tooltip.title" data-container="body"></i>
</span>
<span class="entity-name" ng-if="entity.is_robot"> <span class="entity-name" ng-if="entity.is_robot">
<a href="{{ getRobotUrl(entity.name) }}" ng-if="getIsAdmin(getPrefix(entity.name))"> <a href="{{ getRobotUrl(entity.name) }}" ng-if="getIsAdmin(getPrefix(entity.name))">
<span class="prefix">{{ getPrefix(entity.name) }}+</span><span>{{ getShortenedName(entity.name) }}</span> <span class="prefix">{{ getPrefix(entity.name) }}+</span><span>{{ getShortenedName(entity.name) }}</span>

View file

@ -5,7 +5,7 @@
ng-click="lazyLoad()"> ng-click="lazyLoad()">
<span class="caret"></span> <span class="caret"></span>
</button> </button>
<ul class="dropdown-menu" role="menu" aria-labelledby="entityDropdownMenu"> <ul class="dropdown-menu" ng-class="pullRight == 'true' ? 'pull-right': ''" role="menu" aria-labelledby="entityDropdownMenu">
<li ng-show="lazyLoading" style="padding: 10px"><div class="quay-spinner"></div></li> <li ng-show="lazyLoading" style="padding: 10px"><div class="quay-spinner"></div></li>
<li role="presentation" class="dropdown-header" ng-show="!lazyLoading && !robots && !isAdmin && !teams"> <li role="presentation" class="dropdown-header" ng-show="!lazyLoading && !robots && !isAdmin && !teams">

View file

@ -1,6 +1,6 @@
<span class="external-login-button-element"> <span class="external-login-button-element">
<span ng-if="provider == 'github'"> <span ng-if="provider == 'github'">
<a href="javascript:void(0)" class="btn btn-primary btn-block" quay-require="['GITHUB_LOGIN']" ng-click="startSignin('github')" style="margin-bottom: 10px"> <a href="javascript:void(0)" class="btn btn-primary btn-block" quay-require="['GITHUB_LOGIN']" ng-click="startSignin('github')" style="margin-bottom: 10px" ng-disabled="signingIn">
<i class="fa fa-github fa-lg"></i> <i class="fa fa-github fa-lg"></i>
<span ng-if="action != 'attach'">Sign In with GitHub</span> <span ng-if="action != 'attach'">Sign In with GitHub</span>
<span ng-if="action == 'attach'">Attach to GitHub Account</span> <span ng-if="action == 'attach'">Attach to GitHub Account</span>
@ -8,7 +8,7 @@
</span> </span>
<span ng-if="provider == 'google'"> <span ng-if="provider == 'google'">
<a href="javascript:void(0)" class="btn btn-primary btn-block" quay-require="['GOOGLE_LOGIN']" ng-click="startSignin('google')"> <a href="javascript:void(0)" class="btn btn-primary btn-block" quay-require="['GOOGLE_LOGIN']" ng-click="startSignin('google')" ng-disabled="signingIn">
<i class="fa fa-google fa-lg"></i> <i class="fa fa-google fa-lg"></i>
<span ng-if="action != 'attach'">Sign In with Google</span> <span ng-if="action != 'attach'">Sign In with Google</span>
<span ng-if="action == 'attach'">Attach to Google Account</span> <span ng-if="action == 'attach'">Attach to Google Account</span>

View file

@ -3,7 +3,7 @@
<div class="container header"> <div class="container header">
<span class="header-text"> <span class="header-text">
<span ng-show="!performer">Usage Logs</span> <span ng-show="!performer">Usage Logs</span>
<span class="entity-reference" name="performer.username" isrobot="performer.is_robot" ng-show="performer"></span> <span class="entity-reference" entity="performer" ng-show="performer"></span>
<span id="logs-range" class="mini"> <span id="logs-range" class="mini">
From From
<input type="text" class="logs-date-picker input-sm" name="start" ng-model="logStartDate" data-max-date="{{ logEndDate }}" data-container="body" bs-datepicker/> <input type="text" class="logs-date-picker input-sm" name="start" ng-model="logStartDate" data-max-date="{{ logEndDate }}" data-container="body" bs-datepicker/>

View file

@ -0,0 +1,38 @@
<!-- Modal message dialog -->
<div class="modal fade" id="startTriggerDialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Manully Start Build Trigger</h4>
</div>
<div class="modal-body">
<div class="trigger-description" trigger="trigger"></div>
<form name="runForm" id="runForm">
<table width="100%">
<tr ng-repeat="field in runParameters">
<td class="field-title" valign="top">{{ field.title }}:</td>
<td>
<div ng-switch on="field.type">
<span ng-switch-when="option">
<span class="quay-spinner" ng-show="!fieldOptions[field.name]"></span>
<select ng-model="parameters[field.name]" ng-show="fieldOptions[field.name]"
ng-options="value for value in fieldOptions[field.name]"
required>
</select>
</span>
<input type="text" class="form-control" ng-model="parameters[field.name]" ng-switch-when="string" required>
</div>
</td>
</tr>
</table>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" ng-disabled="runForm.$invalid" ng-click="startTrigger()">Start Build</button>
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->

View file

@ -7,10 +7,13 @@
<span class="orgname">{{ notification.organization }}</span> <span class="orgname">{{ notification.organization }}</span>
</div> </div>
</div> </div>
<div class="datetime">{{ parseDate(notification.created) | date:'medium'}}</div>
<div class="right-controls"> <div class="right-controls">
<a href="javascript:void(0)" ng-if="canDismiss(notification)" ng-click="dismissNotification(notification)"> <a href="javascript:void(0)" ng-if="canDismiss(notification)" ng-click="dismissNotification(notification)">
Dismiss Notification Dismiss Notification
</a> </a>
<button class="btn" ng-class="'btn-' + action.kind" ng-repeat="action in getActions(notification)" ng-click="action.handler(notification)">
{{ action.title }}
</button>
</div> </div>
<div class="datetime">{{ parseDate(notification.created) | date:'medium'}}</div>
</div> </div>

View file

@ -3,7 +3,7 @@
<div class="container" ng-show="!loading"> <div class="container" ng-show="!loading">
<div class="alert alert-info"> <div class="alert alert-info">
Default permissions provide a means of specifying <span class="context-tooltip" data-title="By default, all repositories have the creating user added as an 'Admin'" bs-tooltip="tooltip.title">additional</span> permissions that should be granted automatically to a repository. Default permissions provide a means of specifying <span class="context-tooltip" data-title="By default, all repositories have the creating user added as an 'Admin'" bs-tooltip="tooltip.title">additional</span> permissions that should be granted automatically to a repository <strong>when it is created</strong>.
</div> </div>
<div class="side-controls"> <div class="side-controls">

View file

@ -1,5 +1,6 @@
<div class="signin-form-element"> <div class="signin-form-element">
<form class="form-signin" ng-submit="signin();"> <span class="quay-spinner" ng-show="signingIn"></span>
<form class="form-signin" ng-submit="signin();" ng-show="!signingIn">
<input type="text" class="form-control input-lg" name="username" <input type="text" class="form-control input-lg" name="username"
placeholder="Username or E-mail Address" ng-model="user.username" autofocus> placeholder="Username or E-mail Address" ng-model="user.username" autofocus>
<input type="password" class="form-control input-lg" name="password" <input type="password" class="form-control input-lg" name="password"

View file

@ -1,5 +1,5 @@
<div class="signup-form-element"> <div class="signup-form-element" quay-show="Features.USER_CREATION">
<form class="form-signup" name="signupForm" ng-submit="register()" ngshow="!awaitingConfirmation && !registering"> <form class="form-signup" name="signupForm" ng-submit="register()" ng-show="!awaitingConfirmation && !registering">
<input type="text" class="form-control" placeholder="Create a username" name="username" ng-model="newUser.username" autofocus required ng-pattern="/^[a-z0-9_]{4,30}$/"> <input type="text" class="form-control" placeholder="Create a username" name="username" ng-model="newUser.username" autofocus required ng-pattern="/^[a-z0-9_]{4,30}$/">
<input type="email" class="form-control" placeholder="Email address" ng-model="newUser.email" required> <input type="email" class="form-control" placeholder="Email address" ng-model="newUser.email" required>
<input type="password" class="form-control" placeholder="Create a password" ng-model="newUser.password" required <input type="password" class="form-control" placeholder="Create a password" ng-model="newUser.password" required

View file

@ -0,0 +1,17 @@
<div class="team-view-add-element" focusable-popover-content>
<div class="entity-search"
namespace="orgname" placeholder="'Add a registered user or robot...'"
entity-selected="addNewMember(entity)"
email-selected="inviteEmail(email)"
current-entity="selectedMember"
auto-clear="true"
allowed-entities="['user', 'robot']"
pull-right="true"
allow-emails="allowEmail"
email-message="Press enter to invite the entered e-mail address to this team"
ng-show="!addingMember"></div>
<div class="quay-spinner" ng-show="addingMember"></div>
<div class="help-text" ng-show="!addingMember">
Search by Quay.io username or robot account name
</div>
</div>

View file

@ -14,7 +14,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="panel panel-default"> <div class="panel panel-default" quay-show="Features.USER_CREATION">
<div class="panel-heading"> <div class="panel-heading">
<h6 class="panel-title accordion-title"> <h6 class="panel-title accordion-title">
<a class="accordion-toggle" data-toggle="collapse" data-parent="#accordion" data-target="#collapseRegister"> <a class="accordion-toggle" data-toggle="collapse" data-parent="#accordion" data-target="#collapseRegister">
@ -24,11 +24,11 @@
</div> </div>
<div id="collapseRegister" class="panel-collapse collapse" ng-class="hasSignedIn() ? 'out' : 'in'"> <div id="collapseRegister" class="panel-collapse collapse" ng-class="hasSignedIn() ? 'out' : 'in'">
<div class="panel-body"> <div class="panel-body">
<div class="signup-form"></div> <div class="signup-form" user-registered="handleUserRegistered(username)" invite-code="inviteCode"></div>
</div> </div>
</div> </div>
</div> </div>
<div class="panel panel-default"> <div class="panel panel-default" quay-show="Features.MAILING">
<div class="panel-heading"> <div class="panel-heading">
<h6 class="panel-title accordion-title"> <h6 class="panel-title accordion-title">
<a class="accordion-toggle" data-toggle="collapse" data-parent="#accordion" data-target="#collapseForgot"> <a class="accordion-toggle" data-toggle="collapse" data-parent="#accordion" data-target="#collapseForgot">
@ -37,7 +37,8 @@
</h6> </h6>
</div> </div>
<div id="collapseForgot" class="panel-collapse collapse out"> <div id="collapseForgot" class="panel-collapse collapse out">
<div class="panel-body"> <div class="quay-spinner" ng-show="sendingRecovery"></div>
<div class="panel-body" ng-show="!sendingRecovery">
<form class="form-signin" ng-submit="sendRecovery();"> <form class="form-signin" ng-submit="sendRecovery();">
<input type="text" class="form-control input-lg" placeholder="Email" ng-model="recovery.email"> <input type="text" class="form-control input-lg" placeholder="Email" ng-model="recovery.email">
<button class="btn btn-lg btn-primary btn-block" type="submit">Send Recovery Email</button> <button class="btn btn-lg btn-primary btn-block" type="submit">Send Recovery Email</button>

View file

@ -499,6 +499,11 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
$provide.factory('UtilService', ['$sanitize', function($sanitize) { $provide.factory('UtilService', ['$sanitize', function($sanitize) {
var utilService = {}; var utilService = {};
utilService.isEmailAddress = function(val) {
var emailRegex = /^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/;
return emailRegex.test(val);
};
utilService.escapeHtmlString = function(text) { utilService.escapeHtmlString = function(text) {
var adjusted = text.replace(/&/g, "&amp;") var adjusted = text.replace(/&/g, "&amp;")
.replace(/</g, "&lt;") .replace(/</g, "&lt;")
@ -615,24 +620,46 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
}]); }]);
$provide.factory('TriggerDescriptionBuilder', ['UtilService', '$sanitize', function(UtilService, $sanitize) { $provide.factory('TriggerService', ['UtilService', '$sanitize', function(UtilService, $sanitize) {
var builderService = {}; var triggerService = {};
builderService.getDescription = function(name, config) { var triggerTypes = {
switch (name) { 'github': {
case 'github': 'description': function(config) {
var source = UtilService.textToSafeHtml(config['build_source']); var source = UtilService.textToSafeHtml(config['build_source']);
var desc = '<i class="fa fa-github fa-lg" style="margin-left: 2px; margin-right: 2px"></i> Push to Github Repository '; var desc = '<i class="fa fa-github fa-lg" style="margin-left: 2px; margin-right: 2px"></i> Push to Github Repository ';
desc += '<a href="https://github.com/' + source + '" target="_blank">' + source + '</a>'; desc += '<a href="https://github.com/' + source + '" target="_blank">' + source + '</a>';
desc += '<br>Dockerfile folder: //' + UtilService.textToSafeHtml(config['subdir']); desc += '<br>Dockerfile folder: //' + UtilService.textToSafeHtml(config['subdir']);
return desc; return desc;
},
default: 'run_parameters': [
return 'Unknown'; {
'title': 'Branch',
'type': 'option',
'name': 'branch_name'
}
]
} }
}
triggerService.getDescription = function(name, config) {
var type = triggerTypes[name];
if (!type) {
return 'Unknown';
}
return type['description'](config);
}; };
return builderService; triggerService.getRunParameters = function(name, config) {
var type = triggerTypes[name];
if (!type) {
return [];
}
return type['run_parameters'];
}
return triggerService;
}]); }]);
$provide.factory('StringBuilderService', ['$sce', 'UtilService', function($sce, UtilService) { $provide.factory('StringBuilderService', ['$sce', 'UtilService', function($sce, UtilService) {
@ -675,7 +702,10 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
stringBuilderService.buildString = function(value_or_func, metadata) { stringBuilderService.buildString = function(value_or_func, metadata) {
var fieldIcons = { var fieldIcons = {
'inviter': 'user',
'username': 'user', 'username': 'user',
'user': 'user',
'email': 'envelope',
'activating_username': 'user', 'activating_username': 'user',
'delegate_user': 'user', 'delegate_user': 'user',
'delegate_team': 'group', 'delegate_team': 'group',
@ -885,6 +915,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
// We already have /api/v1/ on the URLs, so remove them from the paths. // We already have /api/v1/ on the URLs, so remove them from the paths.
path = path.substr('/api/v1/'.length, path.length); path = path.substr('/api/v1/'.length, path.length);
// Build the path, adjusted with the inline parameters.
var used = {};
var url = ''; var url = '';
for (var i = 0; i < path.length; ++i) { for (var i = 0; i < path.length; ++i) {
var c = path[i]; var c = path[i];
@ -896,6 +928,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
throw new Error('Missing parameter: ' + varName); throw new Error('Missing parameter: ' + varName);
} }
used[varName] = true;
url += parameters[varName]; url += parameters[varName];
i = end; i = end;
continue; continue;
@ -904,6 +937,20 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
url += c; url += c;
} }
// Append any query parameters.
var isFirst = true;
for (var paramName in parameters) {
if (!parameters.hasOwnProperty(paramName)) { continue; }
if (used[paramName]) { continue; }
var value = parameters[paramName];
if (value) {
url += isFirst ? '?' : '&';
url += paramName + '=' + encodeURIComponent(value)
isFirst = false;
}
}
return url; return url;
}; };
@ -1257,7 +1304,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
return userService; return userService;
}]); }]);
$provide.factory('ExternalNotificationData', ['Config', function(Config) { $provide.factory('ExternalNotificationData', ['Config', 'Features', function(Config, Features) {
var externalNotificationData = {}; var externalNotificationData = {};
var events = [ var events = [
@ -1311,7 +1358,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
'type': 'email', 'type': 'email',
'title': 'E-mail address' 'title': 'E-mail address'
} }
] ],
'enabled': Features.MAILING
}, },
{ {
'id': 'webhook', 'id': 'webhook',
@ -1351,7 +1399,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
{ {
'name': 'notification_token', 'name': 'notification_token',
'type': 'string', 'type': 'string',
'title': 'Notification Token' 'title': 'Room Notification Token',
'help_url': 'https://hipchat.com/rooms/tokens/{room_id}'
} }
] ]
}, },
@ -1391,7 +1440,13 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
}; };
externalNotificationData.getSupportedMethods = function() { externalNotificationData.getSupportedMethods = function() {
return methods; var filtered = [];
for (var i = 0; i < methods.length; ++i) {
if (methods[i].enabled !== false) {
filtered.push(methods[i]);
}
}
return filtered;
}; };
externalNotificationData.getEventInfo = function(event) { externalNotificationData.getEventInfo = function(event) {
@ -1405,8 +1460,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
return externalNotificationData; return externalNotificationData;
}]); }]);
$provide.factory('NotificationService', ['$rootScope', '$interval', 'UserService', 'ApiService', 'StringBuilderService', 'PlanService', 'UserService', 'Config', $provide.factory('NotificationService', ['$rootScope', '$interval', 'UserService', 'ApiService', 'StringBuilderService', 'PlanService', 'UserService', 'Config', '$location',
function($rootScope, $interval, UserService, ApiService, StringBuilderService, PlanService, UserService, Config) { function($rootScope, $interval, UserService, ApiService, StringBuilderService, PlanService, UserService, Config, $location) {
var notificationService = { var notificationService = {
'user': null, 'user': null,
'notifications': [], 'notifications': [],
@ -1424,6 +1479,28 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
'page': '/about/', 'page': '/about/',
'dismissable': true 'dismissable': true
}, },
'org_team_invite': {
'level': 'primary',
'message': '{inviter} is inviting you to join team {team} under organization {org}',
'actions': [
{
'title': 'Join team',
'kind': 'primary',
'handler': function(notification) {
window.location = '/confirminvite?code=' + notification.metadata['code'];
}
},
{
'title': 'Decline',
'kind': 'default',
'handler': function(notification) {
ApiService.declineOrganizationTeamInvite(null, {'code': notification.metadata['code']}).then(function() {
notificationService.update();
});
}
}
]
},
'password_required': { 'password_required': {
'level': 'error', 'level': 'error',
'message': 'In order to begin pushing and pulling repositories, a password must be set for your account', 'message': 'In order to begin pushing and pulling repositories, a password must be set for your account',
@ -1518,6 +1595,15 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
} }
}; };
notificationService.getActions = function(notification) {
var kindInfo = notificationKinds[notification['kind']];
if (!kindInfo) {
return [];
}
return kindInfo['actions'] || [];
};
notificationService.canDismiss = function(notification) { notificationService.canDismiss = function(notification) {
var kindInfo = notificationKinds[notification['kind']]; var kindInfo = notificationKinds[notification['kind']];
if (!kindInfo) { if (!kindInfo) {
@ -1533,10 +1619,10 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
} }
var page = kindInfo['page']; var page = kindInfo['page'];
if (typeof page != 'string') { if (page != null && typeof page != 'string') {
page = page(notification['metadata']); page = page(notification['metadata']);
} }
return page; return page || '';
}; };
notificationService.getMessage = function(notification) { notificationService.getMessage = function(notification) {
@ -2058,7 +2144,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
templateUrl: '/static/partials/plans.html', controller: PlansCtrl}). templateUrl: '/static/partials/plans.html', controller: PlansCtrl}).
when('/security/', {title: 'Security', description: 'Security features used when transmitting and storing data', when('/security/', {title: 'Security', description: 'Security features used when transmitting and storing data',
templateUrl: '/static/partials/security.html'}). templateUrl: '/static/partials/security.html'}).
when('/signin/', {title: 'Sign In', description: 'Sign into ' + title, templateUrl: '/static/partials/signin.html'}). when('/signin/', {title: 'Sign In', description: 'Sign into ' + title, templateUrl: '/static/partials/signin.html', controller: SignInCtrl, reloadOnSearch: false}).
when('/new/', {title: 'Create new repository', description: 'Create a new public or private docker repository, optionally constructing from a dockerfile', when('/new/', {title: 'Create new repository', description: 'Create a new public or private docker repository, optionally constructing from a dockerfile',
templateUrl: '/static/partials/new-repo.html', controller: NewRepoCtrl}). templateUrl: '/static/partials/new-repo.html', controller: NewRepoCtrl}).
when('/organizations/', {title: 'Organizations', description: 'Private docker repository hosting for businesses and organizations', when('/organizations/', {title: 'Organizations', description: 'Private docker repository hosting for businesses and organizations',
@ -2079,6 +2165,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
when('/tour/features', {title: title + ' Features', templateUrl: '/static/partials/tour.html', controller: TourCtrl}). when('/tour/features', {title: title + ' Features', templateUrl: '/static/partials/tour.html', controller: TourCtrl}).
when('/tour/enterprise', {title: 'Enterprise Edition', templateUrl: '/static/partials/tour.html', controller: TourCtrl}). when('/tour/enterprise', {title: 'Enterprise Edition', templateUrl: '/static/partials/tour.html', controller: TourCtrl}).
when('/confirminvite', {title: 'Confirm Invite', templateUrl: '/static/partials/confirm-invite.html', controller: ConfirmInviteCtrl, reloadOnSearch: false}).
when('/', {title: 'Hosted Private Docker Registry', templateUrl: '/static/partials/landing.html', controller: LandingCtrl, when('/', {title: 'Hosted Private Docker Registry', templateUrl: '/static/partials/landing.html', controller: LandingCtrl,
pageClass: 'landing-page'}). pageClass: 'landing-page'}).
otherwise({redirectTo: '/'}); otherwise({redirectTo: '/'});
@ -2167,6 +2255,19 @@ quayApp.directive('quayShow', function($animate, Features, Config) {
}); });
quayApp.directive('ngIfMedia', function ($animate) {
return {
transclude: 'element',
priority: 600,
terminal: true,
restrict: 'A',
link: buildConditionalLinker($animate, 'ngIfMedia', function(value) {
return window.matchMedia(value).matches;
})
};
});
quayApp.directive('quaySection', function($animate, $location, $rootScope) { quayApp.directive('quaySection', function($animate, $location, $rootScope) {
return { return {
priority: 590, priority: 590,
@ -2300,7 +2401,9 @@ quayApp.directive('entityReference', function () {
restrict: 'C', restrict: 'C',
scope: { scope: {
'entity': '=entity', 'entity': '=entity',
'namespace': '=namespace' 'namespace': '=namespace',
'showGravatar': '@showGravatar',
'gravatarSize': '@gravatarSize'
}, },
controller: function($scope, $element, UserService, UtilService) { controller: function($scope, $element, UserService, UtilService) {
$scope.getIsAdmin = function(namespace) { $scope.getIsAdmin = function(namespace) {
@ -2437,6 +2540,36 @@ quayApp.directive('repoBreadcrumb', function () {
return directiveDefinitionObject; return directiveDefinitionObject;
}); });
quayApp.directive('focusablePopoverContent', ['$timeout', '$popover', function ($timeout, $popover) {
return {
restrict: "A",
link: function (scope, element, attrs) {
$body = $('body');
var hide = function() {
$body.off('click');
scope.$apply(function() {
scope.$hide();
});
};
scope.$on('$destroy', function() {
$body.off('click');
});
$timeout(function() {
$body.on('click', function(evt) {
var target = evt.target;
var isPanelMember = $(element).has(target).length > 0 || target == element;
if (!isPanelMember) {
hide();
}
});
$(element).find('input').focus();
}, 100);
}
};
}]);
quayApp.directive('repoCircle', function () { quayApp.directive('repoCircle', function () {
var directiveDefinitionObject = { var directiveDefinitionObject = {
@ -2495,22 +2628,34 @@ quayApp.directive('userSetup', function () {
restrict: 'C', restrict: 'C',
scope: { scope: {
'redirectUrl': '=redirectUrl', 'redirectUrl': '=redirectUrl',
'inviteCode': '=inviteCode',
'signInStarted': '&signInStarted', 'signInStarted': '&signInStarted',
'signedIn': '&signedIn' 'signedIn': '&signedIn',
'userRegistered': '&userRegistered'
}, },
controller: function($scope, $location, $timeout, ApiService, KeyService, UserService) { controller: function($scope, $location, $timeout, ApiService, KeyService, UserService) {
$scope.sendRecovery = function() { $scope.sendRecovery = function() {
$scope.sendingRecovery = true;
ApiService.requestRecoveryEmail($scope.recovery).then(function() { ApiService.requestRecoveryEmail($scope.recovery).then(function() {
$scope.invalidRecovery = false; $scope.invalidRecovery = false;
$scope.errorMessage = ''; $scope.errorMessage = '';
$scope.sent = true; $scope.sent = true;
$scope.sendingRecovery = false;
}, function(result) { }, function(result) {
$scope.invalidRecovery = true; $scope.invalidRecovery = true;
$scope.errorMessage = result.data; $scope.errorMessage = result.data;
$scope.sent = false; $scope.sent = false;
$scope.sendingRecovery = false;
}); });
}; };
$scope.handleUserRegistered = function(username) {
$scope.userRegistered({'username': username});
};
$scope.hasSignedIn = function() { $scope.hasSignedIn = function() {
return UserService.hasEverLoggedIn(); return UserService.hasEverLoggedIn();
}; };
@ -2534,6 +2679,7 @@ quayApp.directive('externalLoginButton', function () {
'action': '@action' 'action': '@action'
}, },
controller: function($scope, $timeout, $interval, ApiService, KeyService, CookieService, Features, Config) { controller: function($scope, $timeout, $interval, ApiService, KeyService, CookieService, Features, Config) {
$scope.signingIn = false;
$scope.startSignin = function(service) { $scope.startSignin = function(service) {
$scope.signInStarted({'service': service}); $scope.signInStarted({'service': service});
@ -2545,6 +2691,7 @@ quayApp.directive('externalLoginButton', function () {
// Needed to ensure that UI work done by the started callback is finished before the location // Needed to ensure that UI work done by the started callback is finished before the location
// changes. // changes.
$scope.signingIn = true;
$timeout(function() { $timeout(function() {
document.location = url; document.location = url;
}, 250); }, 250);
@ -2570,8 +2717,10 @@ quayApp.directive('signinForm', function () {
controller: function($scope, $location, $timeout, $interval, ApiService, KeyService, UserService, CookieService, Features, Config) { controller: function($scope, $location, $timeout, $interval, ApiService, KeyService, UserService, CookieService, Features, Config) {
$scope.tryAgainSoon = 0; $scope.tryAgainSoon = 0;
$scope.tryAgainInterval = null; $scope.tryAgainInterval = null;
$scope.signingIn = false;
$scope.markStarted = function() { $scope.markStarted = function() {
$scope.signingIn = true;
if ($scope.signInStarted != null) { if ($scope.signInStarted != null) {
$scope.signInStarted(); $scope.signInStarted();
} }
@ -2602,25 +2751,30 @@ quayApp.directive('signinForm', function () {
$scope.cancelInterval(); $scope.cancelInterval();
ApiService.signinUser($scope.user).then(function() { ApiService.signinUser($scope.user).then(function() {
$scope.signingIn = false;
$scope.needsEmailVerification = false; $scope.needsEmailVerification = false;
$scope.invalidCredentials = false; $scope.invalidCredentials = false;
if ($scope.signedIn != null) { if ($scope.signedIn != null) {
$scope.signedIn(); $scope.signedIn();
} }
// Load the newly created user.
UserService.load(); UserService.load();
// Redirect to the specified page or the landing page // Redirect to the specified page or the landing page
// Note: The timeout of 500ms is needed to ensure dialogs containing sign in // Note: The timeout of 500ms is needed to ensure dialogs containing sign in
// forms get removed before the location changes. // forms get removed before the location changes.
$timeout(function() { $timeout(function() {
if ($scope.redirectUrl == $location.path()) { var redirectUrl = $scope.redirectUrl;
return; if (redirectUrl == $location.path() || redirectUrl == null) {
} return;
$location.path($scope.redirectUrl ? $scope.redirectUrl : '/'); }
window.location = (redirectUrl ? redirectUrl : '/');
}, 500); }, 500);
}, function(result) { }, function(result) {
$scope.signingIn = false;
if (result.status == 429 /* try again later */) { if (result.status == 429 /* try again later */) {
$scope.needsEmailVerification = false; $scope.needsEmailVerification = false;
$scope.invalidCredentials = false; $scope.invalidCredentials = false;
@ -2654,25 +2808,37 @@ quayApp.directive('signupForm', function () {
transclude: true, transclude: true,
restrict: 'C', restrict: 'C',
scope: { scope: {
'inviteCode': '=inviteCode',
'userRegistered': '&userRegistered'
}, },
controller: function($scope, $location, $timeout, ApiService, KeyService, UserService, Config, UIService) { controller: function($scope, $location, $timeout, ApiService, KeyService, UserService, Config, UIService) {
$('.form-signup').popover(); $('.form-signup').popover();
$scope.awaitingConfirmation = false; $scope.awaitingConfirmation = false;
$scope.registering = false; $scope.registering = false;
$scope.register = function() { $scope.register = function() {
UIService.hidePopover('#signupButton'); UIService.hidePopover('#signupButton');
$scope.registering = true; $scope.registering = true;
ApiService.createNewUser($scope.newUser).then(function() { if ($scope.inviteCode) {
$scope.newUser['invite_code'] = $scope.inviteCode;
}
ApiService.createNewUser($scope.newUser).then(function(resp) {
$scope.registering = false; $scope.registering = false;
$scope.awaitingConfirmation = true; $scope.awaitingConfirmation = !!resp['awaiting_verification'];
if (Config.MIXPANEL_KEY) { if (Config.MIXPANEL_KEY) {
mixpanel.alias($scope.newUser.username); mixpanel.alias($scope.newUser.username);
} }
$scope.userRegistered({'username': $scope.newUser.username});
if (!$scope.awaitingConfirmation) {
document.location = '/';
}
}, function(result) { }, function(result) {
$scope.registering = false; $scope.registering = false;
UIService.showFormError('#signupButton', result); UIService.showFormError('#signupButton', result);
@ -2790,7 +2956,7 @@ quayApp.directive('dockerAuthDialog', function (Config) {
$scope.downloadCfg = function() { $scope.downloadCfg = function() {
var auth = $.base64.encode($scope.username + ":" + $scope.token); var auth = $.base64.encode($scope.username + ":" + $scope.token);
config = {} config = {}
config[Config.getUrl('/v1/')] = { config[Config['SERVER_HOSTNAME']] = {
"auth": auth, "auth": auth,
"email": "" "email": ""
}; };
@ -2917,9 +3083,10 @@ quayApp.directive('logsView', function () {
'user': '=user', 'user': '=user',
'makevisible': '=makevisible', 'makevisible': '=makevisible',
'repository': '=repository', 'repository': '=repository',
'performer': '=performer' 'performer': '=performer',
'allLogs': '@allLogs'
}, },
controller: function($scope, $element, $sce, Restangular, ApiService, TriggerDescriptionBuilder, controller: function($scope, $element, $sce, Restangular, ApiService, TriggerService,
StringBuilderService, ExternalNotificationData) { StringBuilderService, ExternalNotificationData) {
$scope.loading = true; $scope.loading = true;
$scope.logs = null; $scope.logs = null;
@ -2984,7 +3151,7 @@ quayApp.directive('logsView', function () {
'set_repo_description': 'Change description for repository {repo}: {description}', 'set_repo_description': 'Change description for repository {repo}: {description}',
'build_dockerfile': function(metadata) { 'build_dockerfile': function(metadata) {
if (metadata.trigger_id) { if (metadata.trigger_id) {
var triggerDescription = TriggerDescriptionBuilder.getDescription( var triggerDescription = TriggerService.getDescription(
metadata['service'], metadata['config']); metadata['service'], metadata['config']);
return 'Build image from Dockerfile for repository {repo} triggered by ' + triggerDescription; return 'Build image from Dockerfile for repository {repo} triggered by ' + triggerDescription;
} }
@ -2994,6 +3161,24 @@ quayApp.directive('logsView', function () {
'org_delete_team': 'Delete team: {team}', 'org_delete_team': 'Delete team: {team}',
'org_add_team_member': 'Add member {member} to team {team}', 'org_add_team_member': 'Add member {member} to team {team}',
'org_remove_team_member': 'Remove member {member} from team {team}', 'org_remove_team_member': 'Remove member {member} from team {team}',
'org_invite_team_member': function(metadata) {
if (metadata.user) {
return 'Invite {user} to team {team}';
} else {
return 'Invite {email} to team {team}';
}
},
'org_delete_team_member_invite': function(metadata) {
if (metadata.user) {
return 'Rescind invite of {user} to team {team}';
} else {
return 'Rescind invite of {email} to team {team}';
}
},
'org_team_member_invite_accepted': 'User {member}, invited by {inviter}, joined team {team}',
'org_team_member_invite_declined': 'User {member}, invited by {inviter}, declined to join team {team}',
'org_set_team_description': 'Change description of team {team}: {description}', 'org_set_team_description': 'Change description of team {team}: {description}',
'org_set_team_role': 'Change permission of team {team} to {role}', 'org_set_team_role': 'Change permission of team {team} to {role}',
'create_prototype_permission': function(metadata) { 'create_prototype_permission': function(metadata) {
@ -3018,12 +3203,12 @@ quayApp.directive('logsView', function () {
} }
}, },
'setup_repo_trigger': function(metadata) { 'setup_repo_trigger': function(metadata) {
var triggerDescription = TriggerDescriptionBuilder.getDescription( var triggerDescription = TriggerService.getDescription(
metadata['service'], metadata['config']); metadata['service'], metadata['config']);
return 'Setup build trigger - ' + triggerDescription; return 'Setup build trigger - ' + triggerDescription;
}, },
'delete_repo_trigger': function(metadata) { 'delete_repo_trigger': function(metadata) {
var triggerDescription = TriggerDescriptionBuilder.getDescription( var triggerDescription = TriggerService.getDescription(
metadata['service'], metadata['config']); metadata['service'], metadata['config']);
return 'Delete build trigger - ' + triggerDescription; return 'Delete build trigger - ' + triggerDescription;
}, },
@ -3074,7 +3259,11 @@ quayApp.directive('logsView', function () {
'org_create_team': 'Create team', 'org_create_team': 'Create team',
'org_delete_team': 'Delete team', 'org_delete_team': 'Delete team',
'org_add_team_member': 'Add team member', 'org_add_team_member': 'Add team member',
'org_invite_team_member': 'Invite team member',
'org_delete_team_member_invite': 'Rescind team member invitation',
'org_remove_team_member': 'Remove team member', 'org_remove_team_member': 'Remove team member',
'org_team_member_invite_accepted': 'Team invite accepted',
'org_team_member_invite_declined': 'Team invite declined',
'org_set_team_description': 'Change team description', 'org_set_team_description': 'Change team description',
'org_set_team_role': 'Change team permission', 'org_set_team_role': 'Change team permission',
'create_prototype_permission': 'Create default permission', 'create_prototype_permission': 'Create default permission',
@ -3107,7 +3296,7 @@ quayApp.directive('logsView', function () {
var hasValidUser = !!$scope.user; var hasValidUser = !!$scope.user;
var hasValidOrg = !!$scope.organization; var hasValidOrg = !!$scope.organization;
var hasValidRepo = $scope.repository && $scope.repository.namespace; var hasValidRepo = $scope.repository && $scope.repository.namespace;
var isValid = hasValidUser || hasValidOrg || hasValidRepo; var isValid = hasValidUser || hasValidOrg || hasValidRepo || $scope.allLogs;
if (!$scope.makevisible || !isValid) { if (!$scope.makevisible || !isValid) {
return; return;
@ -3130,11 +3319,15 @@ quayApp.directive('logsView', function () {
url = getRestUrl('repository', $scope.repository.namespace, $scope.repository.name, 'logs'); url = getRestUrl('repository', $scope.repository.namespace, $scope.repository.name, 'logs');
} }
if ($scope.allLogs) {
url = getRestUrl('superuser', 'logs')
}
url += '?starttime=' + encodeURIComponent(getDateString($scope.logStartDate)); url += '?starttime=' + encodeURIComponent(getDateString($scope.logStartDate));
url += '&endtime=' + encodeURIComponent(getDateString($scope.logEndDate)); url += '&endtime=' + encodeURIComponent(getDateString($scope.logEndDate));
if ($scope.performer) { if ($scope.performer) {
url += '&performer=' + encodeURIComponent($scope.performer.username); url += '&performer=' + encodeURIComponent($scope.performer.name);
} }
var loadLogs = Restangular.one(url); var loadLogs = Restangular.one(url);
@ -3783,7 +3976,9 @@ quayApp.directive('entitySearch', function () {
'allowedEntities': '=allowedEntities', 'allowedEntities': '=allowedEntities',
'currentEntity': '=currentEntity', 'currentEntity': '=currentEntity',
'entitySelected': '&entitySelected', 'entitySelected': '&entitySelected',
'emailSelected': '&emailSelected',
// When set to true, the contents of the control will be cleared as soon // When set to true, the contents of the control will be cleared as soon
// as an entity is selected. // as an entity is selected.
@ -3791,8 +3986,15 @@ quayApp.directive('entitySearch', function () {
// Set this property to immediately clear the contents of the control. // Set this property to immediately clear the contents of the control.
'clearValue': '=clearValue', 'clearValue': '=clearValue',
// Whether e-mail addresses are allowed.
'allowEmails': '=allowEmails',
'emailMessage': '@emailMessage',
// True if the menu should pull right.
'pullRight': '@pullRight'
}, },
controller: function($rootScope, $scope, $element, Restangular, UserService, ApiService, Config) { controller: function($rootScope, $scope, $element, Restangular, UserService, ApiService, UtilService, Config) {
$scope.lazyLoading = true; $scope.lazyLoading = true;
$scope.teams = null; $scope.teams = null;
@ -3989,8 +4191,12 @@ quayApp.directive('entitySearch', function () {
return null; return null;
} }
if (val.indexOf('@') > 0) { if (UtilService.isEmailAddress(val)) {
return '<div class="tt-empty">A ' + Config.REGISTRY_TITLE_SHORT + ' username (not an e-mail address) must be specified</div>'; if ($scope.allowEmails) {
return '<div class="tt-message">' + $scope.emailMessage + '</div>';
} else {
return '<div class="tt-empty">A ' + Config.REGISTRY_TITLE_SHORT + ' username (not an e-mail address) must be specified</div>';
}
} }
var classes = []; var classes = [];
@ -4046,6 +4252,16 @@ quayApp.directive('entitySearch', function () {
}} }}
}); });
$(input).on('keypress', function(e) {
var val = $(input).val();
var code = e.keyCode || e.which;
if (code == 13 && $scope.allowEmails && UtilService.isEmailAddress(val)) {
$scope.$apply(function() {
$scope.emailSelected({'email': val});
});
}
});
$(input).on('input', function(e) { $(input).on('input', function(e) {
$scope.$apply(function() { $scope.$apply(function() {
$scope.clearEntityInternal(); $scope.clearEntityInternal();
@ -4694,6 +4910,66 @@ quayApp.directive('dropdownSelectMenu', function () {
}); });
quayApp.directive('manualTriggerBuildDialog', function () {
var directiveDefinitionObject = {
templateUrl: '/static/directives/manual-trigger-build-dialog.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'repository': '=repository',
'counter': '=counter',
'trigger': '=trigger',
'startBuild': '&startBuild'
},
controller: function($scope, $element, ApiService, TriggerService) {
$scope.parameters = {};
$scope.fieldOptions = {};
$scope.startTrigger = function() {
$('#startTriggerDialog').modal('hide');
$scope.startBuild({
'trigger': $scope.trigger,
'parameters': $scope.parameters
});
};
$scope.show = function() {
$scope.parameters = {};
$scope.fieldOptions = {};
var parameters = TriggerService.getRunParameters($scope.trigger.service);
for (var i = 0; i < parameters.length; ++i) {
var parameter = parameters[i];
if (parameter['type'] == 'option') {
// Load the values for this parameter.
var params = {
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
'trigger_uuid': $scope.trigger.id,
'field_name': parameter['name']
};
ApiService.listTriggerFieldValues(null, params).then(function(resp) {
$scope.fieldOptions[parameter['name']] = resp['values'];
});
}
}
$scope.runParameters = parameters;
$('#startTriggerDialog').modal('show');
};
$scope.$watch('counter', function(counter) {
if (counter) {
$scope.show();
}
});
}
};
return directiveDefinitionObject;
});
quayApp.directive('setupTriggerDialog', function () { quayApp.directive('setupTriggerDialog', function () {
var directiveDefinitionObject = { var directiveDefinitionObject = {
templateUrl: '/static/directives/setup-trigger-dialog.html', templateUrl: '/static/directives/setup-trigger-dialog.html',
@ -5522,6 +5798,10 @@ quayApp.directive('notificationView', function () {
$scope.getClass = function(notification) { $scope.getClass = function(notification) {
return NotificationService.getClass(notification); return NotificationService.getClass(notification);
}; };
$scope.getActions = function(notification) {
return NotificationService.getActions(notification);
};
} }
}; };
return directiveDefinitionObject; return directiveDefinitionObject;
@ -5737,7 +6017,7 @@ quayApp.directive('dockerfileBuildForm', function () {
var data = { var data = {
'mimeType': mimeType 'mimeType': mimeType
}; };
var getUploadUrl = ApiService.getFiledropUrl(data).then(function(resp) { var getUploadUrl = ApiService.getFiledropUrl(data).then(function(resp) {
conductUpload(file, resp.url, resp.file_id, mimeType); conductUpload(file, resp.url, resp.file_id, mimeType);
}, function() { }, function() {
@ -5890,7 +6170,7 @@ quayApp.directive('tagSpecificImagesView', function () {
} }
var currentTag = $scope.repository.tags[$scope.tag]; var currentTag = $scope.repository.tags[$scope.tag];
if (image.dbid == currentTag.dbid) { if (image.id == currentTag.image_id) {
classes += 'tag-image '; classes += 'tag-image ';
} }
@ -5900,15 +6180,15 @@ quayApp.directive('tagSpecificImagesView', function () {
var forAllTagImages = function(tag, callback, opt_cutoff) { var forAllTagImages = function(tag, callback, opt_cutoff) {
if (!tag) { return; } if (!tag) { return; }
if (!$scope.imageByDBID) { if (!$scope.imageByDockerId) {
$scope.imageByDBID = []; $scope.imageByDockerId = [];
for (var i = 0; i < $scope.images.length; ++i) { for (var i = 0; i < $scope.images.length; ++i) {
var currentImage = $scope.images[i]; var currentImage = $scope.images[i];
$scope.imageByDBID[currentImage.dbid] = currentImage; $scope.imageByDockerId[currentImage.id] = currentImage;
} }
} }
var tag_image = $scope.imageByDBID[tag.dbid]; var tag_image = $scope.imageByDockerId[tag.image_id];
if (!tag_image) { if (!tag_image) {
return; return;
} }
@ -5917,7 +6197,7 @@ quayApp.directive('tagSpecificImagesView', function () {
var ancestors = tag_image.ancestors.split('/').reverse(); var ancestors = tag_image.ancestors.split('/').reverse();
for (var i = 0; i < ancestors.length; ++i) { for (var i = 0; i < ancestors.length; ++i) {
var image = $scope.imageByDBID[ancestors[i]]; var image = $scope.imageByDockerId[ancestors[i]];
if (image) { if (image) {
if (image == opt_cutoff) { if (image == opt_cutoff) {
return; return;
@ -5943,7 +6223,7 @@ quayApp.directive('tagSpecificImagesView', function () {
var getIdsForTag = function(currentTag) { var getIdsForTag = function(currentTag) {
var ids = {}; var ids = {};
forAllTagImages(currentTag, function(image) { forAllTagImages(currentTag, function(image) {
ids[image.dbid] = true; ids[image.id] = true;
}, $scope.imageCutoff); }, $scope.imageCutoff);
return ids; return ids;
}; };
@ -5953,8 +6233,8 @@ quayApp.directive('tagSpecificImagesView', function () {
for (var currentTagName in $scope.repository.tags) { for (var currentTagName in $scope.repository.tags) {
var currentTag = $scope.repository.tags[currentTagName]; var currentTag = $scope.repository.tags[currentTagName];
if (currentTag != tag) { if (currentTag != tag) {
for (var dbid in getIdsForTag(currentTag)) { for (var id in getIdsForTag(currentTag)) {
delete toDelete[dbid]; delete toDelete[id];
} }
} }
} }
@ -5963,7 +6243,7 @@ quayApp.directive('tagSpecificImagesView', function () {
var images = []; var images = [];
for (var i = 0; i < $scope.images.length; ++i) { for (var i = 0; i < $scope.images.length; ++i) {
var image = $scope.images[i]; var image = $scope.images[i];
if (toDelete[image.dbid]) { if (toDelete[image.id]) {
images.push(image); images.push(image);
} }
} }
@ -5974,7 +6254,7 @@ quayApp.directive('tagSpecificImagesView', function () {
return result; return result;
} }
return b.dbid - a.dbid; return b.sort_index - a.sort_index;
}); });
$scope.tagSpecificImages = images; $scope.tagSpecificImages = images;

View file

@ -1,3 +1,7 @@
function SignInCtrl($scope, $location) {
$scope.redirectUrl = '/';
}
function GuideCtrl() { function GuideCtrl() {
} }
@ -536,7 +540,7 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
}; };
$scope.findImageForTag = function(tag) { $scope.findImageForTag = function(tag) {
return tag && $scope.imageByDBID && $scope.imageByDBID[tag.dbid]; return tag && $scope.imageByDockerId && $scope.imageByDockerId[tag.image_id];
}; };
$scope.createOrMoveTag = function(image, tagName, opt_invalid) { $scope.createOrMoveTag = function(image, tagName, opt_invalid) {
@ -608,6 +612,8 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
}; };
$scope.setImage = function(imageId, opt_updateURL) { $scope.setImage = function(imageId, opt_updateURL) {
if (!$scope.images) { return; }
var image = null; var image = null;
for (var i = 0; i < $scope.images.length; ++i) { for (var i = 0; i < $scope.images.length; ++i) {
var currentImage = $scope.images[i]; var currentImage = $scope.images[i];
@ -728,9 +734,9 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
}; };
var forAllTagImages = function(tag, callback) { var forAllTagImages = function(tag, callback) {
if (!tag || !$scope.imageByDBID) { return; } if (!tag || !$scope.imageByDockerId) { return; }
var tag_image = $scope.imageByDBID[tag.dbid]; var tag_image = $scope.imageByDockerId[tag.image_id];
if (!tag_image) { return; } if (!tag_image) { return; }
// Callback the tag's image itself. // Callback the tag's image itself.
@ -740,7 +746,7 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
if (!tag_image.ancestors) { return; } if (!tag_image.ancestors) { return; }
var ancestors = tag_image.ancestors.split('/'); var ancestors = tag_image.ancestors.split('/');
for (var i = 0; i < ancestors.length; ++i) { for (var i = 0; i < ancestors.length; ++i) {
var image = $scope.imageByDBID[ancestors[i]]; var image = $scope.imageByDockerId[ancestors[i]];
if (image) { if (image) {
callback(image); callback(image);
} }
@ -829,10 +835,10 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
$scope.specificImages = []; $scope.specificImages = [];
// Build various images for quick lookup of images. // Build various images for quick lookup of images.
$scope.imageByDBID = {}; $scope.imageByDockerId = {};
for (var i = 0; i < $scope.images.length; ++i) { for (var i = 0; i < $scope.images.length; ++i) {
var currentImage = $scope.images[i]; var currentImage = $scope.images[i];
$scope.imageByDBID[currentImage.dbid] = currentImage; $scope.imageByDockerId[currentImage.id] = currentImage;
} }
// Dispose of any existing tree. // Dispose of any existing tree.
@ -1275,7 +1281,9 @@ function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope
fetchRepository(); fetchRepository();
} }
function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, $routeParams, $rootScope, $location, UserService, Config, Features, ExternalNotificationData) { function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, TriggerService, $routeParams,
$rootScope, $location, UserService, Config, Features, ExternalNotificationData) {
var namespace = $routeParams.namespace; var namespace = $routeParams.namespace;
var name = $routeParams.name; var name = $routeParams.name;
@ -1580,14 +1588,22 @@ function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, $routeParams
$scope.deleteTrigger(trigger); $scope.deleteTrigger(trigger);
}; };
$scope.startTrigger = function(trigger) { $scope.showManualBuildDialog = 0;
$scope.startTrigger = function(trigger, opt_custom) {
var parameters = TriggerService.getRunParameters(trigger.service);
if (parameters.length && !opt_custom) {
$scope.currentStartTrigger = trigger;
$scope.showManualBuildDialog++;
return;
}
var params = { var params = {
'repository': namespace + '/' + name, 'repository': namespace + '/' + name,
'trigger_uuid': trigger.id 'trigger_uuid': trigger.id
}; };
ApiService.manuallyStartBuildTrigger(null, params).then(function(resp) { ApiService.manuallyStartBuildTrigger(opt_custom || {}, params).then(function(resp) {
window.console.log(resp);
var url = '/repository/' + namespace + '/' + name + '/build?current=' + resp['id']; var url = '/repository/' + namespace + '/' + name + '/build?current=' + resp['id'];
document.location = url; document.location = url;
}, ApiService.errorDisplay('Could not start build')); }, ApiService.errorDisplay('Could not start build'));
@ -2326,29 +2342,92 @@ function OrgAdminCtrl($rootScope, $scope, $timeout, Restangular, $routeParams, U
loadOrganization(); loadOrganization();
} }
function TeamViewCtrl($rootScope, $scope, Restangular, ApiService, $routeParams) { function TeamViewCtrl($rootScope, $scope, $timeout, Features, Restangular, ApiService, $routeParams) {
var teamname = $routeParams.teamname; var teamname = $routeParams.teamname;
var orgname = $routeParams.orgname; var orgname = $routeParams.orgname;
$scope.orgname = orgname; $scope.orgname = orgname;
$scope.teamname = teamname; $scope.teamname = teamname;
$scope.addingMember = false;
$scope.memberMap = null;
$scope.allowEmail = Features.MAILING;
$rootScope.title = 'Loading...'; $rootScope.title = 'Loading...';
$scope.addNewMember = function(member) { $scope.filterFunction = function(invited, robots) {
if (!member || $scope.members[member.name]) { return; } return function(item) {
// Note: The !! is needed because is_robot will be undefined for invites.
var robot_check = (!!item.is_robot == robots);
return robot_check && item.invited == invited;
};
};
$scope.inviteEmail = function(email) {
if (!email || $scope.memberMap[email]) { return; }
$scope.addingMember = true;
var params = {
'orgname': orgname,
'teamname': teamname,
'email': email
};
var errorHandler = ApiService.errorDisplay('Cannot invite team member', function() {
$scope.addingMember = false;
});
ApiService.inviteTeamMemberEmail(null, params).then(function(resp) {
$scope.members.push(resp);
$scope.memberMap[resp.email] = resp;
$scope.addingMember = false;
}, errorHandler);
};
$scope.addNewMember = function(member) {
if (!member || $scope.memberMap[member.name]) { return; }
var params = { var params = {
'orgname': orgname, 'orgname': orgname,
'teamname': teamname, 'teamname': teamname,
'membername': member.name 'membername': member.name
}; };
ApiService.updateOrganizationTeamMember(null, params).then(function(resp) { var errorHandler = ApiService.errorDisplay('Cannot add team member', function() {
$scope.members[member.name] = resp; $scope.addingMember = false;
}, function() {
$('#cannotChangeMembersModal').modal({});
}); });
$scope.addingMember = true;
ApiService.updateOrganizationTeamMember(null, params).then(function(resp) {
$scope.members.push(resp);
$scope.memberMap[resp.name] = resp;
$scope.addingMember = false;
}, errorHandler);
};
$scope.revokeInvite = function(inviteInfo) {
if (inviteInfo.kind == 'invite') {
// E-mail invite.
$scope.revokeEmailInvite(inviteInfo.email);
} else {
// User invite.
$scope.removeMember(inviteInfo.name);
}
};
$scope.revokeEmailInvite = function(email) {
var params = {
'orgname': orgname,
'teamname': teamname,
'email': email
};
ApiService.deleteTeamMemberEmailInvite(null, params).then(function(resp) {
if (!$scope.memberMap[email]) { return; }
var index = $.inArray($scope.memberMap[email], $scope.members);
$scope.members.splice(index, 1);
delete $scope.memberMap[email];
}, ApiService.errorDisplay('Cannot revoke team invite'));
}; };
$scope.removeMember = function(username) { $scope.removeMember = function(username) {
@ -2359,10 +2438,11 @@ function TeamViewCtrl($rootScope, $scope, Restangular, ApiService, $routeParams)
}; };
ApiService.deleteOrganizationTeamMember(null, params).then(function(resp) { ApiService.deleteOrganizationTeamMember(null, params).then(function(resp) {
delete $scope.members[username]; if (!$scope.memberMap[username]) { return; }
}, function() { var index = $.inArray($scope.memberMap[username], $scope.members);
$('#cannotChangeMembersModal').modal({}); $scope.members.splice(index, 1);
}); delete $scope.memberMap[username];
}, ApiService.errorDisplay('Cannot remove team member'));
}; };
$scope.updateForDescription = function(content) { $scope.updateForDescription = function(content) {
@ -2394,7 +2474,8 @@ function TeamViewCtrl($rootScope, $scope, Restangular, ApiService, $routeParams)
var loadMembers = function() { var loadMembers = function() {
var params = { var params = {
'orgname': orgname, 'orgname': orgname,
'teamname': teamname 'teamname': teamname,
'includePending': true
}; };
$scope.membersResource = ApiService.getOrganizationTeamMembersAsResource(params).get(function(resp) { $scope.membersResource = ApiService.getOrganizationTeamMembersAsResource(params).get(function(resp) {
@ -2406,6 +2487,12 @@ function TeamViewCtrl($rootScope, $scope, Restangular, ApiService, $routeParams)
'html': true 'html': true
}); });
$scope.memberMap = {};
for (var i = 0; i < $scope.members.length; ++i) {
var current = $scope.members[i];
$scope.memberMap[current.name || current.email] = current;
}
return resp.members; return resp.members;
}); });
}; };
@ -2533,7 +2620,7 @@ function OrgMemberLogsCtrl($scope, $routeParams, $rootScope, $timeout, Restangul
$scope.memberResource = ApiService.getOrganizationMemberAsResource(params).get(function(resp) { $scope.memberResource = ApiService.getOrganizationMemberAsResource(params).get(function(resp) {
$scope.memberInfo = resp.member; $scope.memberInfo = resp.member;
$rootScope.title = 'Logs for ' + $scope.memberInfo.username + ' (' + $scope.orgname + ')'; $rootScope.title = 'Logs for ' + $scope.memberInfo.name + ' (' + $scope.orgname + ')';
$rootScope.description = 'Shows all the actions of ' + $scope.memberInfo.username + $rootScope.description = 'Shows all the actions of ' + $scope.memberInfo.username +
' under organization ' + $scope.orgname; ' under organization ' + $scope.orgname;
@ -2656,6 +2743,14 @@ function SuperUserAdminCtrl($scope, ApiService, Features, UserService) {
// Monitor any user changes and place the current user into the scope. // Monitor any user changes and place the current user into the scope.
UserService.updateUserIn($scope); UserService.updateUserIn($scope);
$scope.logsCounter = 0;
$scope.newUser = {};
$scope.createdUsers = [];
$scope.loadLogs = function() {
$scope.logsCounter++;
};
$scope.loadUsers = function() { $scope.loadUsers = function() {
if ($scope.users) { if ($scope.users) {
return; return;
@ -2667,6 +2762,7 @@ function SuperUserAdminCtrl($scope, ApiService, Features, UserService) {
$scope.loadUsersInternal = function() { $scope.loadUsersInternal = function() {
ApiService.listAllUsers().then(function(resp) { ApiService.listAllUsers().then(function(resp) {
$scope.users = resp['users']; $scope.users = resp['users'];
$scope.showInterface = true;
}, function(resp) { }, function(resp) {
$scope.users = []; $scope.users = [];
$scope.usersError = resp['data']['message'] || resp['data']['error_description']; $scope.usersError = resp['data']['message'] || resp['data']['error_description'];
@ -2678,6 +2774,19 @@ function SuperUserAdminCtrl($scope, ApiService, Features, UserService) {
$('#changePasswordModal').modal({}); $('#changePasswordModal').modal({});
}; };
$scope.createUser = function() {
$scope.creatingUser = true;
var errorHandler = ApiService.errorDisplay('Cannot create user', function() {
$scope.creatingUser = false;
});
ApiService.createInstallUser($scope.newUser, null).then(function(resp) {
$scope.creatingUser = false;
$scope.newUser = {};
$scope.createdUsers.push(resp);
}, errorHandler)
};
$scope.showDeleteUser = function(user) { $scope.showDeleteUser = function(user) {
if (user.username == UserService.currentUser().username) { if (user.username == UserService.currentUser().username) {
bootbox.dialog({ bootbox.dialog({
@ -2725,9 +2834,58 @@ function SuperUserAdminCtrl($scope, ApiService, Features, UserService) {
}, ApiService.errorDisplay('Cannot delete user')); }, ApiService.errorDisplay('Cannot delete user'));
}; };
$scope.sendRecoveryEmail = function(user) {
var params = {
'username': user.username
};
ApiService.sendInstallUserRecoveryEmail(null, params).then(function(resp) {
bootbox.dialog({
"message": "A recovery email has been sent to " + resp['email'],
"title": "Recovery email sent",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
}, ApiService.errorDisplay('Cannot send recovery email'))
};
$scope.loadUsers(); $scope.loadUsers();
} }
function TourCtrl($scope, $location) { function TourCtrl($scope, $location) {
$scope.kind = $location.path().substring('/tour/'.length); $scope.kind = $location.path().substring('/tour/'.length);
} }
function ConfirmInviteCtrl($scope, $location, UserService, ApiService, NotificationService) {
// Monitor any user changes and place the current user into the scope.
$scope.loading = false;
$scope.inviteCode = $location.search()['code'] || '';
UserService.updateUserIn($scope, function(user) {
if (!user.anonymous && !$scope.loading) {
// Make sure to not redirect now that we have logged in. We'll conduct the redirect
// manually.
$scope.redirectUrl = null;
$scope.loading = true;
var params = {
'code': $location.search()['code']
};
ApiService.acceptOrganizationTeamInvite(null, params).then(function(resp) {
NotificationService.update();
$location.path('/organization/' + resp.org + '/teams/' + resp.team);
}, function(resp) {
$scope.loading = false;
$scope.invalid = ApiService.getErrorMessage(resp, 'Invalid confirmation code');
});
}
});
$scope.redirectUrl = window.location.href;
}

View file

@ -262,6 +262,9 @@ ImageHistoryTree.prototype.draw = function(container) {
// Update the dimensions of the tree. // Update the dimensions of the tree.
var dimensions = this.updateDimensions_(); var dimensions = this.updateDimensions_();
if (!dimensions) {
return this;
}
// Populate the tree. // Populate the tree.
this.root_.x0 = dimensions.cw / 2; this.root_.x0 = dimensions.cw / 2;
@ -307,8 +310,8 @@ ImageHistoryTree.prototype.setHighlightedPath_ = function(image) {
this.markPath_(this.currentNode_, false); this.markPath_(this.currentNode_, false);
} }
var imageByDBID = this.imageByDBID_; var imageByDockerId = this.imageByDockerId_;
var currentNode = imageByDBID[image.dbid]; var currentNode = imageByDockerId[image.id];
if (currentNode) { if (currentNode) {
this.markPath_(currentNode, true); this.markPath_(currentNode, true);
this.currentNode_ = currentNode; this.currentNode_ = currentNode;
@ -386,7 +389,7 @@ ImageHistoryTree.prototype.buildRoot_ = function() {
var formatted = {"name": "No images found"}; var formatted = {"name": "No images found"};
// Build a node for each image. // Build a node for each image.
var imageByDBID = {}; var imageByDockerId = {};
for (var i = 0; i < this.images_.length; ++i) { for (var i = 0; i < this.images_.length; ++i) {
var image = this.images_[i]; var image = this.images_[i];
var imageNode = { var imageNode = {
@ -395,9 +398,9 @@ ImageHistoryTree.prototype.buildRoot_ = function() {
"image": image, "image": image,
"tags": image.tags "tags": image.tags
}; };
imageByDBID[image.dbid] = imageNode; imageByDockerId[image.id] = imageNode;
} }
this.imageByDBID_ = imageByDBID; this.imageByDockerId_ = imageByDockerId;
// For each node, attach it to its immediate parent. If there is no immediate parent, // For each node, attach it to its immediate parent. If there is no immediate parent,
// then the node is the root. // then the node is the root.
@ -408,10 +411,10 @@ ImageHistoryTree.prototype.buildRoot_ = function() {
// Skip images that are currently uploading. // Skip images that are currently uploading.
if (image.uploading) { continue; } if (image.uploading) { continue; }
var imageNode = imageByDBID[image.dbid]; var imageNode = imageByDockerId[image.id];
var ancestors = this.getAncestors_(image); var ancestors = this.getAncestors_(image);
var immediateParent = ancestors[ancestors.length - 1] * 1; var immediateParent = ancestors[ancestors.length - 1];
var parent = imageByDBID[immediateParent]; var parent = imageByDockerId[immediateParent];
if (parent) { if (parent) {
// Add a reference to the parent. This makes walking the tree later easier. // Add a reference to the parent. This makes walking the tree later easier.
imageNode.parent = parent; imageNode.parent = parent;
@ -442,7 +445,7 @@ ImageHistoryTree.prototype.buildRoot_ = function() {
// Skip images that are currently uploading. // Skip images that are currently uploading.
if (image.uploading) { continue; } if (image.uploading) { continue; }
var imageNode = imageByDBID[image.dbid]; var imageNode = imageByDockerId[image.id];
maxChildCount = Math.max(maxChildCount, this.determineMaximumChildCount_(imageNode)); maxChildCount = Math.max(maxChildCount, this.determineMaximumChildCount_(imageNode));
} }
@ -573,7 +576,7 @@ ImageHistoryTree.prototype.setTag_ = function(tagName) {
return; return;
} }
var imageByDBID = this.imageByDBID_; var imageByDockerId = this.imageByDockerId_;
// Save the current tag. // Save the current tag.
var previousTagName = this.currentTag_; var previousTagName = this.currentTag_;
@ -596,10 +599,10 @@ ImageHistoryTree.prototype.setTag_ = function(tagName) {
// Skip images that are currently uploading. // Skip images that are currently uploading.
if (image.uploading) { continue; } if (image.uploading) { continue; }
var imageNode = this.imageByDBID_[image.dbid]; var imageNode = this.imageByDockerId_[image.id];
var ancestors = this.getAncestors_(image); var ancestors = this.getAncestors_(image);
var immediateParent = ancestors[ancestors.length - 1] * 1; var immediateParent = ancestors[ancestors.length - 1];
var parent = imageByDBID[immediateParent]; var parent = imageByDockerId[immediateParent];
if (parent && imageNode.highlighted) { if (parent && imageNode.highlighted) {
var arr = parent.children; var arr = parent.children;
if (parent._children) { if (parent._children) {

View file

@ -0,0 +1,15 @@
<div class="confirm-invite">
<div class="container signin-container">
<div class="row">
<div class="col-sm-6 col-sm-offset-3">
<div class="user-setup" ng-show="user.anonymous" redirect-url="redirectUrl"
invite-code="inviteCode">
</div>
<div class="quay-spinner" ng-show="!user.anonymous && loading"></div>
<div class="alert alert-danger" ng-show="!user.anonymous && invalid">
{{ invalid }}
</div>
</div>
</div>
</div>
</div>

View file

@ -1,6 +1,7 @@
<div class="resource-view" resource="memberResource" error-message="'Member not found'"> <div class="resource-view" resource="memberResource" error-message="'Member not found'">
<div class="org-member-logs container"> <div class="org-member-logs container">
<div class="organization-header" organization="organization" clickable="true"></div> <div class="organization-header" organization="organization" clickable="true"></div>
<div class="logs-view" organization="organization" performer="memberInfo" makevisible="organization && memberInfo && ready"></div> <div class="logs-view" organization="organization" performer="memberInfo"
makevisible="organization && memberInfo && ready"></div>
</div> </div>
</div> </div>

View file

@ -19,7 +19,7 @@
<ul class="nav nav-pills nav-stacked"> <ul class="nav nav-pills nav-stacked">
<li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#permissions">Permissions</a></li> <li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#permissions">Permissions</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#trigger" ng-click="loadTriggers()" <li><a href="javascript:void(0)" data-toggle="tab" data-target="#trigger" ng-click="loadTriggers()"
quay-require="['BUILD_SUPPORT']">Build Triggers</a></li> quay-show="Features.BUILD_SUPPORT">Build Triggers</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#badge">Status Badge</a></li> <li><a href="javascript:void(0)" data-toggle="tab" data-target="#badge">Status Badge</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#notification" ng-click="loadNotifications()">Notifications</a></li> <li><a href="javascript:void(0)" data-toggle="tab" data-target="#notification" ng-click="loadNotifications()">Notifications</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#publicprivate">Public/Private</a></li> <li><a href="javascript:void(0)" data-toggle="tab" data-target="#publicprivate">Public/Private</a></li>
@ -226,7 +226,7 @@
</div> </div>
<!-- Triggers tab --> <!-- Triggers tab -->
<div id="trigger" class="tab-pane" quay-require="['BUILD_SUPPORT']"> <div id="trigger" class="tab-pane" quay-show="['BUILD_SUPPORT']">
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading">Build Triggers <div class="panel-heading">Build Triggers
<i class="info-icon fa fa-info-circle" data-placement="left" data-content="Triggers from various services (such as GitHub) which tell the repository to be built and updated."></i> <i class="info-icon fa fa-info-circle" data-placement="left" data-content="Triggers from various services (such as GitHub) which tell the repository to be built and updated."></i>
@ -378,6 +378,12 @@
counter="showNewNotificationCounter" counter="showNewNotificationCounter"
notification-created="handleNotificationCreated(notification)"></div> notification-created="handleNotificationCreated(notification)"></div>
<!-- Manual trigger dialog -->
<div class="manual-trigger-build-dialog" repository="repo"
trigger="currentStartTrigger"
counter="showManualBuildDialog"
start-build="startTrigger(trigger, parameters)"></div>
<!-- Modal message dialog --> <!-- Modal message dialog -->
<div class="modal fade" id="makepublicModal"> <div class="modal fade" id="makepublicModal">
<div class="modal-dialog"> <div class="modal-dialog">

View file

@ -1,7 +1,7 @@
<div class="container signin-container"> <div class="container signin-container">
<div class="row"> <div class="row">
<div class="col-sm-6 col-sm-offset-3"> <div class="col-sm-6 col-sm-offset-3">
<div class="user-setup" redirect-url="'/'"></div> <div class="user-setup" redirect-url="redirectUrl"></div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,4 +1,4 @@
<div class="container" quay-show="Features.SUPER_USERS"> <div class="container" quay-show="Features.SUPER_USERS && showInterface">
<div class="alert alert-info"> <div class="alert alert-info">
This panel provides administrator access to <strong>super users of this installation of the registry</strong>. Super users can be managed in the configuration for this installation. This panel provides administrator access to <strong>super users of this installation of the registry</strong>. Super users can be managed in the configuration for this installation.
</div> </div>
@ -10,18 +10,64 @@
<li class="active"> <li class="active">
<a href="javascript:void(0)" data-toggle="tab" data-target="#users" ng-click="loadUsers()">Users</a> <a href="javascript:void(0)" data-toggle="tab" data-target="#users" ng-click="loadUsers()">Users</a>
</li> </li>
<li>
<a href="javascript:void(0)" data-toggle="tab" data-target="#create-user">Create User</a>
</li>
<li>
<a href="javascript:void(0)" data-toggle="tab" data-target="#logs" ng-click="loadLogs()">System Logs</a>
</li>
</ul> </ul>
</div> </div>
<!-- Content --> <!-- Content -->
<div class="col-md-10"> <div class="col-md-10">
<div class="tab-content"> <div class="tab-content">
<!-- Logs tab -->
<div id="logs" class="tab-pane">
<div class="logsView" makevisible="logsCounter" all-logs="true"></div>
</div>
<!-- Create user tab -->
<div id="create-user" class="tab-pane">
<span class="quay-spinner" ng-show="creatingUser"></span>
<form name="createUserForm" ng-submit="createUser()" ng-show="!creatingUser">
<div class="form-group">
<label>Username</label>
<input class="form-control" type="text" ng-model="newUser.username" ng-pattern="/^[a-z0-9_]{4,30}$/" required>
</div>
<div class="form-group">
<label>Email address</label>
<input class="form-control" type="email" ng-model="newUser.email" required>
</div>
<button class="btn btn-primary" type="submit" ng-disabled="!createUserForm.$valid">Create User</button>
</form>
<div style="margin-top: 20px; padding-top: 20px; border-top: 1px solid #eee;" ng-show="createdUsers.length">
<table class="table">
<thead>
<th>Username</th>
<th>E-mail address</th>
<th>Temporary Password</th>
</thead>
<tr ng-repeat="created_user in createdUsers"
class="user-row">
<td>{{ created_user.username }}</td>
<td>{{ created_user.email }}</td>
<td>{{ created_user.password }}</td>
</tr>
</table>
</div>
</div>
<!-- Users tab --> <!-- Users tab -->
<div id="users" class="tab-pane active"> <div id="users" class="tab-pane active">
<div class="quay-spinner" ng-show="!users"></div> <div class="quay-spinner" ng-show="!users"></div>
<div class="alert alert-error" ng-show="usersError"> <div class="alert alert-error" ng-show="usersError">
{{ usersError }} {{ usersError }}
</div> </div>
<div ng-show="users"> <div ng-show="users">
<div class="side-controls"> <div class="side-controls">
<div class="result-count"> <div class="result-count">
@ -37,8 +83,7 @@
<thead> <thead>
<th>Username</th> <th>Username</th>
<th>E-mail address</th> <th>E-mail address</th>
<th></th> <th style="width: 24px;"></th>
<th></th>
</thead> </thead>
<tr ng-repeat="current_user in (users | filter:search | orderBy:'username' | limitTo:100)" <tr ng-repeat="current_user in (users | filter:search | orderBy:'username' | limitTo:100)"
@ -51,19 +96,20 @@
<td> <td>
<a href="mailto:{{ current_user.email }}">{{ current_user.email }}</a> <a href="mailto:{{ current_user.email }}">{{ current_user.email }}</a>
</td> </td>
<td class="user-class"> <td style="text-align: center;">
<span ng-if="current_user.super_user">Super user</span> <i class="fa fa-ge fa-lg" ng-if="current_user.super_user" data-title="Super User" bs-tooltip></i>
</td> <div class="dropdown" style="text-align: left;" ng-if="user.username != current_user.username && !current_user.super_user">
<td>
<div class="dropdown" ng-if="user.username != current_user.username && !user.super_user">
<button class="btn btn-default dropdown-toggle" data-toggle="dropdown"> <button class="btn btn-default dropdown-toggle" data-toggle="dropdown">
<i class="fa fa-ellipsis-h"></i> <i class="caret"></i>
</button> </button>
<ul class="dropdown-menu"> <ul class="dropdown-menu pull-right">
<li> <li>
<a href="javascript:void(0)" ng-click="showChangePassword(current_user)"> <a href="javascript:void(0)" ng-click="showChangePassword(current_user)">
<i class="fa fa-key"></i> Change Password <i class="fa fa-key"></i> Change Password
</a> </a>
<a href="javascript:void(0)" ng-click="sendRecoveryEmail(current_user)" quay-show="Features.MAILING">
<i class="fa fa-envelope"></i> Send Recovery Email
</a>
<a href="javascript:void(0)" ng-click="showDeleteUser(current_user)"> <a href="javascript:void(0)" ng-click="showDeleteUser(current_user)">
<i class="fa fa-times"></i> Delete User <i class="fa fa-times"></i> Delete User
</a> </a>

View file

@ -1,40 +1,92 @@
<div class="resource-view" resource="orgResource" error-message="'No matching organization'"> <div class="resource-view" resource="orgResource" error-message="'No matching organization'">
<div class="team-view container"> <div class="team-view container">
<div class="organization-header" organization="organization" team-name="teamname"></div> <div class="organization-header" organization="organization" team-name="teamname">
<div ng-show="canEditMembers" class="side-controls">
<div class="hidden-sm hidden-xs">
<button class="btn btn-success"
id="showAddMember"
data-title="Add Team Member"
data-content-template="/static/directives/team-view-add.html"
data-placement="bottom-right"
bs-popover="bs-popover">
<i class="fa fa-plus"></i>
Add Team Member
</button>
</div>
</div>
</div>
<div class="resource-view" resource="membersResource" error-message="'No matching team found'"> <div class="resource-view" resource="membersResource" error-message="'No matching team found'">
<div class="description markdown-input" content="team.description" can-write="organization.is_admin" <div class="description markdown-input" content="team.description" can-write="organization.is_admin"
content-changed="updateForDescription" field-title="'team description'"></div> content-changed="updateForDescription" field-title="'team description'"></div>
<div class="panel panel-default"> <div class="empty-message" ng-if="!members.length">
<div class="panel-heading">Team Members This team has no members
<i class="info-icon fa fa-info-circle" data-placement="left" data-content="Users that inherit all permissions delegated to this team"></i> </div>
</div>
<div class="panel-body"> <div class="empty-message" ng-if="members.length && !(members | filter:search).length">
<table class="permissions"> No matching team members found
<tr ng-repeat="(name, member) in members"> </div>
<td class="user entity">
<span class="entity-reference" entity="member" namespace="organization.name"></span> <table class="member-listing" style="margin-top: -20px" ng-show="members.length">
</td> <!-- Members -->
<td> <tr ng-if="(members | filter:search | filter: filterFunction(false, false)).length">
<span class="delete-ui" delete-title="'Remove User From Team'" button-title="'Remove'" <td colspan="2"><div class="section-header">Team Members</div></td>
perform-delete="removeMember(member.name)" ng-if="canEditMembers"></span> </tr>
</td>
</tr> <tr ng-repeat="member in members | filter:search | filter: filterFunction(false, false) | orderBy: 'name'">
<td class="user entity">
<tr ng-show="canEditMembers"> <span class="entity-reference" entity="member" namespace="organization.name" show-gravatar="true" gravatar-size="32"></span>
<td colspan="3"> </td>
<div class="entity-search" style="width: 100%" <td>
namespace="orgname" placeholder="'Add a registered user or robot...'" <span class="delete-ui" delete-title="'Remove ' + member.name + ' from team'" button-title="'Remove'"
entity-selected="addNewMember(entity)" perform-delete="removeMember(member.name)" ng-if="canEditMembers"></span>
current-entity="selectedMember" </td>
auto-clear="true" </tr>
allowed-entities="['user', 'robot']"></div>
</td> <!-- Robots -->
</tr> <tr ng-if="(members | filter:search | filter: filterFunction(false, true)).length">
</table> <td colspan="2"><div class="section-header">Robot Accounts</div></td>
</tr>
<tr ng-repeat="member in members | filter:search | filter: filterFunction(false, true) | orderBy: 'name'">
<td class="user entity">
<span class="entity-reference" entity="member" namespace="organization.name"></span>
</td>
<td>
<span class="delete-ui" delete-title="'Remove ' + member.name + ' from team'" button-title="'Remove'"
perform-delete="removeMember(member.name)" ng-if="canEditMembers"></span>
</td>
</tr>
<!-- Invited -->
<tr ng-if="(members | filter:search | filter: filterFunction(true, false)).length">
<td colspan="2"><div class="section-header">Invited To Join</div></td>
</tr>
<tr ng-repeat="member in members | filter:search | filter: filterFunction(true, false) | orderBy: 'name'">
<td class="user entity">
<span ng-if="member.kind != 'invite'">
<span class="entity-reference" entity="member" namespace="organization.name" show-gravatar="true" gravatar-size="32"></span>
</span>
<span class="invite-listing" ng-if="member.kind == 'invite'">
<img class="gravatar"ng-src="//www.gravatar.com/avatar/{{ member.gravatar }}?s=32&amp;d=identicon">
{{ member.email }}
</span>
</td>
<td>
<span class="delete-ui" delete-title="'Revoke invite to join team'" button-title="'Revoke'"
perform-delete="revokeInvite(member)" ng-if="canEditMembers"></span>
</td>
</tr>
</table>
<div ng-show="canEditMembers">
<div ng-if-media="'(max-width: 560px)'">
<div ng-include="'/static/directives/team-view-add.html'"></div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -122,7 +122,7 @@
</div> </div>
</div> </div>
<div class="panel" ng-show="!updatingUser" > <div class="panel" ng-show="!updatingUser" quay-show="Features.MAILING">
<div class="panel-title">Change e-mail address</div> <div class="panel-title">Change e-mail address</div>
<div class="panel-body"> <div class="panel-body">

View file

@ -8,17 +8,19 @@
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
<h2>There was an error logging in with {{ service_name }}.</h2> <h2 style="margin-bottom: 20px;">There was an error logging in with {{ service_name }}.</h2>
{% if error_message %} {% if error_message %}
<div class="alert alert-danger">{{ error_message }}</div> <div class="alert alert-danger">{{ error_message }}</div>
{% endif %} {% endif %}
{% if user_creation %}
<div> <div>
Please register using the <a ng-href="{{ service_url }}/signin" target="_self">registration form</a> to continue. Please register using the <a ng-href="{{ service_url }}/signin" target="_self">registration form</a> to continue.
You will be able to connect your account to your Quay.io account You will be able to connect your account to your Quay.io account
in the user settings. in the user settings.
</div> </div>
{% endif %}
</div> </div>
</div> </div>

Binary file not shown.

View file

@ -1,14 +1,16 @@
import unittest import unittest
import json import json
import datetime
from urllib import urlencode from urllib import urlencode
from urlparse import urlparse, urlunparse, parse_qs from urlparse import urlparse, urlunparse, parse_qs
from app import app from app import app
from data import model
from initdb import setup_database_for_testing, finished_database_for_testing from initdb import setup_database_for_testing, finished_database_for_testing
from endpoints.api import api_bp, api from endpoints.api import api_bp, api
from endpoints.api.team import TeamMember, TeamMemberList, OrganizationTeam from endpoints.api.team import TeamMember, TeamMemberList, OrganizationTeam, TeamMemberInvite
from endpoints.api.tag import RepositoryTagImages, RepositoryTag from endpoints.api.tag import RepositoryTagImages, RepositoryTag
from endpoints.api.search import FindRepositories, EntitySearch from endpoints.api.search import FindRepositories, EntitySearch
from endpoints.api.image import RepositoryImageChanges, RepositoryImage, RepositoryImageList from endpoints.api.image import RepositoryImageChanges, RepositoryImage, RepositoryImageList
@ -19,7 +21,7 @@ from endpoints.api.robot import (UserRobotList, OrgRobot, OrgRobotList, UserRobo
from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs, from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs,
TriggerBuildList, ActivateBuildTrigger, BuildTrigger, TriggerBuildList, ActivateBuildTrigger, BuildTrigger,
BuildTriggerList, BuildTriggerAnalyze) BuildTriggerList, BuildTriggerAnalyze, BuildTriggerFieldValues)
from endpoints.api.repoemail import RepositoryAuthorizedEmail from endpoints.api.repoemail import RepositoryAuthorizedEmail
from endpoints.api.repositorynotification import RepositoryNotification, RepositoryNotificationList from endpoints.api.repositorynotification import RepositoryNotification, RepositoryNotificationList
from endpoints.api.user import (PrivateRepositories, ConvertToOrganization, Recovery, Signout, from endpoints.api.user import (PrivateRepositories, ConvertToOrganization, Recovery, Signout,
@ -40,7 +42,8 @@ from endpoints.api.repository import RepositoryList, RepositoryVisibility, Repos
from endpoints.api.permission import (RepositoryUserPermission, RepositoryTeamPermission, from endpoints.api.permission import (RepositoryUserPermission, RepositoryTeamPermission,
RepositoryTeamPermissionList, RepositoryUserPermissionList) RepositoryTeamPermissionList, RepositoryUserPermissionList)
from endpoints.api.superuser import SuperUserLogs, SuperUserList, SuperUserManagement from endpoints.api.superuser import (SuperUserLogs, SuperUserList, SuperUserManagement,
SuperUserSendRecoveryEmail)
try: try:
@ -75,7 +78,9 @@ class ApiTestCase(unittest.TestCase):
with client.session_transaction() as sess: with client.session_transaction() as sess:
if auth_username: if auth_username:
sess['user_id'] = auth_username loaded = model.get_user(auth_username)
sess['user_id'] = loaded.id
sess['login_time'] = datetime.datetime.now()
sess[CSRF_TOKEN_KEY] = CSRF_TOKEN sess[CSRF_TOKEN_KEY] = CSRF_TOKEN
# Restore the teardown functions # Restore the teardown functions
@ -510,13 +515,13 @@ class TestUser(ApiTestCase):
self._run_test('PUT', 401, None, {}) self._run_test('PUT', 401, None, {})
def test_put_freshuser(self): def test_put_freshuser(self):
self._run_test('PUT', 401, 'freshuser', {}) self._run_test('PUT', 200, 'freshuser', {})
def test_put_reader(self): def test_put_reader(self):
self._run_test('PUT', 401, 'reader', {}) self._run_test('PUT', 200, 'reader', {})
def test_put_devtable(self): def test_put_devtable(self):
self._run_test('PUT', 401, 'devtable', {}) self._run_test('PUT', 200, 'devtable', {})
def test_post_anonymous(self): def test_post_anonymous(self):
self._run_test('POST', 400, None, {u'username': 'T946', u'password': '0SG4', u'email': 'MENT'}) self._run_test('POST', 400, None, {u'username': 'T946', u'password': '0SG4', u'email': 'MENT'})
@ -1061,6 +1066,62 @@ class TestBuildTriggerActivateSwo1BuynlargeOrgrepo(ApiTestCase):
def test_post_devtable(self): def test_post_devtable(self):
self._run_test('POST', 404, 'devtable', {'config': {}}) self._run_test('POST', 404, 'devtable', {'config': {}})
class TestBuildTriggerFieldValuesSwo1PublicPublicrepo(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(BuildTriggerFieldValues, trigger_uuid="SWO1", repository="public/publicrepo",
field_name="test_field")
def test_get_anonymous(self):
self._run_test('GET', 401, None, {})
def test_get_freshuser(self):
self._run_test('GET', 403, 'freshuser', {})
def test_get_reader(self):
self._run_test('GET', 403, 'reader', {})
def test_get_devtable(self):
self._run_test('GET', 403, 'devtable', {})
class TestBuildTriggerFieldValuesSwo1DevtableShared(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(BuildTriggerFieldValues, trigger_uuid="SWO1", repository="devtable/shared",
field_name="test_field")
def test_get_anonymous(self):
self._run_test('GET', 401, None, {})
def test_get_freshuser(self):
self._run_test('GET', 403, 'freshuser', {})
def test_get_reader(self):
self._run_test('GET', 403, 'reader', {})
def test_get_devtable(self):
self._run_test('GET', 404, 'devtable', {'config': {}})
class TestBuildTriggerFieldValuesSwo1BuynlargeOrgrepo(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(BuildTriggerFieldValues, trigger_uuid="SWO1", repository="buynlarge/orgrepo",
field_name="test_field")
def test_get_anonymous(self):
self._run_test('GET', 401, None, {})
def test_get_freshuser(self):
self._run_test('GET', 403, 'freshuser', {})
def test_get_reader(self):
self._run_test('GET', 403, 'reader', {})
def test_get_devtable(self):
self._run_test('GET', 404, 'devtable', {'config': {}})
class TestBuildTriggerSources831cPublicPublicrepo(ApiTestCase): class TestBuildTriggerSources831cPublicPublicrepo(ApiTestCase):
def setUp(self): def setUp(self):
@ -1292,7 +1353,7 @@ class TestActivateBuildTrigger0byeDevtableShared(ApiTestCase):
self._run_test('POST', 403, 'reader', None) self._run_test('POST', 403, 'reader', None)
def test_post_devtable(self): def test_post_devtable(self):
self._run_test('POST', 404, 'devtable', None) self._run_test('POST', 404, 'devtable', {})
class TestActivateBuildTrigger0byeBuynlargeOrgrepo(ApiTestCase): class TestActivateBuildTrigger0byeBuynlargeOrgrepo(ApiTestCase):
@ -1310,7 +1371,7 @@ class TestActivateBuildTrigger0byeBuynlargeOrgrepo(ApiTestCase):
self._run_test('POST', 403, 'reader', None) self._run_test('POST', 403, 'reader', None)
def test_post_devtable(self): def test_post_devtable(self):
self._run_test('POST', 404, 'devtable', None) self._run_test('POST', 404, 'devtable', {})
class TestBuildTriggerAnalyze0byePublicPublicrepo(ApiTestCase): class TestBuildTriggerAnalyze0byePublicPublicrepo(ApiTestCase):
@ -3527,13 +3588,61 @@ class TestSuperUserLogs(ApiTestCase):
self._run_test('GET', 200, 'devtable', None) self._run_test('GET', 200, 'devtable', None)
class TestSuperUserSendRecoveryEmail(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(SuperUserSendRecoveryEmail, username='someuser')
def test_post_anonymous(self):
self._run_test('POST', 401, None, None)
def test_post_freshuser(self):
self._run_test('POST', 403, 'freshuser', None)
def test_post_reader(self):
self._run_test('POST', 403, 'reader', None)
def test_post_devtable(self):
self._run_test('POST', 404, 'devtable', None)
class TestTeamMemberInvite(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(TeamMemberInvite, code='foobarbaz')
def test_put_anonymous(self):
self._run_test('PUT', 401, None, None)
def test_put_freshuser(self):
self._run_test('PUT', 400, 'freshuser', None)
def test_put_reader(self):
self._run_test('PUT', 400, 'reader', None)
def test_put_devtable(self):
self._run_test('PUT', 400, 'devtable', None)
def test_delete_anonymous(self):
self._run_test('DELETE', 401, None, None)
def test_delete_freshuser(self):
self._run_test('DELETE', 400, 'freshuser', None)
def test_delete_reader(self):
self._run_test('DELETE', 400, 'reader', None)
def test_delete_devtable(self):
self._run_test('DELETE', 400, 'devtable', None)
class TestSuperUserList(ApiTestCase): class TestSuperUserList(ApiTestCase):
def setUp(self): def setUp(self):
ApiTestCase.setUp(self) ApiTestCase.setUp(self)
self._set_url(SuperUserList) self._set_url(SuperUserList)
def test_get_anonymous(self): def test_get_anonymous(self):
self._run_test('GET', 403, None, None) self._run_test('GET', 401, None, None)
def test_get_freshuser(self): def test_get_freshuser(self):
self._run_test('GET', 403, 'freshuser', None) self._run_test('GET', 403, 'freshuser', None)
@ -3545,14 +3654,13 @@ class TestSuperUserList(ApiTestCase):
self._run_test('GET', 200, 'devtable', None) self._run_test('GET', 200, 'devtable', None)
class TestSuperUserManagement(ApiTestCase): class TestSuperUserManagement(ApiTestCase):
def setUp(self): def setUp(self):
ApiTestCase.setUp(self) ApiTestCase.setUp(self)
self._set_url(SuperUserManagement, username='freshuser') self._set_url(SuperUserManagement, username='freshuser')
def test_get_anonymous(self): def test_get_anonymous(self):
self._run_test('GET', 403, None, None) self._run_test('GET', 401, None, None)
def test_get_freshuser(self): def test_get_freshuser(self):
self._run_test('GET', 403, 'freshuser', None) self._run_test('GET', 403, 'freshuser', None)
@ -3565,7 +3673,7 @@ class TestSuperUserManagement(ApiTestCase):
def test_put_anonymous(self): def test_put_anonymous(self):
self._run_test('PUT', 403, None, {}) self._run_test('PUT', 401, None, {})
def test_put_freshuser(self): def test_put_freshuser(self):
self._run_test('PUT', 403, 'freshuser', {}) self._run_test('PUT', 403, 'freshuser', {})
@ -3578,7 +3686,7 @@ class TestSuperUserManagement(ApiTestCase):
def test_delete_anonymous(self): def test_delete_anonymous(self):
self._run_test('DELETE', 403, None, None) self._run_test('DELETE', 401, None, None)
def test_delete_freshuser(self): def test_delete_freshuser(self):
self._run_test('DELETE', 403, 'freshuser', None) self._run_test('DELETE', 403, 'freshuser', None)

View file

@ -1,3 +1,5 @@
# coding=utf-8
import unittest import unittest
import json as py_json import json as py_json
@ -11,7 +13,7 @@ from app import app
from initdb import setup_database_for_testing, finished_database_for_testing from initdb import setup_database_for_testing, finished_database_for_testing
from data import model, database from data import model, database
from endpoints.api.team import TeamMember, TeamMemberList, OrganizationTeam from endpoints.api.team import TeamMember, TeamMemberList, TeamMemberInvite, OrganizationTeam
from endpoints.api.tag import RepositoryTagImages, RepositoryTag from endpoints.api.tag import RepositoryTagImages, RepositoryTag
from endpoints.api.search import FindRepositories, EntitySearch from endpoints.api.search import FindRepositories, EntitySearch
from endpoints.api.image import RepositoryImage, RepositoryImageList from endpoints.api.image import RepositoryImage, RepositoryImageList
@ -20,7 +22,7 @@ from endpoints.api.robot import (UserRobotList, OrgRobot, OrgRobotList, UserRobo
RegenerateUserRobot, RegenerateOrgRobot) RegenerateUserRobot, RegenerateOrgRobot)
from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs, from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs,
TriggerBuildList, ActivateBuildTrigger, BuildTrigger, TriggerBuildList, ActivateBuildTrigger, BuildTrigger,
BuildTriggerList, BuildTriggerAnalyze) BuildTriggerList, BuildTriggerAnalyze, BuildTriggerFieldValues)
from endpoints.api.repoemail import RepositoryAuthorizedEmail from endpoints.api.repoemail import RepositoryAuthorizedEmail
from endpoints.api.repositorynotification import RepositoryNotification, RepositoryNotificationList from endpoints.api.repositorynotification import RepositoryNotification, RepositoryNotificationList
from endpoints.api.user import (PrivateRepositories, ConvertToOrganization, Signout, Signin, User, from endpoints.api.user import (PrivateRepositories, ConvertToOrganization, Signout, Signin, User,
@ -131,6 +133,10 @@ class ApiTestCase(unittest.TestCase):
def deleteResponse(self, resource_name, params={}, expected_code=204): def deleteResponse(self, resource_name, params={}, expected_code=204):
rv = self.app.delete(self.url_for(resource_name, params)) rv = self.app.delete(self.url_for(resource_name, params))
if rv.status_code != expected_code:
print 'Mismatch data for resource DELETE %s: %s' % (resource_name, rv.data)
self.assertEquals(rv.status_code, expected_code) self.assertEquals(rv.status_code, expected_code)
return rv.data return rv.data
@ -162,6 +168,13 @@ class ApiTestCase(unittest.TestCase):
parsed = py_json.loads(data) parsed = py_json.loads(data)
return parsed return parsed
def assertInTeam(self, data, membername):
for memberData in data['members']:
if memberData['name'] == membername:
return
self.fail(membername + ' not found in team: ' + py_json.dumps(data))
def login(self, username, password='password'): def login(self, username, password='password'):
return self.postJsonResponse(Signin, data=dict(username=username, password=password)) return self.postJsonResponse(Signin, data=dict(username=username, password=password))
@ -328,6 +341,12 @@ class TestChangeUserDetails(ApiTestCase):
data=dict(password='newpasswordiscool')) data=dict(password='newpasswordiscool'))
self.login(READ_ACCESS_USER, password='newpasswordiscool') self.login(READ_ACCESS_USER, password='newpasswordiscool')
def test_changepassword_unicode(self):
self.login(READ_ACCESS_USER)
self.putJsonResponse(User,
data=dict(password=u'someunicode北京市pass'))
self.login(READ_ACCESS_USER, password=u'someunicode北京市pass')
def test_changeeemail(self): def test_changeeemail(self):
self.login(READ_ACCESS_USER) self.login(READ_ACCESS_USER)
@ -375,10 +394,30 @@ class TestCreateNewUser(ApiTestCase):
self.assertEquals('Invalid username auserName: Username must match expression [a-z0-9_]+', json['error_description']) self.assertEquals('Invalid username auserName: Username must match expression [a-z0-9_]+', json['error_description'])
def test_createuser(self): def test_createuser(self):
data = self.postResponse(User, data = self.postJsonResponse(User,
data=NEW_USER_DETAILS, data=NEW_USER_DETAILS,
expected_code=201) expected_code=200)
self.assertEquals('"Created"', data) self.assertEquals(True, data['awaiting_verification'])
def test_createuser_withteaminvite(self):
inviter = model.get_user(ADMIN_ACCESS_USER)
team = model.get_organization_team(ORGANIZATION, 'owners')
invite = model.add_or_invite_to_team(inviter, team, None, 'foo@example.com')
details = {
'invite_code': invite.invite_token
}
details.update(NEW_USER_DETAILS);
data = self.postJsonResponse(User, data=details, expected_code=200)
self.assertEquals(True, data['awaiting_verification'])
# Make sure the user was added to the team.
self.login(ADMIN_ACCESS_USER)
json = self.getJsonResponse(TeamMemberList,
params=dict(orgname=ORGANIZATION,
teamname='owners'))
self.assertInTeam(json, NEW_USER_DETAILS['username'])
class TestSignout(ApiTestCase): class TestSignout(ApiTestCase):
@ -741,16 +780,43 @@ class TestGetOrganizationTeamMembers(ApiTestCase):
params=dict(orgname=ORGANIZATION, params=dict(orgname=ORGANIZATION,
teamname='readers')) teamname='readers'))
assert READ_ACCESS_USER in json['members'] self.assertEquals(READ_ACCESS_USER, json['members'][1]['name'])
class TestUpdateOrganizationTeamMember(ApiTestCase): class TestUpdateOrganizationTeamMember(ApiTestCase):
def test_addmember(self): def test_addmember_alreadyteammember(self):
self.login(ADMIN_ACCESS_USER) self.login(ADMIN_ACCESS_USER)
membername = READ_ACCESS_USER
self.putResponse(TeamMember,
params=dict(orgname=ORGANIZATION, teamname='readers',
membername=membername),
expected_code=400)
def test_addmember_orgmember(self):
self.login(ADMIN_ACCESS_USER)
membername = READ_ACCESS_USER
self.putJsonResponse(TeamMember,
params=dict(orgname=ORGANIZATION, teamname='owners',
membername=membername))
# Verify the user was added to the team.
json = self.getJsonResponse(TeamMemberList,
params=dict(orgname=ORGANIZATION,
teamname='owners'))
self.assertInTeam(json, membername)
def test_addmember_robot(self):
self.login(ADMIN_ACCESS_USER)
membername = ORGANIZATION + '+coolrobot'
self.putJsonResponse(TeamMember, self.putJsonResponse(TeamMember,
params=dict(orgname=ORGANIZATION, teamname='readers', params=dict(orgname=ORGANIZATION, teamname='readers',
membername=NO_ACCESS_USER)) membername=membername))
# Verify the user was added to the team. # Verify the user was added to the team.
@ -758,10 +824,168 @@ class TestUpdateOrganizationTeamMember(ApiTestCase):
params=dict(orgname=ORGANIZATION, params=dict(orgname=ORGANIZATION,
teamname='readers')) teamname='readers'))
assert NO_ACCESS_USER in json['members'] self.assertInTeam(json, membername)
def test_addmember_invalidrobot(self):
self.login(ADMIN_ACCESS_USER)
membername = 'freshuser+anotherrobot'
self.putResponse(TeamMember,
params=dict(orgname=ORGANIZATION, teamname='readers',
membername=membername),
expected_code=400)
def test_addmember_nonorgmember(self):
self.login(ADMIN_ACCESS_USER)
membername = NO_ACCESS_USER
response = self.putJsonResponse(TeamMember,
params=dict(orgname=ORGANIZATION, teamname='owners',
membername=membername))
self.assertEquals(True, response['invited'])
# Make sure the user is not (yet) part of the team.
json = self.getJsonResponse(TeamMemberList,
params=dict(orgname=ORGANIZATION,
teamname='readers'))
for member in json['members']:
self.assertNotEqual(membername, member['name'])
class TestAcceptTeamMemberInvite(ApiTestCase):
def assertInTeam(self, data, membername):
for memberData in data['members']:
if memberData['name'] == membername:
return
self.fail(membername + ' not found in team: ' + json.dumps(data))
def test_accept(self):
self.login(ADMIN_ACCESS_USER)
# Create the invite.
membername = NO_ACCESS_USER
response = self.putJsonResponse(TeamMember,
params=dict(orgname=ORGANIZATION, teamname='owners',
membername=membername))
self.assertEquals(True, response['invited'])
# Login as the user.
self.login(membername)
# Accept the invite.
user = model.get_user(membername)
invites = list(model.lookup_team_invites(user))
self.assertEquals(1, len(invites))
self.putJsonResponse(TeamMemberInvite,
params=dict(code=invites[0].invite_token))
# Verify the user is now on the team.
json = self.getJsonResponse(TeamMemberList,
params=dict(orgname=ORGANIZATION,
teamname='owners'))
self.assertInTeam(json, membername)
# Verify the accept now fails.
self.putResponse(TeamMemberInvite,
params=dict(code=invites[0].invite_token),
expected_code=400)
class TestDeclineTeamMemberInvite(ApiTestCase):
def test_decline_wronguser(self):
self.login(ADMIN_ACCESS_USER)
# Create the invite.
membername = NO_ACCESS_USER
response = self.putJsonResponse(TeamMember,
params=dict(orgname=ORGANIZATION, teamname='owners',
membername=membername))
self.assertEquals(True, response['invited'])
# Try to decline the invite.
user = model.get_user(membername)
invites = list(model.lookup_team_invites(user))
self.assertEquals(1, len(invites))
self.deleteResponse(TeamMemberInvite,
params=dict(code=invites[0].invite_token),
expected_code=400)
def test_decline(self):
self.login(ADMIN_ACCESS_USER)
# Create the invite.
membername = NO_ACCESS_USER
response = self.putJsonResponse(TeamMember,
params=dict(orgname=ORGANIZATION, teamname='owners',
membername=membername))
self.assertEquals(True, response['invited'])
# Login as the user.
self.login(membername)
# Decline the invite.
user = model.get_user(membername)
invites = list(model.lookup_team_invites(user))
self.assertEquals(1, len(invites))
self.deleteResponse(TeamMemberInvite,
params=dict(code=invites[0].invite_token))
# Make sure the invite was deleted.
self.deleteResponse(TeamMemberInvite,
params=dict(code=invites[0].invite_token),
expected_code=400)
class TestDeleteOrganizationTeamMember(ApiTestCase): class TestDeleteOrganizationTeamMember(ApiTestCase):
def test_deletememberinvite(self):
self.login(ADMIN_ACCESS_USER)
membername = NO_ACCESS_USER
response = self.putJsonResponse(TeamMember,
params=dict(orgname=ORGANIZATION, teamname='readers',
membername=membername))
self.assertEquals(True, response['invited'])
# Verify the invite was added.
json = self.getJsonResponse(TeamMemberList,
params=dict(orgname=ORGANIZATION,
teamname='readers',
includePending=True))
assert len(json['members']) == 3
# Delete the invite.
self.deleteResponse(TeamMember,
params=dict(orgname=ORGANIZATION, teamname='readers',
membername=membername))
# Verify the user was removed from the team.
json = self.getJsonResponse(TeamMemberList,
params=dict(orgname=ORGANIZATION,
teamname='readers',
includePending=True))
assert len(json['members']) == 2
def test_deletemember(self): def test_deletemember(self):
self.login(ADMIN_ACCESS_USER) self.login(ADMIN_ACCESS_USER)
@ -775,7 +999,7 @@ class TestDeleteOrganizationTeamMember(ApiTestCase):
params=dict(orgname=ORGANIZATION, params=dict(orgname=ORGANIZATION,
teamname='readers')) teamname='readers'))
assert not READ_ACCESS_USER in json['members'] assert len(json['members']) == 1
class TestCreateRepo(ApiTestCase): class TestCreateRepo(ApiTestCase):
@ -1205,6 +1429,7 @@ class TestListAndGetImage(ApiTestCase):
params=dict(repository=ADMIN_ACCESS_USER + '/simple')) params=dict(repository=ADMIN_ACCESS_USER + '/simple'))
assert len(json['images']) > 0 assert len(json['images']) > 0
for image in json['images']: for image in json['images']:
assert 'id' in image assert 'id' in image
assert 'tags' in image assert 'tags' in image
@ -1212,7 +1437,6 @@ class TestListAndGetImage(ApiTestCase):
assert 'comment' in image assert 'comment' in image
assert 'command' in image assert 'command' in image
assert 'ancestors' in image assert 'ancestors' in image
assert 'dbid' in image
assert 'size' in image assert 'size' in image
ijson = self.getJsonResponse(RepositoryImage, ijson = self.getJsonResponse(RepositoryImage,
@ -1810,7 +2034,7 @@ class FakeBuildTrigger(BuildTriggerBase):
config['active'] = False config['active'] = False
return config return config
def manual_start(self, auth_token, config): def manual_start(self, auth_token, config, run_parameters=None):
return ('foo', ['bar'], 'build-name', 'subdir') return ('foo', ['bar'], 'build-name', 'subdir')
def dockerfile_url(self, auth_token, config): def dockerfile_url(self, auth_token, config):
@ -1822,6 +2046,12 @@ class FakeBuildTrigger(BuildTriggerBase):
return config['dockerfile'] return config['dockerfile']
def list_field_values(self, auth_token, config, field_name):
if field_name == 'test_field':
return [1, 2, 3]
return None
class TestBuildTriggers(ApiTestCase): class TestBuildTriggers(ApiTestCase):
def test_list_build_triggers(self): def test_list_build_triggers(self):
@ -1994,9 +2224,22 @@ class TestBuildTriggers(ApiTestCase):
data={'config': trigger_config}, data={'config': trigger_config},
expected_code=400) expected_code=400)
# Retrieve values for a field.
result = self.getJsonResponse(BuildTriggerFieldValues,
params=dict(repository=ADMIN_ACCESS_USER + '/simple',
trigger_uuid=trigger.uuid, field_name="test_field"))
self.assertEquals(result['values'], [1, 2, 3])
self.getResponse(BuildTriggerFieldValues,
params=dict(repository=ADMIN_ACCESS_USER + '/simple',
trigger_uuid=trigger.uuid, field_name="another_field"),
expected_code = 404)
# Start a manual build. # Start a manual build.
start_json = self.postJsonResponse(ActivateBuildTrigger, start_json = self.postJsonResponse(ActivateBuildTrigger,
params=dict(repository=ADMIN_ACCESS_USER + '/simple', trigger_uuid=trigger.uuid), params=dict(repository=ADMIN_ACCESS_USER + '/simple', trigger_uuid=trigger.uuid),
data=dict(),
expected_code=201) expected_code=201)
assert 'id' in start_json assert 'id' in start_json
@ -2061,6 +2304,7 @@ class TestBuildTriggers(ApiTestCase):
# Start a manual build. # Start a manual build.
start_json = self.postJsonResponse(ActivateBuildTrigger, start_json = self.postJsonResponse(ActivateBuildTrigger,
params=dict(repository=ADMIN_ACCESS_USER + '/simple', trigger_uuid=trigger.uuid), params=dict(repository=ADMIN_ACCESS_USER + '/simple', trigger_uuid=trigger.uuid),
data=dict(),
expected_code=201) expected_code=201)
assert 'id' in start_json assert 'id' in start_json
@ -2120,7 +2364,7 @@ class TestSuperUserManagement(ApiTestCase):
json = self.getJsonResponse(SuperUserManagement, params=dict(username = 'freshuser')) json = self.getJsonResponse(SuperUserManagement, params=dict(username = 'freshuser'))
self.assertEquals('freshuser', json['username']) self.assertEquals('freshuser', json['username'])
self.assertEquals('no@thanks.com', json['email']) self.assertEquals('jschorr+test@devtable.com', json['email'])
self.assertEquals(False, json['super_user']) self.assertEquals(False, json['super_user'])
def test_delete_user(self): def test_delete_user(self):
@ -2143,7 +2387,7 @@ class TestSuperUserManagement(ApiTestCase):
# Verify the user exists. # Verify the user exists.
json = self.getJsonResponse(SuperUserManagement, params=dict(username = 'freshuser')) json = self.getJsonResponse(SuperUserManagement, params=dict(username = 'freshuser'))
self.assertEquals('freshuser', json['username']) self.assertEquals('freshuser', json['username'])
self.assertEquals('no@thanks.com', json['email']) self.assertEquals('jschorr+test@devtable.com', json['email'])
# Update the user. # Update the user.
self.putJsonResponse(SuperUserManagement, params=dict(username='freshuser'), data=dict(email='foo@bar.com')) self.putJsonResponse(SuperUserManagement, params=dict(username='freshuser'), data=dict(email='foo@bar.com'))

View file

@ -34,6 +34,7 @@ class TestConfig(DefaultConfig):
FEATURE_SUPER_USERS = True FEATURE_SUPER_USERS = True
FEATURE_BILLING = True FEATURE_BILLING = True
FEATURE_MAILING = True
SUPER_USERS = ['devtable'] SUPER_USERS = ['devtable']
LICENSE_USER_LIMIT = 500 LICENSE_USER_LIMIT = 500

View file

@ -45,8 +45,8 @@ class TestBuildLogs(RedisBuildLogs):
'pull_completion': 0.0, 'pull_completion': 0.0,
} }
def __init__(self, redis_host, namespace, repository, test_build_id, allow_delegate=True): def __init__(self, redis_config, namespace, repository, test_build_id, allow_delegate=True):
super(TestBuildLogs, self).__init__(redis_host) super(TestBuildLogs, self).__init__(redis_config)
self.namespace = namespace self.namespace = namespace
self.repository = repository self.repository = repository
self.test_build_id = test_build_id self.test_build_id = test_build_id

View file

@ -1,7 +1,7 @@
import logging import logging
import json import json
from data.database import Image, ImageStorage, Repository, configure from data.database import Image, ImageStorage, Repository, User, configure
from data import model from data import model
from app import app, storage as store from app import app, storage as store
@ -16,17 +16,18 @@ logging.getLogger('boto').setLevel(logging.CRITICAL)
query = (Image query = (Image
.select(Image, ImageStorage, Repository) .select(Image, ImageStorage, Repository, User)
.join(ImageStorage) .join(ImageStorage)
.switch(Image) .switch(Image)
.join(Repository) .join(Repository)
.where(ImageStorage.uploading == False)) .join(User)
.where(ImageStorage.uploading == False))
bad_count = 0 bad_count = 0
good_count = 0 good_count = 0
def resolve_or_create(repo, docker_image_id, new_ancestry): def resolve_or_create(repo, docker_image_id, new_ancestry):
existing = model.get_repo_image(repo.namespace, repo.name, docker_image_id) existing = model.get_repo_image(repo.namespace_user.username, repo.name, docker_image_id)
if existing: if existing:
logger.debug('Found existing image: %s, %s', existing.id, docker_image_id) logger.debug('Found existing image: %s, %s', existing.id, docker_image_id)
return existing return existing
@ -45,7 +46,7 @@ def resolve_or_create(repo, docker_image_id, new_ancestry):
return created return created
except ImageStorage.DoesNotExist: except ImageStorage.DoesNotExist:
msg = 'No image available anywhere for storage: %s in namespace: %s' msg = 'No image available anywhere for storage: %s in namespace: %s'
logger.error(msg, docker_image_id, repo.namespace) logger.error(msg, docker_image_id, repo.namespace_user.username)
raise RuntimeError() raise RuntimeError()
@ -62,20 +63,19 @@ def all_ancestors_exist(ancestors):
cant_fix = [] cant_fix = []
for img in query: for img in query:
try: try:
with_locations = model.get_repo_image(img.repository.namespace, img.repository.name, with_locations = model.get_repo_image(img.repository.namespace_user.username,
img.docker_image_id) img.repository.name, img.docker_image_id)
ancestry_storage = store.image_ancestry_path(img.storage.uuid) ancestry_storage = store.image_ancestry_path(img.storage.uuid)
if store.exists(with_locations.storage.locations, ancestry_storage): if store.exists(with_locations.storage.locations, ancestry_storage):
full_ancestry = json.loads(store.get_content(with_locations.storage.locations, ancestry_storage))[1:] full_ancestry = json.loads(store.get_content(with_locations.storage.locations,
ancestry_storage))[1:]
full_ancestry.reverse() full_ancestry.reverse()
ancestor_dbids = [int(anc_id) ancestor_dbids = [int(anc_id) for anc_id in img.ancestors.split('/')[1:-1]]
for anc_id in img.ancestors.split('/')[1:-1]]
if len(full_ancestry) != len(ancestor_dbids) or not all_ancestors_exist(ancestor_dbids): if len(full_ancestry) != len(ancestor_dbids) or not all_ancestors_exist(ancestor_dbids):
logger.error('Image has incomplete ancestry: %s, %s, %s, %s' % logger.error('Image has incomplete ancestry: %s, %s, %s, %s', img.id, img.docker_image_id,
(img.id, img.docker_image_id, full_ancestry, full_ancestry, ancestor_dbids)
ancestor_dbids))
fixed_ancestry = '/' fixed_ancestry = '/'
for ancestor in full_ancestry: for ancestor in full_ancestry:
@ -99,5 +99,5 @@ for img in query:
len(cant_fix)) len(cant_fix))
for cant in cant_fix: for cant in cant_fix:
logger.error('Unable to fix %s in repo %s/%s', cant.id, logger.error('Unable to fix %s in repo %s/%s', cant.id, cant.repository.namespace_user.username,
cant.repository.namespace, cant.repository.name) cant.repository.name)

View file

@ -1,22 +1,15 @@
from data.database import Image from data.database import Image, ImageStorage
from app import app, storage as store from peewee import JOIN_LEFT_OUTER, fn
from app import app
live_image_id_set = set() orphaned = (ImageStorage
.select()
.where(ImageStorage.uploading == False)
.join(Image, JOIN_LEFT_OUTER)
.group_by(ImageStorage)
.having(fn.Count(Image.id) == 0))
for image in Image.select(): counter = 0
live_image_id_set.add(image.docker_image_id) for orphan in orphaned:
counter += 1
storage_image_id_set = set() print orphan.uuid
for customer in store.list_directory('images/'):
for repo in store.list_directory(customer):
for image in store.list_directory(repo):
storage_image_id_set.add(image.split('/')[-1])
orphans = storage_image_id_set.difference(live_image_id_set)
missing_image_data = live_image_id_set.difference(storage_image_id_set)
for orphan in orphans:
print "Orphan: %s" % orphan
for missing in missing_image_data:
print "Missing: %s" % missing

View file

@ -9,7 +9,7 @@ with open('outfile.dot', 'w') as outfile:
outfile.write('digraph relationships {\n') outfile.write('digraph relationships {\n')
for repo in Repository.select(): for repo in Repository.select():
ns = fix_ident(repo.namespace) ns = fix_ident(repo.namespace_user.username)
outfile.write('%s_%s -> %s\n' % (ns, fix_ident(repo.name), ns)) outfile.write('%s_%s -> %s\n' % (ns, fix_ident(repo.name), ns))
teams_in_orgs = set() teams_in_orgs = set()

75
tools/uncompressedsize.py Normal file
View file

@ -0,0 +1,75 @@
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, db_random_func
from util.gzipstream import ZLIB_GZIP_WINDOW
logger = logging.getLogger(__name__)
CHUNK_SIZE = 5 * 1024 * 1024
def backfill_sizes_from_data():
while True:
# Load the record from the DB.
batch_ids = list(ImageStorage
.select(ImageStorage.uuid)
.where(ImageStorage.uncompressed_size >> None,
ImageStorage.uploading == False)
.limit(100)
.order_by(db_random_func()))
if len(batch_ids) == 0:
# We're done!
return
for record in batch_ids:
uuid = record.uuid
try:
with_locs = model.get_storage_by_uuid(uuid)
if with_locs.uncompressed_size is not None:
logger.debug('Somebody else already filled this in for us: %s', uuid)
continue
# Read the layer from backing storage and calculate the uncompressed size.
logger.debug('Loading data: %s (%s bytes)', uuid, with_locs.image_size)
decompressor = zlib.decompressobj(ZLIB_GZIP_WINDOW)
uncompressed_size = 0
with store.stream_read_file(with_locs.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))
# 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, uncompressed_size)
with app.config['DB_TRANSACTION_FACTORY'](db):
current_record = model.get_storage_by_uuid(uuid)
if not current_record.uploading and current_record.uncompressed_size == None:
current_record.uncompressed_size = uncompressed_size
current_record.save()
else:
logger.debug('Somebody else already filled this in for us, after we did the work: %s',
uuid)
except model.InvalidImageException:
logger.warning('Storage with uuid no longer exists: %s', uuid)
except MemoryError:
logger.warning('MemoryError on %s', uuid)
if __name__ == "__main__":
logging.basicConfig(level=logging.DEBUG)
logging.getLogger('boto').setLevel(logging.CRITICAL)
logging.getLogger('peewee').setLevel(logging.CRITICAL)
backfill_sizes_from_data()

28
util/gzipstream.py Normal file
View file

@ -0,0 +1,28 @@
"""
Defines utility methods for working with gzip streams.
"""
import zlib
# Window size for decompressing GZIP streams.
# This results in ZLIB automatically detecting the GZIP headers.
# 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.
"""
size_info = SizeInfo()
decompressor = zlib.decompressobj(ZLIB_GZIP_WINDOW)
def fn(buf):
size_info.size += len(decompressor.decompress(buf))
return size_info, fn

View file

@ -34,6 +34,27 @@ def parse_robot_username(robot_username):
return robot_username.split('+', 2) return robot_username.split('+', 2)
def parse_urn(urn):
""" Parses a URN, returning a pair that contains a list of URN
namespace parts, followed by the URN's unique ID.
"""
if not urn.startswith('urn:'):
return None
parts = urn[len('urn:'):].split(':')
return (parts[0:len(parts) - 1], parts[len(parts) - 1])
def parse_single_urn(urn):
""" Parses a URN, returning a pair that contains the first
namespace part, followed by the URN's unique ID.
"""
result = parse_urn(urn)
if result is None or not len(result[0]):
return None
return (result[0][0], result[1])
uuid_generator = lambda: str(uuid4()) uuid_generator = lambda: str(uuid4())

View file

@ -1,116 +1,158 @@
from flask.ext.mail import Message from flask.ext.mail import Message
from app import mail, app, get_app_url from app import mail, app, get_app_url
from jinja2 import Template, Environment, FileSystemLoader, contextfilter
from data import model
from util.gravatar import compute_hash
def user_reference(username):
user = model.get_user_or_org(username)
if not user:
return username
return """
<span>
<img src="http://www.gravatar.com/avatar/%s?s=16&amp;d=identicon" style="vertical-align: middle; margin-left: 6px; margin-right: 4px;">
<b>%s</b>
</span>""" % (compute_hash(user.email), username)
CONFIRM_MESSAGE = """ def repository_reference(pair):
This email address was recently used to register the username '%s' (namespace, repository) = pair
at <a href="%s">Quay.io</a>.<br>
<br> owner = model.get_user(namespace)
To confirm this email address, please click the following link:<br> if not owner:
<a href="%s/confirm?code=%s">%s/confirm?code=%s</a> return "%s/%s" % (namespace, repository)
"""
return """
<span style="white-space: nowrap;">
<img src="http://www.gravatar.com/avatar/%s?s=16&amp;d=identicon" style="vertical-align: middle; margin-left: 6px; margin-right: 4px;">
<a href="%s/repository/%s/%s">%s/%s</a>
</span>
""" % (compute_hash(owner.email), get_app_url(), namespace, repository, namespace, repository)
CHANGE_MESSAGE = """ def admin_reference(username):
This email address was recently asked to become the new e-mail address for username '%s' user = model.get_user(username)
at <a href="%s">Quay.io</a>.<br> if not user:
<br> return 'account settings'
To confirm this email address, please click the following link:<br>
<a href="%s/confirm?code=%s">%s/confirm?code=%s</a> if user.organization:
""" return """
<a href="%s/organization/%s/admin">organization's admin setting</a>
""" % (get_app_url(), username)
else:
return """
<a href="%s/user/">account settings</a>
""" % (get_app_url())
RECOVERY_MESSAGE = """ template_loader = FileSystemLoader(searchpath="emails")
A user at <a href="%s">Quay.io</a> has attempted to recover their account template_env = Environment(loader=template_loader)
using this email address.<br> template_env.filters['user_reference'] = user_reference
<br> template_env.filters['admin_reference'] = admin_reference
If you made this request, please click the following link to recover your account and template_env.filters['repository_reference'] = repository_reference
change your password:
<a href="%s/recovery?code=%s">%s/recovery?code=%s</a><br>
<br>
If you did not make this request, your account has not been compromised and the user was
not given access. Please disregard this email.<br>
"""
SUBSCRIPTION_CHANGE = """ def send_email(recipient, subject, template_file, parameters):
Change: {0}<br> app_title = app.config['REGISTRY_TITLE_SHORT']
Customer id: <a href="https://manage.stripe.com/customers/{1}">{1}</a><br> app_url = get_app_url()
Customer email: <a href="mailto:{2}">{2}</a><br>
Quay user or org name: {3}<br> def app_link_handler(url=None, title=None):
""" real_url = app_url + '/' + url if url else app_url
if not title:
title = real_url if url else app_title
return '<a href="%s">%s</a>' % (real_url, title)
parameters.update({
'subject': subject,
'app_logo': 'https://quay.io/static/img/quay-logo.png', # TODO: make this pull from config
'app_url': app_url,
'app_title': app_title,
'app_link': app_link_handler
})
rendered_html = template_env.get_template(template_file + '.html').render(parameters)
msg = Message('[%s] %s' % (app_title, subject), sender='support@quay.io', recipients=[recipient])
msg.html = rendered_html
mail.send(msg)
PAYMENT_FAILED = """ def send_password_changed(username, email):
Hi {0},<br> send_email(email, 'Account password changed', 'passwordchanged', {
<br> 'username': username
Your recent payment for Quay.io failed, which usually results in our payments processorcanceling })
your subscription automatically. If you would like to continue to use Quay.io without interruption,
please add a new card to Quay.io and re-subscribe to your plan.<br>
<br>
You can find the card and subscription management features under your account settings.<br>
<br>
Thanks and have a great day!<br>
<br>
-Quay.io Support<br>
"""
AUTH_FORREPO_MESSAGE = """
A request has been made to send notifications to this email address for the
<a href="%s">Quay.io</a> repository <a href="%s/repository/%s/%s">%s/%s</a>.
<br>
To confirm this email address, please click the following link:<br>
<a href="%s/authrepoemail?code=%s">%s/authrepoemail?code=%s</a>
"""
SUBSCRIPTION_CHANGE_TITLE = 'Subscription Change - {0} {1}'
def send_email_changed(username, old_email, new_email):
send_email(old_email, 'Account e-mail address changed', 'emailchanged', {
'username': username,
'new_email': new_email
})
def send_change_email(username, email, token): def send_change_email(username, email, token):
msg = Message('Quay.io email change. Please confirm your email.', send_email(email, 'E-mail address change requested', 'changeemail', {
sender='support@quay.io', # Why do I need this? 'username': username,
recipients=[email]) 'token': token
msg.html = CHANGE_MESSAGE % (username, get_app_url(), get_app_url(), token, get_app_url(), token) })
mail.send(msg)
def send_confirmation_email(username, email, token): def send_confirmation_email(username, email, token):
msg = Message('Welcome to Quay.io! Please confirm your email.', send_email(email, 'Please confirm your e-mail address', 'confirmemail', {
sender='support@quay.io', # Why do I need this? 'username': username,
recipients=[email]) 'token': token
msg.html = CONFIRM_MESSAGE % (username, get_app_url(), get_app_url(), token, get_app_url(), token) })
mail.send(msg)
def send_repo_authorization_email(namespace, repository, email, token): def send_repo_authorization_email(namespace, repository, email, token):
msg = Message('Quay.io Notification: Please confirm your email.', subject = 'Please verify your e-mail address for repository %s/%s' % (namespace, repository)
sender='support@quay.io', # Why do I need this? send_email(email, subject, 'repoauthorizeemail', {
recipients=[email]) 'namespace': namespace,
msg.html = AUTH_FORREPO_MESSAGE % (get_app_url(), get_app_url(), namespace, repository, namespace, 'repository': repository,
repository, get_app_url(), token, get_app_url(), token) 'token': token
mail.send(msg) })
def send_recovery_email(email, token): def send_recovery_email(email, token):
msg = Message('Quay.io account recovery.', subject = 'Account recovery'
sender='support@quay.io', # Why do I need this? send_email(email, subject, 'recovery', {
recipients=[email]) 'email': email,
msg.html = RECOVERY_MESSAGE % (get_app_url(), get_app_url(), token, get_app_url(), token) 'token': token
mail.send(msg) })
def send_payment_failed(email, username):
send_email(email, 'Subscription Payment Failure', 'paymentfailure', {
'username': username
})
def send_org_invite_email(member_name, member_email, orgname, team, adder, code):
send_email(member_email, 'Invitation to join team', 'teaminvite', {
'inviter': adder,
'token': code,
'organization': orgname,
'teamname': team
})
def send_invoice_email(email, contents): def send_invoice_email(email, contents):
# Note: This completely generates the contents of the email, so we don't use the
# normal template here.
msg = Message('Quay.io payment received - Thank you!', msg = Message('Quay.io payment received - Thank you!',
sender='support@quay.io', # Why do I need this? sender='support@quay.io',
recipients=[email]) recipients=[email])
msg.html = contents msg.html = contents
mail.send(msg) mail.send(msg)
# INTERNAL EMAILS BELOW
def send_subscription_change(change_description, customer_id, customer_email, quay_username): def send_subscription_change(change_description, customer_id, customer_email, quay_username):
SUBSCRIPTION_CHANGE_TITLE = 'Subscription Change - {0} {1}'
SUBSCRIPTION_CHANGE = """
Change: {0}<br>
Customer id: <a href="https://manage.stripe.com/customers/{1}">{1}</a><br>
Customer email: <a href="mailto:{2}">{2}</a><br>
Quay user or org name: {3}<br>
"""
title = SUBSCRIPTION_CHANGE_TITLE.format(quay_username, change_description) title = SUBSCRIPTION_CHANGE_TITLE.format(quay_username, change_description)
msg = Message(title, sender='support@quay.io', recipients=['stripe@quay.io']) msg = Message(title, sender='support@quay.io', recipients=['stripe@quay.io'])
msg.html = SUBSCRIPTION_CHANGE.format(change_description, customer_id, customer_email, msg.html = SUBSCRIPTION_CHANGE.format(change_description, customer_id, customer_email,
@ -118,8 +160,3 @@ def send_subscription_change(change_description, customer_id, customer_email, qu
mail.send(msg) mail.send(msg)
def send_payment_failed(customer_email, quay_username):
msg = Message('Quay.io Subscription Payment Failure', sender='support@quay.io',
recipients=[customer_email])
msg.html = PAYMENT_FAILED.format(quay_username)
mail.send(msg)

View file

@ -7,11 +7,12 @@ from gzip import GzipFile
from data import model from data import model
from data.archivedlogs import JSON_MIMETYPE from data.archivedlogs import JSON_MIMETYPE
from data.database import RepositoryBuild from data.database import RepositoryBuild, db_random_func
from app import build_logs, log_archive from app import build_logs, log_archive
from util.streamingjsonencoder import StreamingJSONEncoder from util.streamingjsonencoder import StreamingJSONEncoder
POLL_PERIOD_SECONDS = 30 POLL_PERIOD_SECONDS = 30
MEMORY_TEMPFILE_SIZE = 64 * 1024 # Large enough to handle approximately 99% of builds in memory
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
sched = BlockingScheduler() sched = BlockingScheduler()
@ -22,7 +23,7 @@ def archive_redis_buildlogs():
avoid needing two-phase commit. """ avoid needing two-phase commit. """
try: try:
# Get a random build to archive # Get a random build to archive
to_archive = model.archivable_buildlogs_query().order_by(fn.Random()).get() to_archive = model.archivable_buildlogs_query().order_by(db_random_func()).get()
logger.debug('Archiving: %s', to_archive.uuid) logger.debug('Archiving: %s', to_archive.uuid)
length, entries = build_logs.get_log_entries(to_archive.uuid, 0) length, entries = build_logs.get_log_entries(to_archive.uuid, 0)
@ -32,7 +33,7 @@ def archive_redis_buildlogs():
'logs': entries, 'logs': entries,
} }
with SpooledTemporaryFile() as tempfile: with SpooledTemporaryFile(MEMORY_TEMPFILE_SIZE) as tempfile:
with GzipFile('testarchive', fileobj=tempfile) as zipstream: with GzipFile('testarchive', fileobj=tempfile) as zipstream:
for chunk in StreamingJSONEncoder().iterencode(to_encode): for chunk in StreamingJSONEncoder().iterencode(to_encode):
zipstream.write(chunk) zipstream.write(chunk)

View file

@ -33,7 +33,8 @@ class DiffsWorker(Worker):
return True return True
logging.config.fileConfig('conf/logging.conf', disable_existing_loggers=False) if __name__ == "__main__":
logging.config.fileConfig('conf/logging.conf', disable_existing_loggers=False)
worker = DiffsWorker(image_diff_queue) worker = DiffsWorker(image_diff_queue)
worker.start() worker.start()

View file

@ -38,6 +38,8 @@ TIMEOUT_PERIOD_MINUTES = 20
CACHE_EXPIRATION_PERIOD_HOURS = 24 CACHE_EXPIRATION_PERIOD_HOURS = 24
NO_TAGS = ['<none>:<none>'] NO_TAGS = ['<none>:<none>']
RESERVATION_TIME = (TIMEOUT_PERIOD_MINUTES + 5) * 60 RESERVATION_TIME = (TIMEOUT_PERIOD_MINUTES + 5) * 60
DOCKER_BASE_URL = None # Set this if you want to use a different docker URL/socket.
def matches_system_error(status_str): def matches_system_error(status_str):
""" Returns true if the given status string matches a known system error in the """ Returns true if the given status string matches a known system error in the
@ -128,8 +130,8 @@ class DockerfileBuildContext(object):
# Note: We have two different clients here because we (potentially) login # Note: We have two different clients here because we (potentially) login
# with both, but with different credentials that we do not want shared between # with both, but with different credentials that we do not want shared between
# the build and push operations. # the build and push operations.
self._push_cl = StreamingDockerClient(timeout=1200) self._push_cl = StreamingDockerClient(timeout=1200, base_url = DOCKER_BASE_URL)
self._build_cl = StreamingDockerClient(timeout=1200) self._build_cl = StreamingDockerClient(timeout=1200, base_url = DOCKER_BASE_URL)
dockerfile_path = os.path.join(self._build_dir, dockerfile_subdir, dockerfile_path = os.path.join(self._build_dir, dockerfile_subdir,
'Dockerfile') 'Dockerfile')
@ -221,6 +223,13 @@ class DockerfileBuildContext(object):
raise RuntimeError(message) raise RuntimeError(message)
def pull(self): def pull(self):
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)
raise JobException('Missing FROM command in Dockerfile')
image_and_tag = ':'.join(image_and_tag_tuple)
# Login with the specified credentials (if any). # Login with the specified credentials (if any).
if self._pull_credentials: if self._pull_credentials:
logger.debug('Logging in with pull credentials: %s@%s', logger.debug('Logging in with pull credentials: %s@%s',
@ -236,13 +245,6 @@ class DockerfileBuildContext(object):
registry=self._pull_credentials['registry'], reauth=True) registry=self._pull_credentials['registry'], reauth=True)
# Pull the image, in case it was updated since the last build # 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)
raise JobException('Missing FROM command in Dockerfile')
image_and_tag = ':'.join(image_and_tag_tuple)
self._build_logger('Pulling base image: %s' % image_and_tag, log_data = { self._build_logger('Pulling base image: %s' % image_and_tag, log_data = {
'phasestep': 'pull', 'phasestep': 'pull',
'repo_url': image_and_tag 'repo_url': image_and_tag
@ -478,9 +480,8 @@ class DockerfileBuildWorker(Worker):
def watchdog(self): def watchdog(self):
logger.debug('Running build watchdog code.') logger.debug('Running build watchdog code.')
try: try:
docker_cl = Client() docker_cl = Client(base_url = DOCKER_BASE_URL)
# Iterate the running containers and kill ones that have been running more than 20 minutes # Iterate the running containers and kill ones that have been running more than 20 minutes
for container in docker_cl.containers(): for container in docker_cl.containers():
@ -519,7 +520,20 @@ class DockerfileBuildWorker(Worker):
log_appender = partial(build_logs.append_log_message, log_appender = partial(build_logs.append_log_message,
repository_build.uuid) repository_build.uuid)
log_appender('initializing', build_logs.PHASE) # Lookup and save the version of docker being used.
docker_cl = Client(base_url = DOCKER_BASE_URL)
docker_version = docker_cl.version().get('Version', '')
dash = docker_version.find('-')
# Strip any -tutum or whatever off of the version.
if dash > 0:
docker_version = docker_version[:dash]
log_appender('initializing', build_logs.PHASE, log_data = {
'docker_version': docker_version
})
log_appender('Docker version: %s' % docker_version)
start_msg = ('Starting job with resource url: %s repo: %s' % (resource_url, start_msg = ('Starting job with resource url: %s repo: %s' % (resource_url,
repo)) repo))

View file

@ -8,6 +8,7 @@ from workers.worker import Worker
from endpoints.notificationmethod import NotificationMethod, InvalidNotificationMethodException from endpoints.notificationmethod import NotificationMethod, InvalidNotificationMethodException
from endpoints.notificationevent import NotificationEvent, InvalidNotificationEventException from endpoints.notificationevent import NotificationEvent, InvalidNotificationEventException
from workers.worker import JobException
from data import model from data import model
@ -29,7 +30,7 @@ class NotificationWorker(Worker):
notification = model.get_repo_notification(repo_namespace, repo_name, notification_uuid) notification = model.get_repo_notification(repo_namespace, repo_name, notification_uuid)
if not notification: if not notification:
# Probably deleted. # Probably deleted.
return True return
event_name = notification.event.name event_name = notification.event.name
method_name = notification.method.name method_name = notification.method.name
@ -39,15 +40,17 @@ class NotificationWorker(Worker):
method_handler = NotificationMethod.get_method(method_name) method_handler = NotificationMethod.get_method(method_name)
except InvalidNotificationMethodException as ex: except InvalidNotificationMethodException as ex:
logger.exception('Cannot find notification method: %s' % ex.message) logger.exception('Cannot find notification method: %s' % ex.message)
return False raise JobException('Cannot find notification method: %s' % ex.message)
except InvalidNotificationEventException as ex: except InvalidNotificationEventException as ex:
logger.exception('Cannot find notification method: %s' % ex.message) logger.exception('Cannot find notification event: %s' % ex.message)
return False raise JobException('Cannot find notification event: %s' % ex.message)
return method_handler.perform(notification, event_handler, job_details) method_handler.perform(notification, event_handler, job_details)
logging.config.fileConfig('conf/logging.conf', disable_existing_loggers=False)
worker = NotificationWorker(notification_queue, poll_period_seconds=15, if __name__ == "__main__":
reservation_seconds=3600) logging.config.fileConfig('conf/logging.conf', disable_existing_loggers=False)
worker.start()
worker = NotificationWorker(notification_queue, poll_period_seconds=10, reservation_seconds=30,
retry_after_seconds=30)
worker.start()

View file

@ -63,11 +63,12 @@ class WorkerStatusHandler(BaseHTTPRequestHandler):
class Worker(object): class Worker(object):
def __init__(self, queue, poll_period_seconds=30, reservation_seconds=300, def __init__(self, queue, poll_period_seconds=30, reservation_seconds=300,
watchdog_period_seconds=60): watchdog_period_seconds=60, retry_after_seconds=300):
self._sched = BackgroundScheduler() self._sched = BackgroundScheduler()
self._poll_period_seconds = poll_period_seconds self._poll_period_seconds = poll_period_seconds
self._reservation_seconds = reservation_seconds self._reservation_seconds = reservation_seconds
self._watchdog_period_seconds = watchdog_period_seconds self._watchdog_period_seconds = watchdog_period_seconds
self._retry_after_seconds = retry_after_seconds
self._stop = Event() self._stop = Event()
self._terminated = Event() self._terminated = Event()
self._queue = queue self._queue = queue
@ -103,7 +104,8 @@ class Worker(object):
try: try:
self.watchdog() self.watchdog()
except WorkerUnhealthyException as exc: except WorkerUnhealthyException as exc:
logger.error('The worker has encountered an error via watchdog and will not take new jobs: %s' % exc.message) logger.error('The worker has encountered an error via watchdog and will not take new jobs')
logger.error(exc.message)
self.mark_current_incomplete(restore_retry=True) self.mark_current_incomplete(restore_retry=True)
self._stop.set() self._stop.set()
@ -111,7 +113,7 @@ class Worker(object):
logger.debug('Getting work item from queue.') logger.debug('Getting work item from queue.')
with self._current_item_lock: with self._current_item_lock:
self.current_queue_item = self._queue.get() self.current_queue_item = self._queue.get(processing_time=self._reservation_seconds)
while True: while True:
# Retrieve the current item in the queue over which to operate. We do so under # Retrieve the current item in the queue over which to operate. We do so under
@ -129,12 +131,14 @@ class Worker(object):
self.process_queue_item(job_details) self.process_queue_item(job_details)
self.mark_current_complete() self.mark_current_complete()
except JobException: except JobException as jex:
logger.warning('An error occurred processing request: %s', current_queue_item.body) logger.warning('An error occurred processing request: %s', current_queue_item.body)
logger.warning('Job exception: %s' % jex)
self.mark_current_incomplete(restore_retry=False) self.mark_current_incomplete(restore_retry=False)
except WorkerUnhealthyException as exc: except WorkerUnhealthyException as exc:
logger.error('The worker has encountered an error via the job and will not take new jobs: %s' % exc.message) logger.error('The worker has encountered an error via the job and will not take new jobs')
logger.error(exc.message)
self.mark_current_incomplete(restore_retry=True) self.mark_current_incomplete(restore_retry=True)
self._stop.set() self._stop.set()
@ -190,7 +194,8 @@ class Worker(object):
def mark_current_incomplete(self, restore_retry=False): def mark_current_incomplete(self, restore_retry=False):
with self._current_item_lock: with self._current_item_lock:
if self.current_queue_item is not None: if self.current_queue_item is not None:
self._queue.incomplete(self.current_queue_item, restore_retry=restore_retry) self._queue.incomplete(self.current_queue_item, restore_retry=restore_retry,
retry_after=self._retry_after_seconds)
self.current_queue_item = None self.current_queue_item = None
def mark_current_complete(self): def mark_current_complete(self):