Merge remote-tracking branch 'origin/redalert'

Conflicts:
	app.py
This commit is contained in:
Jake Moshenko 2014-08-04 16:56:34 -04:00
commit 0372013f70
46 changed files with 2432 additions and 432 deletions

View file

@ -36,6 +36,9 @@ ADD conf/init/runmigration.sh /etc/my_init.d/
ADD conf/init/gunicorn /etc/service/gunicorn
ADD conf/init/nginx /etc/service/nginx
ADD conf/init/diffsworker /etc/service/diffsworker
ADD conf/init/notificationworker /etc/service/notificationworker
# TODO: Remove this after the prod CL push
ADD conf/init/webhookworker /etc/service/webhookworker
# Download any external libs.

6
app.py
View file

@ -79,8 +79,14 @@ tf = app.config['DB_TRANSACTION_FACTORY']
image_diff_queue = WorkQueue(app.config['DIFFS_QUEUE_NAME'], tf)
dockerfile_build_queue = WorkQueue(app.config['DOCKERFILE_BUILD_QUEUE_NAME'], tf,
reporter=queue_metrics.report)
notification_queue = WorkQueue(app.config['NOTIFICATION_QUEUE_NAME'], tf)
# TODO: Remove this in the prod push following the notifications change.
webhook_queue = WorkQueue(app.config['WEBHOOK_QUEUE_NAME'], tf)
database.configure(app.config)
model.config.app_config = app.config
model.config.store = storage
def get_app_url():
return '%s://%s' % (app.config['PREFERRED_URL_SCHEME'], app.config['SERVER_HOSTNAME'])

View file

@ -0,0 +1,2 @@
#!/bin/sh
exec svlogd -t /var/log/notificationworker/

View file

@ -0,0 +1,8 @@
#! /bin/bash
echo 'Starting notification worker'
cd /
venv/bin/python -m workers.notificationworker
echo 'Notification worker exited'

View file

@ -121,10 +121,13 @@ class DefaultConfig(object):
with open(tag_path) as tag_svg:
STATUS_TAGS[tag_name] = tag_svg.read()
WEBHOOK_QUEUE_NAME = 'webhook'
NOTIFICATION_QUEUE_NAME = 'notification'
DIFFS_QUEUE_NAME = 'imagediff'
DOCKERFILE_BUILD_QUEUE_NAME = 'dockerfilebuild'
# TODO: Remove this in the prod push following the notifications change.
WEBHOOK_QUEUE_NAME = 'webhook'
# Super user config. Note: This MUST BE an empty list for the default config.
SUPER_USERS = []

View file

@ -187,12 +187,6 @@ class PermissionPrototype(BaseModel):
)
class Webhook(BaseModel):
public_id = CharField(default=random_string_generator(length=64),
unique=True, index=True)
repository = ForeignKeyField(Repository)
parameters = TextField()
class AccessToken(BaseModel):
friendly_name = CharField(null=True)
@ -369,11 +363,46 @@ class Notification(BaseModel):
target = ForeignKeyField(User, index=True)
metadata_json = TextField(default='{}')
created = DateTimeField(default=datetime.now, index=True)
dismissed = BooleanField(default=False)
class ExternalNotificationEvent(BaseModel):
name = CharField(index=True, unique=True)
class ExternalNotificationMethod(BaseModel):
name = CharField(index=True, unique=True)
class RepositoryNotification(BaseModel):
uuid = CharField(default=uuid_generator, index=True)
repository = ForeignKeyField(Repository, index=True)
event = ForeignKeyField(ExternalNotificationEvent)
method = ForeignKeyField(ExternalNotificationMethod)
config_json = TextField()
class RepositoryAuthorizedEmail(BaseModel):
repository = ForeignKeyField(Repository, index=True)
email = CharField()
code = CharField(default=random_string_generator(), unique=True, index=True)
confirmed = BooleanField(default=False)
class Meta:
database = db
read_slaves = (read_slave,)
indexes = (
# create a unique index on email and repository
(('email', 'repository'), True),
)
all_models = [User, Repository, Image, AccessToken, Role, RepositoryPermission, Visibility,
RepositoryTag, EmailConfirmation, FederatedLogin, LoginService, QueueItem,
RepositoryBuild, Team, TeamMember, TeamRole, Webhook, LogEntryKind, LogEntry,
RepositoryBuild, Team, TeamMember, TeamRole, LogEntryKind, LogEntry,
PermissionPrototype, ImageStorage, BuildTriggerService, RepositoryBuildTrigger,
OAuthApplication, OAuthAuthorizationCode, OAuthAccessToken, NotificationKind,
Notification, ImageStorageLocation, ImageStoragePlacement]
Notification, ImageStorageLocation, ImageStoragePlacement,
ExternalNotificationEvent, ExternalNotificationMethod, RepositoryNotification,
RepositoryAuthorizedEmail]

View file

@ -0,0 +1,138 @@
"""Prepare the database for the new notifications system
Revision ID: 325a4d7c79d9
Revises: 4b7ef0c7bdb2
Create Date: 2014-07-31 13:08:18.667393
"""
# revision identifiers, used by Alembic.
revision = '325a4d7c79d9'
down_revision = '4b7ef0c7bdb2'
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
from data.model.sqlalchemybridge import gen_sqlalchemy_metadata
from data.database import all_models
def upgrade():
schema = gen_sqlalchemy_metadata(all_models)
### commands auto generated by Alembic - please adjust! ###
op.create_table('externalnotificationmethod',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index('externalnotificationmethod_name', 'externalnotificationmethod', ['name'], unique=True)
op.bulk_insert(schema.tables['externalnotificationmethod'],
[
{'id':1, 'name':'quay_notification'},
{'id':2, 'name':'email'},
{'id':3, 'name':'webhook'},
])
op.create_table('externalnotificationevent',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index('externalnotificationevent_name', 'externalnotificationevent', ['name'], unique=True)
op.bulk_insert(schema.tables['externalnotificationevent'],
[
{'id':1, 'name':'repo_push'},
{'id':2, 'name':'build_queued'},
{'id':3, 'name':'build_start'},
{'id':4, 'name':'build_success'},
{'id':5, 'name':'build_failure'},
])
op.create_table('repositoryauthorizedemail',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('repository_id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('code', sa.String(length=255), nullable=False),
sa.Column('confirmed', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('repositoryauthorizedemail_code', 'repositoryauthorizedemail', ['code'], unique=True)
op.create_index('repositoryauthorizedemail_email_repository_id', 'repositoryauthorizedemail', ['email', 'repository_id'], unique=True)
op.create_index('repositoryauthorizedemail_repository_id', 'repositoryauthorizedemail', ['repository_id'], unique=False)
op.create_table('repositorynotification',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('uuid', sa.String(length=255), nullable=False),
sa.Column('repository_id', sa.Integer(), nullable=False),
sa.Column('event_id', sa.Integer(), nullable=False),
sa.Column('method_id', sa.Integer(), nullable=False),
sa.Column('config_json', sa.Text(), nullable=False),
sa.ForeignKeyConstraint(['event_id'], ['externalnotificationevent.id'], ),
sa.ForeignKeyConstraint(['method_id'], ['externalnotificationmethod.id'], ),
sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('repositorynotification_event_id', 'repositorynotification', ['event_id'], unique=False)
op.create_index('repositorynotification_method_id', 'repositorynotification', ['method_id'], unique=False)
op.create_index('repositorynotification_repository_id', 'repositorynotification', ['repository_id'], unique=False)
op.create_index('repositorynotification_uuid', 'repositorynotification', ['uuid'], unique=False)
op.add_column(u'notification', sa.Column('dismissed', sa.Boolean(), nullable=False))
# Manually add the new notificationkind types
op.bulk_insert(schema.tables['notificationkind'],
[
{'id':5, 'name':'repo_push'},
{'id':6, 'name':'build_queued'},
{'id':7, 'name':'build_start'},
{'id':8, 'name':'build_success'},
{'id':9, 'name':'build_failure'},
])
### end Alembic commands ###
def downgrade():
schema = gen_sqlalchemy_metadata(all_models)
### commands auto generated by Alembic - please adjust! ###
op.drop_column(u'notification', 'dismissed')
op.drop_index('repositorynotification_uuid', table_name='repositorynotification')
op.drop_index('repositorynotification_repository_id', table_name='repositorynotification')
op.drop_index('repositorynotification_method_id', table_name='repositorynotification')
op.drop_index('repositorynotification_event_id', table_name='repositorynotification')
op.drop_table('repositorynotification')
op.drop_index('repositoryauthorizedemail_repository_id', table_name='repositoryauthorizedemail')
op.drop_index('repositoryauthorizedemail_email_repository_id', table_name='repositoryauthorizedemail')
op.drop_index('repositoryauthorizedemail_code', table_name='repositoryauthorizedemail')
op.drop_table('repositoryauthorizedemail')
op.drop_index('externalnotificationevent_name', table_name='externalnotificationevent')
op.drop_table('externalnotificationevent')
op.drop_index('externalnotificationmethod_name', table_name='externalnotificationmethod')
op.drop_table('externalnotificationmethod')
# Manually remove the notificationkind types
notificationkind = schema.tables['notificationkind']
op.execute(
(notificationkind.delete()
.where(notificationkind.c.name == op.inline_literal('repo_push')))
)
op.execute(
(notificationkind.delete()
.where(notificationkind.c.name == op.inline_literal('build_queued')))
)
op.execute(
(notificationkind.delete()
.where(notificationkind.c.name == op.inline_literal('build_start')))
)
op.execute(
(notificationkind.delete()
.where(notificationkind.c.name == op.inline_literal('build_success')))
)
op.execute(
(notificationkind.delete()
.where(notificationkind.c.name == op.inline_literal('build_failure')))
)
### end Alembic commands ###

View file

@ -0,0 +1,31 @@
"""Migrate existing webhooks to notifications.
Revision ID: 47670cbeced
Revises: 325a4d7c79d9
Create Date: 2014-07-31 13:49:38.332807
Hand Edited By Joseph Schorr
"""
# revision identifiers, used by Alembic.
revision = '47670cbeced'
down_revision = '325a4d7c79d9'
from alembic import op, context
import sqlalchemy as sa
def get_id(query):
conn = op.get_bind()
return list(conn.execute(query, ()).fetchall())[0][0]
def upgrade():
conn = op.get_bind()
event_id = get_id('Select id From ExternalNotificationEvent Where name="repo_push" Limit 1')
method_id = get_id('Select id From ExternalNotificationMethod Where name="webhook" Limit 1')
conn.execute('Insert Into RepositoryNotification (uuid, repository_id, event_id, method_id, config_json) Select public_id, repository_id, %s, %s, parameters FROM Webhook' % (event_id, method_id))
def downgrade():
conn = op.get_bind()
event_id = get_id('Select id From ExternalNotificationEvent Where name="repo_push" Limit 1')
method_id = get_id('Select id From ExternalNotificationMethod Where name="webhook" Limit 1')
conn.execute('Insert Into Webhook (public_id, repository_id, parameters) Select uuid, repository_id, config_json FROM RepositoryNotification Where event_id=%s And method_id=%s' % (event_id, method_id))

View file

@ -56,7 +56,7 @@ class InvalidRepositoryBuildException(DataModelException):
pass
class InvalidWebhookException(DataModelException):
class InvalidNotificationException(DataModelException):
pass
@ -836,6 +836,13 @@ def get_repository_for_resource(resource_key):
return None
def lookup_repository(repo_id):
try:
return Repository.get(Repository.id == repo_id)
except Repository.DoesNotExist:
return None
def get_repository(namespace_name, repository_name):
try:
return Repository.get(Repository.name == repository_name,
@ -1533,32 +1540,42 @@ def get_pull_credentials(robotname):
}
def create_webhook(repo, params_obj):
return Webhook.create(repository=repo, parameters=json.dumps(params_obj))
def create_repo_notification(repo, event_name, method_name, config):
event = ExternalNotificationEvent.get(ExternalNotificationEvent.name == event_name)
method = ExternalNotificationMethod.get(ExternalNotificationMethod.name == method_name)
return RepositoryNotification.create(repository=repo, event=event, method=method,
config_json=json.dumps(config))
def get_webhook(namespace_name, repository_name, public_id):
joined = Webhook.select().join(Repository)
def get_repo_notification(namespace_name, repository_name, uuid):
joined = RepositoryNotification.select().join(Repository)
found = list(joined.where(Repository.namespace == namespace_name,
Repository.name == repository_name,
Webhook.public_id == public_id))
RepositoryNotification.uuid == uuid))
if not found:
raise InvalidWebhookException('No webhook found with id: %s' % public_id)
raise InvalidNotificationException('No repository notification found with id: %s' % uuid)
return found[0]
def list_webhooks(namespace_name, repository_name):
joined = Webhook.select().join(Repository)
return joined.where(Repository.namespace == namespace_name,
Repository.name == repository_name)
def delete_repo_notification(namespace_name, repository_name, uuid):
found = get_repo_notification(namespace_name, repository_name, uuid)
found.delete_instance()
return found
def delete_webhook(namespace_name, repository_name, public_id):
webhook = get_webhook(namespace_name, repository_name, public_id)
webhook.delete_instance()
return webhook
def list_repo_notifications(namespace_name, repository_name, event_name=None):
joined = RepositoryNotification.select().join(Repository)
where = joined.where(Repository.namespace == namespace_name,
Repository.name == repository_name)
if event_name:
event = ExternalNotificationEvent.get(ExternalNotificationEvent.name == event_name)
where = where.where(RepositoryNotification.event == event)
return where
def list_logs(start_time, end_time, performer=None, repository=None, namespace=None):
@ -1646,7 +1663,15 @@ def create_unique_notification(kind_name, target, metadata={}):
create_notification(kind_name, target, metadata)
def list_notifications(user, kind_name=None):
def lookup_notification(user, uuid):
results = list(list_notifications(user, id_filter=uuid, include_dismissed=True))
if not results:
return None
return results[0]
def list_notifications(user, kind_name=None, id_filter=None, include_dismissed=False):
Org = User.alias()
AdminTeam = Team.alias()
AdminTeamMember = TeamMember.alias()
@ -1669,6 +1694,9 @@ def list_notifications(user, kind_name=None):
((AdminUser.id == user) & (TeamRole.name == 'admin')))
.order_by(Notification.created)
.desc())
if not include_dismissed:
query = query.switch(Notification).where(Notification.dismissed == False)
if kind_name:
query = (query
@ -1676,6 +1704,11 @@ def list_notifications(user, kind_name=None):
.join(NotificationKind)
.where(NotificationKind.name == kind_name))
if id_filter:
query = (query
.switch(Notification)
.where(Notification.uuid == id_filter))
return query
@ -1710,3 +1743,39 @@ def check_health():
return found_count > 0
except:
return False
def get_email_authorized_for_repo(namespace, repository, email):
found = list(RepositoryAuthorizedEmail.select()
.join(Repository)
.where(Repository.namespace == namespace,
Repository.name == repository,
RepositoryAuthorizedEmail.email == email)
.switch(RepositoryAuthorizedEmail)
.limit(1))
if not found or len(found) < 1:
return None
return found[0]
def create_email_authorization_for_repo(namespace_name, repository_name, email):
try:
repo = Repository.get(Repository.name == repository_name,
Repository.namespace == namespace_name)
except Repository.DoesNotExist:
raise DataModelException('Invalid repository %s/%s' %
(namespace_name, repository_name))
return RepositoryAuthorizedEmail.create(repository=repo, email=email, confirmed=False)
def confirm_email_authorization_for_repo(code):
try:
found = RepositoryAuthorizedEmail.get(RepositoryAuthorizedEmail.code == code)
except RepositoryAuthorizedEmail.DoesNotExist:
raise DataModelException('Invalid confirmation code.')
found.confirmed = True
found.save()
return found

View file

@ -307,6 +307,8 @@ import endpoints.api.organization
import endpoints.api.permission
import endpoints.api.prototype
import endpoints.api.repository
import endpoints.api.repositorynotification
import endpoints.api.repoemail
import endpoints.api.repotoken
import endpoints.api.robot
import endpoints.api.search
@ -315,4 +317,3 @@ import endpoints.api.tag
import endpoints.api.team
import endpoints.api.trigger
import endpoints.api.user
import endpoints.api.webhook

View file

@ -0,0 +1,56 @@
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)
from app import tf
from data import model
from data.database import db
from util.useremails import send_repo_authorization_email
import features
logger = logging.getLogger(__name__)
def record_view(record):
return {
'email': record.email,
'repository': record.repository.name,
'namespace': record.repository.namespace,
'confirmed': record.confirmed
}
@internal_only
@resource('/v1/repository/<repopath:repository>/authorizedemail/<email>')
class RepositoryAuthorizedEmail(RepositoryParamResource):
""" Resource for checking and authorizing e-mail addresses to receive repo notifications. """
@require_repo_admin
@nickname('checkRepoEmailAuthorized')
def get(self, namespace, repository, email):
""" Checks to see if the given e-mail address is authorized on this repository. """
record = model.get_email_authorized_for_repo(namespace, repository, email)
if not record:
abort(404)
return record_view(record)
@require_repo_admin
@nickname('sendAuthorizeRepoEmail')
def post(self, namespace, repository, email):
""" Starts the authorization process for an e-mail address on a repository. """
with tf(db):
record = model.get_email_authorized_for_repo(namespace, repository, email)
if record and record.confirmed:
return record_view(record)
if not record:
record = model.create_email_authorization_for_repo(namespace, repository, email)
send_repo_authorization_email(namespace, repository, email, record.code)
return record_view(record)

View file

@ -0,0 +1,142 @@
import json
from flask import request, abort
from app import notification_queue
from endpoints.api import (RepositoryParamResource, nickname, resource, require_repo_admin,
log_action, validate_json_request, api, NotFound, request_error)
from endpoints.notificationevent import NotificationEvent
from endpoints.notificationmethod import (NotificationMethod,
CannotValidateNotificationMethodException)
from endpoints.notificationhelper import build_notification_data
from data import model
def notification_view(notification):
config = {}
try:
config = json.loads(notification.config_json)
except:
config = {}
return {
'uuid': notification.uuid,
'event': notification.event.name,
'method': notification.method.name,
'config': config
}
@resource('/v1/repository/<repopath:repository>/notification/')
class RepositoryNotificationList(RepositoryParamResource):
""" Resource for dealing with listing and creating notifications on a repository. """
schemas = {
'NotificationCreateRequest': {
'id': 'NotificationCreateRequest',
'type': 'object',
'description': 'Information for creating a notification on a repository',
'required': [
'event',
'method',
'config'
],
'properties': {
'event': {
'type': 'string',
'description': 'The event on which the notification will respond',
},
'method': {
'type': 'string',
'description': 'The method of notification (such as email or web callback)',
},
'config': {
'type': 'object',
'description': 'JSON config information for the specific method of notification'
}
}
},
}
@require_repo_admin
@nickname('createRepoNotification')
@validate_json_request('NotificationCreateRequest')
def post(self, namespace, repository):
""" Create a new notification for the specified repository. """
repo = model.get_repository(namespace, repository)
json = request.get_json()
method_handler = NotificationMethod.get_method(json['method'])
if not method_handler:
raise request_error(message='Unknown method')
try:
method_handler.validate(repo, json['config'])
except CannotValidateNotificationMethodException as ex:
raise request_error(message=ex.message)
notification = model.create_repo_notification(repo, json['event'], json['method'],
json['config'])
resp = notification_view(notification)
log_action('add_repo_notification', namespace,
{'repo': repository, 'notification_id': notification.uuid,
'event': json['event'], 'method': json['method']},
repo=repo)
return resp, 201
@require_repo_admin
@nickname('listRepoNotifications')
def get(self, namespace, repository):
""" List the notifications for the specified repository. """
notifications = model.list_repo_notifications(namespace, repository)
return {
'notifications': [notification_view(n) for n in notifications]
}
@resource('/v1/repository/<repopath:repository>/notification/<uuid>')
class RepositoryNotification(RepositoryParamResource):
""" Resource for dealing with specific notifications. """
@require_repo_admin
@nickname('getRepoNotification')
def get(self, namespace, repository, uuid):
""" Get information for the specified notification. """
try:
notification = model.get_repo_notification(namespace, repository, uuid)
except model.InvalidNotificationException:
raise NotFound()
return notification_view(notification)
@require_repo_admin
@nickname('deleteRepoNotification')
def delete(self, namespace, repository, uuid):
""" Deletes the specified notification. """
notification = model.delete_repo_notification(namespace, repository, uuid)
log_action('delete_repo_notification', namespace,
{'repo': repository, 'notification_id': uuid,
'event': notification.event.name, 'method': notification.method.name},
repo=model.get_repository(namespace, repository))
return 'No Content', 204
@resource('/v1/repository/<repopath:repository>/notification/<uuid>/test')
class TestRepositoryNotification(RepositoryParamResource):
""" Resource for queuing a test of a notification. """
@require_repo_admin
@nickname('testRepoNotification')
def post(self, namespace, repository, uuid):
""" Queues a test notification for this repository. """
try:
notification = model.get_repo_notification(namespace, repository, uuid)
except model.InvalidNotificationException:
raise NotFound()
event_info = NotificationEvent.get_event(notification.event.name)
sample_data = event_info.get_sample_data(repository=notification.repository)
notification_data = build_notification_data(notification, sample_data)
notification_queue.put([namespace, repository, notification.event.name],
json.dumps(notification_data))
return {}

View file

@ -2,9 +2,11 @@ from endpoints.api import (ApiResource, parse_args, query_param, truthy_bool, ni
require_scope)
from data import model
from auth.permissions import (OrganizationMemberPermission, ViewTeamPermission,
ReadRepositoryPermission, UserAdminPermission)
ReadRepositoryPermission, UserAdminPermission,
AdministerOrganizationPermission)
from auth.auth_context import get_authenticated_user
from auth import scopes
from util.gravatar import compute_hash
@resource('/v1/entities/<prefix>')
@ -14,10 +16,12 @@ class EntitySearch(ApiResource):
@query_param('namespace', 'Namespace to use when querying for org entities.', type=str,
default='')
@query_param('includeTeams', 'Whether to include team names.', type=truthy_bool, default=False)
@query_param('includeOrgs', 'Whether to include orgs names.', type=truthy_bool, default=False)
@nickname('getMatchingEntities')
def get(self, args, prefix):
""" Get a list of entities that match the specified prefix. """
teams = []
org_data = []
namespace_name = args['namespace']
robot_namespace = None
@ -34,6 +38,15 @@ class EntitySearch(ApiResource):
if args['includeTeams']:
teams = model.get_matching_teams(prefix, organization)
if args['includeOrgs'] and AdministerOrganizationPermission(namespace_name) \
and namespace_name.startswith(prefix):
org_data = [{
'name': namespace_name,
'kind': 'org',
'is_org_member': True,
'gravatar': compute_hash(organization.email),
}]
except model.InvalidOrganizationException:
# namespace name was a user
user = get_authenticated_user()
@ -69,7 +82,7 @@ class EntitySearch(ApiResource):
user_data = [user_view(user) for user in users]
return {
'results': team_data + user_data
'results': team_data + user_data + org_data
}
@ -113,4 +126,4 @@ class FindRepositories(ApiResource):
'repositories': [repo_view(repo) for repo in matching
if (repo.visibility.name == 'public' or
ReadRepositoryPermission(repo.namespace, repo.name).can())]
}
}

