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 TEST=true venv/bin/python -m unittest discover
VOLUME ["/conf/stack", "/var/log", "/datastorage"]
VOLUME ["/conf/stack", "/var/log", "/datastorage", "/tmp"]
EXPOSE 443 80

View file

@ -7,7 +7,7 @@ from peewee import Proxy
from app import app as application
from flask import request, Request
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
logging.getLogger('boto').setLevel(logging.CRITICAL)

View file

@ -25,7 +25,7 @@ def _load_user_from_cookie():
if not current_user.is_anonymous():
logger.debug('Loading user from cookie: %s', 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)
return current_user.db_user()
return None
@ -58,12 +58,10 @@ def _validate_and_apply_oauth_token(token):
set_authenticated_user(validated.authorized_user)
set_validated_oauth_token(validated)
new_identity = QuayDeferredPermissionUser(validated.authorized_user.username, 'username',
scope_set)
new_identity = QuayDeferredPermissionUser(validated.authorized_user.id, 'user_db_id', scope_set)
identity_changed.send(app, identity=new_identity)
def process_basic_auth(auth):
normalized = [part.strip() for part in auth.split(' ') if part]
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])
set_authenticated_user(robot)
deferred_robot = QuayDeferredPermissionUser(robot.username, 'username',
{scopes.DIRECT_LOGIN})
deferred_robot = QuayDeferredPermissionUser(robot.id, 'user_db_id', {scopes.DIRECT_LOGIN})
identity_changed.send(app, identity=deferred_robot)
return
except model.InvalidRobotException:
@ -114,7 +111,7 @@ def process_basic_auth(auth):
logger.debug('Successfully validated user: %s' % authenticated.username)
set_authenticated_user(authenticated)
new_identity = QuayDeferredPermissionUser(authenticated.username, 'username',
new_identity = QuayDeferredPermissionUser(authenticated.id, 'user_db_id',
{scopes.DIRECT_LOGIN})
identity_changed.send(app, identity=new_identity)
return

View file

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

View file

@ -58,8 +58,8 @@ SCOPE_MAX_USER_ROLES.update({
class QuayDeferredPermissionUser(Identity):
def __init__(self, id, auth_type, scopes):
super(QuayDeferredPermissionUser, self).__init__(id, auth_type)
def __init__(self, db_id, auth_type, scopes):
super(QuayDeferredPermissionUser, self).__init__(db_id, auth_type)
self._permissions_loaded = False
self._scope_set = scopes
@ -88,7 +88,7 @@ class QuayDeferredPermissionUser(Identity):
def can(self, permission):
if not self._permissions_loaded:
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.
if (user_object.username is not None and
@ -112,7 +112,7 @@ class QuayDeferredPermissionUser(Identity):
# Add repository permissions
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))
logger.debug('User added permission: {0}'.format(repo_grant))
self.provides.add(repo_grant)
@ -230,16 +230,16 @@ def on_identity_loaded(sender, identity):
if isinstance(identity, QuayDeferredPermissionUser):
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)
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)
elif identity.auth_type == 'token':
logger.debug('Loading permissions for token: %s', 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.role.name)
logger.debug('Delegate token added permission: {0}'.format(repo_grant))

View file

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

View file

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

View file

@ -24,4 +24,16 @@ location / {
proxy_pass http://app_server;
proxy_read_timeout 2000;
proxy_temp_path /var/log/nginx/proxy_temp 1 2;
}
location /static/ {
# checks for static file, if not found proxy to app
alias /static/;
}
location /v1/_ping {
add_header Content-Type text/plain;
add_header X-Docker-Registry-Version 0.6.0;
add_header X-Docker-Registry-Standalone 0;
return 200 'okay';
}

View file

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

View file

@ -16,8 +16,8 @@ class RedisBuildLogs(object):
COMMAND = 'command'
PHASE = 'phase'
def __init__(self, redis_host):
self._redis = redis.StrictRedis(host=redis_host)
def __init__(self, redis_config):
self._redis = redis.StrictRedis(socket_connect_timeout=5, **redis_config)
@staticmethod
def _logs_key(build_id):
@ -104,7 +104,13 @@ class BuildLogs(object):
self.state = None
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_import = app.config.get('BUILDLOGS_MODULE_AND_CLASS', None)
@ -113,7 +119,7 @@ class BuildLogs(object):
else:
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
app.extensions = getattr(app, 'extensions', {})

View file

@ -8,7 +8,7 @@ from peewee import *
from data.read_slave import ReadSlaveModel
from sqlalchemy.engine.url import make_url
from urlparse import urlparse
from util.names import urn_generator
logger = logging.getLogger(__name__)
@ -21,8 +21,24 @@ SCHEME_DRIVERS = {
'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()
read_slave = Proxy()
db_random_func = CallableProxy()
def _db_from_url(url, db_kwargs):
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)
def configure(config_object):
db_kwargs = dict(config_object['DB_CONNECTION_ARGS'])
write_db_uri = config_object['DB_URI']
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)
if read_slave_uri is not None:
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):
name = CharField(unique=True, index=True)
@ -139,7 +168,7 @@ class Visibility(BaseModel):
class Repository(BaseModel):
namespace = CharField()
namespace_user = ForeignKeyField(User)
name = CharField()
visibility = ForeignKeyField(Visibility)
description = TextField(null=True)
@ -150,7 +179,7 @@ class Repository(BaseModel):
read_slaves = (read_slave,)
indexes = (
# 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):
uuid = CharField(default=uuid_generator)
uuid = CharField(default=uuid_generator, index=True)
checksum = CharField(null=True)
created = DateTimeField(null=True)
comment = TextField(null=True)
@ -333,7 +362,7 @@ class RepositoryBuild(BaseModel):
class QueueItem(BaseModel):
queue_name = CharField(index=True, max_length=1024)
body = TextField()
available_after = DateTimeField(default=datetime.now, index=True)
available_after = DateTimeField(default=datetime.utcnow, index=True)
available = BooleanField(default=True, index=True)
processing_expires = DateTimeField(null=True, index=True)
retries_remaining = IntegerField(default=5)
@ -438,4 +467,5 @@ all_models = [User, Repository, Image, AccessToken, Role, RepositoryPermission,
OAuthApplication, OAuthAuthorizationCode, OAuthAccessToken, NotificationKind,
Notification, ImageStorageLocation, ImageStoragePlacement,
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.drop_index('logentrykind_name', table_name='logentrykind')
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('command', mysql.LONGTEXT(), nullable=True))
op.add_column('image', sa.Column('image_size', mysql.BIGINT(display_width=20), nullable=True))
op.add_column('image', sa.Column('checksum', mysql.VARCHAR(length=255), nullable=True))
op.add_column('image', sa.Column('comment', mysql.LONGTEXT(), nullable=True))
op.add_column('image', sa.Column('created', sa.DateTime(), nullable=True))
op.add_column('image', sa.Column('command', sa.Text(), nullable=True))
op.add_column('image', sa.Column('image_size', sa.BigInteger(), nullable=True))
op.add_column('image', sa.Column('checksum', sa.String(length=255), nullable=True))
op.add_column('image', sa.Column('comment', sa.Text(), nullable=True))
op.drop_index('buildtriggerservice_name', table_name='buildtriggerservice')
op.create_index('buildtriggerservice_name', 'buildtriggerservice', ['name'], unique=False)
### 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
Revision ID: 6f2ecf5afcf
Revises: 3f6d26399bd2
Revises: 13da56878560
Create Date: 2014-09-22 14:39:13.470566
"""
# revision identifiers, used by Alembic.
revision = '6f2ecf5afcf'
down_revision = '3f6d26399bd2'
down_revision = '13da56878560'
from alembic import op
from tools.uncompressedsize import backfill_sizes
import sqlalchemy as sa
@ -20,9 +19,6 @@ def upgrade(tables):
op.add_column('imagestorage', sa.Column('uncompressed_size', sa.BigInteger(), nullable=True))
### end Alembic commands ###
# Backfill the uncompressed size to the image storage table.
backfill_sizes()
def downgrade(tables):
### commands auto generated by Alembic - please adjust! ###
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):
### commands auto generated by Alembic - please adjust! ###
op.create_table('webhook',
sa.Column('id', mysql.INTEGER(display_width=11), nullable=False),
sa.Column('public_id', mysql.VARCHAR(length=255), nullable=False),
sa.Column('repository_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('parameters', mysql.LONGTEXT(), nullable=False),
sa.ForeignKeyConstraint(['repository_id'], [u'repository.id'], name=u'fk_webhook_repository_repository_id'),
sa.PrimaryKeyConstraint('id'),
mysql_default_charset=u'latin1',
mysql_engine=u'InnoDB'
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('public_id', sa.String(length=255), nullable=False),
sa.Column('repository_id', sa.Integer(), nullable=False),
sa.Column('parameters', sa.Text(), nullable=False),
sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], ),
sa.PrimaryKeyConstraint('id')
)
### 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):
metadata = MetaData()
metadata = MetaData(naming_convention={
"ix": 'ix_%(column_0_label)s',
"uq": "uq_%(table_name)s_%(column_0_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s"
})
for model in peewee_model_list:
meta = model._meta

View file

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

View file

@ -7,14 +7,14 @@ class UserEventBuilder(object):
Defines a helper class for constructing UserEvent and UserEventListener
instances.
"""
def __init__(self, redis_host):
self._redis_host = redis_host
def __init__(self, redis_config):
self._redis_config = redis_config
def get_event(self, username):
return UserEvent(self._redis_host, username)
return UserEvent(self._redis_config, username)
def get_listener(self, username, events):
return UserEventListener(self._redis_host, username, events)
return UserEventListener(self._redis_config, username, events)
class UserEventsBuilderModule(object):
@ -26,8 +26,14 @@ class UserEventsBuilderModule(object):
self.state = None
def init_app(self, app):
redis_hostname = app.config.get('USER_EVENTS_REDIS_HOSTNAME')
user_events = UserEventBuilder(redis_hostname)
redis_config = app.config.get('USER_EVENTS_REDIS')
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
app.extensions = getattr(app, 'extensions', {})
@ -43,8 +49,8 @@ class UserEvent(object):
Defines a helper class for publishing to realtime user events
as backed by Redis.
"""
def __init__(self, redis_host, username):
self._redis = redis.StrictRedis(host=redis_host)
def __init__(self, redis_config, username):
self._redis = redis.StrictRedis(socket_connect_timeout=5, **redis_config)
self._username = username
@staticmethod
@ -74,10 +80,10 @@ class UserEventListener(object):
Defines a helper class for subscribing to realtime user events as
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]
self._redis = redis.StrictRedis(host=redis_host)
self._redis = redis.StrictRedis(socket_connect_timeout=5, **redis_config)
self._pubsub = self._redis.pubsub()
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.init_app(api_bp)
api.decorators = [csrf_protect,
process_oauth,
crossdomain(origin='*', headers=['Authorization', 'Content-Type'])]
crossdomain(origin='*', headers=['Authorization', 'Content-Type']),
process_oauth]
class ApiException(Exception):
@ -90,6 +90,7 @@ def handle_api_error(error):
if error.error_type is not None:
response.headers['WWW-Authenticate'] = ('Bearer error="%s" error_description="%s"' %
(error.error_type, error.error_description))
return response
@ -191,6 +192,7 @@ def query_param(name, help_str, type=reqparse.text_type, default=None,
'default': default,
'choices': choices,
'required': required,
'location': ('args')
})
return func
return add_param

View file

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

View file

@ -125,7 +125,11 @@ def swagger_route_data(include_internal=False, compact=False):
new_operation['requires_fresh_login'] = True
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)
new_resource = {

View file

@ -9,22 +9,33 @@ from data import model
from util.cache import cache_control_flask_restful
def image_view(image):
def image_view(image, image_map):
extended_props = image
if image.storage and image.storage.id:
extended_props = image.storage
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 {
'id': image.docker_image_id,
'created': format_date(extended_props.created),
'comment': extended_props.comment,
'command': json.loads(command) if command else None,
'ancestors': image.ancestors,
'dbid': image.id,
'size': extended_props.image_size,
'locations': list(image.storage.locations),
'uploading': image.storage.uploading,
'ancestors': ancestors_string,
'sort_index': len(image.ancestors)
}
@ -42,14 +53,16 @@ class RepositoryImageList(RepositoryParamResource):
for tag in all_tags:
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):
image_json['tags'] = tags_by_image_id[image_json['id']]
return image_json
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:
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')

View file

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

View file

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

View file

@ -111,7 +111,7 @@ class FindRepositories(ApiResource):
def repo_view(repo):
return {
'namespace': repo.namespace,
'namespace': repo.namespace_user.username,
'name': repo.name,
'description': repo.description
}
@ -125,5 +125,5 @@ class FindRepositories(ApiResource):
return {
'repositories': [repo_view(repo) for repo in matching
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 json
from random import SystemRandom
from app import app
from flask import request
from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error,
log_action, internal_only, NotFound, require_user_admin, format_date,
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 data import model
from auth.permissions import SuperUserPermission
from auth.auth_context import get_authenticated_user
from util.useremails import send_confirmation_email, send_recovery_email
import features
@ -55,6 +57,26 @@ def user_view(user):
@show_if(features.SUPER_USERS)
class SuperUserList(ApiResource):
""" 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')
def get(self):
""" Returns a list of all users in the system. """
@ -67,6 +89,63 @@ class SuperUserList(ApiResource):
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>')
@internal_only
@show_if(features.SUPER_USERS)
@ -90,18 +169,20 @@ class SuperUserManagement(ApiResource):
},
}
@require_fresh_login
@nickname('getInstallUser')
def get(self, username):
""" Returns information about the specified user. """
if SuperUserPermission().can():
user = model.get_user(username)
if not user or user.organization or user.robot:
abort(404)
return user_view(user)
user = model.get_user(username)
if not user or user.organization or user.robot:
abort(404)
return user_view(user)
abort(403)
@require_fresh_login
@nickname('deleteInstallUser')
def delete(self, username):
""" Deletes the specified user. """
@ -118,6 +199,7 @@ class SuperUserManagement(ApiResource):
abort(403)
@require_fresh_login
@nickname('changeInstallUser')
@validate_json_request('UpdateUser')
def put(self, username):

