Merge remote-tracking branch 'origin/master' into ncc1701

Conflicts:
	endpoints/web.py
	static/directives/signup-form.html
	static/js/app.js
	static/js/controllers.js
	static/partials/landing.html
	static/partials/view-repo.html
	test/data/test.db
This commit is contained in:
jakedt 2014-04-14 19:37:22 -04:00
commit 0827e0fbac
45 changed files with 1149 additions and 306 deletions

View file

@ -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.' %

View file

@ -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/<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. """

View file

@ -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

View file

@ -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)

View file

@ -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'),

View file

@ -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 {

View file

@ -30,7 +30,7 @@
</td>
<td>
<a ng-show="invoice.paid" href="/receipt?id={{ invoice.id }}" download="receipt.pdf" target="_new">
<i class="fa fa-download" title="Download Receipt" bs-tooltip="tooltip.title"></i>
<i class="fa fa-download" data-title="Download Receipt" bs-tooltip="tooltip.title"></i>
</a>
</td>
</tr>

View file

@ -2,7 +2,7 @@
<div class="id-container">
<div class="input-group">
<input type="text" class="form-control" value="{{ value }}" readonly>
<span class="input-group-addon" title="Copy to Clipboard">
<span class="input-group-addon" data-title="Copy to Clipboard">
<i class="fa fa-copy"></i>
</span>
</div>

View file

@ -1,4 +1,4 @@
<span class="delete-ui-element" ng-click="focus()">
<span class="delete-ui-button" ng-click="performDelete()"><button class="btn btn-danger">{{ buttonTitleInternal }}</button></span>
<i class="fa fa-times" bs-tooltip="tooltip.title" data-placement="left" title="{{ deleteTitle }}"></i>
<i class="fa fa-times" bs-tooltip="tooltip.title" data-placement="left" data-title="{{ deleteTitle }}"></i>
</span>

View file

@ -1,14 +1,14 @@
<span class="entity-reference-element">
<span ng-if="entity.kind == 'team'">
<i class="fa fa-group" title="Team" bs-tooltip="tooltip.title" data-container="body"></i>
<i class="fa fa-group" data-title="Team" bs-tooltip="tooltip.title" data-container="body"></i>
<span class="entity-name">
<span ng-if="!getIsAdmin(namespace)">{{entity.name}}</span>
<span ng-if="getIsAdmin(namespace)"><a href="/organization/{{ namespace }}/teams/{{ entity.name }}">{{entity.name}}</a></span>
</span>
</span>
<span ng-if="entity.kind != 'team'">
<i class="fa fa-user" ng-show="!entity.is_robot" title="User" bs-tooltip="tooltip.title" data-container="body"></i>
<i class="fa fa-wrench" ng-show="entity.is_robot" title="Robot Account" bs-tooltip="tooltip.title" data-container="body"></i>
<i class="fa fa-user" ng-show="!entity.is_robot" data-title="User" bs-tooltip="tooltip.title" data-container="body"></i>
<i class="fa fa-wrench" ng-show="entity.is_robot" data-title="Robot Account" bs-tooltip="tooltip.title" data-container="body"></i>
<span class="entity-name" ng-if="entity.is_robot">
<a href="{{ getRobotUrl(entity.name) }}" ng-if="getIsAdmin(getPrefix(entity.name))">
<span class="prefix">{{ getPrefix(entity.name) }}+</span><span>{{ getShortenedName(entity.name) }}</span>
@ -22,6 +22,6 @@
</span>
</span>
<i class="fa fa-exclamation-triangle" ng-if="entity.is_org_member === false"
title="This user is not a member of the organization" bs-tooltip="tooltip.title" data-container="body">
data-title="This user is not a member of the organization" bs-tooltip="tooltip.title" data-container="body">
</i>
</span>

View file

@ -1,5 +1,5 @@
<span class="entity-search-element" ng-class="isPersistent ? 'persistent' : ''"><input class="entity-search-control form-control">
<span class="entity-reference block-reference" ng-show="isPersistent && currentEntity" entity="currentEntity"></span>
<span class="entity-reference block-reference" ng-show="isPersistent && currentEntityInternal" entity="currentEntityInternal"></span>
<div class="dropdown">
<button class="btn btn-default dropdown-toggle" type="button" id="entityDropdownMenu" data-toggle="dropdown"
ng-click="lazyLoad()">

View file

@ -28,7 +28,7 @@
</form>
<span class="navbar-left user-tools" ng-show="!user.anonymous">
<a href="/new/"><i class="fa fa-upload user-tool" bs-tooltip="tooltip.title" data-placement="bottom" title="Create new repository"></i></a>
<a href="/new/"><i class="fa fa-upload user-tool" bs-tooltip="tooltip.title" data-placement="bottom" data-title="Create new repository"></i></a>
</span>
</li>
@ -40,7 +40,7 @@
ng-show="notificationService.notifications.length"
ng-class="notificationService.notificationClasses"
bs-tooltip=""
title="User Notifications"
data-title="User Notifications"
data-placement="left"
data-container="body">
{{ notificationService.notifications.length }}

View file

@ -13,9 +13,9 @@
</span>
<span class="right">
<i class="fa fa-bar-chart-o toggle-icon" ng-class="chartVisible ? 'active' : ''"
ng-click="toggleChart()" title="Toggle Chart" bs-tooltip="tooltip.title"></i>
ng-click="toggleChart()" data-title="Toggle Chart" bs-tooltip="tooltip.title"></i>
<a href="{{ logsPath }}" download="usage-log.json" target="_new">
<i class="fa fa-download toggle-icon" title="Download Logs" bs-tooltip="tooltip.title"></i>
<i class="fa fa-download toggle-icon" data-title="Download Logs" bs-tooltip="tooltip.title"></i>
</a>
</span>
</div>
@ -55,7 +55,7 @@
<td>
<span class="log-performer" ng-if="log.metadata.oauth_token_application">
<div>
<span class="application-reference" title="log.metadata.oauth_token_application"
<span class="application-reference" data-title="log.metadata.oauth_token_application"
client-id="log.metadata.oauth_token_application_id"></span>
</div>
<div style="text-align: center; font-size: 12px; color: #aaa; padding: 4px;">on behalf of</div>

View file

@ -18,7 +18,7 @@
</a>
<i class="fa fa-exclamation-triangle" ng-show="requireCreate && !namespaces[org.name].can_create_repo"
title="You do not have permission to create repositories for this organization"
data-title="You do not have permission to create repositories for this organization"
data-placement="right"
bs-tooltip="tooltip.title"></i>
</li>

View file

@ -38,7 +38,7 @@
<td>
{{ plan.title }}
<div class="deprecated-plan-label" ng-show="plan.deprecated">
<span class="context-tooltip" title="This plan has been discontinued. As a valued early adopter, you may continue to stay on this plan indefinitely." bs-tooltip="tooltip.title" data-placement="right">Discontinued Plan</span>
<span class="context-tooltip" data-title="This plan has been discontinued. As a valued early adopter, you may continue to stay on this plan indefinitely." bs-tooltip="tooltip.title" data-placement="right">Discontinued Plan</span>
</div>
</td>
<td>{{ plan.privateRepos }}</td>

View file

@ -3,7 +3,7 @@
<div class="container" ng-show="!loading">
<div class="alert alert-info">
Default permissions provide a means of specifying <span class="context-tooltip" title="By default, all repositories have the creating user added as an 'Admin'" bs-tooltip="tooltip.title">additional</span> permissions that should be granted automatically to a repository.
Default permissions provide a means of specifying <span class="context-tooltip" data-title="By default, all repositories have the creating user added as an 'Admin'" bs-tooltip="tooltip.title">additional</span> permissions that should be granted automatically to a repository.
</div>
<div class="side-controls">
@ -17,13 +17,13 @@
<thead>
<th>
<span class="context-tooltip"
title="The user or robot that is creating a repository. If '(Organization Default)', then any repository created in this organization will be granted the permission."
data-title="The user or robot that is creating a repository. If '(Organization Default)', then any repository created in this organization will be granted the permission."
bs-tooltip="tooltip.title" data-container="body">
Repository Creator
</span>
</th>
<th>
<span class="context-tooltip" title="The user, robot or team that is being granted the permission"
<span class="context-tooltip" data-title="The user, robot or team that is being granted the permission"
bs-tooltip="tooltip.title" data-container="body">
Applies To User/Robot/Team
</span>

View file

@ -1,2 +1,2 @@
<i class="fa fa-lock fa-lg" style="{{ repo.is_public ? 'visibility: hidden' : 'visibility: inherit' }}" title="Private Repository"></i>
<i class="fa fa-lock fa-lg" style="{{ repo.is_public ? 'visibility: hidden' : 'visibility: inherit' }}" data-title="Private Repository"></i>
<i class="fa fa-hdd-o"></i>

View file

@ -0,0 +1,100 @@
<div class="setup-trigger-directive-element">
<!-- Modal message dialog -->
<div class="modal fade" id="setupTriggerModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Setup new build trigger</h4>
</div>
<div class="modal-body">
<!-- Trigger-specific setup -->
<div class="trigger-description-element trigger-option-section" ng-switch on="trigger.service">
<div ng-switch-when="github">
<div class="trigger-setup-github" repository="repository" trigger="trigger"
analyze="checkAnalyze(isValid)"></div>
</div>
</div>
<!-- Pull information -->
<div class="trigger-option-section" ng-show="showPullRequirements">
<div ng-show="!pullRequirements">
<span class="quay-spinner"></span> Checking pull credential requirements...
</div>
<div ng-show="pullRequirements">
<div class="alert alert-danger" ng-if="pullRequirements.status == 'error'">
{{ pullRequirements.message }}
</div>
<div class="alert alert-warning" ng-if="pullRequirements.status == 'warning'">
{{ pullRequirements.message }}
</div>
<div class="alert alert-success" ng-if="pullRequirements.status == 'analyzed' && pullRequirements.is_public === false">
The
<a href="{{ pullRequirements.dockerfile_url }}" ng-if="pullRequirements.dockerfile_url" target="_blank">Dockerfile found</a>
<span ng-if="!pullRequirements.dockerfile_url">Dockerfile found</span>
depends on repository
<a href="/repository/{{ pullRequirements.namespace }}/{{ pullRequirements.name }}" target="_blank">
{{ pullRequirements.namespace }}/{{ pullRequirements.name }}
</a> which requires
a robot account for pull access, because it is marked <strong>private</strong>.
</div>
</div>
<table style="width: 100%;" ng-show="pullRequirements">
<tr>
<td style="width: 114px">
<div class="context-tooltip" data-title="The credentials used by the builder when pulling images" bs-tooltip>
Pull Credentials:
</div>
</td>
<td>
<div ng-if="!isNamespaceAdmin(repository.namespace)" style="color: #aaa;">
In order to set pull credentials for a build trigger, you must be an Administrator of the namespace <strong>{{ repository.namespace }}</strong>
</div>
<div class="btn-group btn-group-sm" ng-if="isNamespaceAdmin(repository.namespace)">
<button type="button" class="btn btn-default"
ng-class="publicPull ? 'active btn-info' : ''" ng-click="setPublicPull(true)">Public</button>
<button type="button" class="btn btn-default"
ng-class="publicPull ? '' : 'active btn-info'" ng-click="setPublicPull(false)">
<i class="fa fa-wrench"></i>
Robot account
</button>
</div>
</td>
</tr>
<tr ng-show="!publicPull">
<td>
</td>
<td>
<div class="entity-search" namespace="repository.namespace" include-teams="false"
input-title="'Select robot account for pulling...'"
is-organization="repository.is_organization"
is-persistent="true"
current-entity="pullEntity"
filter="['robot']"></div>
<div class="alert alert-info" ng-if="pullRequirements.robots.length" style="margin-top: 20px; margin-bottom: 0px;">
Note: We've automatically selected robot account <span class="entity-reference" entity="pullRequirements.robots[0]"></span>, since it has access to the repository.
</div>
<div class="alert alert-warning" ng-if="!pullRequirements.robots.length" style="margin-top: 20px; margin-bottom: 0px;">
Note: No robot account currently has access to the repository. Please create one and/or assign access in the
<a href="/repository/{{ pullRequirements.namespace }}/{{ pullRequirements.name }}/admin" target="_blank">repository's admin panel</a>.
</div>
</td>
</tr>
</table>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary"
ng-disabled="!trigger.$ready || (!publicPull && !pullEntity) || checkingPullRequirements"
ng-click="activate">Finished</button>
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
</div>

View file

@ -1,13 +1,15 @@
<div class="signup-form-element">
<form class="form-signup" name="signupForm" ng-submit="register()" data-trigger="manual"
data-content="{{ registerError }}" data-placement="left" ng-show="!awaitingConfirmation && !registering">
<input type="text" class="form-control" placeholder="Create a username" name="username" ng-model="newUser.username" autofocus required>
<form class="form-signup" name="signupForm" ng-submit="register()" ngshow="!awaitingConfirmation && !registering">
<input type="text" class="form-control" placeholder="Create a username" name="username" ng-model="newUser.username" autofocus required ng-pattern="/^[a-z0-9_]{4,30}$/">
<input type="email" class="form-control" placeholder="Email address" ng-model="newUser.email" required>
<input type="password" class="form-control" placeholder="Create a password" ng-model="newUser.password" required>
<input type="password" class="form-control" placeholder="Create a password" ng-model="newUser.password" required
ng-pattern="/^.{8,}$/">
<input type="password" class="form-control" placeholder="Verify your password" ng-model="newUser.repeatPassword"
match="newUser.password" required>
match="newUser.password" required
ng-pattern="/^.{8,}$/">
<div class="form-group signin-buttons">
<button class="btn btn-primary btn-block landing-signup-button" ng-disabled="signupForm.$invalid" type="submit"
<button id="signupButton"
class="btn btn-primary btn-block landing-signup-button" ng-disabled="signupForm.$invalid" type="submit"
analytics-on analytics-event="register">
<span quay-show="Features.BILLING">Sign Up for Free!</span>
<span quay-show="!Features.BILLING">Sign Up</span>

View file

@ -1,6 +1,6 @@
<span class="trigger-description-element" ng-switch on="trigger.service">
<span ng-switch-when="github">
<i class="fa fa-github fa-lg" style="margin-right: 6px" title="GitHub" bs-tooltip="tooltip.title"></i>
<i class="fa fa-github fa-lg" style="margin-right: 6px" data-title="GitHub" bs-tooltip="tooltip.title"></i>
Push to GitHub repository <a href="https://github.com/{{ trigger.config.build_source }}" target="_new">{{ trigger.config.build_source }}</a>
<div style="margin-top: 4px; margin-left: 26px; font-size: 12px; color: gray;" ng-if="trigger.config.subdir">
<span>Dockerfile:

View file

@ -335,6 +335,42 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
}]);
$provide.factory('UIService', [function() {
var uiService = {};
uiService.hidePopover = function(elem) {
var popover = $('#signupButton').data('bs.popover');
if (popover) {
popover.hide();
}
};
uiService.showPopover = function(elem, content) {
var popover = $(elem).data('bs.popover');
if (!popover) {
$(elem).popover({'content': '-', 'placement': 'left'});
}
setTimeout(function() {
var popover = $(elem).data('bs.popover');
popover.options.content = content;
popover.show();
}, 500);
};
uiService.showFormError = function(elem, result) {
var message = result.data['message'] || result.data['error_description'] || '';
if (message) {
uiService.showPopover(elem, message);
} else {
uiService.hidePopover(elem);
}
};
return uiService;
}]);
$provide.factory('UtilService', ['$sanitize', function($sanitize) {
var utilService = {};
@ -1833,7 +1869,7 @@ quayApp.directive('signupForm', function () {
scope: {
},
controller: function($scope, $location, $timeout, ApiService, KeyService, UserService, Config) {
controller: function($scope, $location, $timeout, ApiService, KeyService, UserService, Config, UIService) {
$('.form-signup').popover();
if (Config.MIXPANEL_KEY) {
@ -1849,22 +1885,19 @@ quayApp.directive('signupForm', function () {
$scope.registering = false;
$scope.register = function() {
$('.form-signup').popover('hide');
UIService.hidePopover('#signupButton');
$scope.registering = true;
ApiService.createNewUser($scope.newUser).then(function() {
$scope.awaitingConfirmation = true;
$scope.registering = false;
$scope.awaitingConfirmation = true;
if (Config.MIXPANEL_KEY) {
mixpanel.alias($scope.newUser.username);
}
}, function(result) {
$scope.registering = false;
$scope.registerError = result.data.message;
$timeout(function() {
$('.form-signup').popover('show');
});
UIService.showFormError('#signupButton', result);
});
};
}
@ -2904,6 +2937,7 @@ quayApp.directive('entitySearch', function () {
controller: function($scope, $element, Restangular, UserService, ApiService) {
$scope.lazyLoading = true;
$scope.isAdmin = false;
$scope.currentEntityInternal = $scope.currentEntity;
$scope.lazyLoad = function() {
if (!$scope.namespace || !$scope.lazyLoading) { return; }
@ -2986,7 +3020,9 @@ quayApp.directive('entitySearch', function () {
};
$scope.clearEntityInternal = function() {
$scope.currentEntityInternal = null;
$scope.currentEntity = null;
if ($scope.entitySelected) {
$scope.entitySelected(null);
}
@ -3000,6 +3036,7 @@ quayApp.directive('entitySearch', function () {
}
if ($scope.isPersistent) {
$scope.currentEntityInternal = entity;
$scope.currentEntity = entity;
}
@ -3124,6 +3161,16 @@ quayApp.directive('entitySearch', function () {
$scope.$watch('inputTitle', function(title) {
input.setAttribute('placeholder', title);
});
$scope.$watch('currentEntity', function(entity) {
if ($scope.currentEntityInternal != entity) {
if (entity) {
$scope.setEntityInternal(entity, false);
} else {
$scope.clearEntityInternal();
}
}
});
}
};
return directiveDefinitionObject;
@ -3661,6 +3708,145 @@ quayApp.directive('dropdownSelectMenu', function () {
});
quayApp.directive('setupTriggerDialog', function () {
var directiveDefinitionObject = {
templateUrl: '/static/directives/setup-trigger-dialog.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'repository': '=repository',
'trigger': '=trigger',
'counter': '=counter',
'canceled': '&canceled',
'activated': '&activated'
},
controller: function($scope, $element, ApiService, UserService) {
$scope.show = function() {
$scope.pullEntity = null;
$scope.publicPull = true;
$scope.showPullRequirements = false;
$('#setupTriggerModal').modal({});
$('#setupTriggerModal').on('hidden.bs.modal', function () {
$scope.$apply(function() {
$scope.cancelSetupTrigger();
});
});
};
$scope.isNamespaceAdmin = function(namespace) {
return UserService.isNamespaceAdmin(namespace);
};
$scope.cancelSetupTrigger = function() {
$scope.canceled({'trigger': $scope.trigger});
};
$scope.hide = function() {
$('#setupTriggerModal').modal('hide');
};
$scope.setPublicPull = function(value) {
$scope.publicPull = value;
};
$scope.checkAnalyze = function(isValid) {
if (!isValid) {
$scope.publicPull = true;
$scope.pullEntity = null;
$scope.showPullRequirements = false;
$scope.checkingPullRequirements = false;
return;
}
$scope.checkingPullRequirements = true;
$scope.showPullRequirements = true;
$scope.pullRequirements = null;
var params = {
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
'trigger_uuid': $scope.trigger.id
};
var data = {
'config': $scope.trigger.config
};
ApiService.analyzeBuildTrigger(data, params).then(function(resp) {
$scope.pullRequirements = resp;
if (resp['status'] == 'publicbase') {
$scope.publicPull = true;
$scope.pullEntity = null;
} else if (resp['namespace']) {
$scope.publicPull = false;
if (resp['robots'] && resp['robots'].length > 0) {
$scope.pullEntity = resp['robots'][0];
} else {
$scope.pullEntity = null;
}
}
$scope.checkingPullRequirements = false;
}, function(resp) {
$scope.pullRequirements = resp;
$scope.checkingPullRequirements = false;
});
};
$scope.activate = function() {
var params = {
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
'trigger_uuid': $scope.trigger.id
};
var data = {
'config': $scope.trigger['config']
};
if ($scope.pullEntity) {
data['pull_robot'] = $scope.pullEntity['name'];
}
ApiService.activateBuildTrigger(data, params).then(function(resp) {
trigger['is_active'] = true;
trigger['pull_robot'] = resp['pull_robot'];
$scope.activated({'trigger': $scope.trigger});
}, function(resp) {
$scope.hide();
$scope.canceled({'trigger': $scope.trigger});
bootbox.dialog({
"message": resp['data']['message'] || 'The build trigger setup could not be completed',
"title": "Could not activate build trigger",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
};
var check = function() {
if ($scope.counter && $scope.trigger && $scope.repository) {
$scope.show();
}
};
$scope.$watch('trigger', check);
$scope.$watch('counter', check);
$scope.$watch('repository', check);
}
};
return directiveDefinitionObject;
});
quayApp.directive('triggerSetupGithub', function () {
var directiveDefinitionObject = {
priority: 0,
@ -3670,15 +3856,18 @@ quayApp.directive('triggerSetupGithub', function () {
restrict: 'C',
scope: {
'repository': '=repository',
'trigger': '=trigger'
'trigger': '=trigger',
'analyze': '&analyze'
},
controller: function($scope, $element, ApiService) {
$scope.analyzeCounter = 0;
$scope.setupReady = false;
$scope.loading = true;
$scope.handleLocationInput = function(location) {
$scope.trigger['config']['subdir'] = location || '';
$scope.isInvalidLocation = $scope.locations.indexOf(location) < 0;
$scope.analyze({'isValid': !$scope.isInvalidLocation});
};
$scope.handleLocationSelected = function(datum) {
@ -3689,6 +3878,7 @@ quayApp.directive('triggerSetupGithub', function () {
$scope.currentLocation = location;
$scope.trigger['config']['subdir'] = location || '';
$scope.isInvalidLocation = false;
$scope.analyze({'isValid': true});
};
$scope.selectRepo = function(repo, org) {
@ -3727,6 +3917,7 @@ quayApp.directive('triggerSetupGithub', function () {
$scope.locations = null;
$scope.trigger.$ready = false;
$scope.isInvalidLocation = false;
$scope.analyze({'isValid': false});
return;
}
@ -3739,12 +3930,14 @@ quayApp.directive('triggerSetupGithub', function () {
} else {
$scope.currentLocation = null;
$scope.isInvalidLocation = resp['subdir'].indexOf('') < 0;
$scope.analyze({'isValid': !$scope.isInvalidLocation});
}
}, function(resp) {
$scope.locationError = resp['message'] || 'Could not load Dockerfile locations';
$scope.locations = null;
$scope.trigger.$ready = false;
$scope.isInvalidLocation = false;
$scope.analyze({'isValid': false});
});
}
};
@ -3789,7 +3982,14 @@ quayApp.directive('triggerSetupGithub', function () {
});
};
var check = function() {
if ($scope.repository && $scope.trigger) {
loadSources();
}
};
$scope.$watch('repository', check);
$scope.$watch('trigger', check);
$scope.$watch('currentRepo', function(repo) {
$scope.selectRepoInternal(repo);
@ -4409,6 +4609,17 @@ quayApp.run(['$location', '$rootScope', 'Restangular', 'UserService', 'PlanServi
});
};
$rootScope.$watch('description', function(description) {
if (!description) {
description = 'Hosted private docker repositories. Includes full user management and history. Free for public repositories.';
}
// Note: We set the content of the description tag manually here rather than using Angular binding
// because we need the <meta> tag to have a default description that is not of the form "{{ description }}",
// we read by tools that do not properly invoke the Angular code.
$('#descriptionTag').attr('content', description);
});
$rootScope.$on('$routeUpdate', function(){
if ($location.search()['tab']) {
changeTab($location.search()['tab']);
@ -4425,7 +4636,7 @@ quayApp.run(['$location', '$rootScope', 'Restangular', 'UserService', 'PlanServi
if (current.$$route.description) {
$rootScope.description = current.$$route.description;
} else {
$rootScope.description = 'Hosted private docker repositories. Includes full user management and history. Free for public repositories.';
$rootScope.description = '';
}
$rootScope.fixFooter = !!current.$$route.fixFooter;

View file

@ -1178,6 +1178,8 @@ function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, $routeParams
$scope.githubRedirectUri = KeyService.githubRedirectUri;
$scope.githubClientId = KeyService.githubClientId;
$scope.showTriggerSetupCounter = 0;
$scope.getBadgeFormat = function(format, repo) {
if (!repo) { return; }
@ -1467,65 +1469,15 @@ function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, $routeParams
};
$scope.setupTrigger = function(trigger) {
$scope.triggerSetupReady = false;
$scope.currentSetupTrigger = trigger;
trigger['_pullEntity'] = null;
trigger['_publicPull'] = true;
$('#setupTriggerModal').modal({});
$('#setupTriggerModal').on('hidden.bs.modal', function () {
$scope.$apply(function() {
$scope.cancelSetupTrigger();
});
});
$scope.showTriggerSetupCounter++;
};
$scope.isNamespaceAdmin = function(namespace) {
return UserService.isNamespaceAdmin(namespace);
};
$scope.cancelSetupTrigger = function(trigger) {
if ($scope.currentSetupTrigger != trigger) { return; }
$scope.finishSetupTrigger = function(trigger) {
$('#setupTriggerModal').modal('hide');
$scope.currentSetupTrigger = null;
var params = {
'repository': namespace + '/' + name,
'trigger_uuid': trigger.id
};
var data = {
'config': trigger['config']
};
if (trigger['_pullEntity']) {
data['pull_robot'] = trigger['_pullEntity']['name'];
}
ApiService.activateBuildTrigger(data, params).then(function(resp) {
trigger['is_active'] = true;
trigger['pull_robot'] = resp['pull_robot'];
}, function(resp) {
$scope.triggers.splice($scope.triggers.indexOf(trigger), 1);
bootbox.dialog({
"message": resp['data']['message'] || 'The build trigger setup could not be completed',
"title": "Could not activate build trigger",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
};
$scope.cancelSetupTrigger = function() {
if (!$scope.currentSetupTrigger) { return; }
$('#setupTriggerModal').modal('hide');
$scope.deleteTrigger($scope.currentSetupTrigger);
$scope.currentSetupTrigger = null;
$scope.deleteTrigger(trigger);
};
$scope.startTrigger = function(trigger) {
@ -1620,7 +1572,7 @@ function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, $routeParams
}
function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, UserService, CookieService, KeyService,
$routeParams, $http, Features) {
$routeParams, $http, UIService, Features) {
$scope.Features = Features;
if ($routeParams['migrate']) {
@ -1657,8 +1609,6 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use
$scope.githubClientId = KeyService.githubClientId;
$scope.authorizedApps = null;
$('.form-change').popover();
$scope.logsShown = 0;
$scope.invoicesShown = 0;
@ -1748,7 +1698,8 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use
};
$scope.changeEmail = function() {
$('#changeEmailForm').popover('hide');
UIService.hidePopover('#changeEmailForm');
$scope.updatingUser = true;
$scope.changeEmailSent = false;
@ -1763,16 +1714,13 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use
$scope.changeEmailForm.$setPristine();
}, function(result) {
$scope.updatingUser = false;
$scope.changeEmailError = result.data.message;
$timeout(function() {
$('#changeEmailForm').popover('show');
});
UIService.showFormError('#changeEmailForm', result);
});
};
$scope.changePassword = function() {
$('#changePasswordForm').popover('hide');
UIService.hidePopover('#changePasswordForm');
$scope.updatingUser = true;
$scope.changePasswordSuccess = false;
@ -1790,11 +1738,7 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use
UserService.load();
}, function(result) {
$scope.updatingUser = false;
$scope.changePasswordError = result.data.message;
$timeout(function() {
$('#changePasswordForm').popover('show');
});
UIService.showFormError('#changePasswordForm', result);
});
};
}
@ -2185,7 +2129,7 @@ function OrgViewCtrl($rootScope, $scope, ApiService, $routeParams) {
loadOrganization();
}
function OrgAdminCtrl($rootScope, $scope, $timeout, Restangular, $routeParams, UserService, PlanService, ApiService, Features) {
function OrgAdminCtrl($rootScope, $scope, $timeout, Restangular, $routeParams, UserService, PlanService, ApiService, Features, UIService) {
var orgname = $routeParams.orgname;
// Load the list of plans.
@ -2226,10 +2170,12 @@ function OrgAdminCtrl($rootScope, $scope, $timeout, Restangular, $routeParams, U
};
$scope.$watch('organizationEmail', function(e) {
$('#changeEmailForm').popover('hide');
UIService.hidePopover('#changeEmailForm');
});
$scope.changeEmail = function() {
UIService.hidePopover('#changeEmailForm');
$scope.changingOrganization = true;
var params = {
'orgname': orgname
@ -2245,10 +2191,7 @@ function OrgAdminCtrl($rootScope, $scope, $timeout, Restangular, $routeParams, U
$scope.organization = org;
}, function(result) {
$scope.changingOrganization = false;
$scope.changeEmailError = result.data.message;
$timeout(function() {
$('#changeEmailForm').popover('show');
});
UIService.showFormError('#changeEmailForm', result);
});
};

View file

@ -223,6 +223,16 @@ ImageHistoryTree.prototype.draw = function(container) {
html += '<span class="command info-line"><i class="fa fa-terminal"></i>' + formatCommand(d.image) + '</span>';
}
html += '<span class="created info-line"><i class="fa fa-calendar"></i>' + formatTime(d.image.created) + '</span>';
var tags = d.tags || [];
html += '<span class="tooltip-tags tags">';
for (var i = 0; i < tags.length; ++i) {
var tag = tags[i];
var kind = 'default';
html += '<span class="label label-' + kind + ' tag" data-tag="' + tag + '">' + tag + '</span>';
}
html += '</span>';
return html;
})
@ -330,6 +340,23 @@ ImageHistoryTree.prototype.changeImage_ = function(imageId) {
};
/**
* Expands the given collapsed node in the tree.
*/
ImageHistoryTree.prototype.expandCollapsed_ = function(imageNode) {
var index = imageNode.parent.children.indexOf(imageNode);
if (index < 0 || imageNode.encountered.length < 2) {
return;
}
// Note: we start at 1 since the 0th encountered node is the parent.
imageNode.parent.children.splice(index, 1, imageNode.encountered[1]);
this.maxHeight_ = this.determineMaximumHeight_(this.root_);
this.update_(this.root_);
this.updateDimensions_();
};
/**
* Builds the root node for the tree.
*/
@ -632,7 +659,10 @@ ImageHistoryTree.prototype.update_ = function(source) {
.attr("dy", ".35em")
.attr("text-anchor", function(d) { return d.children || d._children ? "end" : "start"; })
.text(function(d) { return d.name; })
.on("click", function(d) { if (d.image) { that.changeImage_(d.image.id); } })
.on("click", function(d) {
if (d.image) { that.changeImage_(d.image.id); }
if (d.collapsed) { that.expandCollapsed_(d); }
})
.on('mouseover', tip.show)
.on('mouseout', tip.hide);
@ -709,7 +739,7 @@ ImageHistoryTree.prototype.update_ = function(source) {
if (tag == currentTag) {
kind = 'success';
}
html += '<span class="label label-' + kind + ' tag" data-tag="' + tag + '"">' + tag + '</span>';
html += '<span class="label label-' + kind + ' tag" data-tag="' + tag + '" title="' + tag + '">' + tag + '</span>';
}
return html;
});
@ -1686,7 +1716,7 @@ LogUsageChart.prototype.draw = function(container, logData, startDate, endDate)
.duration(500)
.call(chart);
nv.utils.windowResize(chart.update);
nv.utils.windoweResize(chart.update);
chart.multibar.dispatch.on('elementClick', function(e) { that.handleElementClicked_(e); });
chart.dispatch.on('stateChange', function(e) { that.handleStateChange_(e); });

View file

@ -23,7 +23,7 @@
<dd am-time-ago="parseDate(image.value.created)"></dd>
<dt>Compressed Image Size</dt>
<dd><span class="context-tooltip"
title="The amount of data sent between Docker and Quay.io when pushing/pulling"
data-title="The amount of data sent between Docker and Quay.io when pushing/pulling"
bs-tooltip="tooltip.title" data-container="body">{{ image.value.size | bytes }}</span>
</dd>
@ -64,7 +64,7 @@
</div>
<div class="change" ng-repeat="change in combinedChanges | filter:search | limitTo:50">
<i ng-class="{'added': 'fa fa-plus-square', 'removed': 'fa fa-minus-square', 'changed': 'fa fa-pencil-square'}[change.kind]"></i>
<span title="{{change.file}}">
<span data-title="{{change.file}}">
<span style="color: #888;">
<span ng-repeat="folder in getFolders(change.file)"><a href="javascript:void(0)" ng-click="setFolderFilter(getFolder(change.file), $index)">{{folder}}</a>/</span></span><span>{{getFilename(change.file)}}</span>
</span>

View file

@ -106,7 +106,7 @@
</td>
</tr>
<tr>
<td>Client Secret: <i class="fa fa-lock fa-lg" title="Keep this secret!" bs-tooltip style="margin-left: 10px"></i></td>
<td>Client Secret: <i class="fa fa-lock fa-lg" data-title="Keep this secret!" bs-tooltip style="margin-left: 10px"></i></td>
<td>
{{ application.client_secret }}
</td>

View file

@ -51,7 +51,7 @@
<div class="section">
<div class="repo-option">
<input type="radio" id="publicrepo" name="publicorprivate" ng-model="repo.is_public" value="1">
<i class="fa fa-unlock fa-large" title="Public Repository"></i>
<i class="fa fa-unlock fa-large" data-title="Public Repository"></i>
<div class="option-description">
<label for="publicrepo"><strong>Public</strong></label>
@ -60,7 +60,7 @@
</div>
<div class="repo-option">
<input type="radio" id="privaterepo" name="publicorprivate" ng-model="repo.is_public" value="0">
<i class="fa fa-lock fa-large" title="Private Repository"></i>
<i class="fa fa-lock fa-large" data-title="Private Repository"></i>
<div class="option-description">
<label for="privaterepo"><strong>Private</strong></label>
@ -75,7 +75,7 @@
<span ng-if="isUserNamespace">under your personal namespace</span>
<span ng-if="!isUserNamespace">under the organization <b>{{ repo.namespace }}</b></span>, you will need to upgrade your plan to
<b style="border-bottom: 1px dotted black;" data-html="true"
title="{{ '<b>' + planRequired.title + '</b><br>' + planRequired.privateRepos + ' private repositories' }}" bs-tooltip>
data-title="{{ '<b>' + planRequired.title + '</b><br>' + planRequired.privateRepos + ' private repositories' }}" bs-tooltip>
{{ planRequired.title }}
</b>.
This will cost $<span>{{ planRequired.price / 100 }}</span>/month.

View file

@ -114,7 +114,7 @@
</span>
</td>
<td>
<a href="/organization/{{ organization.name }}/logs/{{ memberInfo.name }}" title="Member Usage Logs" bs-tooltip="tooltip.title">
<a href="/organization/{{ organization.name }}/logs/{{ memberInfo.name }}" data-title="Member Usage Logs" bs-tooltip="tooltip.title">
<i class="fa fa-book"></i>
</a>
</td>

View file

@ -15,7 +15,7 @@
<div class="row hidden-xs">
<div class="col-md-4 col-md-offset-8 col-sm-5 col-sm-offset-7 header-col" ng-show="organization.is_admin">
Team Permissions
<i class="info-icon fa fa-info-circle" data-placement="bottom" data-original-title="" title=""
<i class="info-icon fa fa-info-circle" data-placement="bottom" data-original-title="" data-title=""
data-content="Global permissions for the team and its members<br><br><dl><dt>Member</dt><dd>Permissions are assigned on a per repository basis</dd><dt>Creator</dt><dd>A team can create its own repositories</dd><dt>Admin</dt><dd>A team has full control of the organization</dd></dl>"></i>
</div>
</div>

View file

@ -4,13 +4,13 @@
</div>
<div class="button-bar-right">
<a href="/organizations/new/" title="Starts the process to create a new organization" bs-tooltip="tooltip.title">
<a href="/organizations/new/" data-title="Starts the process to create a new organization" bs-tooltip="tooltip.title">
<button class="btn btn-success">
<i class="fa fa-plus"></i>
Create New Organization
</button>
</a>
<a href="/user/?migrate" ng-show="!user.anonymous" title="Starts the process to convert this account into an organization" bs-tooltip="tooltip.title">
<a href="/user/?migrate" ng-show="!user.anonymous" data-title="Starts the process to convert this account into an organization" bs-tooltip="tooltip.title">
<button class="btn btn-primary">
<i class="fa fa-caret-square-o-right"></i>
Convert account
@ -43,7 +43,7 @@
<div class="tour-section row">
<div class="col-md-7"><img src="/static/img/org-repo-list.png" title="Repositories - Quay.io" data-screenshot-url="https://quay.io/repository/" class="img-responsive"></div>
<div class="col-md-7"><img src="/static/img/org-repo-list.png" data-title="Repositories - Quay.io" data-screenshot-url="https://quay.io/repository/" class="img-responsive"></div>
<div class="col-md-5">
<div class="tour-section-title">A central collection of repositories</div>
<div class="tour-section-description">
@ -57,7 +57,7 @@
</div>
<div class="tour-section row">
<div class="col-md-7 col-md-push-5"><img src="/static/img/org-admin.png" title="buynlarge Admin - Quay.io" data-screenshot-url="https://quay.io/organization/buynlarge/admin" class="img-responsive"></div>
<div class="col-md-7 col-md-push-5"><img src="/static/img/org-admin.png" data-title="buynlarge Admin - Quay.io" data-screenshot-url="https://quay.io/organization/buynlarge/admin" class="img-responsive"></div>
<div class="col-md-5 col-md-pull-7">
<div class="tour-section-title">Organization settings at a glance</div>
<div class="tour-section-description">
@ -73,7 +73,7 @@
</div>
<div class="tour-section row">
<div class="col-md-7"><img src="/static/img/org-logs.png" title="buynlarge Admin - Quay.io" data-screenshot-url="https://quay.io/organization/buynlarge" class="img-responsive"></div>
<div class="col-md-7"><img src="/static/img/org-logs.png" data-title="buynlarge Admin - Quay.io" data-screenshot-url="https://quay.io/organization/buynlarge" class="img-responsive"></div>
<div class="col-md-5">
<div class="tour-section-title">Logging for comprehensive analysis</div>
<div class="tour-section-description">
@ -94,7 +94,7 @@
</div>
<div class="tour-section row">
<div class="col-md-7 col-md-push-5"><img src="/static/img/org-teams.png" title="buynlarge - Quay.io" data-screenshot-url="https://quay.io/organization/buynlarge" class="img-responsive"></div>
<div class="col-md-7 col-md-push-5"><img src="/static/img/org-teams.png" data-title="buynlarge - Quay.io" data-screenshot-url="https://quay.io/organization/buynlarge" class="img-responsive"></div>
<div class="col-md-5 col-md-pull-7">
<div class="tour-section-title">Teams simplify access controls</div>
<div class="tour-section-description">
@ -115,7 +115,7 @@
</div>
<div class="tour-section row">
<div class="col-md-7"><img src="/static/img/org-repo-admin.png" title="buynlarge/orgrepo - Quay.io" data-screenshot-url="https://quay.io/repository/buynlarge/orgrepo" class="img-responsive"></div>
<div class="col-md-7"><img src="/static/img/org-repo-admin.png" data-title="buynlarge/orgrepo - Quay.io" data-screenshot-url="https://quay.io/repository/buynlarge/orgrepo" class="img-responsive"></div>
<div class="col-md-5">
<div class="tour-section-title">Fine-grained control of sharing</div>
<div class="tour-section-description">
@ -133,13 +133,13 @@
</div>
<div class="button-bar-right button-bar-bottom">
<a href="/organizations/new/" title="Starts the process to create a new organization" bs-tooltip="tooltip.title">
<a href="/organizations/new/" data-title="Starts the process to create a new organization" bs-tooltip="tooltip.title">
<button class="btn btn-success">
<i class="fa fa-plus"></i>
Create New Organization
</button>
</a>
<a href="/user/?migrate" ng-show="!user.anonymous" title="Starts the process to convert this account into an organization" bs-tooltip="tooltip.title">
<a href="/user/?migrate" ng-show="!user.anonymous" data-title="Starts the process to convert this account into an organization" bs-tooltip="tooltip.title">
<button class="btn btn-primary">
<i class="fa fa-caret-square-o-right"></i>
Convert account

View file

@ -7,7 +7,7 @@
<div class="feature">
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
title="All plans have unlimited public repositories">
data-title="All plans have unlimited public repositories">
<span class="hidden-sm-inline">Public Repositories</span>
<span class="visible-sm-inline">Public Repos</span>
</span>
@ -15,49 +15,49 @@
</div>
<div class="feature">
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
title="SSL encryption is enabled end-to-end for all operations">
data-title="SSL encryption is enabled end-to-end for all operations">
SSL Encryption
</span>
<i class="fa fa-lock visible-lg"></i>
</div>
<div class="feature">
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
title="Allows users or organizations to grant permissions in multiple repositories to the same non-login-capable account">
data-title="Allows users or organizations to grant permissions in multiple repositories to the same non-login-capable account">
Robot accounts
</span>
<i class="fa fa-wrench visible-lg"></i>
</div>
<div class="feature">
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
title="Repository images can be built directly from Dockerfiles">
data-title="Repository images can be built directly from Dockerfiles">
Dockerfile Build
</span>
<i class="fa fa-upload visible-lg"></i>
</div>
<div class="feature">
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
title="Grant subsets of users in an organization their own permissions, either on a global basis or a per-repository basis">
data-title="Grant subsets of users in an organization their own permissions, either on a global basis or a per-repository basis">
Teams
</span>
<i class="fa fa-group visible-lg"></i>
</div>
<div class="feature">
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
title="Every action take within an organization is logged in detail, with the ability to visualize logs and download them">
data-title="Every action take within an organization is logged in detail, with the ability to visualize logs and download them">
Logging
</span>
<i class="fa fa-bar-chart-o visible-lg"></i>
</div>
<div class="feature">
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
title="Administrators can view and download the full invoice history for their organization">
data-title="Administrators can view and download the full invoice history for their organization">
Invoice History
</span>
<i class="fa fa-calendar visible-lg"></i>
</div>
<div class="feature">
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
title="All plans have a 14-day free trial">
data-title="All plans have a 14-day free trial">
<span class="hidden-sm-inline">14-Day Free Trial</span>
<span class="visible-sm-inline">14-Day Trial</span>
</span>

View file

@ -50,7 +50,7 @@
<!-- Status Image -->
<a ng-href="/repository/{{ repo.namespace }}/{{ repo.name }}" ng-if="repo && repo.name">
<img ng-src="/repository/{{ repo.namespace }}/{{ repo.name }}/status?token={{ repo.status_token }}" title="Docker Repository on Quay.io">
<img ng-src="/repository/{{ repo.namespace }}/{{ repo.name }}/status?token={{ repo.status_token }}" data-title="Docker Repository on Quay.io">
</a>
<!-- Embed formats -->
@ -271,7 +271,7 @@
</div>
<div ng-show="trigger.is_active" class="trigger-description" trigger="trigger"></div>
<div class="trigger-pull-credentials" ng-if="trigger.is_active && trigger.pull_robot">
<span class="context-tooltip" title="The credentials used by the builder when pulling images" bs-tooltip>
<span class="context-tooltip" data-title="The credentials used by the builder when pulling images" bs-tooltip>
Pull Credentials:
</span>
<span class="entity-reference" entity="trigger.pull_robot"></span>
@ -279,7 +279,7 @@
</td>
<td style="white-space: nowrap;">
<div class="dropdown" style="display: inline-block" ng-visible="trigger.is_active">
<button class="btn btn-default dropdown-toggle" data-toggle="dropdown" title="Build History" bs-tooltip="tooltip.title" data-container="body"
<button class="btn btn-default dropdown-toggle" data-toggle="dropdown" data-title="Build History" bs-tooltip="tooltip.title" data-container="body"
ng-click="loadTriggerBuildHistory(trigger)">
<i class="fa fa-tasks"></i>
<b class="caret"></b>
@ -297,7 +297,7 @@
</div>
<div class="dropdown" style="display: inline-block">
<button class="btn btn-default dropdown-toggle" data-toggle="dropdown" title="Trigger Settings" bs-tooltip="tooltip.title" data-container="body">
<button class="btn btn-default dropdown-toggle" data-toggle="dropdown" data-title="Trigger Settings" bs-tooltip="tooltip.title" data-container="body">
<i class="fa fa-cog"></i>
<b class="caret"></b>
</button>
@ -377,76 +377,17 @@
</div>
</div>
<!-- Auth dialog -->
<div class="docker-auth-dialog" username="'$token'" token="shownToken.code"
shown="!!shownToken" counter="shownTokenCounter">
<i class="fa fa-key"></i> {{ shownToken.friendlyName }}
</div>
<!-- Modal message dialog -->
<div class="modal fade" id="setupTriggerModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Setup new build trigger</h4>
</div>
<div class="modal-body">
<div class="trigger-option-section">
<table style="width: 100%;">
<tr>
<td style="width: 114px">
<div class="context-tooltip" title="The credentials used by the builder when pulling images" bs-tooltip>
Pull Credentials:
</div>
</td>
<td>
<div ng-if="!isNamespaceAdmin(repo.namespace)" style="color: #aaa;">
In order to set pull credentials for a build trigger, you must be an Administrator of the namespace <strong>{{ repo.namespace }}</strong>
</div>
<div class="btn-group btn-group-sm" ng-if="isNamespaceAdmin(repo.namespace)">
<button type="button" class="btn btn-default"
ng-class="currentSetupTrigger._publicPull ? 'active btn-info' : ''" ng-click="currentSetupTrigger._publicPull = true">Public</button>
<button type="button" class="btn btn-default"
ng-class="currentSetupTrigger._publicPull ? '' : 'active btn-info'" ng-click="currentSetupTrigger._publicPull = false">
<i class="fa fa-wrench"></i>
Robot account
</button>
</div>
</td>
</tr>
<tr ng-show="!currentSetupTrigger._publicPull">
<td>
</td>
<td>
<div class="entity-search" namespace="repo.namespace" include-teams="false"
input-title="'Select robot account for pulling...'"
is-organization="repo.is_organization"
is-persistent="true"
current-entity="currentSetupTrigger._pullEntity"
filter="['robot']"></div>
</td>
</tr>
</table>
</div>
<div class="trigger-description-element trigger-option-section" ng-switch on="currentSetupTrigger.service">
<div ng-switch-when="github">
<div class="trigger-setup-github" repository="repo" trigger="currentSetupTrigger"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary"
ng-disabled="!currentSetupTrigger.$ready || (!currentSetupTrigger._publicPull && !currentSetupTrigger._pullEntity)"
ng-click="finishSetupTrigger(currentSetupTrigger)">Finished</button>
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- Setup trigger dialog-->
<div class="setup-trigger-dialog" repository="repo"
trigger="currentSetupTrigger"
canceled="cancelSetupTrigger(trigger)"
counter="showTriggerSetupCounter"></div>
<!-- Modal message dialog -->
<div class="modal fade" id="cannotchangeModal">

View file

@ -55,7 +55,7 @@
<i class="fa fa-archive"></i>
<a href="/repository/{{ repo.namespace }}/{{ repo.name }}/build/{{ currentBuild.id }}/buildpack"
style="display: inline-block; margin-left: 6px" bs-tooltip="tooltip.title"
title="View the uploaded build package for this build">Build Package</a>
data-title="View the uploaded build package for this build">Build Package</a>
</span>
</div>
<span class="phase-icon" ng-class="build.phase"></span>

View file

@ -4,7 +4,7 @@
<div class="button-bar-right">
<a href="/new/">
<button class="btn btn-success">
<i class="fa fa-upload user-tool" title="Create new repository"></i>
<i class="fa fa-upload user-tool" data-title="Create new repository"></i>
Create Repository
</button>
</a>
@ -60,11 +60,11 @@
<div class="description markdown-view" content="repository.description" first-line-only="true"></div>
</div>
<div class="page-controls">
<button class="btn btn-default" title="Previous Page" bs-tooltip="title" ng-show="page > 1"
<button class="btn btn-default" data-title="Previous Page" bs-tooltip="title" ng-show="page > 1"
ng-click="movePublicPage(-1)">
<i class="fa fa-chevron-left"></i>
</button>
<button class="btn btn-default" title="Next Page" bs-tooltip="title" ng-show="page < publicPageCount"
<button class="btn btn-default" data-title="Next Page" bs-tooltip="title" ng-show="page < publicPageCount"
ng-click="movePublicPage(1)">
<i class="fa fa-chevron-right"></i>
</button>

View file

@ -69,10 +69,10 @@
<td>
<img src="//www.gravatar.com/avatar/{{ authInfo.gravatar }}?s=16&d=identicon">
<a href="{{ authInfo.application.url }}" ng-if="authInfo.application.url" target="_blank"
title="{{ authInfo.application.description || authInfo.application.name }}" bs-tooltip>
data-title="{{ authInfo.application.description || authInfo.application.name }}" bs-tooltip>
{{ authInfo.application.name }}
</a>
<span ng-if="!authInfo.application.url" title="{{ authInfo.application.description || authInfo.application.name }}" bs-tooltip>
<span ng-if="!authInfo.application.url" data-title="{{ authInfo.application.description || authInfo.application.name }}" bs-tooltip>
{{ authInfo.application.name }}
</span>
<span class="by">{{ authInfo.application.organization.name }}</span>
@ -80,7 +80,7 @@
<td>
<span class="label label-default scope"
ng-class="{'repo:admin': 'label-primary', 'repo:write': 'label-success', 'repo:create': 'label-success'}[scopeInfo.scope]"
ng-repeat="scopeInfo in authInfo.scopes" title="{{ scopeInfo.description }}" bs-tooltip>
ng-repeat="scopeInfo in authInfo.scopes" data-title="{{ scopeInfo.description }}" bs-tooltip>
{{ scopeInfo.scope }}
</span>
</td>
@ -124,8 +124,8 @@
<div class="panel-title">Change e-mail address</div>
<div class="panel-body">
<form class="form-change col-md-6" id="changeEmailForm" name="changeEmailForm" ng-submit="changeEmail()" data-trigger="manual"
data-content="{{ changeEmailError }}" data-placement="right" ng-show="!awaitingConfirmation && !registering">
<form class="form-change col-md-6" id="changeEmailForm" name="changeEmailForm" ng-submit="changeEmail()"
ng-show="!awaitingConfirmation && !registering">
<input type="email" class="form-control" placeholder="Your new e-mail address" ng-model="cuser.email" required>
<button class="btn btn-primary" ng-disabled="changeEmailForm.$invalid || cuser.email == user.email" type="submit">Change E-mail Address</button>
</form>
@ -146,11 +146,12 @@
<span class="help-block" ng-show="changePasswordSuccess">Password changed successfully</span>
<div ng-show="!updatingUser" class="panel-body">
<form class="form-change col-md-6" id="changePasswordForm" name="changePasswordForm" ng-submit="changePassword()" data-trigger="manual"
data-content="{{ changePasswordError }}" data-placement="right" ng-show="!awaitingConfirmation && !registering">
<input type="password" class="form-control" placeholder="Your new password" ng-model="cuser.password" required>
<form class="form-change col-md-6" id="changePasswordForm" name="changePasswordForm" ng-submit="changePassword()"
ng-show="!awaitingConfirmation && !registering">
<input type="password" class="form-control" placeholder="Your new password" ng-model="cuser.password" required
ng-pattern="/^.{8,}$/">
<input type="password" class="form-control" placeholder="Verify your new password" ng-model="cuser.repeatPassword"
match="cuser.password" required>
match="cuser.password" required ng-pattern="/^.{8,}$/">
<button class="btn btn-danger" ng-disabled="changePasswordForm.$invalid" type="submit"
analytics-on analytics-event="change_pass">Change Password</button>
</form>
@ -169,7 +170,7 @@
<div class="panel-title">GitHub Login:</div>
<div class="panel-body">
<div ng-show="githubLogin" class="lead col-md-8">
<i class="fa fa-github fa-lg" style="margin-right: 6px;" title="GitHub" bs-tooltip="tooltip.title"></i>
<i class="fa fa-github fa-lg" style="margin-right: 6px;" data-title="GitHub" bs-tooltip="tooltip.title"></i>
<b><a href="https://github.com/{{githubLogin}}" target="_blank">{{githubLogin}}</a></b>
</div>
<div ng-show="!githubLogin" class="col-md-8">

View file

@ -17,7 +17,7 @@
<!-- Builds -->
<div class="dropdown" data-placement="top" style="display: inline-block"
bs-tooltip=""
title="{{ runningBuilds.length ? 'Dockerfile Builds Running: ' + (runningBuilds.length) : 'Dockerfile Build' }}"
data-title="{{ runningBuilds.length ? 'Dockerfile Builds Running: ' + (runningBuilds.length) : 'Dockerfile Build' }}"
ng-show="repo.can_write || buildHistory.length">
<button class="btn btn-default dropdown-toggle" data-toggle="dropdown">
<i class="fa fa-tasks fa-lg"></i>
@ -51,15 +51,15 @@
<!-- Admin -->
<a id="admin-cog" href="{{ '/repository/' + repo.namespace + '/' + repo.name + '/admin' }}"
ng-show="repo.can_admin">
<button class="btn btn-default" title="Repository Settings" bs-tooltip="tooltip" data-placement="top">
<button class="btn btn-default" data-title="Repository Settings" bs-tooltip="tooltip" data-placement="top">
<i class="fa fa-cog fa-lg"></i></button></a>
<!-- Pull Command -->
<span class="pull-command visible-md-inline">
<div class="pull-container" title="Pull repository" bs-tooltip="tooltip.title">
<div class="pull-container" data-title="Pull repository" bs-tooltip="tooltip.title">
<div class="input-group">
<input id="pull-text" type="text" class="form-control" value="{{ 'docker pull ' + Config.getDomain() + '/' + repo.namespace + '/' + repo.name }}" readonly>
<span id="copyClipboard" class="input-group-addon" title="Copy to Clipboard" data-clipboard-target="pull-text">
<span id="copyClipboard" class="input-group-addon" data-title="Copy to Clipboard" data-clipboard-target="pull-text">
<i class="fa fa-copy"></i>
</span>
</div>
@ -145,10 +145,10 @@
</ul>
</div>
<span class="right-tag-controls">
<i class="fa fa-tag" title="Tags" bs-tooltip="title">
<i class="fa fa-tag" data-title="Tags" bs-tooltip="title">
<span class="tag-count">{{getTagCount(repo)}}</span>
</i>
<i class="fa fa-archive" title="Images" bs-tooltip="title">
<i class="fa fa-archive" data-title="Images" bs-tooltip="title">
<span class="tag-count">{{imageHistory.value.length}}</span>
</i>
</span>
@ -162,7 +162,7 @@
<dd am-time-ago="parseDate(currentTag.image.created)"></dd>
<dt>Total Compressed Size</dt>
<dd><span class="context-tooltip"
title="The amount of data sent between Docker and Quay.io when pushing/pulling"
data-title="The amount of data sent between Docker and Quay.io when pushing/pulling"
bs-tooltip="tooltip.title" data-container="body">{{ getTotalSize(currentTag) | bytes }}</span>
</dd>
</dl>
@ -171,7 +171,7 @@
<div class="tag-image-size" ng-repeat="image in getImagesForTagBySize(currentTag) | limitTo: 10">
<span class="size-limiter">
<span class="size-bar" style="{{ 'width:' + (image.size / getTotalSize(currentTag)) * 100 + '%' }}"
bs-tooltip="" title="{{ image.size | bytes }}"></span>
bs-tooltip="" data-title="{{ image.size | bytes }}"></span>
</span>
<span class="size-title"><a href="javascript:void(0)" ng-click="setImage(image.id, true)">{{ image.id.substr(0, 12) }}</a></span>
</div>
@ -199,14 +199,14 @@
<dd><a href="{{'/repository/' + repo.namespace + '/' + repo.name + '/image/' + currentImage.id}}">{{ currentImage.id }}</a></dd>
<dt>Compressed Image Size</dt>
<dd><span class="context-tooltip"
title="The amount of data sent between Docker and Quay.io when pushing/pulling"
data-title="The amount of data sent between Docker and Quay.io when pushing/pulling"
bs-tooltip="tooltip.title" data-container="body">{{ currentImage.size | bytes }}</span>
</dd>
<dt ng-show="currentImage.command && currentImage.command.length">Command</dt>
<dd ng-show="currentImage.command && currentImage.command.length" class="codetooltipcontainer">
<pre class="formatted-command trimmed"
data-html="true"
bs-tooltip="" title="{{ getTooltipCommand(currentImage) }}"
bs-tooltip="" data-title="{{ getTooltipCommand(currentImage) }}"
data-placement="top">{{ getFormattedCommand(currentImage) }}</pre>
</dd>
</dl>
@ -216,17 +216,17 @@
<div class="changes-container small-changes-container"
ng-show="currentImageChanges.changed.length || currentImageChanges.added.length || currentImageChanges.removed.length">
<div class="changes-count-container accordion-toggle" data-toggle="collapse" data-parent="#accordion" data-target="#collapseChanges">
<span class="change-count added" ng-show="currentImageChanges.added.length > 0" title="Files Added"
<span class="change-count added" ng-show="currentImageChanges.added.length > 0" data-title="Files Added"
bs-tooltip="tooltip.title" data-placement="top">
<i class="fa fa-plus-square"></i>
<b>{{currentImageChanges.added.length}}</b>
</span>
<span class="change-count removed" ng-show="currentImageChanges.removed.length > 0" title="Files Removed"
<span class="change-count removed" ng-show="currentImageChanges.removed.length > 0" data-title="Files Removed"
bs-tooltip="tooltip.title" data-placement="top">
<i class="fa fa-minus-square"></i>
<b>{{currentImageChanges.removed.length}}</b>
</span>
<span class="change-count changed" ng-show="currentImageChanges.changed.length > 0" title="Files Changed"
<span class="change-count changed" ng-show="currentImageChanges.changed.length > 0" data-title="Files Changed"
bs-tooltip="tooltip.title" data-placement="top">
<i class="fa fa-pencil-square"></i>
<b>{{currentImageChanges.changed.length}}</b>
@ -237,15 +237,15 @@
<div class="well well-sm">
<div class="change added" ng-repeat="file in currentImageChanges.added | limitTo:5">
<i class="fa fa-plus-square"></i>
<span title="{{file}}">{{file}}</span>
<span data-title="{{file}}">{{file}}</span>
</div>
<div class="change removed" ng-repeat="file in currentImageChanges.removed | limitTo:5">
<i class="fa fa-minus-square"></i>
<span title="{{file}}">{{file}}</span>
<span data-title="{{file}}">{{file}}</span>
</div>
<div class="change changed" ng-repeat="file in currentImageChanges.changed | limitTo:5">
<i class="fa fa-pencil-square"></i>
<span title="{{file}}">{{file}}</span>
<span data-title="{{file}}">{{file}}</span>
</div>
</div>
<div class="more-changes" ng-show="getMoreCount(currentImageChanges) > 0">
@ -296,7 +296,7 @@
<!--<i class="fa fa-archive"></i>-->
<span class="image-listing-circle"></span>
<span class="image-listing-line"></span>
<span class="context-tooltip image-listing-id" bs-tooltip="" title="{{ getFirstTextLine(image.comment) }}"
<span class="context-tooltip image-listing-id" bs-tooltip="" data-title="{{ getFirstTextLine(image.comment) }}"
data-html="true">
{{ image.id.substr(0, 12) }}
</span>

View file

@ -57,7 +57,7 @@
<!-- ,typeahead.js@0.10.1 -->
<script src="/static/lib/loading-bar.js"></script>
<script src="/static/lib/angular-strap.min.js"></script>
<script src="/static/lib/angular-strap.js"></script>
<script src="/static/lib/angular-strap.tpl.min.js"></script>
{% if mixpanel_key %}

View file

@ -7,7 +7,7 @@
{% block added_meta %}
<base href="/">
<meta name="description" content="{% raw %}{{ description }}{% endraw %}"></meta>
<meta id="descriptionTag" name="description" content="Hosted private docker repositories. Includes full user management and history. Free for public repositories."></meta>
<meta name="google-site-verification" content="GalDznToijTsHYmLjJvE4QaB9uk_IP16aaGDz5D75T4" />
<meta name="fragment" content="!" />
{% endblock %}

Binary file not shown.

View file

@ -17,7 +17,7 @@ from endpoints.api.build import (FileDropResource, RepositoryBuildStatus, Reposi
from endpoints.api.robot import UserRobotList, OrgRobot, OrgRobotList, UserRobot
from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs,
TriggerBuildList, ActivateBuildTrigger, BuildTrigger,
BuildTriggerList)
BuildTriggerList, BuildTriggerAnalyze)
from endpoints.api.webhook import Webhook, WebhookList
from endpoints.api.user import (PrivateRepositories, ConvertToOrganization, Recovery, Signout,
Signin, User, UserAuthorizationList, UserAuthorization)
@ -87,6 +87,9 @@ class ApiTestCase(unittest.TestCase):
rv = client.open(final_url, **open_kwargs)
msg = '%s %s: %s expected: %s' % (method, final_url, rv.status_code, expected_status)
if rv.status_code != expected_status:
print rv.data
self.assertEqual(rv.status_code, expected_status, msg)
def setUp(self):
@ -1198,6 +1201,130 @@ class TestActivateBuildTrigger0byeBuynlargeOrgrepo(ApiTestCase):
def test_post_devtable(self):
self._run_test('POST', 404, 'devtable', None)
class TestActivateBuildTrigger0byeDevtableShared(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(ActivateBuildTrigger, trigger_uuid="0BYE", repository="devtable/shared")
def test_post_anonymous(self):
self._run_test('POST', 401, None, None)
def test_post_freshuser(self):
self._run_test('POST', 403, 'freshuser', None)
def test_post_reader(self):
self._run_test('POST', 403, 'reader', None)
def test_post_devtable(self):
self._run_test('POST', 404, 'devtable', None)
class TestActivateBuildTrigger0byeBuynlargeOrgrepo(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(ActivateBuildTrigger, trigger_uuid="0BYE", repository="buynlarge/orgrepo")
def test_post_anonymous(self):
self._run_test('POST', 401, None, None)
def test_post_freshuser(self):
self._run_test('POST', 403, 'freshuser', None)
def test_post_reader(self):
self._run_test('POST', 403, 'reader', None)
def test_post_devtable(self):
self._run_test('POST', 404, 'devtable', None)
class TestBuildTriggerAnalyze0byePublicPublicrepo(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(BuildTriggerAnalyze, trigger_uuid="0BYE", repository="public/publicrepo")
def test_post_anonymous(self):
self._run_test('POST', 401, None, None)
def test_post_freshuser(self):
self._run_test('POST', 403, 'freshuser', None)
def test_post_reader(self):
self._run_test('POST', 403, 'reader', None)
def test_post_devtable(self):
self._run_test('POST', 403, 'devtable', {'config': {}})
class TestBuildTriggerAnalyze0byeDevtableShared(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(BuildTriggerAnalyze, trigger_uuid="0BYE", repository="devtable/shared")
def test_post_anonymous(self):
self._run_test('POST', 401, None, None)
def test_post_freshuser(self):
self._run_test('POST', 403, 'freshuser', None)
def test_post_reader(self):
self._run_test('POST', 403, 'reader', None)
def test_post_devtable(self):
self._run_test('POST', 404, 'devtable', {'config': {}})
class TestBuildTriggerAnalyze0byeBuynlargeOrgrepo(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(BuildTriggerAnalyze, trigger_uuid="0BYE", repository="buynlarge/orgrepo")
def test_post_anonymous(self):
self._run_test('POST', 401, None, None)
def test_post_freshuser(self):
self._run_test('POST', 403, 'freshuser', None)
def test_post_reader(self):
self._run_test('POST', 403, 'reader', None)
def test_post_devtable(self):
self._run_test('POST', 404, 'devtable', {'config': {}})
class TestBuildTriggerAnalyze0byeDevtableShared(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(BuildTriggerAnalyze, trigger_uuid="0BYE", repository="devtable/shared")
def test_post_anonymous(self):
self._run_test('POST', 401, None, None)
def test_post_freshuser(self):
self._run_test('POST', 403, 'freshuser', None)
def test_post_reader(self):
self._run_test('POST', 403, 'reader', None)
def test_post_devtable(self):
self._run_test('POST', 404, 'devtable', {'config': {}})
class TestBuildTriggerAnalyze0byeBuynlargeOrgrepo(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(BuildTriggerAnalyze, trigger_uuid="0BYE", repository="buynlarge/orgrepo")
def test_post_anonymous(self):
self._run_test('POST', 401, None, None)
def test_post_freshuser(self):
self._run_test('POST', 403, 'freshuser', None)
def test_post_reader(self):
self._run_test('POST', 403, 'reader', None)
def test_post_devtable(self):
self._run_test('POST', 404, 'devtable', {'config': {}})
class TestRepositoryImageChangesPtsgPublicPublicrepo(ApiTestCase):
def setUp(self):

View file

@ -19,7 +19,7 @@ from endpoints.api.build import RepositoryBuildStatus, RepositoryBuildLogs, Repo
from endpoints.api.robot import UserRobotList, OrgRobot, OrgRobotList, UserRobot
from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs,
TriggerBuildList, ActivateBuildTrigger, BuildTrigger,
BuildTriggerList)
BuildTriggerList, BuildTriggerAnalyze)
from endpoints.api.webhook import Webhook, WebhookList
from endpoints.api.user import (PrivateRepositories, ConvertToOrganization, Signout, Signin, User,
UserAuthorizationList, UserAuthorization)
@ -293,6 +293,24 @@ class TestCreateNewUser(ApiTestCase):
self.assertEquals('The username already exists', json['message'])
def test_trycreatetooshort(self):
json = self.postJsonResponse(User,
data=dict(username='a',
password='password',
email='test@example.com'),
expected_code=400)
self.assertEquals('Invalid username a: Username must be between 4 and 30 characters in length', json['error_description'])
def test_trycreateregexmismatch(self):
json = self.postJsonResponse(User,
data=dict(username='auserName',
password='password',
email='test@example.com'),
expected_code=400)
self.assertEquals('Invalid username auserName: Username must match expression [a-z0-9_]+', json['error_description'])
def test_createuser(self):
data = self.postResponse(User,
data=NEW_USER_DETAILS,
@ -1605,6 +1623,15 @@ class FakeBuildTrigger(BuildTriggerBase):
def manual_start(self, auth_token, config):
return ('foo', ['bar'], 'build-name', 'subdir')
def dockerfile_url(self, auth_token, config):
return 'http://some/url'
def load_dockerfile_contents(self, auth_token, config):
if not 'dockerfile' in config:
return None
return config['dockerfile']
class TestBuildTriggers(ApiTestCase):
def test_list_build_triggers(self):
@ -1653,6 +1680,82 @@ class TestBuildTriggers(ApiTestCase):
self.assertEquals(0, len(json['triggers']))
def test_analyze_fake_trigger(self):
self.login(ADMIN_ACCESS_USER)
database.BuildTriggerService.create(name='fakeservice')
# Add a new fake trigger.
repo = model.get_repository(ADMIN_ACCESS_USER, 'simple')
user = model.get_user(ADMIN_ACCESS_USER)
trigger = model.create_build_trigger(repo, 'fakeservice', 'sometoken', user)
# Analyze the trigger's dockerfile: First, no dockerfile.
trigger_config = {}
analyze_json = self.postJsonResponse(BuildTriggerAnalyze,
params=dict(repository=ADMIN_ACCESS_USER + '/simple', trigger_uuid=trigger.uuid),
data={'config': trigger_config})
self.assertEquals('error', analyze_json['status'])
self.assertEquals('Could not read the Dockerfile for the trigger', analyze_json['message'])
# Analyze the trigger's dockerfile: Second, missing FROM in dockerfile.
trigger_config = {'dockerfile': 'MAINTAINER me'}
analyze_json = self.postJsonResponse(BuildTriggerAnalyze,
params=dict(repository=ADMIN_ACCESS_USER + '/simple', trigger_uuid=trigger.uuid),
data={'config': trigger_config})
self.assertEquals('warning', analyze_json['status'])
self.assertEquals('No FROM line found in the Dockerfile', analyze_json['message'])
# Analyze the trigger's dockerfile: Third, dockerfile with public repo.
trigger_config = {'dockerfile': 'FROM somerepo'}
analyze_json = self.postJsonResponse(BuildTriggerAnalyze,
params=dict(repository=ADMIN_ACCESS_USER + '/simple', trigger_uuid=trigger.uuid),
data={'config': trigger_config})
self.assertEquals('publicbase', analyze_json['status'])
# Analyze the trigger's dockerfile: Fourth, dockerfile with private repo with an invalid path.
trigger_config = {'dockerfile': 'FROM localhost:5000/somepath'}
analyze_json = self.postJsonResponse(BuildTriggerAnalyze,
params=dict(repository=ADMIN_ACCESS_USER + '/simple', trigger_uuid=trigger.uuid),
data={'config': trigger_config})
self.assertEquals('warning', analyze_json['status'])
self.assertEquals('"localhost:5000/somepath" is not a valid Quay repository path', analyze_json['message'])
# Analyze the trigger's dockerfile: Fifth, dockerfile with private repo that does not exist.
trigger_config = {'dockerfile': 'FROM localhost:5000/nothere/randomrepo'}
analyze_json = self.postJsonResponse(BuildTriggerAnalyze,
params=dict(repository=ADMIN_ACCESS_USER + '/simple', trigger_uuid=trigger.uuid),
data={'config': trigger_config})
self.assertEquals('error', analyze_json['status'])
self.assertEquals('Repository "localhost:5000/nothere/randomrepo" was not found', analyze_json['message'])
# Analyze the trigger's dockerfile: Sixth, dockerfile with private repo that the user cannot see.
trigger_config = {'dockerfile': 'FROM localhost:5000/randomuser/randomrepo'}
analyze_json = self.postJsonResponse(BuildTriggerAnalyze,
params=dict(repository=ADMIN_ACCESS_USER + '/simple', trigger_uuid=trigger.uuid),
data={'config': trigger_config})
self.assertEquals('error', analyze_json['status'])
self.assertEquals('Repository "localhost:5000/randomuser/randomrepo" was not found', analyze_json['message'])
# Analyze the trigger's dockerfile: Seventh, dockerfile with private repo that the user see.
trigger_config = {'dockerfile': 'FROM localhost:5000/devtable/complex'}
analyze_json = self.postJsonResponse(BuildTriggerAnalyze,
params=dict(repository=ADMIN_ACCESS_USER + '/simple', trigger_uuid=trigger.uuid),
data={'config': trigger_config})
self.assertEquals('analyzed', analyze_json['status'])
self.assertEquals('devtable', analyze_json['namespace'])
self.assertEquals('complex', analyze_json['name'])
self.assertEquals(False, analyze_json['is_public'])
self.assertEquals(ADMIN_ACCESS_USER + '+dtrobot', analyze_json['robots'][0]['name'])
def test_fake_trigger(self):
self.login(ADMIN_ACCESS_USER)

36
tools/renderinvoice.py Normal file
View file

@ -0,0 +1,36 @@
from app import stripe
from app import app
from util.invoice import renderInvoiceToPdf
from data import model
import argparse
from flask import Flask, current_app
from flask_mail import Mail
def sendInvoice(invoice_id):
invoice = stripe.Invoice.retrieve(invoice_id)
if not invoice['customer']:
print 'No customer found'
return
customer_id = invoice['customer']
user = model.get_user_or_org_by_customer_id(customer_id)
if not user:
print 'No user found for customer %s' % (customer_id)
return
with app.app_context():
file_data = renderInvoiceToPdf(invoice, user)
with open('invoice.pdf', 'wb') as f:
f.write(file_data)
print 'Invoice output as invoice.pdf'
parser = argparse.ArgumentParser(description='Generate an invoice')
parser.add_argument('invoice_id', help='The invoice ID')
args = parser.parse_args()
sendInvoice(args.invoice_id)

81
util/dockerfileparse.py Normal file
View file

@ -0,0 +1,81 @@
import re
LINE_CONTINUATION_REGEX = re.compile('\s*\\\s*\n')
COMMAND_REGEX = re.compile('([A-Z]+)\s(.*)')
COMMENT_CHARACTER = '#'
class ParsedDockerfile(object):
def __init__(self, commands):
self.commands = commands
def get_commands_of_kind(self, kind):
return [command for command in self.commands if command['command'] == kind]
def get_base_image(self):
image_and_tag = self.get_base_image_and_tag()
if not image_and_tag:
return None
return self.base_image_from_repo_identifier(image_and_tag)
@staticmethod
def base_image_from_repo_identifier(image_and_tag):
# Note:
# Dockerfile images references can be of multiple forms:
# server:port/some/path
# somepath
# server/some/path
# server/some/path:tag
# server:port/some/path:tag
parts = image_and_tag.strip().split(':')
if len(parts) == 1:
# somepath
return parts[0]
# Otherwise, determine if the last part is a port
# or a tag.
if parts[-1].find('/') >= 0:
# Last part is part of the hostname.
return image_and_tag
# Remaining cases:
# server/some/path:tag
# server:port/some/path:tag
return ':'.join(parts[0:-1])
def get_base_image_and_tag(self):
from_commands = self.get_commands_of_kind('FROM')
if not from_commands:
return None
return from_commands[-1]['parameters']
def strip_comments(contents):
lines = [line for line in contents.split('\n') if not line.startswith(COMMENT_CHARACTER)]
return '\n'.join(lines)
def join_continued_lines(contents):
return LINE_CONTINUATION_REGEX.sub('', contents)
def parse_dockerfile(contents):
contents = join_continued_lines(strip_comments(contents))
lines = [line for line in contents.split('\n') if len(line) > 0]
commands = []
for line in lines:
match_command = COMMAND_REGEX.match(line)
if match_command:
command = match_command.group(1)
parameters = match_command.group(2)
commands.append({
'command': command,
'parameters': parameters
})
return ParsedDockerfile(commands)

View file

@ -10,10 +10,16 @@ def validate_email(email_address):
def validate_username(username):
# Minimum length of 2, maximum length of 255, no url unsafe characters
return (re.search(r'[^a-z0-9_]', username) is None and
len(username) >= 4 and
len(username) <= 30)
# Based off the restrictions defined in the Docker Registry API spec
regex_match = (re.search(r'[^a-z0-9_]', username) is None)
if not regex_match:
return (False, 'Username must match expression [a-z0-9_]+')
length_match = (len(username) >= 4 and len(username) <= 30)
if not length_match:
return (False, 'Username must be between 4 and 30 characters in length')
return (True, '')
def validate_password(password):

View file

@ -14,12 +14,14 @@ from zipfile import ZipFile
from functools import partial
from datetime import datetime, timedelta
from threading import Event
from uuid import uuid4
from data.queue import dockerfile_build_queue
from data import model
from workers.worker import Worker
from app import app, userfiles as user_files
from util.safetar import safe_extractall
from util.dockerfileparse import parse_dockerfile, ParsedDockerfile
root_logger = logging.getLogger('')
@ -33,6 +35,7 @@ logger = logging.getLogger(__name__)
build_logs = app.config['BUILDLOGS']
TIMEOUT_PERIOD_MINUTES = 20
CACHE_EXPIRATION_PERIOD_HOURS = 24
class StatusWrapper(object):
@ -94,6 +97,9 @@ class StreamingDockerClient(Client):
class DockerfileBuildContext(object):
image_id_to_cache_time = {}
private_repo_tags = set()
def __init__(self, build_context_dir, dockerfile_subdir, repo, tag_names,
push_token, build_uuid, pull_credentials=None):
self._build_dir = build_context_dir
@ -104,6 +110,7 @@ class DockerfileBuildContext(object):
self._status = StatusWrapper(build_uuid)
self._build_logger = partial(build_logs.append_log_message, build_uuid)
self._pull_credentials = pull_credentials
self._public_repos = set()
# Note: We have two different clients here because we (potentially) login
# with both, but with different credentials that we do not want shared between
@ -113,29 +120,27 @@ class DockerfileBuildContext(object):
dockerfile_path = os.path.join(self._build_dir, dockerfile_subdir,
'Dockerfile')
self._num_steps = DockerfileBuildContext.__count_steps(dockerfile_path)
# Compute the number of steps
with open(dockerfile_path, 'r') as dockerfileobj:
self._parsed_dockerfile = parse_dockerfile(dockerfileobj.read())
self._num_steps = len(self._parsed_dockerfile.commands)
logger.debug('Will build and push to repo %s with tags named: %s' %
(self._repo, self._tag_names))
def __enter__(self):
self.__cleanup_containers()
self.__evict_expired_images()
self.__cleanup()
return self
def __exit__(self, exc_type, value, traceback):
self.__cleanup_containers()
self.__cleanup()
shutil.rmtree(self._build_dir)
@staticmethod
def __count_steps(dockerfile_path):
with open(dockerfile_path, 'r') as dockerfileobj:
steps = 0
for line in dockerfileobj.readlines():
stripped = line.strip()
if stripped and stripped[0] is not '#':
steps += 1
return steps
@staticmethod
def __total_completion(statuses, total_images):
percentage_with_sizes = float(len(statuses.values()))/total_images
@ -151,6 +156,11 @@ class DockerfileBuildContext(object):
self._build_cl.login(self._pull_credentials['username'], self._pull_credentials['password'],
registry=self._pull_credentials['registry'], reauth=True)
# Pull the image, in case it was updated since the last build
base_image = self._parsed_dockerfile.get_base_image()
self._build_logger('Pulling base image: %s' % base_image)
self._build_cl.pull(base_image)
# Start the build itself.
logger.debug('Starting build.')
@ -260,7 +270,39 @@ class DockerfileBuildContext(object):
raise RuntimeError(message)
def __cleanup(self):
def __is_repo_public(self, repo_name):
if repo_name in self._public_repos:
return True
repo_portions = repo_name.split('/')
registry_hostname = 'index.docker.io'
local_repo_name = repo_name
if len(repo_portions) > 2:
registry_hostname = repo_portions[0]
local_repo_name = '/'.join(repo_portions[1:])
repo_url_template = '%s://%s/v1/repositories/%s/images'
protocols = ['https', 'http']
secure_repo_url, repo_url = [repo_url_template % (protocol, registry_hostname, local_repo_name)
for protocol in protocols]
try:
try:
repo_info = requests.get(secure_repo_url)
except requests.exceptions.SSLError:
repo_info = requests.get(repo_url)
except requests.exceptions.ConnectionError:
return False
if repo_info.status_code / 100 == 2:
self._public_repos.add(repo_name)
return True
else:
return False
def __cleanup_containers(self):
# First clean up any containers that might be holding the images
for running in self._build_cl.containers(quiet=True):
logger.debug('Killing container: %s' % running['Id'])
@ -271,40 +313,62 @@ class DockerfileBuildContext(object):
logger.debug('Removing container: %s' % container['Id'])
self._build_cl.remove_container(container['Id'])
# Iterate all of the images and remove the ones that the public registry
# doesn't know about, this should preserve base images.
images_to_remove = set()
repos = set()
def __evict_expired_images(self):
logger.debug('Cleaning images older than %s hours.', CACHE_EXPIRATION_PERIOD_HOURS)
now = datetime.now()
verify_removed = set()
for image in self._build_cl.images():
images_to_remove.add(image['Id'])
image_id = image[u'Id']
created = datetime.fromtimestamp(image[u'Created'])
# If we don't have a cache time, use the created time (e.g. worker reboot)
cache_time = self.image_id_to_cache_time.get(image_id, created)
expiration = cache_time + timedelta(hours=CACHE_EXPIRATION_PERIOD_HOURS)
if expiration < now:
logger.debug('Removing expired image: %s' % image_id)
for tag in image['RepoTags']:
tag_repo = tag.split(':')[0]
if tag_repo != '<none>':
repos.add(tag_repo)
# We can forget about this particular tag if it was indeed one of our renamed tags
self.private_repo_tags.discard(tag)
for repo in repos:
repo_url = 'https://index.docker.io/v1/repositories/%s/images' % repo
repo_info = requests.get(repo_url)
if repo_info.status_code / 100 == 2:
for repo_image in repo_info.json():
if repo_image['id'] in images_to_remove:
logger.debug('Image was deemed public: %s' % repo_image['id'])
images_to_remove.remove(repo_image['id'])
for to_remove in images_to_remove:
logger.debug('Removing private image: %s' % to_remove)
verify_removed.add(image_id)
try:
self._build_cl.remove_image(to_remove)
self._build_cl.remove_image(image_id)
except APIError:
# Sometimes an upstream image removed this one
pass
# Verify that our images were actually removed
for image in self._build_cl.images():
if image['Id'] in images_to_remove:
if image['Id'] in verify_removed:
raise RuntimeError('Image was not removed: %s' % image['Id'])
def __cleanup(self):
# Iterate all of the images and rename the ones that aren't public. This should preserve
# base images and also allow the cache to function.
now = datetime.now()
for image in self._build_cl.images():
image_id = image[u'Id']
if image_id not in self.image_id_to_cache_time:
logger.debug('Setting image %s cache time to %s', image_id, now)
self.image_id_to_cache_time[image_id] = now
for tag in image['RepoTags']:
tag_repo = ParsedDockerfile.base_image_from_repo_identifier(tag)
if tag_repo != '<none>':
if tag_repo in self.private_repo_tags:
logger.debug('Repo is private and has already been renamed: %s' % tag_repo)
elif self.__is_repo_public(tag_repo):
logger.debug('Repo was deemed public: %s', tag_repo)
else:
new_name = str(uuid4())
logger.debug('Private repo tag being renamed %s -> %s', tag, new_name)
self._build_cl.tag(image_id, new_name)
self._build_cl.remove_image(tag)
self.private_repo_tags.add(new_name)
class DockerfileBuildWorker(Worker):
def __init__(self, *vargs, **kwargs):
@ -317,6 +381,7 @@ class DockerfileBuildWorker(Worker):
'application/octet-stream': DockerfileBuildWorker.__prepare_dockerfile,
'application/x-tar': DockerfileBuildWorker.__prepare_tarball,
'application/gzip': DockerfileBuildWorker.__prepare_tarball,
'application/x-gzip': DockerfileBuildWorker.__prepare_tarball,
}
self._timeout = Event()
@ -327,7 +392,7 @@ class DockerfileBuildWorker(Worker):
# Save the zip file to temp somewhere
with TemporaryFile() as zip_file:
zip_file.write(request_file.raw)
zip_file.write(request_file.content)
to_extract = ZipFile(zip_file)
to_extract.extractall(build_dir)