Merge master into laffa

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -26,7 +26,8 @@ def render_ologin_error(service_name,
error_message='Could not load user data. The token may have expired.'):
return render_page_template('ologinerror.html', service_name=service_name,
error_message=error_message,
service_url=get_app_url())
service_url=get_app_url(),
user_creation=features.USER_CREATION)
def exchange_code_for_token(code, service_name='GITHUB', for_login=True, form_encode=False,
redirect_suffix=''):
@ -85,7 +86,12 @@ def get_google_user(token):
def conduct_oauth_login(service_name, user_id, username, email, metadata={}):
to_login = model.verify_federated_login(service_name.lower(), user_id)
if not to_login:
# try to create the user
# See if we can create a new user.
if not features.USER_CREATION:
error_message = 'User creation is disabled. Please contact your administrator'
return render_ologin_error(service_name, error_message)
# Try to create the user
try:
valid = next(generate_valid_usernames(username))
to_login = model.create_federated_user(valid, email, service_name.lower(),
@ -147,7 +153,7 @@ def github_oauth_callback():
token = exchange_code_for_token(request.args.get('code'), service_name='GITHUB')
user_data = get_github_user(token)
if not user_data:
if not user_data or not 'login' in user_data:
return render_ologin_error('GitHub')
username = user_data['login']

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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