View file

@ -85,11 +85,14 @@ class RepositoryTagImages(RepositoryParamResource):
raise NotFound()
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.reverse()
all_images = [tag_image] + parents
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 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.auth_context import get_authenticated_user
from auth import scopes
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):
view_permission = ViewTeamPermission(orgname, team.name)
@ -19,14 +58,28 @@ def team_view(orgname, team):
'role': role
}
def member_view(member):
def member_view(member, invited=False):
return {
'name': member.username,
'kind': 'user',
'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>')
@internal_only
class OrganizationTeam(ApiResource):
@ -114,8 +167,10 @@ class OrganizationTeam(ApiResource):
@internal_only
class TeamMemberList(ApiResource):
""" 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')
def get(self, orgname, teamname):
def get(self, args, orgname, teamname):
""" Retrieve the list of members for the specified team. """
view_permission = ViewTeamPermission(orgname, teamname)
edit_permission = AdministerOrganizationPermission(orgname)
@ -128,11 +183,18 @@ class TeamMemberList(ApiResource):
raise NotFound()
members = model.get_organization_team_members(team.id)
return {
'members': {m.username : member_view(m) for m in members},
invites = []
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()
}
return data
raise Unauthorized()
@ -142,7 +204,7 @@ class TeamMember(ApiResource):
@require_scope(scopes.ORG_ADMIN)
@nickname('updateOrganizationTeamMember')
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)
if permission.can():
team = None
@ -159,23 +221,151 @@ class TeamMember(ApiResource):
if not user:
raise request_error(message='Unknown user')
# Add the user to the team.
model.add_user_to_team(user, team)
log_action('org_add_team_member', orgname, {'member': membername, 'team': teamname})
return member_view(user)
# Add or invite the user to the team.
inviter = get_authenticated_user()
invite = handle_addinvite_team(inviter, team, user=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()
@require_scope(scopes.ORG_ADMIN)
@nickname('deleteOrganizationTeamMember')
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)
if permission.can():
# Remote the user from the team.
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)
log_action('org_remove_team_member', orgname, {'member': membername, 'team': teamname})
return 'Deleted', 204
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.trigger import (BuildTrigger as BuildTriggerBase, TriggerDeactivationException,
TriggerActivationException, EmptyRepositoryException,
RepositoryReadException)
RepositoryReadException, TriggerStartException)
from data import model
from auth.permissions import UserAdminPermission, AdministerOrganizationPermission, ReadRepositoryPermission
from util.names import parse_robot_username
@ -205,7 +205,7 @@ class BuildTriggerActivate(RepositoryParamResource):
'write')
try:
repository_path = '%s/%s' % (trigger.repository.namespace,
repository_path = '%s/%s' % (trigger.repository.namespace_user.username,
trigger.repository.name)
path = url_for('webhooks.build_trigger_webhook',
repository=repository_path, trigger_uuid=trigger.uuid)
@ -374,9 +374,24 @@ class BuildTriggerAnalyze(RepositoryParamResource):
@resource('/v1/repository/<repopath:repository>/trigger/<trigger_uuid>/start')
class ActivateBuildTrigger(RepositoryParamResource):
""" 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
@nickname('manuallyStartBuildTrigger')
@validate_json_request('RunParameters')
def post(self, namespace, repository, trigger_uuid):
""" Manually start a build from the specified trigger. """
try:
@ -389,14 +404,18 @@ class ActivateBuildTrigger(RepositoryParamResource):
if not handler.is_active(config_dict):
raise InvalidRequest('Trigger is not active.')
specs = handler.manual_start(trigger.auth_token, config_dict)
dockerfile_id, tags, name, subdir = specs
try:
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)
pull_robot_name = model.get_pull_robot_name(trigger)
repo = model.get_repository(namespace, repository)
pull_robot_name = model.get_pull_robot_name(trigger)
build_request = start_build(repo, dockerfile_id, tags, name, subdir, True,
pull_robot_name=pull_robot_name)
build_request = start_build(repo, dockerfile_id, tags, name, subdir, True,
pull_robot_name=pull_robot_name)
except TriggerStartException as tse:
raise InvalidRequest(tse.message)
resp = build_status_view(build_request, True)
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')
@internal_only
class BuildTriggerSources(RepositoryParamResource):

View file