View file

@ -74,10 +74,12 @@ def user_view(user):
def notification_view(notification):
return {
'id': notification.uuid,
'organization': notification.target.username if notification.target.organization else None,
'kind': notification.kind.name,
'created': format_date(notification.created),
'metadata': json.loads(notification.metadata_json),
'dismissed': notification.dismissed
}
@ -409,6 +411,46 @@ class UserNotificationList(ApiResource):
}
@resource('/v1/user/notifications/<uuid>')
@internal_only
class UserNotification(ApiResource):
schemas = {
'UpdateNotification': {
'id': 'UpdateNotification',
'type': 'object',
'description': 'Information for updating a notification',
'properties': {
'dismissed': {
'type': 'boolean',
'description': 'Whether the notification is dismissed by the user',
},
},
},
}
@require_user_admin
@nickname('getUserNotification')
def get(self, uuid):
notification = model.lookup_notification(get_authenticated_user(), uuid)
if not notification:
raise NotFound()
return notification_view(notification)
@require_user_admin
@nickname('updateUserNotification')
@validate_json_request('UpdateNotification')
def put(self, uuid):
notification = model.lookup_notification(get_authenticated_user(), uuid)
if not notification:
raise NotFound()
notification.dismissed = request.get_json().get('dismissed', False)
notification.save()
return notification_view(notification)
def authorization_view(access_token):
oauth_app = access_token.application
return {

View file

@ -1,77 +0,0 @@
import json
from flask import request
from endpoints.api import (RepositoryParamResource, nickname, resource, require_repo_admin,
log_action, validate_json_request, api, NotFound)
from data import model
def webhook_view(webhook):
return {
'public_id': webhook.public_id,
'parameters': json.loads(webhook.parameters),
}
@resource('/v1/repository/<repopath:repository>/webhook/')
class WebhookList(RepositoryParamResource):
""" Resource for dealing with listing and creating webhooks. """
schemas = {
'WebhookCreateRequest': {
'id': 'WebhookCreateRequest',
'type': 'object',
'description': 'Arbitrary json.',
},
}
@require_repo_admin
@nickname('createWebhook')
@validate_json_request('WebhookCreateRequest')
def post(self, namespace, repository):
""" Create a new webhook for the specified repository. """
repo = model.get_repository(namespace, repository)
webhook = model.create_webhook(repo, request.get_json())
resp = webhook_view(webhook)
repo_string = '%s/%s' % (namespace, repository)
headers = {
'Location': api.url_for(Webhook, repository=repo_string, public_id=webhook.public_id),
}
log_action('add_repo_webhook', namespace,
{'repo': repository, 'webhook_id': webhook.public_id},
repo=repo)
return resp, 201, headers
@require_repo_admin
@nickname('listWebhooks')
def get(self, namespace, repository):
""" List the webhooks for the specified repository. """
webhooks = model.list_webhooks(namespace, repository)
return {
'webhooks': [webhook_view(webhook) for webhook in webhooks]
}
@resource('/v1/repository/<repopath:repository>/webhook/<public_id>')
class Webhook(RepositoryParamResource):
""" Resource for dealing with specific webhooks. """
@require_repo_admin
@nickname('getWebhook')
def get(self, namespace, repository, public_id):
""" Get information for the specified webhook. """
try:
webhook = model.get_webhook(namespace, repository, public_id)
except model.InvalidWebhookException:
raise NotFound()
return webhook_view(webhook)
@require_repo_admin
@nickname('deleteWebhook')
def delete(self, namespace, repository, public_id):
""" Delete the specified webhook. """
model.delete_webhook(namespace, repository, public_id)
log_action('delete_repo_webhook', namespace,
{'repo': repository, 'webhook_id': public_id},
repo=model.get_repository(namespace, repository))
return 'No Content', 204

View file

@ -9,7 +9,7 @@ from flask.ext.principal import identity_changed
from random import SystemRandom
from data import model
from app import app, login_manager, dockerfile_build_queue
from app import app, login_manager, dockerfile_build_queue, notification_queue
from auth.permissions import QuayDeferredPermissionUser
from auth import scopes
from endpoints.api.discovery import swagger_route_data
@ -17,10 +17,12 @@ from werkzeug.routing import BaseConverter
from functools import wraps
from config import getFrontendVisibleConfig
from external_libraries import get_external_javascript, get_external_css
from endpoints.notificationhelper import spawn_notification
import features
logger = logging.getLogger(__name__)
profile = logging.getLogger('application.profiler')
route_data = None
@ -220,6 +222,7 @@ def start_build(repository, dockerfile_id, tags, build_name, subdir, manual,
'pull_credentials': model.get_pull_credentials(pull_robot_name) if pull_robot_name else None
}), retries_remaining=1)
# Add the build to the repo's log.
metadata = {
'repo': repository.name,
'namespace': repository.namespace,
@ -236,4 +239,21 @@ def start_build(repository, dockerfile_id, tags, build_name, subdir, manual,
ip=request.remote_addr, metadata=metadata,
repository=repository)
# Add notifications for the build queue.
profile.debug('Adding notifications for repository')
event_data = {
'build_id': build_request.uuid,
'build_name': build_name,
'docker_tags': tags,
'is_manual': manual
}
if trigger:
event_data['trigger_id'] = trigger.uuid
event_data['trigger_kind'] = trigger.service.name
spawn_notification(repository, 'build_queued', event_data,
subpage='build?current=%s' % build_request.uuid,
pathargs=['build', build_request.uuid])
return build_request

View file

@ -8,7 +8,7 @@ from collections import OrderedDict
from data import model
from data.model import oauth
from app import analytics, app, webhook_queue, authentication, userevents, storage
from app import analytics, app, authentication, userevents, storage
from auth.auth import process_auth
from auth.auth_context import get_authenticated_user, get_validated_token, get_validated_oauth_token
from util.names import parse_repository_name
@ -17,6 +17,7 @@ from auth.permissions import (ModifyRepositoryPermission, UserAdminPermission,
ReadRepositoryPermission, CreateRepositoryPermission)
from util.http import abort
from endpoints.notificationhelper import spawn_notification
logger = logging.getLogger(__name__)
@ -307,7 +308,7 @@ def update_images(namespace, repository):
'action': 'pushed_repo',
'repository': repository,
'namespace': namespace
}
}
event = userevents.get_event(username)
event.publish_event_data('docker-cli', user_data)
@ -315,28 +316,16 @@ def update_images(namespace, repository):
profile.debug('GCing repository')
num_removed = model.garbage_collect_repository(namespace, repository)
# Generate a job for each webhook that has been added to this repo
profile.debug('Adding webhooks for repository')
# Generate a job for each notification that has been added to this repo
profile.debug('Adding notifications for repository')
webhooks = model.list_webhooks(namespace, repository)
for webhook in webhooks:
webhook_data = json.loads(webhook.parameters)
repo_string = '%s/%s' % (namespace, repository)
profile.debug('Creating webhook for repository \'%s\' for url \'%s\'',
repo_string, webhook_data['url'])
webhook_data['payload'] = {
'repository': repo_string,
'namespace': namespace,
'name': repository,
'docker_url': 'quay.io/%s' % repo_string,
'homepage': 'https://quay.io/repository/%s' % repo_string,
'visibility': repo.visibility.name,
'updated_tags': updated_tags,
'pushed_image_count': len(image_with_checksums),
'pruned_image_count': num_removed,
}
webhook_queue.put([namespace, repository], json.dumps(webhook_data))
event_data = {
'updated_tags': updated_tags,
'pushed_image_count': len(image_with_checksums),
'pruned_image_count': num_removed
}
spawn_notification(repo, 'repo_push', event_data)
return make_response('Updated', 204)
abort(403)

