diff --git a/data/model/legacy.py b/data/model/legacy.py index 98583907d..296b6b1f5 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -61,8 +61,10 @@ class InvalidBuildTriggerException(DataModelException): def create_user(username, password, email, is_organization=False): if not validate_email(email): raise InvalidEmailAddressException('Invalid email address: %s' % email) - if not validate_username(username): - raise InvalidUsernameException('Invalid username: %s' % username) + + (username_valid, username_issue) = validate_username(username) + if not username_valid: + raise InvalidUsernameException('Invalid username %s: %s' % (username, username_issue)) # We allow password none for the federated login case. if password is not None and not validate_password(password): @@ -125,9 +127,10 @@ def create_organization(name, email, creating_user): def create_robot(robot_shortname, parent): - if not validate_username(robot_shortname): - raise InvalidRobotException('The name for the robot \'%s\' is invalid.' % - robot_shortname) + (username_valid, username_issue) = validate_username(robot_shortname) + if not username_valid: + raise InvalidRobotException('The name for the robot \'%s\' is invalid: %s' % + (robot_shortname, username_issue)) username = format_robot_username(parent.username, robot_shortname) @@ -214,8 +217,9 @@ def convert_user_to_organization(user, admin_user): def create_team(name, org, team_role_name, description=''): - if not validate_username(name): - raise InvalidTeamException('Invalid team name: %s' % name) + (username_valid, username_issue) = validate_username(name) + if not username_valid: + raise InvalidTeamException('Invalid team name %s: %s' % (name, username_issue)) if not org.organization: raise InvalidOrganizationException('User with name %s is not an org.' % diff --git a/endpoints/api/trigger.py b/endpoints/api/trigger.py index 13e2658b0..20ab04330 100644 --- a/endpoints/api/trigger.py +++ b/endpoints/api/trigger.py @@ -16,8 +16,9 @@ from endpoints.trigger import (BuildTrigger as BuildTriggerBase, TriggerDeactiva TriggerActivationException, EmptyRepositoryException, RepositoryReadException) from data import model -from auth.permissions import UserAdminPermission, AdministerOrganizationPermission +from auth.permissions import UserAdminPermission, AdministerOrganizationPermission, ReadRepositoryPermission from util.names import parse_robot_username +from util.dockerfileparse import parse_dockerfile logger = logging.getLogger(__name__) @@ -231,6 +232,141 @@ class BuildTriggerActivate(RepositoryParamResource): raise Unauthorized() +@resource('/v1/repository//trigger//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//trigger//start') class ActivateBuildTrigger(RepositoryParamResource): """ Custom verb to manually activate a build trigger. """ diff --git a/endpoints/trigger.py b/endpoints/trigger.py index ddd68deac..77a818206 100644 --- a/endpoints/trigger.py +++ b/endpoints/trigger.py @@ -2,6 +2,7 @@ import logging import io import os.path import tarfile +import base64 from github import Github, UnknownObjectException, GithubException from tempfile import SpooledTemporaryFile @@ -45,6 +46,19 @@ class BuildTrigger(object): def __init__(self): pass + def dockerfile_url(self, auth_token, config): + """ + Returns the URL at which the Dockerfile for the trigger can be found or None if none/not applicable. + """ + return None + + def load_dockerfile_contents(self, auth_token, config): + """ + Loads the Dockerfile found for the trigger's config and returns them or None if none could + be found/loaded. + """ + return None + def list_build_sources(self, auth_token): """ Take the auth information for the specific trigger type and load the @@ -167,7 +181,6 @@ class GithubBuildTrigger(BuildTrigger): return config - def list_build_sources(self, auth_token): gh_client = self._get_client(auth_token) usr = gh_client.get_user() @@ -218,6 +231,41 @@ class GithubBuildTrigger(BuildTrigger): raise RepositoryReadException(message) + def dockerfile_url(self, auth_token, config): + source = config['build_source'] + subdirectory = config.get('subdir', '') + path = subdirectory + '/Dockerfile' if subdirectory else 'Dockerfile' + + gh_client = self._get_client(auth_token) + try: + repo = gh_client.get_repo(source) + master_branch = repo.master_branch or 'master' + return 'https://github.com/%s/blob/%s/%s' % (source, master_branch, path) + except GithubException as ge: + return None + + def load_dockerfile_contents(self, auth_token, config): + gh_client = self._get_client(auth_token) + + source = config['build_source'] + subdirectory = config.get('subdir', '') + path = subdirectory + '/Dockerfile' if subdirectory else 'Dockerfile' + + try: + repo = gh_client.get_repo(source) + file_info = repo.get_file_contents(path) + if file_info is None: + return None + + content = file_info.content + if file_info.encoding == 'base64': + content = base64.b64decode(content) + return content + + except GithubException as ge: + message = ge.data.get('message', 'Unable to read Dockerfile: %s' % source) + raise RepositoryReadException(message) + @staticmethod def _prepare_build(config, repo, commit_sha, build_name, ref): # Prepare the download and upload URLs diff --git a/endpoints/web.py b/endpoints/web.py index 04b0326ef..a9cace61d 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -9,6 +9,7 @@ from urlparse import urlparse from data import model from data.model.oauth import DatabaseAuthorizationProvider from app import app, billing as stripe +from auth.auth import require_session_login from auth.permissions import AdministerOrganizationPermission from util.invoice import renderInvoiceToPdf from util.seo import render_snapshot @@ -161,6 +162,7 @@ def privacy(): @web.route('/receipt', methods=['GET']) @route_show_if(features.BILLING) +@require_session_login def receipt(): if not current_user.is_authenticated(): abort(401) diff --git a/initdb.py b/initdb.py index 9381ea282..2f8a77429 100644 --- a/initdb.py +++ b/initdb.py @@ -292,7 +292,7 @@ def populate_database(): __generate_repository(new_user_1, 'complex', 'Complex repository with many branches and tags.', - False, [(new_user_2, 'read')], + False, [(new_user_2, 'read'), (dtrobot[0], 'read')], (2, [(3, [], 'v2.0'), (1, [(1, [(1, [], ['prod'])], 'staging'), diff --git a/static/css/quay.css b/static/css/quay.css index b00e09492..8eb25f740 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -2406,12 +2406,19 @@ p.editable:hover i { text-align: center; } -#image-history-container .tags .tag, #confirmdeleteTagModal .tag { +.tags .tag, #confirmdeleteTagModal .tag { border-radius: 10px; margin-right: 4px; cursor: pointer; } +.tooltip-tags { + display: block; + margin-top: 10px; + border-top: 1px dotted #aaa; + padding-top: 10px; +} + #changes-tree-container { overflow: hidden; } @@ -3451,7 +3458,7 @@ pre.command:before { position: relative; - height: 100px; + height: 75px; opacity: 1; } @@ -3602,10 +3609,10 @@ pre.command:before { margin-right: 34px; } -.trigger-option-section:not(:last-child) { - border-bottom: 1px solid #eee; - padding-bottom: 16px; - margin-bottom: 16px; +.trigger-option-section:not(:first-child) { + border-top: 1px solid #eee; + padding-top: 16px; + margin-top: 10px; } .trigger-option-section .entity-search-element .twitter-typeahead { diff --git a/static/directives/billing-invoices.html b/static/directives/billing-invoices.html index fc64b1abf..2e2bfd469 100644 --- a/static/directives/billing-invoices.html +++ b/static/directives/billing-invoices.html @@ -30,7 +30,7 @@ - + diff --git a/static/directives/copy-box.html b/static/directives/copy-box.html index 204bd3a57..1d996cc31 100644 --- a/static/directives/copy-box.html +++ b/static/directives/copy-box.html @@ -2,7 +2,7 @@
- +
diff --git a/static/directives/delete-ui.html b/static/directives/delete-ui.html index d04e840f0..275c25b50 100644 --- a/static/directives/delete-ui.html +++ b/static/directives/delete-ui.html @@ -1,4 +1,4 @@ - + diff --git a/static/directives/entity-reference.html b/static/directives/entity-reference.html index 12ee81f86..6aecc1ebe 100644 --- a/static/directives/entity-reference.html +++ b/static/directives/entity-reference.html @@ -1,14 +1,14 @@ - + {{entity.name}} {{entity.name}} - - + + {{ getPrefix(entity.name) }}+{{ getShortenedName(entity.name) }} @@ -22,6 +22,6 @@ + data-title="This user is not a member of the organization" bs-tooltip="tooltip.title" data-container="body"> diff --git a/static/directives/entity-search.html b/static/directives/entity-search.html index 04dac6c0f..bc1c0a94e 100644 --- a/static/directives/entity-search.html +++ b/static/directives/entity-search.html @@ -1,5 +1,5 @@ - + @@ -55,7 +55,7 @@
-
on behalf of
diff --git a/static/directives/namespace-selector.html b/static/directives/namespace-selector.html index 98d91394d..98f6a353a 100644 --- a/static/directives/namespace-selector.html +++ b/static/directives/namespace-selector.html @@ -18,7 +18,7 @@ diff --git a/static/directives/plan-manager.html b/static/directives/plan-manager.html index f2ad26df4..5978043a7 100644 --- a/static/directives/plan-manager.html +++ b/static/directives/plan-manager.html @@ -38,7 +38,7 @@ {{ plan.title }}
- Discontinued Plan + Discontinued Plan
{{ plan.privateRepos }} diff --git a/static/directives/prototype-manager.html b/static/directives/prototype-manager.html index 26cec48c8..f5d5b0f9c 100644 --- a/static/directives/prototype-manager.html +++ b/static/directives/prototype-manager.html @@ -3,7 +3,7 @@
- Default permissions provide a means of specifying additional permissions that should be granted automatically to a repository. + Default permissions provide a means of specifying additional permissions that should be granted automatically to a repository.
@@ -17,13 +17,13 @@ Repository Creator - Applies To User/Robot/Team diff --git a/static/directives/repo-circle.html b/static/directives/repo-circle.html index ca5cbddf4..82648863e 100644 --- a/static/directives/repo-circle.html +++ b/static/directives/repo-circle.html @@ -1,2 +1,2 @@ - + diff --git a/static/directives/setup-trigger-dialog.html b/static/directives/setup-trigger-dialog.html new file mode 100644 index 000000000..1e84a782a --- /dev/null +++ b/static/directives/setup-trigger-dialog.html @@ -0,0 +1,100 @@ +
+ + + +
diff --git a/static/directives/signup-form.html b/static/directives/signup-form.html index 0e87f17e5..fb0ccc6fa 100644 --- a/static/directives/signup-form.html +++ b/static/directives/signup-form.html @@ -1,13 +1,15 @@ -
{{ shownToken.friendlyName }}
- - - + +
diff --git a/static/partials/repo-list.html b/static/partials/repo-list.html index 2c7da9c9d..5c4e5d5ae 100644 --- a/static/partials/repo-list.html +++ b/static/partials/repo-list.html @@ -4,7 +4,7 @@
@@ -60,11 +60,11 @@
- - diff --git a/static/partials/user-admin.html b/static/partials/user-admin.html index 0ad550232..25ef0ec32 100644 --- a/static/partials/user-admin.html +++ b/static/partials/user-admin.html @@ -69,10 +69,10 @@ + data-title="{{ authInfo.application.description || authInfo.application.name }}" bs-tooltip> {{ authInfo.application.name }} - + {{ authInfo.application.name }} {{ authInfo.application.organization.name }} @@ -80,7 +80,7 @@ + ng-repeat="scopeInfo in authInfo.scopes" data-title="{{ scopeInfo.description }}" bs-tooltip> {{ scopeInfo.scope }} @@ -124,8 +124,8 @@
Change e-mail address
- + @@ -146,11 +146,12 @@ Password changed successfully
-
- + + + match="cuser.password" required ng-pattern="/^.{8,}$/">
@@ -169,7 +170,7 @@
GitHub Login:
diff --git a/static/partials/view-repo.html b/static/partials/view-repo.html index b5db777de..51f34204c 100644 --- a/static/partials/view-repo.html +++ b/static/partials/view-repo.html @@ -17,7 +17,7 @@