@ -12,6 +12,8 @@ from endpoints.api import (ApiResource, nickname, resource, validate_json_reques
license_error, require_fresh_login)
from endpoints.api.subscribe import subscribe
from endpoints.common import common_login
from endpoints.api.team import try_accept_invite
from data import model
from data.billing import get_plan
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 import scopes
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
@ -117,6 +120,10 @@ class User(ApiResource):
'type': 'string',
'description': 'The user\'s email address',
},
'invite_code': {
'type': 'string',
'description': 'The optional invite code'
}
}
},
'UpdateUser': {
@ -166,6 +173,9 @@ class User(ApiResource):
log_action('account_change_password', user.username)
model.change_password(user, user_data['password'])
if features.MAILING:
send_password_changed(user.username, user.email)
if 'invoice_email' in user_data:
logger.debug('Changing invoice_email for user: %s', user.username)
model.change_invoice_email(user, user_data['invoice_email'])
@ -176,22 +186,27 @@ class User(ApiResource):
# Email already used.
raise request_error(message='E-mail address already used')
logger.debug('Sending email to change email address for user: %s',
user.username)
code = model.create_confirm_email_code(user, new_email=new_email)
send_change_email(user.username, user_data['email'], code.code)
if features.MAILING:
logger.debug('Sending email to change email address for user: %s',
user.username)
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:
raise request_error(exception=ex)
return user_view(user)
@show_if(features.USER_CREATION)
@nickname('createNewUser')
@internal_only
@validate_json_request('NewUser')
def post(self):
""" Create a new user. """
user_data = request.get_json()
invite_code = user_data.get('invite_code', '')
existing_user = model.get_user(user_data['username'])
if existing_user:
@ -199,10 +214,29 @@ class User(ApiResource):
try:
new_user = model.create_user(user_data['username'], user_data['password'],
user_data['email'])
code = model.create_confirm_email_code(new_user)
send_confirmation_email(new_user.username, new_user.email, code.code)
return 'Created', 201
user_data['email'], auto_verify=not features.MAILING)
# Handle any invite codes.
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:
raise license_error(exception=ex)
except model.DataModelException as ex:
@ -422,6 +456,7 @@ class DetachExternal(ApiResource):
@resource("/v1/recovery")
@show_if(features.MAILING)
@internal_only
class Recovery(ApiResource):
""" 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.'):
return render_page_template('ologinerror.html', service_name=service_name,
error_message=error_message,
service_url=get_app_url())
service_url=get_app_url(),
user_creation=features.USER_CREATION)
def exchange_code_for_token(code, service_name='GITHUB', for_login=True, form_encode=False,
redirect_suffix=''):
@ -85,7 +86,12 @@ def get_google_user(token):
def conduct_oauth_login(service_name, user_id, username, email, metadata={}):
to_login = model.verify_federated_login(service_name.lower(), user_id)
if not to_login:
# try to create the user
# See if we can create a new user.
if not features.USER_CREATION:
error_message = 'User creation is disabled. Please contact your administrator'
return render_ologin_error(service_name, error_message)
# Try to create the user
try:
valid = next(generate_valid_usernames(username))
to_login = model.create_federated_user(valid, email, service_name.lower(),
@ -147,7 +153,7 @@ def github_oauth_callback():
token = exchange_code_for_token(request.args.get('code'), service_name='GITHUB')
user_data = get_github_user(token)
if not user_data:
if not user_data or not 'login' in user_data:
return render_ologin_error('GitHub')
username = user_data['login']

View file

@ -82,20 +82,23 @@ def param_required(param_name):
@login_manager.user_loader
def load_user(username):
logger.debug('User loader loading deferred user: %s' % username)
return _LoginWrappedDBUser(username)
def load_user(user_db_id):
logger.debug('User loader loading deferred user id: %s' % user_db_id)
try:
user_db_id_int = int(user_db_id)
return _LoginWrappedDBUser(user_db_id_int)
except ValueError:
return None
class _LoginWrappedDBUser(UserMixin):
def __init__(self, db_username, db_user=None):
self._db_username = db_username
def __init__(self, user_db_id, db_user=None):
self._db_id = user_db_id
self._db_user = db_user
def db_user(self):
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
def is_authenticated(self):
@ -105,13 +108,13 @@ class _LoginWrappedDBUser(UserMixin):
return self.db_user().verified
def get_id(self):
return unicode(self._db_username)
return unicode(self._db_id)
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)
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)
session['login_time'] = datetime.datetime.now()
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,
trigger=None, pull_robot_name=None):
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')
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,
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,
'namespace': repository.namespace,
'namespace': repository.namespace_user.username,
'repository': repository.name,
'pull_credentials': model.get_pull_credentials(pull_robot_name) if pull_robot_name else None
}), 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.
metadata = {
'repo': repository.name,
'namespace': repository.namespace,
'namespace': repository.namespace_user.username,
'fileid': dockerfile_id,
'manual': manual,
}
@ -238,9 +241,8 @@ def start_build(repository, dockerfile_id, tags, build_name, subdir, manual,
metadata['config'] = json.loads(trigger.config)
metadata['service'] = trigger.service.name
model.log_action('build_dockerfile', repository.namespace,
ip=request.remote_addr, metadata=metadata,
repository=repository)
model.log_action('build_dockerfile', repository.namespace_user.username, ip=request.remote_addr,
metadata=metadata, repository=repository)
# Add notifications for the build queue.
profile.debug('Adding notifications for repository')

View file

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

View file

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

View file

@ -4,7 +4,7 @@ from data import model
import json
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'],
app.config['SERVER_HOSTNAME'],
repo_string)
@ -17,7 +17,7 @@ def build_event_data(repo, extra_data={}, subpage=None):
event_data = {
'repository': repo_string,
'namespace': repo.namespace,
'namespace': repo.namespace_user.username,
'name': repo.name,
'docker_url': '%s/%s' % (app.config['SERVER_HOSTNAME'], repo_string),
'homepage': homepage,
@ -30,7 +30,7 @@ def build_event_data(repo, extra_data={}, subpage=None):
def build_notification_data(notification, event_data):
return {
'notification_uuid': notification.uuid,
'repository_namespace': notification.repository.namespace,
'repository_namespace': notification.repository.namespace_user.username,
'repository_name': notification.repository.name,
'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=[]):
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:
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))

View file

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

View file

@ -14,6 +14,7 @@ from util.http import abort, exact_abort
from auth.permissions import (ReadRepositoryPermission,
ModifyRepositoryPermission)
from data import model
from util import gzipstream
registry = Blueprint('registry', __name__)
@ -193,21 +194,33 @@ def put_image_layer(namespace, repository, image_id):
# encoding (Gunicorn)
input_stream = request.environ['wsgi.input']
# compute checksums
csums = []
# Create a socket reader to read the input stream containing the layer data.
sr = SocketReader(input_stream)
# Add a handler that store the data in storage.
tmp, store_hndlr = store.temp_store_handler()
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)
sr.add_handler(sum_hndlr)
# Stream write the data to storage.
store.stream_write(repo_image.storage.locations, layer_path, sr)
# Append the computed checksum.
csums = []
csums.append('sha256:{0}'.format(h.hexdigest()))
try:
image_size = tmp.tell()
# Save the size of the image.
model.set_image_size(image_id, namespace, repository, image_size)
model.set_image_size(image_id, namespace, repository, image_size, uncompressed_size_info.size)
tmp.seek(0)
csums.append(checksums.compute_tarsum(tmp, json_data))
@ -451,12 +464,6 @@ def put_image_json(namespace, repository, image_id):
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
# on a failed push
# save the metadata

View file

@ -36,6 +36,9 @@ class TriggerActivationException(Exception):
class TriggerDeactivationException(Exception):
pass
class TriggerStartException(Exception):
pass
class ValidationRequestException(Exception):
pass
@ -109,12 +112,19 @@ class BuildTrigger(object):
"""
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.
"""
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
def service_name(cls):
"""
@ -345,14 +355,37 @@ class GithubBuildTrigger(BuildTrigger):
return GithubBuildTrigger._prepare_build(config, repo, commit_sha,
short_sha, ref)
def manual_start(self, auth_token, config):
source = config['build_source']
def manual_start(self, auth_token, config, run_parameters = None):
try:
source = config['build_source']
run_parameters = run_parameters or {}
gh_client = self._get_client(auth_token)
repo = gh_client.get_repo(source)
master = repo.get_branch(repo.default_branch)
master_sha = master.commit.sha
short_sha = GithubBuildTrigger.get_display_name(master_sha)
ref = 'refs/heads/%s' % repo.default_branch
gh_client = self._get_client(auth_token)
repo = gh_client.get_repo(source)
master = repo.get_branch(repo.default_branch)
master_sha = master.commit.sha
short_sha = GithubBuildTrigger.get_display_name(master_sha)
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 util.names import parse_repository_name
from util.gravatar import compute_hash
from util.useremails import send_email_changed
from auth import scopes
import features
@ -32,8 +33,8 @@ STATUS_TAGS = app.config['STATUS_TAGS']
@web.route('/', methods=['GET'], defaults={'path': ''})
@web.route('/organization/<path:path>', methods=['GET'])
@no_cache
def index(path):
return render_page_template('index.html')
def index(path, **kwargs):
return render_page_template('index.html', **kwargs)
@web.route('/500', methods=['GET'])
@ -101,7 +102,7 @@ def superuser():
@web.route('/signin/')
@no_cache
def signin():
def signin(redirect=None):
return index('')
@ -123,6 +124,13 @@ def new():
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/<path:path>', methods=['GET'])
@no_cache
@ -215,6 +223,7 @@ def receipt():
@web.route('/authrepoemail', methods=['GET'])
@route_show_if(features.MAILING)
def confirm_repo_email():
code = request.values['code']
record = None
@ -228,23 +237,27 @@ def confirm_repo_email():
Your E-mail address has been authorized to receive notifications for repository
<a href="%s://%s/repository/%s/%s">%s/%s</a>.
""" % (app.config['PREFERRED_URL_SCHEME'], app.config['SERVER_HOSTNAME'],
record.repository.namespace, record.repository.name,
record.repository.namespace, record.repository.name)
record.repository.namespace_user.username, record.repository.name,
record.repository.namespace_user.username, record.repository.name)
return render_page_template('message.html', message=message)
@web.route('/confirm', methods=['GET'])
@route_show_if(features.MAILING)
def confirm_email():
code = request.values['code']
user = None
new_email = None
try:
user, new_email = model.confirm_user_email(code)
user, new_email, old_email = model.confirm_user_email(code)
except model.DataModelException as ex:
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)
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):
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)
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)
command_list = SAMPLE_CMDS[image_num % len(SAMPLE_CMDS)]
command = json.dumps(command_list) if command_list else None
new_image = model.set_image_metadata(docker_image_id, repo.namespace,
repo.name, str(creation_time),
'no comment', command, parent)
new_image = model.set_image_metadata(docker_image_id, repo.namespace_user.username, repo.name,
str(creation_time), 'no comment', command, parent)
model.set_image_size(docker_image_id, repo.namespace, repo.name,
random.randrange(1, 1024 * 1024 * 1024))
compressed_size = random.randrange(1, 1024 * 1024 * 1024)
model.set_image_size(docker_image_id, repo.namespace_user.username, repo.name, compressed_size,
int(compressed_size * 1.4))
# Populate the diff file
diff_path = store.image_file_diffs_path(new_image.storage.uuid)
@ -100,7 +100,7 @@ def __create_subtree(repo, structure, creator_username, parent):
last_node_tags = [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)
for subtree in subtrees:
@ -214,7 +214,11 @@ def initialize_database():
LogEntryKind.create(name='org_create_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_team_member_invite_accepted')
LogEntryKind.create(name='org_team_member_invite_declined')
LogEntryKind.create(name='org_remove_team_member')
LogEntryKind.create(name='org_set_team_description')
LogEntryKind.create(name='org_set_team_role')
@ -271,6 +275,7 @@ def initialize_database():
NotificationKind.create(name='over_private_usage')
NotificationKind.create(name='expiring_license')
NotificationKind.create(name='maintenance')
NotificationKind.create(name='org_team_invite')
NotificationKind.create(name='test_notification')
@ -302,7 +307,7 @@ def populate_database():
new_user_2.verified = True
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.save()
@ -323,7 +328,8 @@ def populate_database():
outside_org.verified = True
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()
to_date = from_date + timedelta(hours=1)
@ -387,18 +393,20 @@ def populate_database():
})
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 = {
'repository': repo,
'docker_tags': ['latest'],
'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.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,
'68daeebd-a5b9-457f-80a0-4363b882f8ea',
@ -425,12 +433,12 @@ def populate_database():
model.create_robot('coolrobot', org)
oauth.create_application(org, 'Some Test App', 'http://localhost:8000', 'http://localhost:8000/o2c.html',
client_id='deadbeef')
oauth.create_application(org, 'Some Test App', 'http://localhost:8000',
'http://localhost:8000/o2c.html', client_id='deadbeef')
oauth.create_application(org, 'Some Other Test App', 'http://quay.io', 'http://localhost:8000/o2c.html',
client_id='deadpork',
description = 'This is another test application')
oauth.create_application(org, 'Some Other Test App', 'http://quay.io',
'http://localhost:8000/o2c.html', client_id='deadpork',
description='This is another test application')
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',
'Readers of orgrepo.')
model.set_team_repo_permission(reader_team.name, org_repo.namespace,
org_repo.name, 'read')
model.set_team_repo_permission(reader_team.name, org_repo.namespace_user.username, org_repo.name,
'read')
model.add_user_to_team(new_user_2, reader_team)
model.add_user_to_team(reader, reader_team)
@ -475,12 +483,9 @@ def populate_database():
(2, [], 'latest17'),
(2, [], 'latest18'),])
model.add_prototype_permission(org, 'read', activating_user=new_user_1,
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, 'write', activating_user=new_user_2,
delegate_user=new_user_1)
model.add_prototype_permission(org, 'read', activating_user=new_user_1, 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, 'write', activating_user=new_user_2, delegate_user=new_user_1)
today = datetime.today()
week_ago = today - timedelta(6)

View file

@ -144,6 +144,15 @@ nav.navbar-default .navbar-nav>li>a.active {
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 {
margin-top: 8px;
float: left;
@ -3593,6 +3602,12 @@ p.editable:hover i {
white-space: nowrap;
}
.tt-message {
padding: 10px;
font-size: 12px;
white-space: nowrap;
}
.tt-suggestion p {
margin: 0;
}
@ -4284,7 +4299,7 @@ pre.command:before {
}
.user-row.super-user td {
background-color: #d9edf7;
background-color: #eeeeee;
}
.user-row .user-class {
@ -4672,4 +4687,68 @@ i.slack-icon {
.external-notification-view-element:hover .side-controls button {
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 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 ng-if="!getIsAdmin(entity.name)">{{entity.name}}</span>
<span ng-if="getIsAdmin(entity.name)"><a href="/organization/{{ entity.name }}">{{entity.name}}</a></span>
</span>
</span>
<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>
<i class="fa fa-wrench" ng-show="entity.is_robot" data-title="Robot Account" 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">
<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">
<a href="{{ getRobotUrl(entity.name) }}" ng-if="getIsAdmin(getPrefix(entity.name))">
<span class="prefix">{{ getPrefix(entity.name) }}+</span><span>{{ getShortenedName(entity.name) }}</span>

View file

@ -5,7 +5,7 @@
ng-click="lazyLoad()">
<span class="caret"></span>
</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 role="presentation" class="dropdown-header" ng-show="!lazyLoading && !robots && !isAdmin && !teams">

View file

@ -1,6 +1,6 @@
<span class="external-login-button-element">
<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>
<span ng-if="action != 'attach'">Sign In with GitHub</span>
<span ng-if="action == 'attach'">Attach to GitHub Account</span>
@ -8,7 +8,7 @@
</span>
<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>
<span ng-if="action != 'attach'">Sign In with Google</span>
<span ng-if="action == 'attach'">Attach to Google Account</span>

View file

@ -3,7 +3,7 @@
<div class="container header">
<span class="header-text">
<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">
From
<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>
</div>
</div>
<div class="datetime">{{ parseDate(notification.created) | date:'medium'}}</div>
<div class="right-controls">
<a href="javascript:void(0)" ng-if="canDismiss(notification)" ng-click="dismissNotification(notification)">
Dismiss Notification
</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 class="datetime">{{ parseDate(notification.created) | date:'medium'}}</div>
</div>

View file

@ -3,7 +3,7 @@
<div class="container" ng-show="!loading">
<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 class="side-controls">

View file

@ -1,5 +1,6 @@
<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"
placeholder="Username or E-mail Address" ng-model="user.username" autofocus>
<input type="password" class="form-control input-lg" name="password"

View file

@ -1,5 +1,5 @@
<div class="signup-form-element">
<form class="form-signup" name="signupForm" ng-submit="register()" ngshow="!awaitingConfirmation && !registering">
<div class="signup-form-element" quay-show="Features.USER_CREATION">
<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="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

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 class="panel panel-default">
<div class="panel panel-default" quay-show="Features.USER_CREATION">
<div class="panel-heading">
<h6 class="panel-title accordion-title">
<a class="accordion-toggle" data-toggle="collapse" data-parent="#accordion" data-target="#collapseRegister">
@ -24,11 +24,11 @@
</div>
<div id="collapseRegister" class="panel-collapse collapse" ng-class="hasSignedIn() ? 'out' : 'in'">
<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 class="panel panel-default">
<div class="panel panel-default" quay-show="Features.MAILING">
<div class="panel-heading">
<h6 class="panel-title accordion-title">
<a class="accordion-toggle" data-toggle="collapse" data-parent="#accordion" data-target="#collapseForgot">
@ -37,7 +37,8 @@
</h6>
</div>
<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();">
<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>

View file

@ -499,6 +499,11 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
$provide.factory('UtilService', ['$sanitize', function($sanitize) {
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) {
var adjusted = text.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
@ -615,24 +620,46 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
}]);
$provide.factory('TriggerDescriptionBuilder', ['UtilService', '$sanitize', function(UtilService, $sanitize) {
var builderService = {};
$provide.factory('TriggerService', ['UtilService', '$sanitize', function(UtilService, $sanitize) {
var triggerService = {};
builderService.getDescription = function(name, config) {
switch (name) {
case 'github':
var triggerTypes = {
'github': {
'description': function(config) {
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 ';
desc += '<a href="https://github.com/' + source + '" target="_blank">' + source + '</a>';
desc += '<br>Dockerfile folder: //' + UtilService.textToSafeHtml(config['subdir']);
return desc;
},
default:
return 'Unknown';
'run_parameters': [
{
'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) {
@ -675,7 +702,10 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
stringBuilderService.buildString = function(value_or_func, metadata) {
var fieldIcons = {
'inviter': 'user',
'username': 'user',
'user': 'user',
'email': 'envelope',
'activating_username': 'user',
'delegate_user': 'user',
'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.
path = path.substr('/api/v1/'.length, path.length);
// Build the path, adjusted with the inline parameters.
var used = {};
var url = '';
for (var i = 0; i < path.length; ++i) {
var c = path[i];
@ -896,6 +928,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
throw new Error('Missing parameter: ' + varName);
}
used[varName] = true;
url += parameters[varName];
i = end;
continue;
@ -904,6 +937,20 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
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;
};
@ -1257,7 +1304,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
return userService;
}]);
$provide.factory('ExternalNotificationData', ['Config', function(Config) {
$provide.factory('ExternalNotificationData', ['Config', 'Features', function(Config, Features) {
var externalNotificationData = {};
var events = [
@ -1311,7 +1358,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
'type': 'email',
'title': 'E-mail address'
}
]
],
'enabled': Features.MAILING
},
{
'id': 'webhook',
@ -1351,7 +1399,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
{
'name': 'notification_token',
'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() {
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) {
@ -1405,8 +1460,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
return externalNotificationData;
}]);
$provide.factory('NotificationService', ['$rootScope', '$interval', 'UserService', 'ApiService', 'StringBuilderService', 'PlanService', 'UserService', 'Config',
function($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, $location) {
var notificationService = {
'user': null,
'notifications': [],
@ -1424,6 +1479,28 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
'page': '/about/',
'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': {
'level': 'error',
'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) {
var kindInfo = notificationKinds[notification['kind']];
if (!kindInfo) {
@ -1533,10 +1619,10 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
}
var page = kindInfo['page'];
if (typeof page != 'string') {
if (page != null && typeof page != 'string') {
page = page(notification['metadata']);
}
return page;
return page || '';
};
notificationService.getMessage = function(notification) {
@ -2058,7 +2144,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
templateUrl: '/static/partials/plans.html', controller: PlansCtrl}).
when('/security/', {title: 'Security', description: 'Security features used when transmitting and storing data',
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',
templateUrl: '/static/partials/new-repo.html', controller: NewRepoCtrl}).
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/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,
pageClass: 'landing-page'}).
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) {
return {
priority: 590,
@ -2300,7 +2401,9 @@ quayApp.directive('entityReference', function () {
restrict: 'C',
scope: {
'entity': '=entity',
'namespace': '=namespace'
'namespace': '=namespace',
'showGravatar': '@showGravatar',
'gravatarSize': '@gravatarSize'
},
controller: function($scope, $element, UserService, UtilService) {
$scope.getIsAdmin = function(namespace) {
@ -2437,6 +2540,36 @@ quayApp.directive('repoBreadcrumb', function () {
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 () {
var directiveDefinitionObject = {
@ -2495,22 +2628,34 @@ quayApp.directive('userSetup', function () {
restrict: 'C',
scope: {
'redirectUrl': '=redirectUrl',
'inviteCode': '=inviteCode',
'signInStarted': '&signInStarted',
'signedIn': '&signedIn'
'signedIn': '&signedIn',
'userRegistered': '&userRegistered'
},
controller: function($scope, $location, $timeout, ApiService, KeyService, UserService) {
$scope.sendRecovery = function() {
$scope.sendingRecovery = true;
ApiService.requestRecoveryEmail($scope.recovery).then(function() {
$scope.invalidRecovery = false;
$scope.errorMessage = '';
$scope.sent = true;
$scope.sendingRecovery = false;
}, function(result) {
$scope.invalidRecovery = true;
$scope.errorMessage = result.data;
$scope.sent = false;
$scope.sendingRecovery = false;
});
};
$scope.handleUserRegistered = function(username) {
$scope.userRegistered({'username': username});
};
$scope.hasSignedIn = function() {
return UserService.hasEverLoggedIn();
};
@ -2534,6 +2679,7 @@ quayApp.directive('externalLoginButton', function () {
'action': '@action'
},
controller: function($scope, $timeout, $interval, ApiService, KeyService, CookieService, Features, Config) {
$scope.signingIn = false;
$scope.startSignin = function(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
// changes.
$scope.signingIn = true;
$timeout(function() {
document.location = url;
}, 250);
@ -2570,8 +2717,10 @@ quayApp.directive('signinForm', function () {
controller: function($scope, $location, $timeout, $interval, ApiService, KeyService, UserService, CookieService, Features, Config) {
$scope.tryAgainSoon = 0;
$scope.tryAgainInterval = null;
$scope.signingIn = false;
$scope.markStarted = function() {
$scope.signingIn = true;
if ($scope.signInStarted != null) {
$scope.signInStarted();
}
@ -2602,25 +2751,30 @@ quayApp.directive('signinForm', function () {
$scope.cancelInterval();
ApiService.signinUser($scope.user).then(function() {
$scope.signingIn = false;
$scope.needsEmailVerification = false;
$scope.invalidCredentials = false;
if ($scope.signedIn != null) {
$scope.signedIn();
}
// Load the newly created user.
UserService.load();
// Redirect to the specified page or the landing page
// Note: The timeout of 500ms is needed to ensure dialogs containing sign in
// forms get removed before the location changes.
$timeout(function() {
if ($scope.redirectUrl == $location.path()) {
return;
}
$location.path($scope.redirectUrl ? $scope.redirectUrl : '/');
var redirectUrl = $scope.redirectUrl;
if (redirectUrl == $location.path() || redirectUrl == null) {
return;
}
window.location = (redirectUrl ? redirectUrl : '/');
}, 500);
}, function(result) {
$scope.signingIn = false;
if (result.status == 429 /* try again later */) {
$scope.needsEmailVerification = false;
$scope.invalidCredentials = false;
@ -2654,25 +2808,37 @@ quayApp.directive('signupForm', function () {
transclude: true,
restrict: 'C',
scope: {
'inviteCode': '=inviteCode',
'userRegistered': '&userRegistered'
},
controller: function($scope, $location, $timeout, ApiService, KeyService, UserService, Config, UIService) {
$('.form-signup').popover();
$scope.awaitingConfirmation = false;
$scope.awaitingConfirmation = false;
$scope.registering = false;
$scope.register = function() {
UIService.hidePopover('#signupButton');
$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.awaitingConfirmation = true;
$scope.awaitingConfirmation = !!resp['awaiting_verification'];
if (Config.MIXPANEL_KEY) {
mixpanel.alias($scope.newUser.username);
}
$scope.userRegistered({'username': $scope.newUser.username});
if (!$scope.awaitingConfirmation) {
document.location = '/';
}
}, function(result) {
$scope.registering = false;
UIService.showFormError('#signupButton', result);
@ -2790,7 +2956,7 @@ quayApp.directive('dockerAuthDialog', function (Config) {
$scope.downloadCfg = function() {
var auth = $.base64.encode($scope.username + ":" + $scope.token);
config = {}
config[Config.getUrl('/v1/')] = {
config[Config['SERVER_HOSTNAME']] = {
"auth": auth,
"email": ""
};
@ -2917,9 +3083,10 @@ quayApp.directive('logsView', function () {
'user': '=user',
'makevisible': '=makevisible',
'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) {
$scope.loading = true;
$scope.logs = null;
@ -2984,7 +3151,7 @@ quayApp.directive('logsView', function () {
'set_repo_description': 'Change description for repository {repo}: {description}',
'build_dockerfile': function(metadata) {
if (metadata.trigger_id) {
var triggerDescription = TriggerDescriptionBuilder.getDescription(
var triggerDescription = TriggerService.getDescription(
metadata['service'], metadata['config']);
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_add_team_member': 'Add member {member} to 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_role': 'Change permission of team {team} to {role}',
'create_prototype_permission': function(metadata) {
@ -3018,12 +3203,12 @@ quayApp.directive('logsView', function () {
}
},
'setup_repo_trigger': function(metadata) {
var triggerDescription = TriggerDescriptionBuilder.getDescription(
var triggerDescription = TriggerService.getDescription(
metadata['service'], metadata['config']);
return 'Setup build trigger - ' + triggerDescription;
},
'delete_repo_trigger': function(metadata) {
var triggerDescription = TriggerDescriptionBuilder.getDescription(
var triggerDescription = TriggerService.getDescription(
metadata['service'], metadata['config']);
return 'Delete build trigger - ' + triggerDescription;
},
@ -3074,7 +3259,11 @@ quayApp.directive('logsView', function () {
'org_create_team': 'Create team',
'org_delete_team': 'Delete team',
'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_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_role': 'Change team permission',
'create_prototype_permission': 'Create default permission',
@ -3107,7 +3296,7 @@ quayApp.directive('logsView', function () {
var hasValidUser = !!$scope.user;
var hasValidOrg = !!$scope.organization;
var hasValidRepo = $scope.repository && $scope.repository.namespace;
var isValid = hasValidUser || hasValidOrg || hasValidRepo;
var isValid = hasValidUser || hasValidOrg || hasValidRepo || $scope.allLogs;
if (!$scope.makevisible || !isValid) {
return;
@ -3130,11 +3319,15 @@ quayApp.directive('logsView', function () {
url = getRestUrl('repository', $scope.repository.namespace, $scope.repository.name, 'logs');
}
if ($scope.allLogs) {
url = getRestUrl('superuser', 'logs')
}
url += '?starttime=' + encodeURIComponent(getDateString($scope.logStartDate));
url += '&endtime=' + encodeURIComponent(getDateString($scope.logEndDate));
if ($scope.performer) {
url += '&performer=' + encodeURIComponent($scope.performer.username);
url += '&performer=' + encodeURIComponent($scope.performer.name);
}
var loadLogs = Restangular.one(url);
@ -3783,7 +3976,9 @@ quayApp.directive('entitySearch', function () {
'allowedEntities': '=allowedEntities',
'currentEntity': '=currentEntity',
'entitySelected': '&entitySelected',
'emailSelected': '&emailSelected',
// When set to true, the contents of the control will be cleared as soon
// as an entity is selected.
@ -3791,8 +3986,15 @@ quayApp.directive('entitySearch', function () {
// Set this property to immediately clear the contents of the control.
'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.teams = null;
@ -3989,8 +4191,12 @@ quayApp.directive('entitySearch', function () {
return null;
}
if (val.indexOf('@') > 0) {
return '<div class="tt-empty">A ' + Config.REGISTRY_TITLE_SHORT + ' username (not an e-mail address) must be specified</div>';
if (UtilService.isEmailAddress(val)) {
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 = [];
@ -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) {
$scope.$apply(function() {
$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 () {
var directiveDefinitionObject = {
templateUrl: '/static/directives/setup-trigger-dialog.html',
@ -5522,6 +5798,10 @@ quayApp.directive('notificationView', function () {
$scope.getClass = function(notification) {
return NotificationService.getClass(notification);
};
$scope.getActions = function(notification) {
return NotificationService.getActions(notification);
};
}
};
return directiveDefinitionObject;
@ -5737,7 +6017,7 @@ quayApp.directive('dockerfileBuildForm', function () {
var data = {
'mimeType': mimeType
};
var getUploadUrl = ApiService.getFiledropUrl(data).then(function(resp) {
conductUpload(file, resp.url, resp.file_id, mimeType);
}, function() {
@ -5890,7 +6170,7 @@ quayApp.directive('tagSpecificImagesView', function () {
}
var currentTag = $scope.repository.tags[$scope.tag];
if (image.dbid == currentTag.dbid) {
if (image.id == currentTag.image_id) {
classes += 'tag-image ';
}
@ -5900,15 +6180,15 @@ quayApp.directive('tagSpecificImagesView', function () {
var forAllTagImages = function(tag, callback, opt_cutoff) {
if (!tag) { return; }
if (!$scope.imageByDBID) {
$scope.imageByDBID = [];
if (!$scope.imageByDockerId) {
$scope.imageByDockerId = [];
for (var i = 0; i < $scope.images.length; ++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) {
return;
}
@ -5917,7 +6197,7 @@ quayApp.directive('tagSpecificImagesView', function () {
var ancestors = tag_image.ancestors.split('/').reverse();
for (var i = 0; i < ancestors.length; ++i) {
var image = $scope.imageByDBID[ancestors[i]];
var image = $scope.imageByDockerId[ancestors[i]];
if (image) {
if (image == opt_cutoff) {
return;
@ -5943,7 +6223,7 @@ quayApp.directive('tagSpecificImagesView', function () {
var getIdsForTag = function(currentTag) {
var ids = {};
forAllTagImages(currentTag, function(image) {
ids[image.dbid] = true;
ids[image.id] = true;
}, $scope.imageCutoff);
return ids;
};
@ -5953,8 +6233,8 @@ quayApp.directive('tagSpecificImagesView', function () {
for (var currentTagName in $scope.repository.tags) {
var currentTag = $scope.repository.tags[currentTagName];
if (currentTag != tag) {
for (var dbid in getIdsForTag(currentTag)) {
delete toDelete[dbid];
for (var id in getIdsForTag(currentTag)) {
delete toDelete[id];
}
}
}
@ -5963,7 +6243,7 @@ quayApp.directive('tagSpecificImagesView', function () {
var images = [];
for (var i = 0; i < $scope.images.length; ++i) {
var image = $scope.images[i];
if (toDelete[image.dbid]) {
if (toDelete[image.id]) {
images.push(image);
}
}
@ -5974,7 +6254,7 @@ quayApp.directive('tagSpecificImagesView', function () {
return result;
}
return b.dbid - a.dbid;
return b.sort_index - a.sort_index;
});
$scope.tagSpecificImages = images;

View file

@ -1,3 +1,7 @@
function SignInCtrl($scope, $location) {
$scope.redirectUrl = '/';
}
function GuideCtrl() {
}
@ -536,7 +540,7 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
};
$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) {
@ -608,6 +612,8 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
};
$scope.setImage = function(imageId, opt_updateURL) {
if (!$scope.images) { return; }
var image = null;
for (var i = 0; i < $scope.images.length; ++i) {
var currentImage = $scope.images[i];
@ -728,9 +734,9 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
};
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; }
// Callback the tag's image itself.
@ -740,7 +746,7 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
if (!tag_image.ancestors) { return; }
var ancestors = tag_image.ancestors.split('/');
for (var i = 0; i < ancestors.length; ++i) {
var image = $scope.imageByDBID[ancestors[i]];
var image = $scope.imageByDockerId[ancestors[i]];
if (image) {
callback(image);
}
@ -829,10 +835,10 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
$scope.specificImages = [];
// Build various images for quick lookup of images.
$scope.imageByDBID = {};
$scope.imageByDockerId = {};
for (var i = 0; i < $scope.images.length; ++i) {
var currentImage = $scope.images[i];
$scope.imageByDBID[currentImage.dbid] = currentImage;
$scope.imageByDockerId[currentImage.id] = currentImage;
}
// Dispose of any existing tree.
@ -1275,7 +1281,9 @@ function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope
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 name = $routeParams.name;
@ -1580,14 +1588,22 @@ function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, $routeParams
$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 = {
'repository': namespace + '/' + name,
'trigger_uuid': trigger.id
};
ApiService.manuallyStartBuildTrigger(null, params).then(function(resp) {
window.console.log(resp);
ApiService.manuallyStartBuildTrigger(opt_custom || {}, params).then(function(resp) {
var url = '/repository/' + namespace + '/' + name + '/build?current=' + resp['id'];
document.location = url;
}, ApiService.errorDisplay('Could not start build'));
@ -2326,29 +2342,92 @@ function OrgAdminCtrl($rootScope, $scope, $timeout, Restangular, $routeParams, U
loadOrganization();
}
function TeamViewCtrl($rootScope, $scope, Restangular, ApiService, $routeParams) {
function TeamViewCtrl($rootScope, $scope, $timeout, Features, Restangular, ApiService, $routeParams) {
var teamname = $routeParams.teamname;
var orgname = $routeParams.orgname;
$scope.orgname = orgname;
$scope.teamname = teamname;
$scope.addingMember = false;
$scope.memberMap = null;
$scope.allowEmail = Features.MAILING;
$rootScope.title = 'Loading...';
$scope.addNewMember = function(member) {
if (!member || $scope.members[member.name]) { return; }
$scope.filterFunction = function(invited, robots) {
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 = {
'orgname': orgname,
'teamname': teamname,
'membername': member.name
};
ApiService.updateOrganizationTeamMember(null, params).then(function(resp) {
$scope.members[member.name] = resp;
}, function() {
$('#cannotChangeMembersModal').modal({});
var errorHandler = ApiService.errorDisplay('Cannot add team member', function() {
$scope.addingMember = false;
});
$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) {
@ -2359,10 +2438,11 @@ function TeamViewCtrl($rootScope, $scope, Restangular, ApiService, $routeParams)
};
ApiService.deleteOrganizationTeamMember(null, params).then(function(resp) {
delete $scope.members[username];
}, function() {
$('#cannotChangeMembersModal').modal({});
});
if (!$scope.memberMap[username]) { return; }
var index = $.inArray($scope.memberMap[username], $scope.members);
$scope.members.splice(index, 1);
delete $scope.memberMap[username];
}, ApiService.errorDisplay('Cannot remove team member'));
};
$scope.updateForDescription = function(content) {
@ -2394,7 +2474,8 @@ function TeamViewCtrl($rootScope, $scope, Restangular, ApiService, $routeParams)
var loadMembers = function() {
var params = {
'orgname': orgname,
'teamname': teamname
'teamname': teamname,
'includePending': true
};
$scope.membersResource = ApiService.getOrganizationTeamMembersAsResource(params).get(function(resp) {
@ -2406,6 +2487,12 @@ function TeamViewCtrl($rootScope, $scope, Restangular, ApiService, $routeParams)
'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;
});
};
@ -2533,7 +2620,7 @@ function OrgMemberLogsCtrl($scope, $routeParams, $rootScope, $timeout, Restangul
$scope.memberResource = ApiService.getOrganizationMemberAsResource(params).get(function(resp) {
$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 +
' 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.
UserService.updateUserIn($scope);
$scope.logsCounter = 0;
$scope.newUser = {};
$scope.createdUsers = [];
$scope.loadLogs = function() {
$scope.logsCounter++;
};
$scope.loadUsers = function() {
if ($scope.users) {
return;
@ -2667,6 +2762,7 @@ function SuperUserAdminCtrl($scope, ApiService, Features, UserService) {
$scope.loadUsersInternal = function() {
ApiService.listAllUsers().then(function(resp) {
$scope.users = resp['users'];
$scope.showInterface = true;
}, function(resp) {
$scope.users = [];
$scope.usersError = resp['data']['message'] || resp['data']['error_description'];
@ -2678,6 +2774,19 @@ function SuperUserAdminCtrl($scope, ApiService, Features, UserService) {
$('#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) {
if (user.username == UserService.currentUser().username) {
bootbox.dialog({
@ -2725,9 +2834,58 @@ function SuperUserAdminCtrl($scope, ApiService, Features, UserService) {
}, 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();
}
function TourCtrl($scope, $location) {
$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.
var dimensions = this.updateDimensions_();
if (!dimensions) {
return this;
}
// Populate the tree.
this.root_.x0 = dimensions.cw / 2;
@ -307,8 +310,8 @@ ImageHistoryTree.prototype.setHighlightedPath_ = function(image) {
this.markPath_(this.currentNode_, false);
}
var imageByDBID = this.imageByDBID_;
var currentNode = imageByDBID[image.dbid];
var imageByDockerId = this.imageByDockerId_;
var currentNode = imageByDockerId[image.id];
if (currentNode) {
this.markPath_(currentNode, true);
this.currentNode_ = currentNode;
@ -386,7 +389,7 @@ ImageHistoryTree.prototype.buildRoot_ = function() {
var formatted = {"name": "No images found"};
// Build a node for each image.
var imageByDBID = {};
var imageByDockerId = {};
for (var i = 0; i < this.images_.length; ++i) {
var image = this.images_[i];
var imageNode = {
@ -395,9 +398,9 @@ ImageHistoryTree.prototype.buildRoot_ = function() {
"image": image,
"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,
// then the node is the root.
@ -408,10 +411,10 @@ ImageHistoryTree.prototype.buildRoot_ = function() {
// Skip images that are currently uploading.
if (image.uploading) { continue; }
var imageNode = imageByDBID[image.dbid];
var imageNode = imageByDockerId[image.id];
var ancestors = this.getAncestors_(image);
var immediateParent = ancestors[ancestors.length - 1] * 1;
var parent = imageByDBID[immediateParent];
var immediateParent = ancestors[ancestors.length - 1];
var parent = imageByDockerId[immediateParent];
if (parent) {
// Add a reference to the parent. This makes walking the tree later easier.
imageNode.parent = parent;
@ -442,7 +445,7 @@ ImageHistoryTree.prototype.buildRoot_ = function() {
// Skip images that are currently uploading.
if (image.uploading) { continue; }
var imageNode = imageByDBID[image.dbid];
var imageNode = imageByDockerId[image.id];
maxChildCount = Math.max(maxChildCount, this.determineMaximumChildCount_(imageNode));
}
@ -573,7 +576,7 @@ ImageHistoryTree.prototype.setTag_ = function(tagName) {
return;
}
var imageByDBID = this.imageByDBID_;
var imageByDockerId = this.imageByDockerId_;
// Save the current tag.
var previousTagName = this.currentTag_;
@ -596,10 +599,10 @@ ImageHistoryTree.prototype.setTag_ = function(tagName) {
// Skip images that are currently uploading.
if (image.uploading) { continue; }
var imageNode = this.imageByDBID_[image.dbid];
var imageNode = this.imageByDockerId_[image.id];
var ancestors = this.getAncestors_(image);
var immediateParent = ancestors[ancestors.length - 1] * 1;
var parent = imageByDBID[immediateParent];
var immediateParent = ancestors[ancestors.length - 1];
var parent = imageByDockerId[immediateParent];
if (parent && imageNode.highlighted) {
var arr = 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="org-member-logs container">
<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>

View file

@ -19,7 +19,7 @@
<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><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="#notification" ng-click="loadNotifications()">Notifications</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#publicprivate">Public/Private</a></li>
@ -226,7 +226,7 @@
</div>
<!-- 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-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>
@ -378,6 +378,12 @@
counter="showNewNotificationCounter"
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 -->
<div class="modal fade" id="makepublicModal">
<div class="modal-dialog">

View file

@ -1,7 +1,7 @@
<div class="container signin-container">
<div class="row">
<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>

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">
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>
@ -10,18 +10,64 @@
<li class="active">
<a href="javascript:void(0)" data-toggle="tab" data-target="#users" ng-click="loadUsers()">Users</a>
</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>
</div>
<!-- Content -->
<div class="col-md-10">
<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 -->
<div id="users" class="tab-pane active">
<div class="quay-spinner" ng-show="!users"></div>
<div class="alert alert-error" ng-show="usersError">
{{ usersError }}
</div>
</div>
<div ng-show="users">
<div class="side-controls">
<div class="result-count">
@ -37,8 +83,7 @@
<thead>
<th>Username</th>
<th>E-mail address</th>
<th></th>
<th></th>
<th style="width: 24px;"></th>
</thead>
<tr ng-repeat="current_user in (users | filter:search | orderBy:'username' | limitTo:100)"
@ -51,19 +96,20 @@
<td>
<a href="mailto:{{ current_user.email }}">{{ current_user.email }}</a>
</td>
<td class="user-class">
<span ng-if="current_user.super_user">Super user</span>
</td>
<td>
<div class="dropdown" ng-if="user.username != current_user.username && !user.super_user">
<td style="text-align: center;">
<i class="fa fa-ge fa-lg" ng-if="current_user.super_user" data-title="Super User" bs-tooltip></i>
<div class="dropdown" style="text-align: left;" ng-if="user.username != current_user.username && !current_user.super_user">
<button class="btn btn-default dropdown-toggle" data-toggle="dropdown">
<i class="fa fa-ellipsis-h"></i>
<i class="caret"></i>
</button>
<ul class="dropdown-menu">
<ul class="dropdown-menu pull-right">
<li>
<a href="javascript:void(0)" ng-click="showChangePassword(current_user)">
<i class="fa fa-key"></i> Change Password
</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)">
<i class="fa fa-times"></i> Delete User
</a>

View file

@ -1,40 +1,92 @@
<div class="resource-view" resource="orgResource" error-message="'No matching organization'">
<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="description markdown-input" content="team.description" can-write="organization.is_admin"
content-changed="updateForDescription" field-title="'team description'"></div>
<div class="panel panel-default">
<div class="panel-heading">Team 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 class="panel-body">
<table class="permissions">
<tr ng-repeat="(name, member) in members">
<td class="user entity">
<span class="entity-reference" entity="member" namespace="organization.name"></span>
</td>
<td>
<span class="delete-ui" delete-title="'Remove User From Team'" button-title="'Remove'"
perform-delete="removeMember(member.name)" ng-if="canEditMembers"></span>
</td>
</tr>
<tr ng-show="canEditMembers">
<td colspan="3">
<div class="entity-search" style="width: 100%"
namespace="orgname" placeholder="'Add a registered user or robot...'"
entity-selected="addNewMember(entity)"
current-entity="selectedMember"
auto-clear="true"
allowed-entities="['user', 'robot']"></div>
</td>
</tr>
</table>
<div class="empty-message" ng-if="!members.length">
This team has no members
</div>
<div class="empty-message" ng-if="members.length && !(members | filter:search).length">
No matching team members found
</div>
<table class="member-listing" style="margin-top: -20px" ng-show="members.length">
<!-- Members -->
<tr ng-if="(members | filter:search | filter: filterFunction(false, false)).length">
<td colspan="2"><div class="section-header">Team Members</div></td>
</tr>
<tr ng-repeat="member in members | filter:search | filter: filterFunction(false, false) | orderBy: 'name'">
<td class="user entity">
<span class="entity-reference" entity="member" namespace="organization.name" show-gravatar="true" gravatar-size="32"></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>
<!-- Robots -->
<tr ng-if="(members | filter:search | filter: filterFunction(false, true)).length">
<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>

View file

@ -122,7 +122,7 @@
</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-body">

View file

@ -8,17 +8,19 @@
<div class="container">
<div class="row">
<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 %}
<div class="alert alert-danger">{{ error_message }}</div>
{% endif %}
{% if user_creation %}
<div>
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
in the user settings.
</div>
{% endif %}
</div>
</div>

Binary file not shown.

View file

@ -1,14 +1,16 @@
import unittest
import json
import datetime
from urllib import urlencode
from urlparse import urlparse, urlunparse, parse_qs
from app import app
from data import model
from initdb import setup_database_for_testing, finished_database_for_testing
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.search import FindRepositories, EntitySearch
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,
TriggerBuildList, ActivateBuildTrigger, BuildTrigger,
BuildTriggerList, BuildTriggerAnalyze)
BuildTriggerList, BuildTriggerAnalyze, BuildTriggerFieldValues)
from endpoints.api.repoemail import RepositoryAuthorizedEmail
from endpoints.api.repositorynotification import RepositoryNotification, RepositoryNotificationList
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,
RepositoryTeamPermissionList, RepositoryUserPermissionList)
from endpoints.api.superuser import SuperUserLogs, SuperUserList, SuperUserManagement
from endpoints.api.superuser import (SuperUserLogs, SuperUserList, SuperUserManagement,
SuperUserSendRecoveryEmail)
try:
@ -75,7 +78,9 @@ class ApiTestCase(unittest.TestCase):
with client.session_transaction() as sess:
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
# Restore the teardown functions
@ -510,13 +515,13 @@ class TestUser(ApiTestCase):
self._run_test('PUT', 401, None, {})
def test_put_freshuser(self):
self._run_test('PUT', 401, 'freshuser', {})
self._run_test('PUT', 200, 'freshuser', {})
def test_put_reader(self):
self._run_test('PUT', 401, 'reader', {})
self._run_test('PUT', 200, 'reader', {})
def test_put_devtable(self):
self._run_test('PUT', 401, 'devtable', {})
self._run_test('PUT', 200, 'devtable', {})
def test_post_anonymous(self):
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):
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):
def setUp(self):
@ -1292,7 +1353,7 @@ class TestActivateBuildTrigger0byeDevtableShared(ApiTestCase):
self._run_test('POST', 403, 'reader', None)
def test_post_devtable(self):
self._run_test('POST', 404, 'devtable', None)
self._run_test('POST', 404, 'devtable', {})
class TestActivateBuildTrigger0byeBuynlargeOrgrepo(ApiTestCase):
@ -1310,7 +1371,7 @@ class TestActivateBuildTrigger0byeBuynlargeOrgrepo(ApiTestCase):
self._run_test('POST', 403, 'reader', None)
def test_post_devtable(self):
self._run_test('POST', 404, 'devtable', None)
self._run_test('POST', 404, 'devtable', {})
class TestBuildTriggerAnalyze0byePublicPublicrepo(ApiTestCase):
@ -3527,13 +3588,61 @@ class TestSuperUserLogs(ApiTestCase):
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):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(SuperUserList)
def test_get_anonymous(self):
self._run_test('GET', 403, None, None)
self._run_test('GET', 401, None, None)
def test_get_freshuser(self):
self._run_test('GET', 403, 'freshuser', None)
@ -3545,14 +3654,13 @@ class TestSuperUserList(ApiTestCase):
self._run_test('GET', 200, 'devtable', None)
class TestSuperUserManagement(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(SuperUserManagement, username='freshuser')
def test_get_anonymous(self):
self._run_test('GET', 403, None, None)
self._run_test('GET', 401, None, None)
def test_get_freshuser(self):
self._run_test('GET', 403, 'freshuser', None)
@ -3565,7 +3673,7 @@ class TestSuperUserManagement(ApiTestCase):
def test_put_anonymous(self):
self._run_test('PUT', 403, None, {})
self._run_test('PUT', 401, None, {})
def test_put_freshuser(self):
self._run_test('PUT', 403, 'freshuser', {})
@ -3578,7 +3686,7 @@ class TestSuperUserManagement(ApiTestCase):
def test_delete_anonymous(self):
self._run_test('DELETE', 403, None, None)
self._run_test('DELETE', 401, None, None)
def test_delete_freshuser(self):
self._run_test('DELETE', 403, 'freshuser', None)

View file

@ -1,3 +1,5 @@
# coding=utf-8
import unittest
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 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.search import FindRepositories, EntitySearch
from endpoints.api.image import RepositoryImage, RepositoryImageList
@ -20,7 +22,7 @@ from endpoints.api.robot import (UserRobotList, OrgRobot, OrgRobotList, UserRobo
RegenerateUserRobot, RegenerateOrgRobot)
from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs,
TriggerBuildList, ActivateBuildTrigger, BuildTrigger,
BuildTriggerList, BuildTriggerAnalyze)
BuildTriggerList, BuildTriggerAnalyze, BuildTriggerFieldValues)
from endpoints.api.repoemail import RepositoryAuthorizedEmail
from endpoints.api.repositorynotification import RepositoryNotification, RepositoryNotificationList
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):
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)
return rv.data
@ -162,6 +168,13 @@ class ApiTestCase(unittest.TestCase):
parsed = py_json.loads(data)
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'):
return self.postJsonResponse(Signin, data=dict(username=username, password=password))
@ -328,6 +341,12 @@ class TestChangeUserDetails(ApiTestCase):
data=dict(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):
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'])
def test_createuser(self):
data = self.postResponse(User,
data = self.postJsonResponse(User,
data=NEW_USER_DETAILS,
expected_code=201)
self.assertEquals('"Created"', data)
expected_code=200)
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):
@ -741,16 +780,43 @@ class TestGetOrganizationTeamMembers(ApiTestCase):
params=dict(orgname=ORGANIZATION,
teamname='readers'))
assert READ_ACCESS_USER in json['members']
self.assertEquals(READ_ACCESS_USER, json['members'][1]['name'])
class TestUpdateOrganizationTeamMember(ApiTestCase):
def test_addmember(self):
def test_addmember_alreadyteammember(self):
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,
params=dict(orgname=ORGANIZATION, teamname='readers',
membername=NO_ACCESS_USER))
membername=membername))
# Verify the user was added to the team.
@ -758,10 +824,168 @@ class TestUpdateOrganizationTeamMember(ApiTestCase):
params=dict(orgname=ORGANIZATION,
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):
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):
self.login(ADMIN_ACCESS_USER)
@ -775,7 +999,7 @@ class TestDeleteOrganizationTeamMember(ApiTestCase):
params=dict(orgname=ORGANIZATION,
teamname='readers'))
assert not READ_ACCESS_USER in json['members']
assert len(json['members']) == 1
class TestCreateRepo(ApiTestCase):
@ -1205,6 +1429,7 @@ class TestListAndGetImage(ApiTestCase):
params=dict(repository=ADMIN_ACCESS_USER + '/simple'))
assert len(json['images']) > 0
for image in json['images']:
assert 'id' in image
assert 'tags' in image
@ -1212,7 +1437,6 @@ class TestListAndGetImage(ApiTestCase):
assert 'comment' in image
assert 'command' in image
assert 'ancestors' in image
assert 'dbid' in image
assert 'size' in image
ijson = self.getJsonResponse(RepositoryImage,
@ -1810,7 +2034,7 @@ class FakeBuildTrigger(BuildTriggerBase):
config['active'] = False
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')
def dockerfile_url(self, auth_token, config):
@ -1822,6 +2046,12 @@ class FakeBuildTrigger(BuildTriggerBase):
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):
def test_list_build_triggers(self):
@ -1994,9 +2224,22 @@ class TestBuildTriggers(ApiTestCase):
data={'config': trigger_config},
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_json = self.postJsonResponse(ActivateBuildTrigger,
params=dict(repository=ADMIN_ACCESS_USER + '/simple', trigger_uuid=trigger.uuid),
data=dict(),
expected_code=201)
assert 'id' in start_json
@ -2061,6 +2304,7 @@ class TestBuildTriggers(ApiTestCase):
# Start a manual build.
start_json = self.postJsonResponse(ActivateBuildTrigger,
params=dict(repository=ADMIN_ACCESS_USER + '/simple', trigger_uuid=trigger.uuid),
data=dict(),
expected_code=201)
assert 'id' in start_json
@ -2120,7 +2364,7 @@ class TestSuperUserManagement(ApiTestCase):
json = self.getJsonResponse(SuperUserManagement, params=dict(username = 'freshuser'))
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'])
def test_delete_user(self):
@ -2143,7 +2387,7 @@ class TestSuperUserManagement(ApiTestCase):
# Verify the user exists.
json = self.getJsonResponse(SuperUserManagement, params=dict(username = 'freshuser'))
self.assertEquals('freshuser', json['username'])
self.assertEquals('no@thanks.com', json['email'])
self.assertEquals('jschorr+test@devtable.com', json['email'])
# Update the user.
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_BILLING = True
FEATURE_MAILING = True
SUPER_USERS = ['devtable']
LICENSE_USER_LIMIT = 500

View file

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

View file

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

View file

@ -1,22 +1,15 @@
from data.database import Image
from app import app, storage as store
from data.database import Image, ImageStorage
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():
live_image_id_set.add(image.docker_image_id)
storage_image_id_set = set()
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
counter = 0
for orphan in orphaned:
counter += 1
print orphan.uuid

View file

@ -9,7 +9,7 @@ with open('outfile.dot', 'w') as outfile:
outfile.write('digraph relationships {\n')
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))
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)
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())

View file

@ -1,116 +1,158 @@
from flask.ext.mail import Message
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 = """
This email address was recently used to register the username '%s'
at <a href="%s">Quay.io</a>.<br>
<br>
To confirm this email address, please click the following link:<br>
<a href="%s/confirm?code=%s">%s/confirm?code=%s</a>
"""
def repository_reference(pair):
(namespace, repository) = pair
owner = model.get_user(namespace)
if not owner:
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 = """
This email address was recently asked to become the new e-mail address for username '%s'
at <a href="%s">Quay.io</a>.<br>
<br>
To confirm this email address, please click the following link:<br>
<a href="%s/confirm?code=%s">%s/confirm?code=%s</a>
"""
def admin_reference(username):
user = model.get_user(username)
if not user:
return 'account settings'
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 = """
A user at <a href="%s">Quay.io</a> 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:
<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>
"""
template_loader = FileSystemLoader(searchpath="emails")
template_env = Environment(loader=template_loader)
template_env.filters['user_reference'] = user_reference
template_env.filters['admin_reference'] = admin_reference
template_env.filters['repository_reference'] = repository_reference
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>
"""
def send_email(recipient, subject, template_file, parameters):
app_title = app.config['REGISTRY_TITLE_SHORT']
app_url = get_app_url()
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 = """
Hi {0},<br>
<br>
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_password_changed(username, email):
send_email(email, 'Account password changed', 'passwordchanged', {
'username': username
})
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):
msg = Message('Quay.io email change. Please confirm your email.',
sender='support@quay.io', # Why do I need this?
recipients=[email])
msg.html = CHANGE_MESSAGE % (username, get_app_url(), get_app_url(), token, get_app_url(), token)
mail.send(msg)
send_email(email, 'E-mail address change requested', 'changeemail', {
'username': username,
'token': token
})
def send_confirmation_email(username, email, token):
msg = Message('Welcome to Quay.io! Please confirm your email.',
sender='support@quay.io', # Why do I need this?
recipients=[email])
msg.html = CONFIRM_MESSAGE % (username, get_app_url(), get_app_url(), token, get_app_url(), token)
mail.send(msg)
send_email(email, 'Please confirm your e-mail address', 'confirmemail', {
'username': username,
'token': token
})
def send_repo_authorization_email(namespace, repository, email, token):
msg = Message('Quay.io Notification: Please confirm your email.',
sender='support@quay.io', # Why do I need this?
recipients=[email])
msg.html = AUTH_FORREPO_MESSAGE % (get_app_url(), get_app_url(), namespace, repository, namespace,
repository, get_app_url(), token, get_app_url(), token)
mail.send(msg)
subject = 'Please verify your e-mail address for repository %s/%s' % (namespace, repository)
send_email(email, subject, 'repoauthorizeemail', {
'namespace': namespace,
'repository': repository,
'token': token
})
def send_recovery_email(email, token):
msg = Message('Quay.io account recovery.',
sender='support@quay.io', # Why do I need this?
recipients=[email])
msg.html = RECOVERY_MESSAGE % (get_app_url(), get_app_url(), token, get_app_url(), token)
mail.send(msg)
subject = 'Account recovery'
send_email(email, subject, 'recovery', {
'email': email,
'token': token
})
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):
# 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!',
sender='support@quay.io', # Why do I need this?
sender='support@quay.io',
recipients=[email])
msg.html = contents
mail.send(msg)
# INTERNAL EMAILS BELOW
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)
msg = Message(title, sender='support@quay.io', recipients=['stripe@quay.io'])
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)
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.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 util.streamingjsonencoder import StreamingJSONEncoder
POLL_PERIOD_SECONDS = 30
MEMORY_TEMPFILE_SIZE = 64 * 1024 # Large enough to handle approximately 99% of builds in memory
logger = logging.getLogger(__name__)
sched = BlockingScheduler()
@ -22,7 +23,7 @@ def archive_redis_buildlogs():
avoid needing two-phase commit. """
try:
# 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)
length, entries = build_logs.get_log_entries(to_archive.uuid, 0)
@ -32,7 +33,7 @@ def archive_redis_buildlogs():
'logs': entries,
}
with SpooledTemporaryFile() as tempfile:
with SpooledTemporaryFile(MEMORY_TEMPFILE_SIZE) as tempfile:
with GzipFile('testarchive', fileobj=tempfile) as zipstream:
for chunk in StreamingJSONEncoder().iterencode(to_encode):
zipstream.write(chunk)