View file

@ -0,0 +1,207 @@
import logging
import io
import os.path
import tarfile
import base64
from notificationhelper import build_event_data
logger = logging.getLogger(__name__)
class InvalidNotificationEventException(Exception):
pass
class NotificationEvent(object):
def __init__(self):
pass
def get_summary(self, event_data, notification_data):
"""
Returns a human readable one-line summary for the given notification data.
"""
raise NotImplementedError
def get_message(self, event_data, notification_data):
"""
Returns a human readable HTML message for the given notification data.
"""
raise NotImplementedError
def get_sample_data(self, repository=None):
"""
Returns sample data for testing the raising of this notification, with an optional
repository.
"""
raise NotImplementedError
@classmethod
def event_name(cls):
"""
Particular event implemented by subclasses.
"""
raise NotImplementedError
@classmethod
def get_event(cls, eventname):
for subc in cls.__subclasses__():
if subc.event_name() == eventname:
return subc()
raise InvalidNotificationEventException('Unable to find event: %s' % eventname)
class RepoPushEvent(NotificationEvent):
@classmethod
def event_name(cls):
return 'repo_push'
def get_summary(self, event_data, notification_data):
return 'Repository %s updated' % (event_data['repository'])
def get_message(self, event_data, notification_data):
if not event_data.get('updated_tags', []):
html = """
Repository <a href="%s">%s</a> has been updated via a push.
""" % (event_data['homepage'],
event_data['repository'])
else:
html = """
Repository <a href="%s">%s</a> has been updated via a push.
<br><br>
Tags Updated: %s
""" % (event_data['homepage'],
event_data['repository'],
', '.join(event_data['updated_tags']))
return html
def get_sample_data(self, repository):
return build_event_data(repository, {
'updated_tags': ['latest', 'foo', 'bar'],
'pushed_image_count': 10,
'pruned_image_count': 3
})
class BuildQueueEvent(NotificationEvent):
@classmethod
def event_name(cls):
return 'build_queued'
def get_sample_data(self, repository):
build_uuid = 'fake-build-id'
return build_event_data(repository, {
'is_manual': False,
'build_id': build_uuid,
'build_name': 'some-fake-build',
'docker_tags': ['latest', 'foo', 'bar'],
'trigger_kind': 'GitHub'
}, subpage='/build?current=%s' % build_uuid)
def get_summary(self, event_data, notification_data):
return 'Build queued for repository %s' % (event_data['repository'])
def get_message(self, event_data, notification_data):
is_manual = event_data['is_manual']
if is_manual:
html = """
A <a href="%s">new build</a> has been manually queued to start on repository %s.
<br><br>
Build ID: %s
""" % (event_data['homepage'], event_data['repository'], event_data['build_id'])
else:
html = """
A <a href="%s">new build</a> has been queued via a %s trigger to start on repository %s.
<br><br>
Build ID: %s
""" % (event_data['homepage'], event_data['trigger_kind'],
event_data['repository'], event_data['build_id'])
return html
class BuildStartEvent(NotificationEvent):
@classmethod
def event_name(cls):
return 'build_start'
def get_sample_data(self, repository):
build_uuid = 'fake-build-id'
return build_event_data(repository, {
'build_id': build_uuid,
'build_name': 'some-fake-build',
'docker_tags': ['latest', 'foo', 'bar'],
'trigger_kind': 'GitHub'
}, subpage='/build?current=%s' % build_uuid)
def get_summary(self, event_data, notification_data):
return 'Build started for repository %s' % (event_data['repository'])
def get_message(self, event_data, notification_data):
html = """
A <a href="%s">new build</a> has started on repository %s.
<br><br>
Build ID: %s
""" % (event_data['homepage'], event_data['repository'], event_data['build_id'])
return html
class BuildSuccessEvent(NotificationEvent):
@classmethod
def event_name(cls):
return 'build_success'
def get_sample_data(self, repository):
build_uuid = 'fake-build-id'
return build_event_data(repository, {
'build_id': build_uuid,
'build_name': 'some-fake-build',
'docker_tags': ['latest', 'foo', 'bar'],
'trigger_kind': 'GitHub'
}, subpage='/build?current=%s' % build_uuid)
def get_summary(self, event_data, notification_data):
return 'Build succeeded for repository %s' % (event_data['repository'])
def get_message(self, event_data, notification_data):
html = """
A <a href="%s">build</a> has finished on repository %s.
<br><br>
Build ID: %s
""" % (event_data['homepage'], event_data['repository'], event_data['build_id'])
return html
class BuildFailureEvent(NotificationEvent):
@classmethod
def event_name(cls):
return 'build_failure'
def get_sample_data(self, repository):
return build_event_data(repository, {
'build_id': build_uuid,
'build_name': 'some-fake-build',
'docker_tags': ['latest', 'foo', 'bar'],
'trigger_kind': 'GitHub',
'error_message': 'This is a fake error message'
}, subpage='/build?current=%s' % build_uuid)
def get_summary(self, event_data, notification_data):
return 'Build failure for repository %s' % (event_data['repository'])
def get_message(self, event_data, notification_data):
html = """
A <a href="%s">build</a> has failed on repository %s.
<br><br>
Reason: %s<br>
Build ID: %s<br>
""" % (event_data['homepage'], event_data['repository'],
event_data['error_message'], event_data['build_id'])
return html

View file

@ -0,0 +1,46 @@
from app import app, notification_queue
from data import model
import json
def build_event_data(repo, extra_data={}, subpage=None):
repo_string = '%s/%s' % (repo.namespace, repo.name)
homepage = '%s://%s/repository/%s' % (app.config['PREFERRED_URL_SCHEME'],
app.config['SERVER_HOSTNAME'],
repo_string)
if subpage:
if not subpage.startswith('/'):
subpage = '/' + subpage
homepage = homepage + subpage
event_data = {
'repository': repo_string,
'namespace': repo.namespace,
'name': repo.name,
'docker_url': '%s/%s' % (app.config['SERVER_HOSTNAME'], repo_string),
'homepage': homepage,
'visibility': repo.visibility.name
}
event_data.update(extra_data)
return event_data
def build_notification_data(notification, event_data):
return {
'notification_uuid': notification.uuid,
'repository_namespace': notification.repository.namespace,
'repository_name': notification.repository.name,
'event_data': 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)
for notification in notifications:
notification_data = build_notification_data(notification, event_data)
path = [repo.namespace, repo.name, event_name] + pathargs
notification_queue.put(path, json.dumps(notification_data))

View file

@ -0,0 +1,189 @@
import logging
import io
import os.path
import tarfile
import base64
import json
from flask.ext.mail import Message
from app import mail, app
from data import model
logger = logging.getLogger(__name__)
class InvalidNotificationMethodException(Exception):
pass
class CannotValidateNotificationMethodException(Exception):
pass
class NotificationMethod(object):
def __init__(self):
pass
@classmethod
def method_name(cls):
"""
Particular method implemented by subclasses.
"""
raise NotImplementedError
def validate(self, repository, config_data):
"""
Validates that the notification can be created with the given data. Throws
a CannotValidateNotificationMethodException on failure.
"""
raise NotImplementedError
def perform(self, notification, event_handler, notification_data):
"""
Performs the notification method.
notification: The noticication record itself.
event_handler: The NotificationEvent handler.
notification_data: The dict of notification data placed in the queue.
"""
raise NotImplementedError
@classmethod
def get_method(cls, methodname):
for subc in cls.__subclasses__():
if subc.method_name() == methodname:
return subc()
raise InvalidNotificationMethodException('Unable to find method: %s' % methodname)
class QuayNotificationMethod(NotificationMethod):
@classmethod
def method_name(cls):
return 'quay_notification'
def validate(self, repository, config_data):
status, err_message, target_users = self.find_targets(repository, config_data)
if err_message:
raise CannotValidateNotificationMethodException(err_message)
def find_targets(self, repository, config_data):
target_info = config_data['target']
if target_info['kind'] == 'user':
target = model.get_user(target_info['name'])
if not target:
# Just to be safe.
return (True, 'Unknown user %s' % target_info['name'], [])
return (True, None, [target])
elif target_info['kind'] == 'org':
target = model.get_organization(target_info['name'])
if not target:
# Just to be safe.
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:
return (False, 'Organization name must match repository namespace')
return (True, None, [target])
elif target_info['kind'] == 'team':
# Lookup the team.
team = None
try:
team = model.get_organization_team(repository.namespace, target_info['name'])
except model.InvalidTeamException:
# Probably deleted.
return (True, 'Unknown team %s' % target_info['name'], None)
# Lookup the team's members
return (True, None, model.get_organization_team_members(team.id))
def perform(self, notification, event_handler, notification_data):
repository = notification.repository
if not repository:
# Probably deleted.
return True
# 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
# 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):
@classmethod
def method_name(cls):
return 'email'
def validate(self, repository, config_data):
email = config_data.get('email', '')
if not email:
raise CannotValidateNotificationMethodException('Missing e-mail address')
record = model.get_email_authorized_for_repo(repository.namespace, repository.name, email)
if not record or not record.confirmed:
raise CannotValidateNotificationMethodException('The specified e-mail address '
'is not authorized to receive '
'notifications for this repository')
def perform(self, notification, event_handler, notification_data):
config_data = json.loads(notification.config_json)
email = config_data.get('email', '')
if not email:
return False
msg = Message(event_handler.get_summary(notification_data['event_data'], notification_data),
sender='support@quay.io',
recipients=[email])
msg.html = event_handler.get_message(notification_data['event_data'], notification_data)
try:
with app.app_context():
mail.send(msg)
except Exception as ex:
logger.exception('Email was unable to be sent: %s' % ex.message)
return False
return True
class WebhookMethod(NotificationMethod):
@classmethod
def method_name(cls):
return 'webhook'
def validate(self, repository, config_data):
url = config_data.get('url', '')
if not url:
raise CannotValidateNotificationMethodException('Missing webhook URL')
def perform(self, notification, event_handler, notification_data):
config_data = json.loads(notification.config_json)
url = config_data.get('url', '')
if not url:
return False
payload = notification_data['event_data']
headers = {'Content-type': 'application/json'}
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
except requests.exceptions.RequestException as ex:
logger.exception('Webhook was unable to be sent: %s' % ex.message)
return False
return True

View file

