Merge remote-tracking branch 'origin/master' into tagyourit
Conflicts: static/css/quay.css static/js/graphing.js static/partials/view-repo.html test/data/test.db
This commit is contained in:
commit
3f42d15335
132 changed files with 4266 additions and 1924 deletions
|
@ -85,11 +85,32 @@ def handle_api_error(error):
|
|||
|
||||
def resource(*urls, **kwargs):
|
||||
def wrapper(api_resource):
|
||||
if not api_resource:
|
||||
return None
|
||||
|
||||
api.add_resource(api_resource, *urls, **kwargs)
|
||||
return api_resource
|
||||
return wrapper
|
||||
|
||||
|
||||
def show_if(value):
|
||||
def f(inner):
|
||||
if not value:
|
||||
return None
|
||||
|
||||
return inner
|
||||
return f
|
||||
|
||||
|
||||
def hide_if(value):
|
||||
def f(inner):
|
||||
if value:
|
||||
return None
|
||||
|
||||
return inner
|
||||
return f
|
||||
|
||||
|
||||
def truthy_bool(param):
|
||||
return param not in {False, 'false', 'False', '0', 'FALSE', '', 'null'}
|
||||
|
||||
|
@ -103,6 +124,9 @@ def format_date(date):
|
|||
|
||||
def add_method_metadata(name, value):
|
||||
def modifier(func):
|
||||
if func is None:
|
||||
return None
|
||||
|
||||
if '__api_metadata' not in dir(func):
|
||||
func.__api_metadata = {}
|
||||
func.__api_metadata[name] = value
|
||||
|
@ -111,11 +135,15 @@ def add_method_metadata(name, value):
|
|||
|
||||
|
||||
def method_metadata(func, name):
|
||||
if func is None:
|
||||
return None
|
||||
|
||||
if '__api_metadata' in dir(func):
|
||||
return func.__api_metadata.get(name, None)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
nickname = partial(add_method_metadata, 'nickname')
|
||||
related_user_resource = partial(add_method_metadata, 'related_user_resource')
|
||||
internal_only = add_method_metadata('internal', True)
|
||||
|
@ -274,6 +302,7 @@ import endpoints.api.repository
|
|||
import endpoints.api.repotoken
|
||||
import endpoints.api.robot
|
||||
import endpoints.api.search
|
||||
import endpoints.api.superuser
|
||||
import endpoints.api.tag
|
||||
import endpoints.api.team
|
||||
import endpoints.api.trigger
|
||||
|
|
|
@ -1,16 +1,17 @@
|
|||
import stripe
|
||||
|
||||
from flask import request
|
||||
|
||||
from app import billing
|
||||
from endpoints.api import (resource, nickname, ApiResource, validate_json_request, log_action,
|
||||
related_user_resource, internal_only, Unauthorized, NotFound,
|
||||
require_user_admin)
|
||||
require_user_admin, show_if, hide_if)
|
||||
from endpoints.api.subscribe import subscribe, subscription_view
|
||||
from auth.permissions import AdministerOrganizationPermission
|
||||
from auth.auth_context import get_authenticated_user
|
||||
from data import model
|
||||
from data.plans import PLANS
|
||||
from data.billing import PLANS
|
||||
|
||||
import features
|
||||
|
||||
def carderror_response(e):
|
||||
return {'carderror': e.message}, 402
|
||||
|
@ -22,7 +23,7 @@ def get_card(user):
|
|||
}
|
||||
|
||||
if user.stripe_id:
|
||||
cus = stripe.Customer.retrieve(user.stripe_id)
|
||||
cus = billing.Customer.retrieve(user.stripe_id)
|
||||
if cus and cus.default_card:
|
||||
# Find the default card.
|
||||
default_card = None
|
||||
|
@ -43,7 +44,7 @@ def get_card(user):
|
|||
|
||||
def set_card(user, token):
|
||||
if user.stripe_id:
|
||||
cus = stripe.Customer.retrieve(user.stripe_id)
|
||||
cus = billing.Customer.retrieve(user.stripe_id)
|
||||
if cus:
|
||||
try:
|
||||
cus.card = token
|
||||
|
@ -72,13 +73,14 @@ def get_invoices(customer_id):
|
|||
'plan': i.lines.data[0].plan.id if i.lines.data[0].plan else None
|
||||
}
|
||||
|
||||
invoices = stripe.Invoice.all(customer=customer_id, count=12)
|
||||
invoices = billing.Invoice.all(customer=customer_id, count=12)
|
||||
return {
|
||||
'invoices': [invoice_view(i) for i in invoices.data]
|
||||
}
|
||||
|
||||
|
||||
@resource('/v1/plans/')
|
||||
@show_if(features.BILLING)
|
||||
class ListPlans(ApiResource):
|
||||
""" Resource for listing the available plans. """
|
||||
@nickname('listPlans')
|
||||
|
@ -91,6 +93,7 @@ class ListPlans(ApiResource):
|
|||
|
||||
@resource('/v1/user/card')
|
||||
@internal_only
|
||||
@show_if(features.BILLING)
|
||||
class UserCard(ApiResource):
|
||||
""" Resource for managing a user's credit card. """
|
||||
schemas = {
|
||||
|
@ -132,6 +135,7 @@ class UserCard(ApiResource):
|
|||
@resource('/v1/organization/<orgname>/card')
|
||||
@internal_only
|
||||
@related_user_resource(UserCard)
|
||||
@show_if(features.BILLING)
|
||||
class OrganizationCard(ApiResource):
|
||||
""" Resource for managing an organization's credit card. """
|
||||
schemas = {
|
||||
|
@ -178,6 +182,7 @@ class OrganizationCard(ApiResource):
|
|||
|
||||
@resource('/v1/user/plan')
|
||||
@internal_only
|
||||
@show_if(features.BILLING)
|
||||
class UserPlan(ApiResource):
|
||||
""" Resource for managing a user's subscription. """
|
||||
schemas = {
|
||||
|
@ -220,7 +225,7 @@ class UserPlan(ApiResource):
|
|||
private_repos = model.get_private_repo_count(user.username)
|
||||
|
||||
if user.stripe_id:
|
||||
cus = stripe.Customer.retrieve(user.stripe_id)
|
||||
cus = billing.Customer.retrieve(user.stripe_id)
|
||||
|
||||
if cus.subscription:
|
||||
return subscription_view(cus.subscription, private_repos)
|
||||
|
@ -234,6 +239,7 @@ class UserPlan(ApiResource):
|
|||
@resource('/v1/organization/<orgname>/plan')
|
||||
@internal_only
|
||||
@related_user_resource(UserPlan)
|
||||
@show_if(features.BILLING)
|
||||
class OrganizationPlan(ApiResource):
|
||||
""" Resource for managing a org's subscription. """
|
||||
schemas = {
|
||||
|
@ -279,7 +285,7 @@ class OrganizationPlan(ApiResource):
|
|||
private_repos = model.get_private_repo_count(orgname)
|
||||
organization = model.get_organization(orgname)
|
||||
if organization.stripe_id:
|
||||
cus = stripe.Customer.retrieve(organization.stripe_id)
|
||||
cus = billing.Customer.retrieve(organization.stripe_id)
|
||||
|
||||
if cus.subscription:
|
||||
return subscription_view(cus.subscription, private_repos)
|
||||
|
@ -294,6 +300,7 @@ class OrganizationPlan(ApiResource):
|
|||
|
||||
@resource('/v1/user/invoices')
|
||||
@internal_only
|
||||
@show_if(features.BILLING)
|
||||
class UserInvoiceList(ApiResource):
|
||||
""" Resource for listing a user's invoices. """
|
||||
@require_user_admin
|
||||
|
@ -310,6 +317,7 @@ class UserInvoiceList(ApiResource):
|
|||
@resource('/v1/organization/<orgname>/invoices')
|
||||
@internal_only
|
||||
@related_user_resource(UserInvoiceList)
|
||||
@show_if(features.BILLING)
|
||||
class OrgnaizationInvoiceList(ApiResource):
|
||||
""" Resource for listing an orgnaization's invoices. """
|
||||
@nickname('listOrgInvoices')
|
||||
|
@ -323,4 +331,4 @@ class OrgnaizationInvoiceList(ApiResource):
|
|||
|
||||
return get_invoices(organization.stripe_id)
|
||||
|
||||
raise Unauthorized()
|
||||
raise Unauthorized()
|
||||
|
|
|
@ -3,19 +3,20 @@ import json
|
|||
|
||||
from flask import request
|
||||
|
||||
from app import app
|
||||
from app import app, userfiles as user_files
|
||||
from endpoints.api import (RepositoryParamResource, parse_args, query_param, nickname, resource,
|
||||
require_repo_read, require_repo_write, validate_json_request,
|
||||
ApiResource, internal_only, format_date, api, Unauthorized, NotFound)
|
||||
from endpoints.common import start_build
|
||||
from endpoints.trigger import BuildTrigger
|
||||
from data import model
|
||||
from auth.permissions import ModifyRepositoryPermission
|
||||
from auth.auth_context import get_authenticated_user
|
||||
from auth.permissions import ModifyRepositoryPermission, AdministerOrganizationPermission
|
||||
from data.buildlogs import BuildStatusRetrievalError
|
||||
from util.names import parse_robot_username
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
user_files = app.config['USERFILES']
|
||||
build_logs = app.config['BUILDLOGS']
|
||||
|
||||
|
||||
|
@ -33,7 +34,15 @@ def get_job_config(build_obj):
|
|||
return None
|
||||
|
||||
|
||||
def user_view(user):
|
||||
return {
|
||||
'name': user.username,
|
||||
'kind': 'user',
|
||||
'is_robot': user.robot,
|
||||
}
|
||||
|
||||
def trigger_view(trigger):
|
||||
|
||||
if trigger and trigger.uuid:
|
||||
config_dict = get_trigger_config(trigger)
|
||||
build_trigger = BuildTrigger.get_trigger_for_service(trigger.service.name)
|
||||
|
@ -42,7 +51,8 @@ def trigger_view(trigger):
|
|||
'config': config_dict,
|
||||
'id': trigger.uuid,
|
||||
'connected_user': trigger.connected_user.username,
|
||||
'is_active': build_trigger.is_active(config_dict)
|
||||
'is_active': build_trigger.is_active(config_dict),
|
||||
'pull_robot': user_view(trigger.pull_robot) if trigger.pull_robot else None
|
||||
}
|
||||
|
||||
return None
|
||||
|
@ -67,6 +77,7 @@ def build_status_view(build_obj, can_write=False):
|
|||
'is_writer': can_write,
|
||||
'trigger': trigger_view(build_obj.trigger),
|
||||
'resource_key': build_obj.resource_key,
|
||||
'pull_robot': user_view(build_obj.pull_robot) if build_obj.pull_robot else None,
|
||||
}
|
||||
|
||||
if can_write:
|
||||
|
@ -95,6 +106,10 @@ class RepositoryBuildList(RepositoryParamResource):
|
|||
'type': 'string',
|
||||
'description': 'Subdirectory in which the Dockerfile can be found',
|
||||
},
|
||||
'pull_robot': {
|
||||
'type': 'string',
|
||||
'description': 'Username of a Quay robot account to use as pull credentials',
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -123,6 +138,22 @@ class RepositoryBuildList(RepositoryParamResource):
|
|||
|
||||
dockerfile_id = request_json['file_id']
|
||||
subdir = request_json['subdirectory'] if 'subdirectory' in request_json else ''
|
||||
pull_robot_name = request_json.get('pull_robot', None)
|
||||
|
||||
# Verify the security behind the pull robot.
|
||||
if pull_robot_name:
|
||||
result = parse_robot_username(pull_robot_name)
|
||||
if result:
|
||||
pull_robot = model.lookup_robot(pull_robot_name)
|
||||
if not pull_robot:
|
||||
raise NotFound()
|
||||
|
||||
# Make sure the user has administer permissions for the robot's namespace.
|
||||
(robot_namespace, shortname) = result
|
||||
if not AdministerOrganizationPermission(robot_namespace).can():
|
||||
raise Unauthorized()
|
||||
else:
|
||||
raise Unauthorized()
|
||||
|
||||
# Check if the dockerfile resource has already been used. If so, then it
|
||||
# can only be reused if the user has access to the repository for which it
|
||||
|
@ -137,7 +168,8 @@ class RepositoryBuildList(RepositoryParamResource):
|
|||
repo = model.get_repository(namespace, repository)
|
||||
display_name = user_files.get_file_checksum(dockerfile_id)
|
||||
|
||||
build_request = start_build(repo, dockerfile_id, ['latest'], display_name, subdir, True)
|
||||
build_request = start_build(repo, dockerfile_id, ['latest'], display_name, subdir, True,
|
||||
pull_robot_name=pull_robot_name)
|
||||
|
||||
resp = build_status_view(build_request, True)
|
||||
repo_string = '%s/%s' % (namespace, repository)
|
||||
|
|
|
@ -23,13 +23,12 @@ TYPE_CONVERTER = {
|
|||
int: 'integer',
|
||||
}
|
||||
|
||||
URL_SCHEME = app.config['URL_SCHEME']
|
||||
URL_HOST = app.config['URL_HOST']
|
||||
PREFERRED_URL_SCHEME = app.config['PREFERRED_URL_SCHEME']
|
||||
SERVER_HOSTNAME = app.config['SERVER_HOSTNAME']
|
||||
|
||||
|
||||
def fully_qualified_name(method_view_class):
|
||||
inst = method_view_class()
|
||||
return '%s.%s' % (inst.__module__, inst.__class__.__name__)
|
||||
return '%s.%s' % (method_view_class.__module__, method_view_class.__name__)
|
||||
|
||||
|
||||
def swagger_route_data(include_internal=False, compact=False):
|
||||
|
@ -143,7 +142,7 @@ def swagger_route_data(include_internal=False, compact=False):
|
|||
swagger_data = {
|
||||
'apiVersion': 'v1',
|
||||
'swaggerVersion': '1.2',
|
||||
'basePath': '%s://%s' % (URL_SCHEME, URL_HOST),
|
||||
'basePath': '%s://%s' % (PREFERRED_URL_SCHEME, SERVER_HOSTNAME),
|
||||
'resourcePath': '/',
|
||||
'info': {
|
||||
'title': 'Quay.io API',
|
||||
|
@ -160,7 +159,7 @@ def swagger_route_data(include_internal=False, compact=False):
|
|||
"implicit": {
|
||||
"tokenName": "access_token",
|
||||
"loginEndpoint": {
|
||||
"url": "%s://%s/oauth/authorize" % (URL_SCHEME, URL_HOST),
|
||||
"url": "%s://%s/oauth/authorize" % (PREFERRED_URL_SCHEME, SERVER_HOSTNAME),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -2,16 +2,13 @@ import json
|
|||
|
||||
from collections import defaultdict
|
||||
|
||||
from app import app
|
||||
from app import storage as store
|
||||
from endpoints.api import (resource, nickname, require_repo_read, RepositoryParamResource,
|
||||
format_date, NotFound)
|
||||
from data import model
|
||||
from util.cache import cache_control_flask_restful
|
||||
|
||||
|
||||
store = app.config['STORAGE']
|
||||
|
||||
|
||||
def image_view(image):
|
||||
extended_props = image
|
||||
if image.storage and image.storage.id:
|
||||
|
|
|
@ -29,8 +29,7 @@ def log_view(log):
|
|||
return view
|
||||
|
||||
|
||||
def get_logs(namespace, start_time, end_time, performer_name=None,
|
||||
repository=None):
|
||||
def get_logs(start_time, end_time, performer_name=None, repository=None, namespace=None):
|
||||
performer = None
|
||||
if performer_name:
|
||||
performer = model.get_user(performer_name)
|
||||
|
@ -54,8 +53,8 @@ def get_logs(namespace, start_time, end_time, performer_name=None,
|
|||
if not end_time:
|
||||
end_time = datetime.today()
|
||||
|
||||
logs = model.list_logs(namespace, start_time, end_time, performer=performer,
|
||||
repository=repository)
|
||||
logs = model.list_logs(start_time, end_time, performer=performer, repository=repository,
|
||||
namespace=namespace)
|
||||
return {
|
||||
'start_time': format_date(start_time),
|
||||
'end_time': format_date(end_time),
|
||||
|
@ -80,7 +79,7 @@ class RepositoryLogs(RepositoryParamResource):
|
|||
|
||||
start_time = args['starttime']
|
||||
end_time = args['endtime']
|
||||
return get_logs(namespace, start_time, end_time, repository=repo)
|
||||
return get_logs(start_time, end_time, repository=repo, namespace=namespace)
|
||||
|
||||
|
||||
@resource('/v1/user/logs')
|
||||
|
@ -100,7 +99,7 @@ class UserLogs(ApiResource):
|
|||
end_time = args['endtime']
|
||||
|
||||
user = get_authenticated_user()
|
||||
return get_logs(user.username, start_time, end_time, performer_name=performer_name)
|
||||
return get_logs(start_time, end_time, performer_name=performer_name, namespace=user.username)
|
||||
|
||||
|
||||
@resource('/v1/organization/<orgname>/logs')
|
||||
|
@ -121,6 +120,6 @@ class OrgLogs(ApiResource):
|
|||
start_time = args['starttime']
|
||||
end_time = args['endtime']
|
||||
|
||||
return get_logs(orgname, start_time, end_time, performer_name=performer_name)
|
||||
return get_logs(start_time, end_time, namespace=orgname, performer_name=performer_name)
|
||||
|
||||
raise Unauthorized()
|
||||
raise Unauthorized()
|
||||
|
|
|
@ -1,20 +1,22 @@
|
|||
import logging
|
||||
import stripe
|
||||
|
||||
from flask import request
|
||||
|
||||
from app import billing as stripe
|
||||
from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error,
|
||||
related_user_resource, internal_only, Unauthorized, NotFound,
|
||||
require_user_admin, log_action)
|
||||
require_user_admin, log_action, show_if)
|
||||
from endpoints.api.team import team_view
|
||||
from endpoints.api.user import User, PrivateRepositories
|
||||
from auth.permissions import (AdministerOrganizationPermission, OrganizationMemberPermission,
|
||||
CreateRepositoryPermission)
|
||||
from auth.auth_context import get_authenticated_user
|
||||
from data import model
|
||||
from data.plans import get_plan
|
||||
from data.billing import get_plan
|
||||
from util.gravatar import compute_hash
|
||||
|
||||
import features
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -163,6 +165,7 @@ class Organization(ApiResource):
|
|||
@resource('/v1/organization/<orgname>/private')
|
||||
@internal_only
|
||||
@related_user_resource(PrivateRepositories)
|
||||
@show_if(features.BILLING)
|
||||
class OrgPrivateRepositories(ApiResource):
|
||||
""" Custom verb to compute whether additional private repositories are available. """
|
||||
@nickname('getOrganizationPrivateAllowed')
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import logging
|
||||
import stripe
|
||||
|
||||
from app import billing
|
||||
from endpoints.api import request_error, log_action, NotFound
|
||||
from endpoints.common import check_repository_usage
|
||||
from data import model
|
||||
from data.plans import PLANS
|
||||
from data.billing import PLANS
|
||||
|
||||
import features
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -24,6 +26,9 @@ def subscription_view(stripe_subscription, used_repos):
|
|||
|
||||
|
||||
def subscribe(user, plan, token, require_business_plan):
|
||||
if not features.BILLING:
|
||||
return
|
||||
|
||||
plan_found = None
|
||||
for plan_obj in PLANS:
|
||||
if plan_obj['stripeId'] == plan:
|
||||
|
@ -56,7 +61,7 @@ def subscribe(user, plan, token, require_business_plan):
|
|||
card = token
|
||||
|
||||
try:
|
||||
cus = stripe.Customer.create(email=user.email, plan=plan, card=card)
|
||||
cus = billing.Customer.create(email=user.email, plan=plan, card=card)
|
||||
user.stripe_id = cus.id
|
||||
user.save()
|
||||
check_repository_usage(user, plan_found)
|
||||
|
@ -69,7 +74,7 @@ def subscribe(user, plan, token, require_business_plan):
|
|||
|
||||
else:
|
||||
# Change the plan
|
||||
cus = stripe.Customer.retrieve(user.stripe_id)
|
||||
cus = billing.Customer.retrieve(user.stripe_id)
|
||||
|
||||
if plan_found['price'] == 0:
|
||||
if cus.subscription is not None:
|
||||
|
|
160
endpoints/api/superuser.py
Normal file
160
endpoints/api/superuser.py
Normal file
|
@ -0,0 +1,160 @@
|
|||
import logging
|
||||
import json
|
||||
|
||||
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)
|
||||
|
||||
from endpoints.api.logs import get_logs
|
||||
|
||||
from data import model
|
||||
from auth.permissions import SuperUserPermission
|
||||
from auth.auth_context import get_authenticated_user
|
||||
|
||||
import features
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@resource('/v1/superuser/logs')
|
||||
@internal_only
|
||||
@show_if(features.SUPER_USERS)
|
||||
class SuperUserLogs(ApiResource):
|
||||
""" Resource for fetching all logs in the system. """
|
||||
@nickname('listAllLogs')
|
||||
@parse_args
|
||||
@query_param('starttime', 'Earliest time from which to get logs. (%m/%d/%Y %Z)', type=str)
|
||||
@query_param('endtime', 'Latest time to which to get logs. (%m/%d/%Y %Z)', type=str)
|
||||
@query_param('performer', 'Username for which to filter logs.', type=str)
|
||||
def get(self, args):
|
||||
""" List the logs for the current system. """
|
||||
if SuperUserPermission().can():
|
||||
performer_name = args['performer']
|
||||
start_time = args['starttime']
|
||||
end_time = args['endtime']
|
||||
|
||||
return get_logs(start_time, end_time)
|
||||
|
||||
abort(403)
|
||||
|
||||
|
||||
@resource('/v1/superuser/seats')
|
||||
@internal_only
|
||||
@show_if(features.SUPER_USERS)
|
||||
@hide_if(features.BILLING)
|
||||
class SeatUsage(ApiResource):
|
||||
""" Resource for managing the seats granted in the license for the system. """
|
||||
@nickname('getSeatCount')
|
||||
def get(self):
|
||||
""" Returns the current number of seats being used in the system. """
|
||||
if SuperUserPermission().can():
|
||||
return {
|
||||
'count': model.get_active_user_count(),
|
||||
'allowed': app.config.get('LICENSE_SEAT_COUNT', 0)
|
||||
}
|
||||
|
||||
abort(403)
|
||||
|
||||
|
||||
def user_view(user):
|
||||
return {
|
||||
'username': user.username,
|
||||
'email': user.email,
|
||||
'verified': user.verified,
|
||||
'super_user': user.username in app.config['SUPER_USERS']
|
||||
}
|
||||
|
||||
@resource('/v1/superuser/users/')
|
||||
@internal_only
|
||||
@show_if(features.SUPER_USERS)
|
||||
class SuperUserList(ApiResource):
|
||||
""" Resource for listing users in the system. """
|
||||
@nickname('listAllUsers')
|
||||
def get(self):
|
||||
""" Returns a list of all users in the system. """
|
||||
if SuperUserPermission().can():
|
||||
users = model.get_active_users()
|
||||
return {
|
||||
'users': [user_view(user) for user in users]
|
||||
}
|
||||
|
||||
abort(403)
|
||||
|
||||
|
||||
@resource('/v1/superuser/users/<username>')
|
||||
@internal_only
|
||||
@show_if(features.SUPER_USERS)
|
||||
class SuperUserManagement(ApiResource):
|
||||
""" Resource for managing users in the system. """
|
||||
schemas = {
|
||||
'UpdateUser': {
|
||||
'id': 'UpdateUser',
|
||||
'type': 'object',
|
||||
'description': 'Description of updates for a user',
|
||||
'properties': {
|
||||
'password': {
|
||||
'type': 'string',
|
||||
'description': 'The new password for the user',
|
||||
},
|
||||
'email': {
|
||||
'type': 'string',
|
||||
'description': 'The new e-mail address for the user',
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@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)
|
||||
|
||||
abort(403)
|
||||
|
||||
@nickname('deleteInstallUser')
|
||||
def delete(self, username):
|
||||
""" Deletes the specified user. """
|
||||
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)
|
||||
|
||||
model.delete_user(user)
|
||||
return 'Deleted', 204
|
||||
|
||||
abort(403)
|
||||
|
||||
@nickname('changeInstallUser')
|
||||
@validate_json_request('UpdateUser')
|
||||
def put(self, username):
|
||||
""" Updates information about the specified user. """
|
||||
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)
|
||||
|
||||
user_data = request.get_json()
|
||||
if 'password' in user_data:
|
||||
model.change_password(user, user_data['password'])
|
||||
|
||||
if 'email' in user_data:
|
||||
model.update_email(user, user_data['email'])
|
||||
|
||||
return user_view(user)
|
||||
|
||||
abort(403)
|
|
@ -16,7 +16,9 @@ from endpoints.trigger import (BuildTrigger as BuildTriggerBase, TriggerDeactiva
|
|||
TriggerActivationException, EmptyRepositoryException,
|
||||
RepositoryReadException)
|
||||
from data import model
|
||||
from auth.permissions import UserAdminPermission
|
||||
from auth.permissions import UserAdminPermission, AdministerOrganizationPermission, ReadRepositoryPermission
|
||||
from util.names import parse_robot_username
|
||||
from util.dockerfileparse import parse_dockerfile
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -139,7 +141,19 @@ class BuildTriggerActivate(RepositoryParamResource):
|
|||
'BuildTriggerActivateRequest': {
|
||||
'id': 'BuildTriggerActivateRequest',
|
||||
'type': 'object',
|
||||
'description': 'Arbitrary json.',
|
||||
'required': [
|
||||
'config'
|
||||
],
|
||||
'properties': {
|
||||
'config': {
|
||||
'type': 'object',
|
||||
'description': 'Arbitrary json.',
|
||||
},
|
||||
'pull_robot': {
|
||||
'type': 'string',
|
||||
'description': 'The name of the robot that will be used to pull images.'
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -160,7 +174,27 @@ class BuildTriggerActivate(RepositoryParamResource):
|
|||
|
||||
user_permission = UserAdminPermission(trigger.connected_user.username)
|
||||
if user_permission.can():
|
||||
new_config_dict = request.get_json()
|
||||
# Update the pull robot (if any).
|
||||
pull_robot_name = request.get_json().get('pull_robot', None)
|
||||
if pull_robot_name:
|
||||
pull_robot = model.lookup_robot(pull_robot_name)
|
||||
if not pull_robot:
|
||||
raise NotFound()
|
||||
|
||||
# Make sure the user has administer permissions for the robot's namespace.
|
||||
(robot_namespace, shortname) = parse_robot_username(pull_robot_name)
|
||||
if not AdministerOrganizationPermission(robot_namespace).can():
|
||||
raise Unauthorized()
|
||||
|
||||
# Make sure the namespace matches that of the trigger.
|
||||
if robot_namespace != namespace:
|
||||
raise Unauthorized()
|
||||
|
||||
# Set the pull robot.
|
||||
trigger.pull_robot = pull_robot
|
||||
|
||||
# Update the config.
|
||||
new_config_dict = request.get_json()['config']
|
||||
|
||||
token_name = 'Build Trigger: %s' % trigger.service.name
|
||||
token = model.create_delegate_token(namespace, repository, token_name,
|
||||
|
@ -171,9 +205,8 @@ class BuildTriggerActivate(RepositoryParamResource):
|
|||
trigger.repository.name)
|
||||
path = url_for('webhooks.build_trigger_webhook',
|
||||
repository=repository_path, trigger_uuid=trigger.uuid)
|
||||
authed_url = _prepare_webhook_url(app.config['URL_SCHEME'], '$token',
|
||||
token.code, app.config['URL_HOST'],
|
||||
path)
|
||||
authed_url = _prepare_webhook_url(app.config['PREFERRED_URL_SCHEME'], '$token', token.code,
|
||||
app.config['SERVER_HOSTNAME'], path)
|
||||
|
||||
final_config = handler.activate(trigger.uuid, authed_url,
|
||||
trigger.auth_token, new_config_dict)
|
||||
|
@ -191,6 +224,7 @@ class BuildTriggerActivate(RepositoryParamResource):
|
|||
log_action('setup_repo_trigger', namespace,
|
||||
{'repo': repository, 'namespace': namespace,
|
||||
'trigger_id': trigger.uuid, 'service': trigger.service.name,
|
||||
'pull_robot': trigger.pull_robot.username if trigger.pull_robot else None,
|
||||
'config': final_config}, repo=repo)
|
||||
|
||||
return trigger_view(trigger)
|
||||
|
@ -198,6 +232,141 @@ class BuildTriggerActivate(RepositoryParamResource):
|
|||
raise Unauthorized()
|
||||
|
||||
|
||||
@resource('/v1/repository/<repopath:repository>/trigger/<trigger_uuid>/analyze')
|
||||
@internal_only
|
||||
class BuildTriggerAnalyze(RepositoryParamResource):
|
||||
""" Custom verb for analyzing the config for a build trigger and suggesting various changes
|
||||
(such as a robot account to use for pulling)
|
||||
"""
|
||||
schemas = {
|
||||
'BuildTriggerAnalyzeRequest': {
|
||||
'id': 'BuildTriggerAnalyzeRequest',
|
||||
'type': 'object',
|
||||
'required': [
|
||||
'config'
|
||||
],
|
||||
'properties': {
|
||||
'config': {
|
||||
'type': 'object',
|
||||
'description': 'Arbitrary json.',
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@require_repo_admin
|
||||
@nickname('analyzeBuildTrigger')
|
||||
@validate_json_request('BuildTriggerAnalyzeRequest')
|
||||
def post(self, namespace, repository, trigger_uuid):
|
||||
""" Analyze the specified build trigger configuration. """
|
||||
try:
|
||||
trigger = model.get_build_trigger(namespace, repository, trigger_uuid)
|
||||
except model.InvalidBuildTriggerException:
|
||||
raise NotFound()
|
||||
|
||||
handler = BuildTriggerBase.get_trigger_for_service(trigger.service.name)
|
||||
new_config_dict = request.get_json()['config']
|
||||
|
||||
try:
|
||||
# Load the contents of the Dockerfile.
|
||||
contents = handler.load_dockerfile_contents(trigger.auth_token, new_config_dict)
|
||||
if not contents:
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': 'Could not read the Dockerfile for the trigger'
|
||||
}
|
||||
|
||||
# Parse the contents of the Dockerfile.
|
||||
parsed = parse_dockerfile(contents)
|
||||
if not parsed:
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': 'Could not parse the Dockerfile specified'
|
||||
}
|
||||
|
||||
# Determine the base image (i.e. the FROM) for the Dockerfile.
|
||||
base_image = parsed.get_base_image()
|
||||
if not base_image:
|
||||
return {
|
||||
'status': 'warning',
|
||||
'message': 'No FROM line found in the Dockerfile'
|
||||
}
|
||||
|
||||
# Check to see if the base image lives in Quay.
|
||||
quay_registry_prefix = '%s/' % (app.config['SERVER_HOSTNAME'])
|
||||
|
||||
if not base_image.startswith(quay_registry_prefix):
|
||||
return {
|
||||
'status': 'publicbase'
|
||||
}
|
||||
|
||||
# Lookup the repository in Quay.
|
||||
result = base_image[len(quay_registry_prefix):].split('/', 2)
|
||||
if len(result) != 2:
|
||||
return {
|
||||
'status': 'warning',
|
||||
'message': '"%s" is not a valid Quay repository path' % (base_image)
|
||||
}
|
||||
|
||||
(base_namespace, base_repository) = result
|
||||
found_repository = model.get_repository(base_namespace, base_repository)
|
||||
if not found_repository:
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': 'Repository "%s" was not found' % (base_image)
|
||||
}
|
||||
|
||||
# If the repository is private and the user cannot see that repo, then
|
||||
# mark it as not found.
|
||||
can_read = ReadRepositoryPermission(base_namespace, base_repository)
|
||||
if found_repository.visibility.name != 'public' and not can_read:
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': 'Repository "%s" was not found' % (base_image)
|
||||
}
|
||||
|
||||
# Check to see if the repository is public. If not, we suggest the
|
||||
# usage of a robot account to conduct the pull.
|
||||
read_robots = []
|
||||
|
||||
if AdministerOrganizationPermission(base_namespace).can():
|
||||
def robot_view(robot):
|
||||
return {
|
||||
'name': robot.username,
|
||||
'kind': 'user',
|
||||
'is_robot': True
|
||||
}
|
||||
|
||||
def is_valid_robot(user):
|
||||
# Make sure the user is a robot.
|
||||
if not user.robot:
|
||||
return False
|
||||
|
||||
# Make sure the current user can see/administer the robot.
|
||||
(robot_namespace, shortname) = parse_robot_username(user.username)
|
||||
return AdministerOrganizationPermission(robot_namespace).can()
|
||||
|
||||
repo_perms = model.get_all_repo_users(base_namespace, base_repository)
|
||||
read_robots = [robot_view(perm.user) for perm in repo_perms if is_valid_robot(perm.user)]
|
||||
|
||||
return {
|
||||
'namespace': base_namespace,
|
||||
'name': base_repository,
|
||||
'is_public': found_repository.visibility.name == 'public',
|
||||
'robots': read_robots,
|
||||
'status': 'analyzed',
|
||||
'dockerfile_url': handler.dockerfile_url(trigger.auth_token, new_config_dict)
|
||||
}
|
||||
|
||||
except RepositoryReadException as rre:
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': rre.message
|
||||
}
|
||||
|
||||
raise NotFound()
|
||||
|
||||
|
||||
@resource('/v1/repository/<repopath:repository>/trigger/<trigger_uuid>/start')
|
||||
class ActivateBuildTrigger(RepositoryParamResource):
|
||||
""" Custom verb to manually activate a build trigger. """
|
||||
|
@ -220,8 +389,10 @@ class ActivateBuildTrigger(RepositoryParamResource):
|
|||
dockerfile_id, tags, name, subdir = specs
|
||||
|
||||
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)
|
||||
build_request = start_build(repo, dockerfile_id, tags, name, subdir, True,
|
||||
pull_robot_name=pull_robot_name)
|
||||
|
||||
resp = build_status_view(build_request, True)
|
||||
repo_string = '%s/%s' % (namespace, repository)
|
||||
|
|
|
@ -1,27 +1,27 @@
|
|||
import logging
|
||||
import stripe
|
||||
import json
|
||||
|
||||
from flask import request
|
||||
from flask.ext.login import logout_user
|
||||
from flask.ext.principal import identity_changed, AnonymousIdentity
|
||||
|
||||
from app import app
|
||||
from app import app, billing as stripe
|
||||
from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error,
|
||||
log_action, internal_only, NotFound, require_user_admin,
|
||||
InvalidToken, require_scope, format_date)
|
||||
InvalidToken, require_scope, format_date, hide_if, show_if)
|
||||
from endpoints.api.subscribe import subscribe
|
||||
from endpoints.common import common_login
|
||||
from data import model
|
||||
from data.plans import get_plan
|
||||
from data.billing import get_plan
|
||||
from auth.permissions import (AdministerOrganizationPermission, CreateRepositoryPermission,
|
||||
UserAdminPermission, UserReadPermission)
|
||||
UserAdminPermission, UserReadPermission, SuperUserPermission)
|
||||
from auth.auth_context import get_authenticated_user
|
||||
from auth import scopes
|
||||
from util.gravatar import compute_hash
|
||||
from util.email import (send_confirmation_email, send_recovery_email,
|
||||
send_change_email)
|
||||
|
||||
import features
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -65,6 +65,11 @@ def user_view(user):
|
|||
'preferred_namespace': not (user.stripe_id is None),
|
||||
})
|
||||
|
||||
if features.SUPER_USERS:
|
||||
user_response.update({
|
||||
'super_user': user and user == get_authenticated_user() and SuperUserPermission().can()
|
||||
})
|
||||
|
||||
return user_response
|
||||
|
||||
|
||||
|
@ -193,6 +198,7 @@ class User(ApiResource):
|
|||
|
||||
@resource('/v1/user/private')
|
||||
@internal_only
|
||||
@show_if(features.BILLING)
|
||||
class PrivateRepositories(ApiResource):
|
||||
""" Operations dealing with the available count of private repositories. """
|
||||
@require_user_admin
|
||||
|
@ -248,8 +254,7 @@ class ConvertToOrganization(ApiResource):
|
|||
'description': 'Information required to convert a user to an organization.',
|
||||
'required': [
|
||||
'adminUser',
|
||||
'adminPassword',
|
||||
'plan',
|
||||
'adminPassword'
|
||||
],
|
||||
'properties': {
|
||||
'adminUser': {
|
||||
|
@ -262,7 +267,7 @@ class ConvertToOrganization(ApiResource):
|
|||
},
|
||||
'plan': {
|
||||
'type': 'string',
|
||||
'description': 'The plan to which the organizatino should be subscribed',
|
||||
'description': 'The plan to which the organization should be subscribed',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -289,8 +294,9 @@ class ConvertToOrganization(ApiResource):
|
|||
message='The admin user credentials are not valid')
|
||||
|
||||
# Subscribe the organization to the new plan.
|
||||
plan = convert_data['plan']
|
||||
subscribe(user, plan, None, True) # Require business plans
|
||||
if features.BILLING:
|
||||
plan = convert_data.get('plan', 'free')
|
||||
subscribe(user, plan, None, True) # Require business plans
|
||||
|
||||
# Convert the user to an organization.
|
||||
model.convert_user_to_organization(user, model.get_user(admin_username))
|
||||
|
|
Reference in a new issue