View file

@ -33,7 +33,8 @@ class DiffsWorker(Worker):
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.start()
worker = DiffsWorker(image_diff_queue)
worker.start()

View file

@ -38,6 +38,8 @@ TIMEOUT_PERIOD_MINUTES = 20
CACHE_EXPIRATION_PERIOD_HOURS = 24
NO_TAGS = ['<none>:<none>']
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):
""" 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
# with both, but with different credentials that we do not want shared between
# the build and push operations.
self._push_cl = StreamingDockerClient(timeout=1200)
self._build_cl = StreamingDockerClient(timeout=1200)
self._push_cl = StreamingDockerClient(timeout=1200, base_url = DOCKER_BASE_URL)
self._build_cl = StreamingDockerClient(timeout=1200, base_url = DOCKER_BASE_URL)
dockerfile_path = os.path.join(self._build_dir, dockerfile_subdir,
'Dockerfile')
@ -221,6 +223,13 @@ class DockerfileBuildContext(object):
raise RuntimeError(message)
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).
if self._pull_credentials:
logger.debug('Logging in with pull credentials: %s@%s',
@ -236,13 +245,6 @@ class DockerfileBuildContext(object):
registry=self._pull_credentials['registry'], reauth=True)
# 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 = {
'phasestep': 'pull',
'repo_url': image_and_tag
@ -478,9 +480,8 @@ class DockerfileBuildWorker(Worker):
def watchdog(self):
logger.debug('Running build watchdog code.')
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
for container in docker_cl.containers():
@ -519,7 +520,20 @@ class DockerfileBuildWorker(Worker):
log_appender = partial(build_logs.append_log_message,
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,
repo))

View file

@ -8,6 +8,7 @@ from workers.worker import Worker
from endpoints.notificationmethod import NotificationMethod, InvalidNotificationMethodException
from endpoints.notificationevent import NotificationEvent, InvalidNotificationEventException
from workers.worker import JobException
from data import model
@ -29,7 +30,7 @@ class NotificationWorker(Worker):
notification = model.get_repo_notification(repo_namespace, repo_name, notification_uuid)
if not notification:
# Probably deleted.
return True
return
event_name = notification.event.name
method_name = notification.method.name
@ -39,15 +40,17 @@ class NotificationWorker(Worker):
method_handler = NotificationMethod.get_method(method_name)
except InvalidNotificationMethodException as ex:
logger.exception('Cannot find notification method: %s' % ex.message)
return False
raise JobException('Cannot find notification method: %s' % ex.message)
except InvalidNotificationEventException as ex:
logger.exception('Cannot find notification method: %s' % ex.message)
return False
logger.exception('Cannot find notification event: %s' % ex.message)
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,
reservation_seconds=3600)
worker.start()
if __name__ == "__main__":
logging.config.fileConfig('conf/logging.conf', disable_existing_loggers=False)
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):
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._poll_period_seconds = poll_period_seconds
self._reservation_seconds = reservation_seconds
self._watchdog_period_seconds = watchdog_period_seconds
self._retry_after_seconds = retry_after_seconds
self._stop = Event()
self._terminated = Event()
self._queue = queue
@ -103,7 +104,8 @@ class Worker(object):
try:
self.watchdog()
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._stop.set()
@ -111,7 +113,7 @@ class Worker(object):
logger.debug('Getting work item from queue.')
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:
# 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.mark_current_complete()
except JobException:
except JobException as jex:
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)
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._stop.set()
@ -190,7 +194,8 @@ class Worker(object):
def mark_current_incomplete(self, restore_retry=False):
with self._current_item_lock:
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
def mark_current_complete(self):