@ -214,6 +214,26 @@ def receipt():
abort(404)
@web.route('/authrepoemail', methods=['GET'])
def confirm_repo_email():
code = request.values['code']
record = None
try:
record = model.confirm_email_authorization_for_repo(code)
except model.DataModelException as ex:
return render_page_template('confirmerror.html', error_message=ex.message)
message = """
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)
return render_page_template('message.html', message=message)
@web.route('/confirm', methods=['GET'])
def confirm_email():
code = request.values['code']

View file

@ -18,7 +18,7 @@ EXTERNAL_JS = [
]
EXTERNAL_CSS = [
'netdna.bootstrapcdn.com/font-awesome/4.0.3/css/font-awesome.css',
'netdna.bootstrapcdn.com/font-awesome/4.1.0/css/font-awesome.css',
'netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap.no-icons.min.css',
'fonts.googleapis.com/css?family=Droid+Sans:400,700',
]

View file

@ -206,8 +206,6 @@ def initialize_database():
LogEntryKind.create(name='change_repo_visibility')
LogEntryKind.create(name='add_repo_accesstoken')
LogEntryKind.create(name='delete_repo_accesstoken')
LogEntryKind.create(name='add_repo_webhook')
LogEntryKind.create(name='delete_repo_webhook')
LogEntryKind.create(name='set_repo_description')
LogEntryKind.create(name='build_dockerfile')
@ -231,6 +229,34 @@ def initialize_database():
LogEntryKind.create(name='delete_application')
LogEntryKind.create(name='reset_application_client_secret')
# Note: These are deprecated.
LogEntryKind.create(name='add_repo_webhook')
LogEntryKind.create(name='delete_repo_webhook')
LogEntryKind.create(name='add_repo_notification')
LogEntryKind.create(name='delete_repo_notification')
ImageStorageLocation.create(name='local_eu')
ImageStorageLocation.create(name='local_us')
# NOTE: These MUST be copied over to NotificationKind, since every external
# notification can also generate a Quay.io notification.
ExternalNotificationEvent.create(name='repo_push')
ExternalNotificationEvent.create(name='build_queued')
ExternalNotificationEvent.create(name='build_start')
ExternalNotificationEvent.create(name='build_success')
ExternalNotificationEvent.create(name='build_failure')
ExternalNotificationMethod.create(name='quay_notification')
ExternalNotificationMethod.create(name='email')
ExternalNotificationMethod.create(name='webhook')
NotificationKind.create(name='repo_push')
NotificationKind.create(name='build_queued')
NotificationKind.create(name='build_start')
NotificationKind.create(name='build_success')
NotificationKind.create(name='build_failure')
NotificationKind.create(name='password_required')
NotificationKind.create(name='over_private_usage')
NotificationKind.create(name='expiring_license')
@ -238,9 +264,6 @@ def initialize_database():
NotificationKind.create(name='test_notification')
ImageStorageLocation.create(name='local_eu')
ImageStorageLocation.create(name='local_us')
def wipe_database():
logger.debug('Wiping all data from the DB.')
@ -325,7 +348,7 @@ def populate_database():
(5, [], 'v4.0'),
(1, [(1, [], 'v5.0'), (1, [], 'v6.0')], None)],
None))
__generate_repository(new_user_2, 'publicrepo',
'Public repository pullable by the world.', True,
[], (10, [], 'latest'))
@ -361,6 +384,12 @@ def populate_database():
'build_subdir': '',
}
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')
build2 = model.create_repository_build(building, token, job_config,
'68daeebd-a5b9-457f-80a0-4363b882f8ea',
'build-name', trigger)

Binary file not shown.

View file

@ -89,6 +89,11 @@ nav.navbar-default .navbar-nav>li>a {
background: rgba(66, 139, 202, 0.1);
}
.notification-view-element .right-controls {
text-align: right;
font-size: 12px;
}
.dockerfile-path {
margin-top: 10px;
padding: 20px;
@ -219,11 +224,12 @@ nav.navbar-default .navbar-nav>li>a {
.entity-search-element {
position: relative;
display: block;
}
.entity-search-element .entity-reference {
position: absolute !important;
top: 0px;
top: 7px;
left: 8px;
right: 36px;
z-index: 0;
@ -244,6 +250,7 @@ nav.navbar-default .navbar-nav>li>a {
.entity-search-element input {
vertical-align: middle;
width: 100%;
}
.entity-search-element.persistent input {
@ -253,12 +260,15 @@ nav.navbar-default .navbar-nav>li>a {
.entity-search-element .twitter-typeahead {
vertical-align: middle;
display: block !important;
margin-right: 36px;
}
.entity-search-element .dropdown {
vertical-align: middle;
display: inline-block;
margin-top: 0px;
position: absolute;
top: 0px;
right: 0px;
}
.dropdown-menu i.fa {
@ -2508,10 +2518,6 @@ p.editable:hover i {
text-align: right;
}
.repo-admin .entity-search input {
width: 300px;
}
.repo-admin .panel {
display: inline-block;
width: 720px;
@ -3023,7 +3029,6 @@ p.editable:hover i {
.team-view .entity-search {
margin-top: 10px;
display: inline-block;
}
.team-view .delete-ui {
@ -3889,6 +3894,11 @@ pre.command:before {
display: none;
}
.dropdown-select input.form-control[readonly] {
cursor: pointer;
background-color: #fff;
}
.dropdown-select .lookahead-input {
padding-left: 32px;
}
@ -4429,3 +4439,46 @@ have a fixed width and height (but it's not required).
float: right;
font-size: 22px;
}
i.quay-icon {
background-image: url(/static/img/favicon.ico);
background-size: 16px;
width: 16px;
height: 16px;
}
.external-notification-view-element {
margin: 10px;
padding: 6px;
border: 1px solid #eee;
border-radius: 6px;
}
.external-notification-view-element i.fa {
margin-right: 6px;
}
.external-notification-view-element .view-row {
margin-bottom: 10px;
}
.external-notification-view-element .view-row:last-child {
margin-bottom: 0px;
}
.external-notification-view-element .flow-text {
display: inline-block;
color: #aaa;
text-transform: lowercase;
font-variant: small-caps;
width: 50px;
}
.external-notification-view-element .side-controls button {
border: 1px solid transparent;
transition: all 300ms ease-in-out;
}
.external-notification-view-element:hover .side-controls button {
border: 1px solid #eee;
}

View file

@ -0,0 +1,129 @@
<!-- Modal message dialog -->
<div class="modal fade" id="createNotificationModal">
<div class="modal-dialog">
<div class="modal-content">
<form id="createForm" name="createForm" ng-submit="createNotification()">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true" ng-disabled="creating">&times;</button>
<h4 class="modal-title">
Create Repository Notification
</h4>
</div>
<div class="modal-body">
<!-- Creating spinner -->
<div class="quay-spinner" ng-show="status == 'creating' || status == 'authorizing-email'"></div>
<!-- Authorize e-mail view -->
<div ng-show="status == 'authorizing-email-sent'">
An e-mail has been sent to <code>{{ currentConfig.email }}</code>. Please click the link contained
in the e-mail.
<br><br>
Waiting... <span class="quay-spinner"></span>
</div>
<!-- Authorize e-mail view -->
<div ng-show="status == 'unauthorized-email'">
The e-mail address <code>{{ currentConfig.email }}</code> has not been authorized to receive
notifications from this repository. Please click "Send Authorization E-mail" below to start
the authorization process.
</div>
<!-- Create View -->
<table style="width: 100%" ng-show="status == ''">
<tr>
<td style="width: 120px">When this occurs:</td>
<td>
<div class="dropdown-select" placeholder="'(Notification Event)'" selected-item="currentEvent.title"
handle-item-selected="handleEventSelected(datum)" clear-value="clearCounter">
<!-- Icons -->
<i class="dropdown-select-icon fa fa-lg" ng-class="currentEvent.icon"></i>
<!-- Dropdown menu -->
<ul class="dropdown-select-menu pull-right" role="menu">
<li ng-repeat="event in events">
<a href="javascript:void(0)" ng-click="setEvent(event)">
<i class="fa fa-lg" ng-class="event.icon"></i> {{ event.title }}
</a>
</li>
</ul>
</div>
</td>
</tr>
<tr>
<td>Then issue a:</td>
<td>
<div class="dropdown-select" placeholder="'(Notification Action)'" selected-item="currentMethod.title"
handle-item-selected="handleMethodSelected(datum)" clear-value="clearCounter">
<!-- Icons -->
<i class="dropdown-select-icon fa fa-lg" ng-class="currentMethod.icon"></i>
<!-- Dropdown menu -->
<ul class="dropdown-select-menu pull-right" role="menu">
<li ng-repeat="method in methods">
<a href="javascript:void(0)" ng-click="setMethod(method)">
<i class="fa fa-lg" ng-class="method.icon"></i> {{ method.title }}
</a>
</li>
</ul>
</div>
</td>
</tr>
<tr ng-if="currentMethod.fields.length"><td colspan="2"><hr></td></tr>
<tr ng-repeat="field in currentMethod.fields">
<td>{{ field.title }}:</td>
<td>
<div ng-switch on="field.type">
<span ng-switch-when="email">
<input type="email" class="form-control" ng-model="currentConfig[field.name]" required>
</span>
<input type="url" class="form-control" ng-model="currentConfig[field.name]" ng-switch-when="url" required>
<input type="text" class="form-control" ng-model="currentConfig[field.name]" ng-switch-when="string" required>
<div class="entity-search" namespace="repository.namespace"
placeholder="''"
current-entity="currentConfig[field.name]"
ng-model="currentConfig[field.name]"
allowed-entities="['user', 'team', 'org']"
ng-switch-when="entity">
</div>
</td>
</tr>
<tr ng-if="currentMethod.id == 'webhook'">
<td colspan="2">
<div class="alert alert-info" style="margin-top: 20px; margin-bottom: 0px">
JSON metadata representing the event will be <b>POST</b>ed to the URL.
<br><br>
The contents for each event can be found in the user guide:
<a href="http://docs.quay.io/guides/notifications.html#webhook{{ currentEvent.id ? '_' + currentEvent.id : '' }}"
target="_blank">
http://docs.quay.io/guides/notifications.html
</a>
</div>
</td>
</tr>
</table>
</div>
<!-- Auth e-mail button bar -->
<div class="modal-footer" ng-if="status == 'unauthorized-email'">
<button type="button" class="btn btn-success" ng-click="sendAuthEmail()">
Send Authorization E-mail
</button>
<button type="button" class="btn btn-default" data-dismiss="modal" ng-disabled="creating">Cancel</button>
</div>
<!-- Normal button bar -->
<div class="modal-footer" ng-if="status == '' || status == 'creating'">
<button type="submit" class="btn btn-primary"
ng-disabled="createForm.$invalid || !currentMethod.id || !currentEvent.id || creating">
Create Notification
</button>
<button type="button" class="btn btn-default" data-dismiss="modal" ng-disabled="creating">Cancel</button>
</div>
</form>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->

View file

@ -1,7 +1,8 @@
<div class="dropdown-select-element" ng-class="selectedItem ? 'has-item' : ''">
<div class="current-item">
<div class="dropdown-select-icon-transclude"></div>
<input type="text" class="lookahead-input form-control" placeholder="{{ placeholder }}"></input>
<input type="text" class="lookahead-input form-control" placeholder="{{ placeholder }}"
ng-readonly="!lookaheadItems || !lookaheadItems.length"></input>
</div>
<div class="dropdown">
<button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown">

View file

@ -6,7 +6,14 @@
<span ng-if="getIsAdmin(namespace)"><a href="/organization/{{ namespace }}/teams/{{ entity.name }}">{{entity.name}}</a></span>
</span>
</span>
<span ng-if="entity.kind != 'team'">
<span ng-if="entity.kind == 'org'">
<img src="//www.gravatar.com/avatar/{{ entity.gravatar }}?s=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>
<span class="entity-name" ng-if="entity.is_robot">

View file

@ -1,5 +1,5 @@
<span class="entity-search-element" ng-class="isPersistent ? 'persistent' : ''"><input class="entity-search-control form-control">
<span class="entity-reference block-reference" ng-show="isPersistent && currentEntityInternal" entity="currentEntityInternal"></span>
<span class="entity-search-element" ng-class="autoClear ? '' : 'persistent'"><input class="entity-search-control form-control">
<span class="entity-reference block-reference" ng-show="!autoClear && currentEntityInternal" entity="currentEntityInternal"></span>
<div class="dropdown">
<button class="btn btn-default dropdown-toggle" type="button" id="entityDropdownMenu" data-toggle="dropdown"
ng-click="lazyLoad()">
@ -11,6 +11,29 @@
<li role="presentation" class="dropdown-header" ng-show="!lazyLoading && !robots && !isAdmin && !teams">
You do not have permission to manage teams and robots for this organization
</li>
<li role="presentation" class="dropdown-header"
ng-show="!lazyLoading && !teams.length && !robots.length && !((includeTeams && isOrganization && isAdmin) || (includeRobots && isAdmin))">
<span ng-if="includeRobots && includeTeams && isOrganization">
No robot accounts or teams found
</span>
<span ng-if="!includeRobots && includeTeams && isOrganization">
No teams found
</span>
<span ng-if="includeRobots && !includeTeams && isOrganization">
No robot accounts found
</span>
<span ng-if="!includeRobots && !includeTeams && isOrganization">
Robot accounts and teams are not permitted
</span>
<span ng-if="includeRobots && !isOrganization">
No robot accounts found
</span>
<span ng-if="!includeRobots && !isOrganization">
Robot accounts are not permitted
</span>
</li>
<li role="presentation" ng-repeat="team in teams" ng-show="!lazyLoading"
ng-click="setEntity(team.name, 'team', false)">
@ -34,7 +57,7 @@
<i class="fa fa-group"></i> Create team
</a>
</li>
<li role="presentation" ng-show="!lazyLoading && isAdmin">
<li role="presentation" ng-show="includeRobots && !lazyLoading && isAdmin">
<a role="menuitem" class="new-action" tabindex="-1" href="javascript:void(0)" ng-click="createRobot()">
<i class="fa fa-wrench"></i>
Create robot account

View file

@ -0,0 +1,62 @@
<div class="external-notification-view-element">
<div class="side-controls">
<div class="dropdown" style="display: inline-block">
<button class="btn btn-default dropdown-toggle" data-toggle="dropdown">
<i class="fa fa-cog"></i>
<b class="caret"></b>
</button>
<ul class="dropdown-menu dropdown-menu-right pull-right">
<li ng-if="methodInfo.id == 'webhook'">
<a href="http://docs.quay.io/guides/notifications.html#webhook_{{ eventInfo.id }}"
target="_blank">
<i class="fa fa-book"></i>
Webhook Documentation</a>
</li>
<li class="divider" ng-if="methodInfo.id == 'webhook'"></li>
<li><a href="javascript:void(0)" ng-click="testNotification()">
<i class="fa fa-send"></i>
Issue Test Notification</a>
</li>
<li><a href="javascript:void(0)" ng-click="deleteNotification()">
<i class="fa fa-times"></i>
Delete</a>
</li>
</ul>
</div>
</div>
<div class="view-row">
<span class="flow-text">On</span>
<span class="notification-event">
<i class="fa fa-lg" ng-class="eventInfo.icon"></i>
{{ eventInfo.title }}
</span>
</div>
<div class="view-row">
<span class="flow-text">Issue A</span>
<span class="notification-method">
<i class="fa fa-lg" ng-class="methodInfo.icon"></i>
{{ methodInfo.title }}
</span>
</div>
<div class="view-row">
<span ng-switch on="methodInfo.id">
<span ng-switch-when="email">
<span class="flow-text">To</span>
<code>{{ config.email }}</code>
</span>
<span ng-switch-when="webhook">
<span class="flow-text">To</span>
<code>{{ config.url }}</code>
</span>
<span ng-switch-when="quay_notification">
<span class="flow-text">To</span>
<span class="entity-reference" entity="config.target" namespace="repository.namespace"></span>
</span>
</span>
</div>
</div>

View file

@ -6,6 +6,11 @@
<img src="//www.gravatar.com/avatar/{{ getGravatar(notification.organization) }}?s=24&d=identicon" />
<span class="orgname">{{ notification.organization }}</span>
</div>
<div class="datetime">{{ parseDate(notification.created) | date:'medium'}}</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>
</div>
</div>

View file

@ -83,9 +83,11 @@
<tr ng-show="!newForWholeOrg">
<td>Repository Creator:</td>
<td>
<span class="entity-search" namespace="organization.name" input-title="'User/Robot'"
is-organization="true" include-teams="false" current-entity="activatingForNew" is-persistent="true"
clear-now="clearCounter">
<span class="entity-search" namespace="organization.name"
placeholder="'User/Robot'"
allowed-entities="['user', 'robot']"
current-entity="activatingForNew"
clear-value="clearCounter">
</span>
</td>
</tr>
@ -98,9 +100,9 @@
<tr>
<td>Applied To:</td>
<td>
<span class="entity-search" namespace="organization.name" input-title="'User/Robot/Team'"
is-organization="true" include-teams="true" current-entity="delegateForNew" is-persistent="true"
clear-now="clearCounter">
<span class="entity-search" namespace="organization.name" placeholder="'User/Robot/Team'"
current-entity="delegateForNew"
clear-value="clearCounter">
</span>
</td>
</tr>

View file

@ -71,12 +71,10 @@
<td>
</td>
<td>
<div class="entity-search" namespace="repository.namespace" include-teams="false"
input-title="'Select robot account for pulling...'"
is-organization="repository.is_organization"
is-persistent="true"
<div class="entity-search" namespace="repository.namespace"
placeholder="'Select robot account for pulling...'"
current-entity="pullEntity"
filter="['robot']"></div>
allowed-entities="['robot']"></div>
<div class="alert alert-info" ng-if="pullRequirements.robots.length" style="margin-top: 20px; margin-bottom: 0px;">
Note: We've automatically selected robot account <span class="entity-reference" entity="pullRequirements.robots[0]"></span>, since it has access to the Quay.io repository.

View file

@ -969,6 +969,106 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
return userService;
}]);
$provide.factory('ExternalNotificationData', [function() {
var externalNotificationData = {};
var events = [
{
'id': 'repo_push',
'title': 'Push to Repository',
'icon': 'fa-upload'
},
{
'id': 'build_queued',
'title': 'Dockerfile Build Queued',
'icon': 'fa-tasks'
},
{
'id': 'build_start',
'title': 'Dockerfile Build Started',
'icon': 'fa-circle-o-notch'
},
{
'id': 'build_success',
'title': 'Dockerfile Build Successfully Completed',
'icon': 'fa-check-circle-o'
},
{
'id': 'build_failure',
'title': 'Dockerfile Build Failed',
'icon': 'fa-times-circle-o'
}
];
var methods = [
{
'id': 'quay_notification',
'title': 'Quay.io Notification',
'icon': 'quay-icon',
'fields': [
{
'name': 'target',
'type': 'entity',
'title': 'Recipient'
}
]
},
{
'id': 'email',
'title': 'E-mail',
'icon': 'fa-envelope',
'fields': [
{
'name': 'email',
'type': 'email',
'title': 'E-mail address'
}
]
},
{
'id': 'webhook',
'title': 'Webhook POST',
'icon': 'fa-link',
'fields': [
{
'name': 'url',
'type': 'url',
'title': 'Webhook URL'
}
]
}
];
var methodMap = {};
var eventMap = {};
for (var i = 0; i < methods.length; ++i) {
methodMap[methods[i].id] = methods[i];
}
for (var i = 0; i < events.length; ++i) {
eventMap[events[i].id] = events[i];
}
externalNotificationData.getSupportedEvents = function() {
return events;
};
externalNotificationData.getSupportedMethods = function() {
return methods;
};
externalNotificationData.getEventInfo = function(event) {
return eventMap[event];
};
externalNotificationData.getMethodInfo = function(method) {
return methodMap[method];
};
return externalNotificationData;
}]);
$provide.factory('NotificationService', ['$rootScope', '$interval', 'UserService', 'ApiService', 'StringBuilderService', 'PlanService', 'UserService', 'Config',
function($rootScope, $interval, UserService, ApiService, StringBuilderService, PlanService, UserService, Config) {
var notificationService = {
@ -984,7 +1084,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
'test_notification': {
'level': 'primary',
'message': 'This notification is a long message for testing',
'page': '/about/'
'page': '/about/',
'dismissable': true
},
'password_required': {
'level': 'error',
@ -1015,9 +1116,59 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
'message': 'We will be down for schedule maintenance from {from_date} to {to_date} ' +
'for {reason}. We are sorry about any inconvenience.',
'page': 'http://status.quay.io/'
},
'repo_push': {
'level': 'info',
'message': 'Repository {repository} has been pushed with the following tags updated: {updated_tags}',
'page': function(metadata) {
return '/repository/' + metadata.repository;
},
'dismissable': true
},
'build_queued': {
'level': 'info',
'message': 'A build has been queued for repository {repository}',
'page': function(metadata) {
return '/repository/' + metadata.repository + '/build?current=' + metadata.build_id;
},
'dismissable': true
},
'build_start': {
'level': 'info',
'message': 'A build has been started for repository {repository}',
'page': function(metadata) {
return '/repository/' + metadata.repository + '/build?current=' + metadata.build_id;
},
'dismissable': true
},
'build_failure': {
'level': 'error',
'message': 'A build has failed for repository {repository}',
'page': function(metadata) {
return '/repository/' + metadata.repository + '/build?current=' + metadata.build_id;
},
'dismissable': true
}
};
notificationService.dismissNotification = function(notification) {
notification.dismissed = true;
var params = {
'uuid': notification.id
};
ApiService.updateUserNotification(notification, params);
var index = $.inArray(notification, notificationService.notifications);
if (index >= 0) {
notificationService.notifications.splice(index, 1);
}
};
notificationService.canDismiss = function(notification) {
return !!notificationKinds[notification['kind']]['dismissable'];
};
notificationService.getPage = function(notification) {
var page = notificationKinds[notification['kind']]['page'];
if (typeof page != 'string') {
@ -2274,7 +2425,8 @@ quayApp.directive('logsView', function () {
'repository': '=repository',
'performer': '=performer'
},
controller: function($scope, $element, $sce, Restangular, ApiService, TriggerDescriptionBuilder, StringBuilderService) {
controller: function($scope, $element, $sce, Restangular, ApiService, TriggerDescriptionBuilder,
StringBuilderService, ExternalNotificationData) {
$scope.loading = true;
$scope.logs = null;
$scope.kindsAllowed = null;
@ -2335,8 +2487,6 @@ quayApp.directive('logsView', function () {
'change_repo_visibility': 'Change visibility for repository {repo} to {visibility}',
'add_repo_accesstoken': 'Create access token {token} in repository {repo}',
'delete_repo_accesstoken': 'Delete access token {token} in repository {repo}',
'add_repo_webhook': 'Add webhook in repository {repo}',
'delete_repo_webhook': 'Delete webhook in repository {repo}',
'set_repo_description': 'Change description for repository {repo}: {description}',
'build_dockerfile': function(metadata) {
if (metadata.trigger_id) {
@ -2387,7 +2537,21 @@ quayApp.directive('logsView', function () {
'update_application': 'Update application to {application_name} for client ID {client_id}',
'delete_application': 'Delete application {application_name} with client ID {client_id}',
'reset_application_client_secret': 'Reset the Client Secret of application {application_name} ' +
'with client ID {client_id}'
'with client ID {client_id}',
'add_repo_notification': function(metadata) {
var eventData = ExternalNotificationData.getEventInfo(metadata.event);
return 'Add notification of event "' + eventData['title'] + '" for repository {repo}';
},
'delete_repo_notification': function(metadata) {
var eventData = ExternalNotificationData.getEventInfo(metadata.event);
return 'Delete notification of event "' + eventData['title'] + '" for repository {repo}';
},
// Note: These are deprecated.
'add_repo_webhook': 'Add webhook in repository {repo}',
'delete_repo_webhook': 'Delete webhook in repository {repo}'
};
var logKinds = {
@ -2406,8 +2570,6 @@ quayApp.directive('logsView', function () {
'change_repo_visibility': 'Change repository visibility',
'add_repo_accesstoken': 'Create access token',
'delete_repo_accesstoken': 'Delete access token',
'add_repo_webhook': 'Add webhook',
'delete_repo_webhook': 'Delete webhook',
'set_repo_description': 'Change repository description',
'build_dockerfile': 'Build image from Dockerfile',
'delete_tag': 'Delete Tag',
@ -2427,7 +2589,13 @@ quayApp.directive('logsView', function () {
'create_application': 'Create Application',
'update_application': 'Update Application',
'delete_application': 'Delete Application',
'reset_application_client_secret': 'Reset Client Secret'
'reset_application_client_secret': 'Reset Client Secret',
'add_repo_notification': 'Add repository notification',
'delete_repo_notification': 'Delete repository notification',
// Note: these are deprecated.
'add_repo_webhook': 'Add webhook',
'delete_repo_webhook': 'Delete webhook'
};
var getDateString = function(date) {
@ -3127,48 +3295,73 @@ quayApp.directive('entitySearch', function () {
replace: false,
transclude: false,
restrict: 'C',
require: '?ngModel',
link: function(scope, element, attr, ctrl) {
scope.ngModel = ctrl;
},
scope: {
'namespace': '=namespace',
'inputTitle': '=inputTitle',
'entitySelected': '=entitySelected',
'includeTeams': '=includeTeams',
'isOrganization': '=isOrganization',
'isPersistent': '=isPersistent',
'placeholder': '=placeholder',
// Default: ['user', 'team', 'robot']
'allowedEntities': '=allowedEntities',
'currentEntity': '=currentEntity',
'clearNow': '=clearNow',
'filter': '=filter',
'entitySelected': '&entitySelected',
// When set to true, the contents of the control will be cleared as soon
// as an entity is selected.
'autoClear': '=autoClear',
// Set this property to immediately clear the contents of the control.
'clearValue': '=clearValue',
},
controller: function($scope, $element, Restangular, UserService, ApiService) {
controller: function($rootScope, $scope, $element, Restangular, UserService, ApiService) {
$scope.lazyLoading = true;
$scope.teams = null;
$scope.robots = null;
$scope.isAdmin = false;
$scope.isOrganization = false;
$scope.includeTeams = true;
$scope.includeRobots = true;
$scope.includeOrgs = false;
$scope.currentEntityInternal = $scope.currentEntity;
var isSupported = function(kind, opt_array) {
return $.inArray(kind, opt_array || $scope.allowedEntities || ['user', 'team', 'robot']) >= 0;
};
$scope.lazyLoad = function() {
if (!$scope.namespace || !$scope.lazyLoading) { return; }
// Determine whether we can admin this namespace.
$scope.isAdmin = UserService.isNamespaceAdmin($scope.namespace);
// If the scope is an organization and we are not part of it, then nothing more we can do.
if (!$scope.isAdmin && $scope.isOrganization && !UserService.getOrganization($scope.namespace)) {
$scope.teams = null;
$scope.robots = null;
$scope.lazyLoading = false;
return;
}
if ($scope.isOrganization && $scope.includeTeams) {
// Reset the cached teams and robots.
$scope.teams = null;
$scope.robots = null;
// Load the organization's teams (if applicable).
if ($scope.isOrganization && isSupported('team')) {
// Note: We load the org here again so that we always have the fully up-to-date
// teams list.
ApiService.getOrganization(null, {'orgname': $scope.namespace}).then(function(resp) {
$scope.teams = resp.teams;
});
}
ApiService.getRobots($scope.isOrganization ? $scope.namespace : null).then(function(resp) {
$scope.robots = resp.robots;
// Load the user/organization's robots (if applicable).
if ($scope.isAdmin && isSupported('robot')) {
ApiService.getRobots($scope.isOrganization ? $scope.namespace : null).then(function(resp) {
$scope.robots = resp.robots;
$scope.lazyLoading = false;
}, function() {
$scope.lazyLoading = false;
});
} else {
$scope.lazyLoading = false;
}, function() {
$scope.lazyLoading = false;
});
}
};
$scope.createTeam = function() {
@ -3216,7 +3409,7 @@ quayApp.directive('entitySearch', function () {
'is_robot': is_robot
};
if ($scope.is_organization) {
if ($scope.isOrganization) {
entity['is_org_member'] = true;
}
@ -3226,146 +3419,194 @@ quayApp.directive('entitySearch', function () {
$scope.clearEntityInternal = function() {
$scope.currentEntityInternal = null;
$scope.currentEntity = null;
if ($scope.entitySelected) {
$scope.entitySelected(null);
$scope.entitySelected({'entity': null});
if ($scope.ngModel) {
$scope.ngModel.$setValidity('entity', false);
}
};
$scope.setEntityInternal = function(entity, updateTypeahead) {
if (updateTypeahead) {
$(input).typeahead('val', $scope.isPersistent ? entity.name : '');
$(input).typeahead('val', $scope.autoClear ? '' : entity.name);
} else {
$(input).val($scope.isPersistent ? entity.name : '');
$(input).val($scope.autoClear ? '' : entity.name);
}
if ($scope.isPersistent) {
if (!$scope.autoClear) {
$scope.currentEntityInternal = entity;
$scope.currentEntity = entity;
}
if ($scope.entitySelected) {
$scope.entitySelected(entity);
$scope.entitySelected({'entity': entity});
if ($scope.ngModel) {
$scope.ngModel.$setValidity('entity', !!entity);
}
};
number++;
// Setup the typeahead.
var input = $element[0].firstChild.firstChild;
(function() {
// Create the bloodhound search query system.
$rootScope.__entity_search_counter = (($rootScope.__entity_search_counter || 0) + 1);
var entitySearchB = new Bloodhound({
name: 'entities' + $rootScope.__entity_search_counter,
remote: {
url: '/api/v1/entities/%QUERY',
replace: function (url, uriEncodedQuery) {
var namespace = $scope.namespace || '';
url = url.replace('%QUERY', uriEncodedQuery);
url += '?namespace=' + encodeURIComponent(namespace);
if ($scope.isOrganization && isSupported('team')) {
url += '&includeTeams=true'
}
if (isSupported('org')) {
url += '&includeOrgs=true'
}
return url;
},
filter: function(data) {
var datums = [];
for (var i = 0; i < data.results.length; ++i) {
var entity = data.results[i];
var entitySearchB = new Bloodhound({
name: 'entities' + number,
remote: {
url: '/api/v1/entities/%QUERY',
replace: function (url, uriEncodedQuery) {
var namespace = $scope.namespace || '';
url = url.replace('%QUERY', uriEncodedQuery);
url += '?namespace=' + encodeURIComponent(namespace);
if ($scope.includeTeams) {
url += '&includeTeams=true'
}
return url;
},
filter: function(data) {
var datums = [];
for (var i = 0; i < data.results.length; ++i) {
var entity = data.results[i];
if ($scope.filter) {
var allowed = $scope.filter;
var found = 'user';
if (entity.kind == 'user') {
found = entity.is_robot ? 'robot' : 'user';
} else if (entity.kind == 'team') {
found = 'team';
} else if (entity.kind == 'org') {
found = 'org';
}
if (allowed.indexOf(found)) {
if (!isSupported(found)) {
continue;
}
datums.push({
'value': entity.name,
'tokens': [entity.name],
'entity': entity
});
}
return datums;
}
},
datumTokenizer: function(d) {
return Bloodhound.tokenizers.whitespace(d.val);
},
queryTokenizer: Bloodhound.tokenizers.whitespace
});
entitySearchB.initialize();
// Setup the typeahead.
$(input).typeahead({
'highlight': true
}, {
source: entitySearchB.ttAdapter(),
templates: {
'empty': function(info) {
// Only display the empty dialog if the server load has finished.
if (info.resultKind == 'remote') {
var val = $(input).val();
if (!val) {
return null;
}
if (val.indexOf('@') > 0) {
return '<div class="tt-empty">A Quay.io username (not an e-mail address) must be specified</div>';
}
var classes = [];
if (isSupported('user')) { classes.push('users'); }
if (isSupported('org')) { classes.push('organizations'); }
if ($scope.isAdmin && isSupported('robot')) { classes.push('robot accounts'); }
if ($scope.isOrganization && isSupported('team')) { classes.push('teams'); }
if (classes.length > 1) {
classes[classes.length - 1] = 'or ' + classes[classes.length - 1];
}
var class_string = '';
for (var i = 0; i < classes.length; ++i) {
if (i > 0) {
if (i == classes.length - 1) {
class_string += ' or ';
} else {
class_string += ', ';
}
}
class_string += classes[i];
}
return '<div class="tt-empty">No matching Quay.io ' + class_string + ' found</div>';
}
datums.push({
'value': entity.name,
'tokens': [entity.name],
'entity': entity
});
}
return datums;
}
},
datumTokenizer: function(d) {
return Bloodhound.tokenizers.whitespace(d.val);
},
queryTokenizer: Bloodhound.tokenizers.whitespace
});
entitySearchB.initialize();
var counter = 0;
var input = $element[0].firstChild.firstChild;
$(input).typeahead({
'highlight': true
}, {
source: entitySearchB.ttAdapter(),
templates: {
'empty': function(info) {
// Only display the empty dialog if the server load has finished.
if (info.resultKind == 'remote') {
var val = $(input).val();
if (!val) {
return null;
return null;
},
'suggestion': function (datum) {
template = '<div class="entity-mini-listing">';
if (datum.entity.kind == 'user' && !datum.entity.is_robot) {
template += '<i class="fa fa-user fa-lg"></i>';
} else if (datum.entity.kind == 'user' && datum.entity.is_robot) {
template += '<i class="fa fa-wrench fa-lg"></i>';
} else if (datum.entity.kind == 'team') {
template += '<i class="fa fa-group fa-lg"></i>';
} else if (datum.entity.kind == 'org') {
template += '<i class="fa"><img src="//www.gravatar.com/avatar/' +
datum.entity.gravatar + '?s=16&amp;d=identicon"></i>';
}
if (val.indexOf('@') > 0) {
return '<div class="tt-empty">A Quay.io username (not an e-mail address) must be specified</div>';
template += '<span class="name">' + datum.value + '</span>';
if (datum.entity.is_org_member === false && datum.entity.kind == 'user') {
template += '<i class="fa fa-exclamation-triangle" title="User is outside the organization"></i>';
}
var robots = $scope.isOrganization ? ', robot accounts' : '';
var teams = ($scope.includeTeams && $scope.isOrganization) ? ' or teams' : '';
return '<div class="tt-empty">No matching Quay.io users' + robots + teams + ' found</div>';
}
template += '</div>';
return template;
}}
});
return null;
},
'suggestion': function (datum) {
template = '<div class="entity-mini-listing">';
if (datum.entity.kind == 'user' && !datum.entity.is_robot) {
template += '<i class="fa fa-user fa-lg"></i>';
} else if (datum.entity.kind == 'user' && datum.entity.is_robot) {
template += '<i class="fa fa-wrench fa-lg"></i>';
} else if (datum.entity.kind == 'team') {
template += '<i class="fa fa-group fa-lg"></i>';
}
template += '<span class="name">' + datum.value + '</span>';
if (datum.entity.is_org_member === false && datum.entity.kind == 'user') {
template += '<i class="fa fa-exclamation-triangle" title="User is outside the organization"></i>';
}
template += '</div>';
return template;
}}
});
$(input).on('input', function(e) {
$scope.$apply(function() {
if ($scope.isPersistent) {
$(input).on('input', function(e) {
$scope.$apply(function() {
$scope.clearEntityInternal();
}
});
});
});
$(input).on('typeahead:selected', function(e, datum) {
$scope.$apply(function() {
$scope.setEntityInternal(datum.entity, true);
$(input).on('typeahead:selected', function(e, datum) {
$scope.$apply(function() {
$scope.setEntityInternal(datum.entity, true);
});
});
});
})();
$scope.$watch('clearValue', function() {
if (!input) { return; }
$scope.$watch('clearNow', function() {
$(input).typeahead('val', '');
$scope.clearEntityInternal();
});
$scope.$watch('inputTitle', function(title) {
$scope.$watch('placeholder', function(title) {
input.setAttribute('placeholder', title);
});
$scope.$watch('allowedEntities', function(allowed) {
if (!allowed) { return; }
$scope.includeTeams = isSupported('team', allowed);
$scope.includeRobots = isSupported('robot', allowed);
});
$scope.$watch('namespace', function(namespace) {
if (!namespace) { return; }
$scope.isAdmin = UserService.isNamespaceAdmin(namespace);
$scope.isOrganization = !!UserService.getOrganization(namespace);
});
$scope.$watch('currentEntity', function(entity) {
if ($scope.currentEntityInternal != entity) {
if (entity) {
@ -3778,7 +4019,9 @@ quayApp.directive('dropdownSelect', function ($compile) {
'placeholder': '=placeholder',
'lookaheadItems': '=lookaheadItems',
'handleItemSelected': '&handleItemSelected',
'handleInput': '&handleInput'
'handleInput': '&handleInput',
'clearValue': '=clearValue'
},
controller: function($scope, $element, $rootScope) {
if (!$rootScope.__dropdownSelectCounter) {
@ -3791,6 +4034,13 @@ quayApp.directive('dropdownSelect', function ($compile) {
// Setup lookahead.
var input = $($element).find('.lookahead-input');
$scope.$watch('clearValue', function(cv) {
if (cv) {
$scope.selectedItem = null;
$(input).val('');
}
});
$scope.$watch('selectedItem', function(item) {
if ($scope.selectedItem == $scope.internalItem) {
// The item has already been set due to an internal action.
@ -4462,6 +4712,192 @@ quayApp.directive('buildProgress', function () {
});
quayApp.directive('externalNotificationView', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/external-notification-view.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'repository': '=repository',
'notification': '=notification',
'notificationDeleted': '&notificationDeleted'
},
controller: function($scope, $element, ExternalNotificationData, ApiService) {
$scope.deleteNotification = function() {
var params = {
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
'uuid': $scope.notification.uuid
};
ApiService.deleteRepoNotification(null, params).then(function() {
$scope.notificationDeleted({'notification': $scope.notification});
});
};
$scope.testNotification = function() {
var params = {
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
'uuid': $scope.notification.uuid
};
ApiService.testRepoNotification(null, params).then(function() {
bootbox.dialog({
"title": "Test Notification Queued",
"message": "A test version of this notification has been queued and should appear shortly",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
};
$scope.$watch('notification', function(notification) {
if (notification) {
$scope.eventInfo = ExternalNotificationData.getEventInfo(notification.event);
$scope.methodInfo = ExternalNotificationData.getMethodInfo(notification.method);
$scope.config = notification.config;
}
});
}
};
return directiveDefinitionObject;
});
quayApp.directive('createExternalNotificationDialog', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/create-external-notification-dialog.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'repository': '=repository',
'counter': '=counter',
'notificationCreated': '&notificationCreated'
},
controller: function($scope, $element, ExternalNotificationData, ApiService, $timeout) {
$scope.currentEvent = null;
$scope.currentMethod = null;
$scope.status = '';
$scope.currentConfig = {};
$scope.clearCounter = 0;
$scope.unauthorizedEmail = false;
$scope.events = ExternalNotificationData.getSupportedEvents();
$scope.methods = ExternalNotificationData.getSupportedMethods();
$scope.setEvent = function(event) {
$scope.currentEvent = event;
};
$scope.setMethod = function(method) {
$scope.currentConfig = {};
$scope.currentMethod = method;
$scope.unauthorizedEmail = false;
};
$scope.createNotification = function() {
if (!$scope.currentConfig.email) {
$scope.performCreateNotification();
return;
}
$scope.status = 'checking-email';
$scope.checkEmailAuthorization();
};
$scope.checkEmailAuthorization = function() {
var params = {
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
'email': $scope.currentConfig.email
};
ApiService.checkRepoEmailAuthorized(null, params).then(function(resp) {
$scope.handleEmailCheck(resp.confirmed);
}, function(resp) {
$scope.handleEmailCheck(false);
});
};
$scope.performCreateNotification = function() {
$scope.status = 'creating';
var params = {
'repository': $scope.repository.namespace + '/' + $scope.repository.name
};
var data = {
'event': $scope.currentEvent.id,
'method': $scope.currentMethod.id,
'config': $scope.currentConfig
};
ApiService.createRepoNotification(data, params).then(function(resp) {
$scope.status = '';
$scope.notificationCreated({'notification': resp});
$('#createNotificationModal').modal('hide');
});
};
$scope.handleEmailCheck = function(isAuthorized) {
if (isAuthorized) {
$scope.performCreateNotification();
return;
}
if ($scope.status == 'authorizing-email-sent') {
$scope.watchEmail();
} else {
$scope.status = 'unauthorized-email';
}
$scope.unauthorizedEmail = true;
};
$scope.sendAuthEmail = function() {
$scope.status = 'authorizing-email';
var params = {
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
'email': $scope.currentConfig.email
};
ApiService.sendAuthorizeRepoEmail(null, params).then(function(resp) {
$scope.status = 'authorizing-email-sent';
$scope.watchEmail();
});
};
$scope.watchEmail = function() {
// TODO: change this to SSE?
$timeout(function() {
$scope.checkEmailAuthorization();
}, 1000);
};
$scope.$watch('counter', function(counter) {
if (counter) {
$scope.clearCounter++;
$scope.status = '';
$scope.currentEvent = null;
$scope.currentMethod = null;
$scope.unauthorizedEmail = false;
$('#createNotificationModal').modal({});
}
});
}
};
return directiveDefinitionObject;
});
quayApp.directive('twitterView', function () {
var directiveDefinitionObject = {
priority: 0,
@ -4494,7 +4930,7 @@ quayApp.directive('notificationView', function () {
'notification': '=notification',
'parent': '=parent'
},
controller: function($scope, $element, $window, $location, UserService, NotificationService) {
controller: function($scope, $element, $window, $location, UserService, NotificationService, ApiService) {
var stringStartsWith = function (str, prefix) {
return str.slice(0, prefix.length) == prefix;
};
@ -4530,6 +4966,14 @@ quayApp.directive('notificationView', function () {
}
};
$scope.dismissNotification = function(notification) {
NotificationService.dismissNotification(notification);
};
$scope.canDismiss = function(notification) {
return NotificationService.canDismiss(notification);
};
$scope.getClass = function(notification) {
return NotificationService.getClass(notification);
};

View file

@ -181,7 +181,7 @@ function TutorialCtrl($scope, AngularTour, AngularTourSignals, UserService, Conf
},
{
'title': 'Repository Admin',
'content': "The repository admin panel allows for modification of a repository's permissions, webhooks, visibility and other settings",
'content': "The repository admin panel allows for modification of a repository's permissions, notifications, visibility and other settings",
'overlayable': true,
'mixpanelEvent': 'tutorial_view_admin'
},
@ -1258,7 +1258,7 @@ function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope
fetchRepository();
}
function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, $routeParams, $rootScope, $location, UserService, Config, Features) {
function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, $routeParams, $rootScope, $location, UserService, Config, Features, ExternalNotificationData) {
var namespace = $routeParams.namespace;
var name = $routeParams.name;
@ -1481,43 +1481,32 @@ function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, $routeParams
});
};
$scope.loadWebhooks = function() {
$scope.showNewNotificationCounter = 0;
$scope.showNewNotificationDialog = function() {
$scope.showNewNotificationCounter++;
};
$scope.handleNotificationCreated = function(notification) {
$scope.notifications.push(notification);
};
$scope.handleNotificationDeleted = function(notification) {
var index = $.inArray(notification, $scope.notifications);
if (index < 0) { return; }
$scope.notifications.splice(index, 1);
};
$scope.loadNotifications = function() {
var params = {
'repository': namespace + '/' + name
};
$scope.newWebhook = {};
$scope.webhooksResource = ApiService.listWebhooksAsResource(params).get(function(resp) {
$scope.webhooks = resp.webhooks;
return $scope.webhooks;
});
};
$scope.createWebhook = function() {
if (!$scope.newWebhook.url) {
return;
}
var params = {
'repository': namespace + '/' + name
};
ApiService.createWebhook($scope.newWebhook, params).then(function(resp) {
$scope.webhooks.push(resp);
$scope.newWebhook.url = '';
$scope.createWebhookForm.$setPristine();
});
};
$scope.deleteWebhook = function(webhook) {
var params = {
'repository': namespace + '/' + name,
'public_id': webhook.public_id
};
ApiService.deleteWebhook(null, params).then(function(resp) {
$scope.webhooks.splice($scope.webhooks.indexOf(webhook), 1);
});
$scope.notificationsResource = ApiService.listRepoNotificationsAsResource(params).get(
function(resp) {
$scope.notifications = resp.notifications;
return $scope.notifications;
});
};
$scope.showBuild = function(buildInfo) {
@ -1649,7 +1638,7 @@ function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, $routeParams
$scope.repo = repo;
$rootScope.title = 'Settings - ' + namespace + '/' + name;
$rootScope.description = 'Administrator settings for ' + namespace + '/' + name +
': Permissions, webhooks and other settings';
': Permissions, notifications and other settings';
// Fetch all the permissions and token info for the repository.
fetchPermissions('user');

View file

@ -1,5 +1,5 @@
<div class="container">
<h3>Redirecting...</h3>
<META http-equiv="refresh" content="0;URL=http://docs.quay.io/getting-started.html">
If this page does not redirect, please <a href="http://docs.quay.io/getting-started.html"> click here</a>.
<META http-equiv="refresh" content="0;URL=http://docs.quay.io/solution/getting-started.html">
If this page does not redirect, please <a href="http://docs.quay.io/solution/getting-started.html"> click here</a>.
</div>

View file

@ -20,7 +20,7 @@
<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()">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="#webhook" ng-click="loadWebhooks()">Webhooks</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#notification" ng-click="loadNotifications()">Notifications</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#publicprivate">Public/Private</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#delete">Delete</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#logs" ng-click="loadLogs()">Usage Logs</a></li>
@ -135,10 +135,11 @@
<tr>
<td id="add-entity-permission" colspan="2" class="admin-search">
<span class="entity-search" namespace="repo.namespace" include-teams="true"
input-title="'Add a ' + (repo.is_organization ? 'team or ' : '') + 'user...'"
entity-selected="addNewPermission" is-organization="repo.is_organization"
current-entity="selectedEntity"></span>
<span class="entity-search" namespace="repo.namespace"
placeholder="'Add a ' + (repo.is_organization ? 'team or ' : '') + 'user...'"
entity-selected="addNewPermission(entity)"
current-entity="selectedEntity"
auto-clear="true"></span>
</td>
</tr>
</table>
@ -192,51 +193,32 @@
</div>
</div>
<!-- Webhook tab -->
<div id="webhook" class="tab-pane">
<!-- Notification tab -->
<div id="notification" class="tab-pane">
<div class="panel panel-default">
<div class="panel-heading">Push Webhooks
<i class="info-icon fa fa-info-circle" data-placement="left" data-content="URLs which will be invoked with an HTTP POST and JSON payload when a successful push to the repository occurs."></i>
<div class="panel-heading">Repository Notifications
<i class="info-icon fa fa-info-circle" data-placement="left" data-content="Notifications to call to external services (web, email, etc) on repository events"></i>
</div>
<div class="panel-body">
<div class="resource-view" resource="webhooksResource" error-message="'Could not load webhooks'">
<table class="permissions">
<thead>
<tr>
<td style="width: 500px;">Webhook URL</td>
<td></td>
</tr>
</thead>
<tbody>
<tr ng-repeat="webhook in webhooks">
<td>{{ webhook.parameters.url }}</td>
<td>
<span class="delete-ui" delete-title="'Delete Webhook'" perform-delete="deleteWebhook(webhook)"></span>
</td>
</tr>
</tbody>
</table>
<!-- Notifications list -->
<div class="resource-view" resource="notificationsResource" error-message="'Could not load notifications'">
<div class="empty" ng-if="!notifications.length">
There are no notifications defined for this repository
</div>
<div class="nonempty" ng-show="notifications.length">
<div class="external-notification-view" notification="notification" repository="repo"
notification-deleted="handleNotificationDeleted(notification)"
ng-repeat="notification in notifications"></div>
</div>
</div>
<form name="createWebhookForm" ng-submit="createWebhook()">
<table class="permissions">
<tbody>
<tr>
<td style="width: 500px;">
<input type="url" class="form-control" placeholder="New webhook url..." ng-model="newWebhook.url" required>
</td>
<td>
<button class="btn btn-primary" type="submit" ng-disabled="createWebhookForm.$invalid">Create</button>
</td>
</tr>
</tbody>
</table>
</form>
<div class="right-info">
Quay will <b>POST</b> to these webhooks whenever a push occurs. See the <a href="/guide">User Guide</a> for more information.
<!-- Right controls -->
<div class="right-controls">
<button class="btn btn-success" ng-click="showNewNotificationDialog()">
<i class="fa fa-paper-plane"></i>
New Notification
</button>
</div>
</div>
</div>
@ -390,6 +372,11 @@
canceled="cancelSetupTrigger(trigger)"
counter="showTriggerSetupCounter"></div>
<!-- New notification dialog-->
<div class="create-external-notification-dialog" repository="repo"
counter="showNewNotificationCounter"
notification-created="handleNotificationCreated(notification)"></div>
<!-- Modal message dialog -->
<div class="modal fade" id="cannotchangeModal">
<div class="modal-dialog">

View file

@ -41,7 +41,7 @@
<div class="alert alert-info">
<h4 ng-show="namespace == user.username">You don't have any repositories yet!</h4>
<h4 ng-show="namespace != user.username">This organization doesn't have any repositories, or you have not been provided access.</h4>
<a href="http://docs.quay.io/getting-started.html"><b>Click here</b> to learn how to create a repository</a>
<a href="http://docs.quay.io/solution/getting-started.html"><b>Click here</b> to learn how to create a repository</a>
</div>
</div>
</div>

View file

@ -23,10 +23,13 @@
</tr>
<tr ng-show="canEditMembers">
<td colspan="2">
<span class="entity-search" namespace="orgname" include-teams="false" input-title="'Add a Quay.io user...'"
entity-selected="addNewMember" is-organization="true"
current-entity="selectedMember"></span>
<td colspan="3">
<div class="entity-search" style="width: 100%"
namespace="orgname" placeholder="'Add a Quay.io user or robot...'"
entity-selected="addNewMember(entity)"
current-entity="selectedMember"
auto-clear="true"
allowed-entities="['user', 'robot']"></div>
</td>
</tr>
</table>

14
templates/message.html Normal file
View file

@ -0,0 +1,14 @@
<html>
<title>Quay.io</title>
<head>
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/font-awesome/4.0.3/css/font-awesome.css">
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap.no-icons.min.css">
<link href='//fonts.googleapis.com/css?family=Droid+Sans:400,700' rel='stylesheet' type='text/css'>
</head>
<body>
<div class="container" style="margin-top: 20px">
<img src="/static/img/quay-logo.png">
<h5>{{ message | safe }}</h5>
</div>
</body>
</html>

Binary file not shown.

View file

@ -18,9 +18,10 @@ from endpoints.api.robot import UserRobotList, OrgRobot, OrgRobotList, UserRobot
from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs,
TriggerBuildList, ActivateBuildTrigger, BuildTrigger,
BuildTriggerList, BuildTriggerAnalyze)
from endpoints.api.webhook import Webhook, WebhookList
from endpoints.api.repoemail import RepositoryAuthorizedEmail
from endpoints.api.repositorynotification import RepositoryNotification, RepositoryNotificationList
from endpoints.api.user import (PrivateRepositories, ConvertToOrganization, Recovery, Signout,
Signin, User, UserAuthorizationList, UserAuthorization)
Signin, User, UserAuthorizationList, UserAuthorization, UserNotification)
from endpoints.api.repotoken import RepositoryToken, RepositoryTokenList
from endpoints.api.prototype import PermissionPrototype, PermissionPrototypeList
from endpoints.api.logs import UserLogs, OrgLogs, RepositoryLogs
@ -122,6 +123,37 @@ class TestFindRepositories(ApiTestCase):
self._run_test('GET', 200, 'devtable', None)
class TestUserNotification(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(UserNotification, uuid='someuuid')
def test_get_anonymous(self):
self._run_test('GET', 401, None, None)
def test_get_freshuser(self):
self._run_test('GET', 404, 'freshuser', None)
def test_get_reader(self):
self._run_test('GET', 404, 'reader', None)
def test_get_devtable(self):
self._run_test('GET', 404, 'devtable', None)
def test_put_anonymous(self):
self._run_test('PUT', 401, None, {})
def test_put_freshuser(self):
self._run_test('PUT', 404, 'freshuser', {})
def test_put_reader(self):
self._run_test('PUT', 404, 'reader', {})
def test_put_devtable(self):
self._run_test('PUT', 404, 'devtable', {})
class TestUserInvoiceList(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
@ -1883,10 +1915,10 @@ class TestBuildTriggerD6tiBuynlargeOrgrepo(ApiTestCase):
self._run_test('DELETE', 404, 'devtable', None)
class TestWebhookQfatPublicPublicrepo(ApiTestCase):
class TestRepositoryNotificationQfatPublicPublicrepo(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(Webhook, public_id="QFAT", repository="public/publicrepo")
self._set_url(RepositoryNotification, uuid="QFAT", repository="public/publicrepo")
def test_get_anonymous(self):
self._run_test('GET', 401, None, None)
@ -1913,10 +1945,10 @@ class TestWebhookQfatPublicPublicrepo(ApiTestCase):
self._run_test('DELETE', 403, 'devtable', None)
class TestWebhookQfatDevtableShared(ApiTestCase):
class TestRepositoryNotificationQfatDevtableShared(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(Webhook, public_id="QFAT", repository="devtable/shared")
self._set_url(RepositoryNotification, uuid="QFAT", repository="devtable/shared")
def test_get_anonymous(self):
self._run_test('GET', 401, None, None)
@ -1943,10 +1975,10 @@ class TestWebhookQfatDevtableShared(ApiTestCase):
self._run_test('DELETE', 400, 'devtable', None)
class TestWebhookQfatBuynlargeOrgrepo(ApiTestCase):
class TestRepositoryNotificationQfatBuynlargeOrgrepo(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(Webhook, public_id="QFAT", repository="buynlarge/orgrepo")
self._set_url(RepositoryNotification, uuid="QFAT", repository="buynlarge/orgrepo")
def test_get_anonymous(self):
self._run_test('GET', 401, None, None)
@ -2529,10 +2561,10 @@ class TestBuildTriggerListBuynlargeOrgrepo(ApiTestCase):
self._run_test('GET', 200, 'devtable', None)
class TestWebhookListPublicPublicrepo(ApiTestCase):
class TestRepositoryNotificationListPublicPublicrepo(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(WebhookList, repository="public/publicrepo")
self._set_url(RepositoryNotificationList, repository="public/publicrepo")
def test_get_anonymous(self):
self._run_test('GET', 401, None, None)
@ -2559,10 +2591,10 @@ class TestWebhookListPublicPublicrepo(ApiTestCase):
self._run_test('POST', 403, 'devtable', {})
class TestWebhookListDevtableShared(ApiTestCase):
class TestRepositoryNotificationListDevtableShared(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(WebhookList, repository="devtable/shared")
self._set_url(RepositoryNotificationList, repository="devtable/shared")
def test_get_anonymous(self):
self._run_test('GET', 401, None, None)
@ -2586,13 +2618,14 @@ class TestWebhookListDevtableShared(ApiTestCase):
self._run_test('POST', 403, 'reader', {})
def test_post_devtable(self):
self._run_test('POST', 201, 'devtable', {})
self._run_test('POST', 400, 'devtable', {'event': 'repo_push', 'method': 'email',
'config': {'email': 'a@b.com'}})
class TestWebhookListBuynlargeOrgrepo(ApiTestCase):
class TestRepositoryNotificationListBuynlargeOrgrepo(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(WebhookList, repository="buynlarge/orgrepo")
self._set_url(RepositoryNotificationList, repository="buynlarge/orgrepo")
def test_get_anonymous(self):
self._run_test('GET', 401, None, None)
@ -2616,7 +2649,102 @@ class TestWebhookListBuynlargeOrgrepo(ApiTestCase):
self._run_test('POST', 403, 'reader', {})
def test_post_devtable(self):
self._run_test('POST', 201, 'devtable', {})
self._run_test('POST', 400, 'devtable', {'event': 'repo_push', 'method': 'email',
'config': {'email': 'a@b.com'}})
class TestRepositoryAuthorizedEmailPublicPublicrepo(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(RepositoryAuthorizedEmail, repository="public/publicrepo",
email="jschorr@devtable.com")
def test_get_anonymous(self):
self._run_test('GET', 401, None, None)
def test_get_freshuser(self):
self._run_test('GET', 403, 'freshuser', None)
def test_get_reader(self):
self._run_test('GET', 403, 'reader', None)
def test_get_devtable(self):
self._run_test('GET', 403, 'devtable', None)
def test_post_anonymous(self):
self._run_test('POST', 401, None, {})
def test_post_freshuser(self):
self._run_test('POST', 403, 'freshuser', {})
def test_post_reader(self):
self._run_test('POST', 403, 'reader', {})
def test_post_devtable(self):
self._run_test('POST', 403, 'devtable', {})
class TestRepositoryAuthorizedEmailDevtableSharedrepo(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(RepositoryAuthorizedEmail, repository="devtable/shared",
email="jschorr@devtable.com")
def test_get_anonymous(self):
self._run_test('GET', 401, None, None)
def test_get_freshuser(self):
self._run_test('GET', 403, 'freshuser', None)
def test_get_reader(self):
self._run_test('GET', 403, 'reader', None)
def test_get_devtable(self):
self._run_test('GET', 404, 'devtable', None)
def test_post_anonymous(self):
self._run_test('POST', 401, None, {})
def test_post_freshuser(self):
self._run_test('POST', 403, 'freshuser', {})
def test_post_reader(self):
self._run_test('POST', 403, 'reader', {})
def test_post_devtable(self):
self._run_test('POST', 200, 'devtable', {})
class TestRepositoryAuthorizedEmailBuynlargeOrgrepo(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(RepositoryAuthorizedEmail, repository="buynlarge/orgrepo",
email="jschorr@devtable.com")
def test_get_anonymous(self):
self._run_test('GET', 401, None, None)
def test_get_freshuser(self):
self._run_test('GET', 403, 'freshuser', None)
def test_get_reader(self):
self._run_test('GET', 403, 'reader', None)
def test_get_devtable(self):
self._run_test('GET', 404, 'devtable', None)
def test_post_anonymous(self):
self._run_test('POST', 401, None, {})
def test_post_freshuser(self):
self._run_test('POST', 403, 'freshuser', {})
def test_post_reader(self):
self._run_test('POST', 403, 'reader', {})
def test_post_devtable(self):
self._run_test('POST', 200, 'devtable', {})
class TestRepositoryTokenListPublicPublicrepo(ApiTestCase):

View file

@ -20,9 +20,11 @@ from endpoints.api.robot import UserRobotList, OrgRobot, OrgRobotList, UserRobot
from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs,
TriggerBuildList, ActivateBuildTrigger, BuildTrigger,
BuildTriggerList, BuildTriggerAnalyze)
from endpoints.api.webhook import Webhook, WebhookList
from endpoints.api.repoemail import RepositoryAuthorizedEmail
from endpoints.api.repositorynotification import RepositoryNotification, RepositoryNotificationList
from endpoints.api.user import (PrivateRepositories, ConvertToOrganization, Signout, Signin, User,
UserAuthorizationList, UserAuthorization)
UserAuthorizationList, UserAuthorization, UserNotification,
UserNotificationList)
from endpoints.api.repotoken import RepositoryToken, RepositoryTokenList
from endpoints.api.prototype import PermissionPrototype, PermissionPrototypeList
@ -207,6 +209,26 @@ class TestLoggedInUser(ApiTestCase):
assert json['username'] == READ_ACCESS_USER
class TestUserNotification(ApiTestCase):
def test_get(self):
self.login(ADMIN_ACCESS_USER)
json = self.getJsonResponse(UserNotificationList)
# Make sure each notification can be retrieved.
for notification in json['notifications']:
njson = self.getJsonResponse(UserNotification, params=dict(uuid=notification['id']))
self.assertEquals(notification['id'], njson['id'])
# Update a notification.
assert json['notifications']
assert not json['notifications'][0]['dismissed']
pjson = self.putJsonResponse(UserNotification, params=dict(uuid=notification['id']),
data=dict(dismissed=True))
self.assertEquals(True, pjson['dismissed'])
class TestGetUserPrivateAllowed(ApiTestCase):
def test_nonallowed(self):
self.login(READ_ACCESS_USER)
@ -388,6 +410,16 @@ class TestGetMatchingEntities(ApiTestCase):
assert 'outsideorg' in names
assert 'owners' in names
def test_inorg_withorgs(self):
self.login(ADMIN_ACCESS_USER)
json = self.getJsonResponse(EntitySearch,
params=dict(prefix=ORGANIZATION[0], namespace=ORGANIZATION,
includeOrgs='true'))
names = set([r['name'] for r in json['results']])
assert ORGANIZATION in names
class TestCreateOrganization(ApiTestCase):
def test_existinguser(self):
@ -1072,42 +1104,89 @@ class TestRequestRepoBuild(ApiTestCase):
expected_code=403)
class TestRepositoryEmail(ApiTestCase):
def test_emailnotauthorized(self):
self.login(ADMIN_ACCESS_USER)
# Verify the e-mail address is not authorized.
json = self.getResponse(RepositoryAuthorizedEmail,
params=dict(repository=ADMIN_ACCESS_USER + '/simple', email='test@example.com'),
expected_code=404)
class TestWebhooks(ApiTestCase):
def test_emailnotauthorized_butsent(self):
self.login(ADMIN_ACCESS_USER)
# Verify the e-mail address is not authorized.
json = self.getJsonResponse(RepositoryAuthorizedEmail,
params=dict(repository=ADMIN_ACCESS_USER + '/simple', email='jschorr+other@devtable.com'))
self.assertEquals(False, json['confirmed'])
self.assertEquals(ADMIN_ACCESS_USER, json['namespace'])
self.assertEquals('simple', json['repository'])
def test_emailauthorized(self):
self.login(ADMIN_ACCESS_USER)
# Verify the e-mail address is authorized.
json = self.getJsonResponse(RepositoryAuthorizedEmail,
params=dict(repository=ADMIN_ACCESS_USER + '/simple', email='jschorr@devtable.com'))
self.assertEquals(True, json['confirmed'])
self.assertEquals(ADMIN_ACCESS_USER, json['namespace'])
self.assertEquals('simple', json['repository'])
def test_send_email_authorization(self):
self.login(ADMIN_ACCESS_USER)
# Send the email.
json = self.postJsonResponse(RepositoryAuthorizedEmail,
params=dict(repository=ADMIN_ACCESS_USER + '/simple', email='jschorr+foo@devtable.com'))
self.assertEquals(False, json['confirmed'])
self.assertEquals(ADMIN_ACCESS_USER, json['namespace'])
self.assertEquals('simple', json['repository'])
class TestRepositoryNotifications(ApiTestCase):
def test_webhooks(self):
self.login(ADMIN_ACCESS_USER)
# Add a webhook.
json = self.postJsonResponse(WebhookList,
# Add a notification.
json = self.postJsonResponse(RepositoryNotificationList,
params=dict(repository=ADMIN_ACCESS_USER + '/simple'),
data=dict(url='http://example.com'),
data=dict(config={'url': 'http://example.com'}, event='repo_push', method='webhook'),
expected_code=201)
self.assertEquals('http://example.com', json['parameters']['url'])
wid = json['public_id']
self.assertEquals('repo_push', json['event'])
self.assertEquals('webhook', json['method'])
self.assertEquals('http://example.com', json['config']['url'])
wid = json['uuid']
# Get the webhook.
json = self.getJsonResponse(Webhook,
params=dict(repository=ADMIN_ACCESS_USER + '/simple', public_id=wid))
# Get the notification.
json = self.getJsonResponse(RepositoryNotification,
params=dict(repository=ADMIN_ACCESS_USER + '/simple', uuid=wid))
self.assertEquals(wid, json['public_id'])
self.assertEquals('http://example.com', json['parameters']['url'])
self.assertEquals(wid, json['uuid'])
self.assertEquals('repo_push', json['event'])
self.assertEquals('webhook', json['method'])
# Verify the webhook is listed.
json = self.getJsonResponse(WebhookList,
# Verify the notification is listed.
json = self.getJsonResponse(RepositoryNotificationList,
params=dict(repository=ADMIN_ACCESS_USER + '/simple'))
ids = [w['public_id'] for w in json['webhooks']]
ids = [w['uuid'] for w in json['notifications']]
assert wid in ids
# Delete the webhook.
self.deleteResponse(Webhook,
params=dict(repository=ADMIN_ACCESS_USER + '/simple', public_id=wid),
# Delete the notification.
self.deleteResponse(RepositoryNotification,
params=dict(repository=ADMIN_ACCESS_USER + '/simple', uuid=wid),
expected_code=204)
# Verify the webhook is gone.
self.getResponse(Webhook,
params=dict(repository=ADMIN_ACCESS_USER + '/simple', public_id=wid),
# Verify the notification is gone.
self.getResponse(RepositoryNotification,
params=dict(repository=ADMIN_ACCESS_USER + '/simple', uuid=wid),
expected_code=404)

View file

@ -1,33 +1,33 @@
from flask.ext.mail import Message
from app import mail, app
from app import mail, app, get_app_url
CONFIRM_MESSAGE = """
This email address was recently used to register the username '%s'
at <a href="https://quay.io">Quay.io</a>.<br>
at <a href="%s">Quay.io</a>.<br>
<br>
To confirm this email address, please click the following link:<br>
<a href="https://quay.io/confirm?code=%s">https://quay.io/confirm?code=%s</a>
<a href="%s/confirm?code=%s">%s/confirm?code=%s</a>
"""
CHANGE_MESSAGE = """
This email address was recently asked to become the new e-mail address for username '%s'
at <a href="https://quay.io">Quay.io</a>.<br>
at <a href="%s">Quay.io</a>.<br>
<br>
To confirm this email address, please click the following link:<br>
<a href="https://quay.io/confirm?code=%s">https://quay.io/confirm?code=%s</a>
<a href="%s/confirm?code=%s">%s/confirm?code=%s</a>
"""
RECOVERY_MESSAGE = """
A user at <a href="https://quay.io">Quay.io</a> has attempted to recover their account
using this email.<br>
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="https://quay.io/recovery?code=%s">https://quay.io/recovery?code=%s</a><br>
<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>
@ -57,6 +57,15 @@ Thanks and have a great day!<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}'
@ -64,7 +73,7 @@ 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, token, token)
msg.html = CHANGE_MESSAGE % (username, get_app_url(), get_app_url(), token, get_app_url(), token)
mail.send(msg)
@ -72,7 +81,16 @@ 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, token, token)
msg.html = CONFIRM_MESSAGE % (username, get_app_url(), get_app_url(), token, get_app_url(), token)
mail.send(msg)
def send_repo_authorization_email(namespace, repository, email, token):
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)
@ -80,7 +98,7 @@ 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 % (token, token)
msg.html = RECOVERY_MESSAGE % (get_app_url(), get_app_url(), token, get_app_url(), token)
mail.send(msg)

View file

@ -25,6 +25,7 @@ from requests.exceptions import ConnectionError
from data import model
from workers.worker import Worker, WorkerUnhealthyException, JobException
from app import userfiles as user_files, build_logs, sentry, dockerfile_build_queue
from endpoints.notificationhelper import spawn_notification
from util.safetar import safe_extractall
from util.dockerfileparse import parse_dockerfile, ParsedDockerfile, serialize_dockerfile
@ -529,6 +530,27 @@ class DockerfileBuildWorker(Worker):
build_dir = self._mime_processors[c_type](docker_resource)
# Spawn a notification that the build has started.
event_data = {
'build_id': repository_build.uuid,
'build_name': repository_build.display_name,
'docker_tags': tag_names,
'trigger_id': repository_build.trigger.uuid,
'trigger_kind': repository_build.trigger.service.name
}
spawn_notification(repository_build.repository, 'build_start', event_data,
subpage='build?current=%s' % repository_build.uuid,
pathargs=['build', repository_build.uuid])
# Setup a handler for spawning failure messages.
def spawn_failure(message, event_data):
event_data['error_message'] = message
spawn_notification(repository_build.repository, 'build_failure', event_data,
subpage='build?current=%s' % repository_build.uuid,
pathargs=['build', repository_build.uuid])
# Start the build process.
try:
with DockerfileBuildContext(build_dir, build_subdir, repo, tag_names, access_token,
repository_build.uuid, self._cache_size_gb,
@ -569,12 +591,23 @@ class DockerfileBuildWorker(Worker):
repository_build.phase = 'complete'
repository_build.save()
# Spawn a notification that the build has completed.
spawn_notification(repository_build.repository, 'build_success', event_data,
subpage='build?current=%s' % repository_build.uuid,
pathargs=['build', repository_build.uuid])
except WorkerUnhealthyException as exc:
# Need a separate handler for this so it doesn't get caught by catch all below
# Spawn a notification that the build has failed.
spawn_failure(exc.message, event_data)
# Raise the exception to the queue.
raise exc
except JobException as exc:
# Need a separate handler for this so it doesn't get caught by catch all below
# Spawn a notification that the build has failed.
spawn_failure(exc.message, event_data)
# Raise the exception to the queue.
raise exc
except ConnectionError as exc:
@ -584,12 +617,18 @@ class DockerfileBuildWorker(Worker):
raise WorkerUnhealthyException(exc.message)
except Exception as exc:
# Spawn a notification that the build has failed.
spawn_failure(exc.message, event_data)
# Write the error to the logs.
sentry.client.captureException()
log_appender('error', build_logs.PHASE)
logger.exception('Exception when processing request.')
repository_build.phase = 'error'
repository_build.save()
log_appender(str(exc), build_logs.ERROR)
# Raise the exception to the queue.
raise JobException(str(exc))

View file

@ -0,0 +1,53 @@
import logging
import argparse
import requests
import json
from app import notification_queue
from workers.worker import Worker
from endpoints.notificationmethod import NotificationMethod, InvalidNotificationMethodException
from endpoints.notificationevent import NotificationEvent, InvalidNotificationEventException
from data import model
root_logger = logging.getLogger('')
root_logger.setLevel(logging.DEBUG)
FORMAT = '%(asctime)-15s - %(levelname)s - %(pathname)s - %(funcName)s - %(message)s'
formatter = logging.Formatter(FORMAT)
logger = logging.getLogger(__name__)
class NotificationWorker(Worker):
def process_queue_item(self, job_details):
notification_uuid = job_details['notification_uuid'];
repo_namespace = job_details['repository_namespace']
repo_name = job_details['repository_name']
notification = model.get_repo_notification(repo_namespace, repo_name, notification_uuid)
if not notification:
# Probably deleted.
return True
event_name = notification.event.name
method_name = notification.method.name
try:
event_handler = NotificationEvent.get_event(event_name)
method_handler = NotificationMethod.get_method(method_name)
except InvalidNotificationMethodException as ex:
logger.exception('Cannot find notification method: %s' % ex.message)
return False
except InvalidNotificationEventException as ex:
logger.exception('Cannot find notification method: %s' % ex.message)
return False
return 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()