Merge branch 'master' into comewithmeifyouwanttowork

This commit is contained in:
Joseph Schorr 2014-08-28 20:50:13 -04:00
commit 3b72b26836
62 changed files with 923 additions and 714 deletions

View file

@ -1,11 +1,11 @@
conf/stack conf/stack
screenshots screenshots
tools
test/data/registry test/data/registry
venv venv
.git .git
.gitignore .gitignore
Bobfile Bobfile
README.md README.md
license.py
requirements-nover.txt requirements-nover.txt
run-local.sh run-local.sh

View file

@ -4,10 +4,10 @@ ENV DEBIAN_FRONTEND noninteractive
ENV HOME /root ENV HOME /root
# Install the dependencies. # Install the dependencies.
RUN apt-get update # 06AUG2014 RUN apt-get update # 21AUG2014
# New ubuntu packages should be added as their own apt-get install lines below the existing install commands # New ubuntu packages should be added as their own apt-get install lines below the existing install commands
RUN apt-get install -y git python-virtualenv python-dev libjpeg8 libjpeg62-dev libevent-dev gdebi-core g++ libmagic1 phantomjs nodejs npm libldap2-dev libsasl2-dev RUN apt-get install -y git python-virtualenv python-dev libjpeg8 libjpeg62-dev libevent-dev gdebi-core g++ libmagic1 phantomjs nodejs npm libldap2-dev libsasl2-dev libpq-dev
# Build the python dependencies # Build the python dependencies
ADD requirements.txt requirements.txt ADD requirements.txt requirements.txt

View file

@ -4,10 +4,10 @@ ENV DEBIAN_FRONTEND noninteractive
ENV HOME /root ENV HOME /root
# Install the dependencies. # Install the dependencies.
RUN apt-get update # 06AUG2014 RUN apt-get update # 21AUG2014
# New ubuntu packages should be added as their own apt-get install lines below the existing install commands # New ubuntu packages should be added as their own apt-get install lines below the existing install commands
RUN apt-get install -y git python-virtualenv python-dev libjpeg8 libjpeg62-dev libevent-dev gdebi-core g++ libmagic1 phantomjs nodejs npm libldap2-dev libsasl2-dev RUN apt-get install -y git python-virtualenv python-dev libjpeg8 libjpeg62-dev libevent-dev gdebi-core g++ libmagic1 phantomjs nodejs npm libldap2-dev libsasl2-dev libpq-dev
# Build the python dependencies # Build the python dependencies
ADD requirements.txt requirements.txt ADD requirements.txt requirements.txt
@ -30,6 +30,7 @@ RUN cd grunt && npm install
RUN cd grunt && grunt RUN cd grunt && grunt
ADD conf/init/svlogd_config /svlogd_config ADD conf/init/svlogd_config /svlogd_config
ADD conf/init/doupdatelimits.sh /etc/my_init.d/
ADD conf/init/preplogsdir.sh /etc/my_init.d/ ADD conf/init/preplogsdir.sh /etc/my_init.d/
ADD conf/init/runmigration.sh /etc/my_init.d/ ADD conf/init/runmigration.sh /etc/my_init.d/
@ -38,9 +39,6 @@ ADD conf/init/nginx /etc/service/nginx
ADD conf/init/diffsworker /etc/service/diffsworker ADD conf/init/diffsworker /etc/service/diffsworker
ADD conf/init/notificationworker /etc/service/notificationworker ADD conf/init/notificationworker /etc/service/notificationworker
# TODO: Remove this after the prod CL push
ADD conf/init/webhookworker /etc/service/webhookworker
# Download any external libs. # Download any external libs.
RUN mkdir static/fonts static/ldn RUN mkdir static/fonts static/ldn
RUN venv/bin/python -m external_libraries RUN venv/bin/python -m external_libraries

52
app.py
View file

@ -1,8 +1,9 @@
import logging import logging
import os import os
import json import json
import yaml
from flask import Flask from flask import Flask as BaseFlask, Config as BaseConfig
from flask.ext.principal import Principal from flask.ext.principal import Principal
from flask.ext.login import LoginManager from flask.ext.login import LoginManager
from flask.ext.mail import Mail from flask.ext.mail import Mail
@ -21,11 +22,37 @@ from data.billing import Billing
from data.buildlogs import BuildLogs from data.buildlogs import BuildLogs
from data.queue import WorkQueue from data.queue import WorkQueue
from data.userevent import UserEventsBuilderModule from data.userevent import UserEventsBuilderModule
from license import load_license
from datetime import datetime from datetime import datetime
OVERRIDE_CONFIG_FILENAME = 'conf/stack/config.py' class Config(BaseConfig):
""" Flask config enhanced with a `from_yamlfile` method """
def from_yamlfile(self, config_file):
with open(config_file) as f:
c = yaml.load(f)
if not c:
logger.debug('Empty YAML config file')
return
if isinstance(c, str):
raise Exception('Invalid YAML config file: ' + str(c))
for key in c.iterkeys():
if key.isupper():
self[key] = c[key]
class Flask(BaseFlask):
""" Extends the Flask class to implement our custom Config class. """
def make_config(self, instance_relative=False):
root_path = self.instance_path if instance_relative else self.root_path
return Config(root_path, self.default_config)
OVERRIDE_CONFIG_YAML_FILENAME = 'conf/stack/config.yaml'
OVERRIDE_CONFIG_PY_FILENAME = 'conf/stack/config.py'
OVERRIDE_CONFIG_KEY = 'QUAY_OVERRIDE_CONFIG' OVERRIDE_CONFIG_KEY = 'QUAY_OVERRIDE_CONFIG'
LICENSE_FILENAME = 'conf/stack/license.enc' LICENSE_FILENAME = 'conf/stack/license.enc'
@ -43,22 +70,17 @@ else:
logger.debug('Loading default config.') logger.debug('Loading default config.')
app.config.from_object(DefaultConfig()) app.config.from_object(DefaultConfig())
if os.path.exists(OVERRIDE_CONFIG_FILENAME): if os.path.exists(OVERRIDE_CONFIG_PY_FILENAME):
logger.debug('Applying config file: %s', OVERRIDE_CONFIG_FILENAME) logger.debug('Applying config file: %s', OVERRIDE_CONFIG_PY_FILENAME)
app.config.from_pyfile(OVERRIDE_CONFIG_FILENAME) app.config.from_pyfile(OVERRIDE_CONFIG_PY_FILENAME)
if os.path.exists(OVERRIDE_CONFIG_YAML_FILENAME):
logger.debug('Applying config file: %s', OVERRIDE_CONFIG_YAML_FILENAME)
app.config.from_yamlfile(OVERRIDE_CONFIG_YAML_FILENAME)
environ_config = json.loads(os.environ.get(OVERRIDE_CONFIG_KEY, '{}')) environ_config = json.loads(os.environ.get(OVERRIDE_CONFIG_KEY, '{}'))
app.config.update(environ_config) app.config.update(environ_config)
logger.debug('Applying license config from: %s', LICENSE_FILENAME)
try:
app.config.update(load_license(LICENSE_FILENAME))
except IOError:
raise RuntimeError('License file %s not found; please check your configuration' % LICENSE_FILENAME)
if app.config.get('LICENSE_EXPIRATION', datetime.min) < datetime.utcnow():
raise RuntimeError('License has expired, please contact support@quay.io')
features.import_features(app.config) features.import_features(app.config)
Principal(app, use_sessions=False) Principal(app, use_sessions=False)

5
conf/init/doupdatelimits.sh Executable file
View file

@ -0,0 +1,5 @@
#! /bin/bash
set -e
# Update the connection limit
sysctl -w net.core.somaxconn=1024

View file

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

View file

@ -1,8 +0,0 @@
#! /bin/bash
echo 'Starting webhook worker'
cd /
venv/bin/python -m workers.webhookworker
echo 'Webhook worker exited'

View file

@ -1,4 +1,4 @@
client_max_body_size 8G; client_max_body_size 20G;
client_body_temp_path /var/log/nginx/client_body 1 2; client_body_temp_path /var/log/nginx/client_body 1 2;
server_name _; server_name _;

View file

@ -153,6 +153,9 @@ class DefaultConfig(object):
# Feature Flag: Whether to support GitHub build triggers. # Feature Flag: Whether to support GitHub build triggers.
FEATURE_GITHUB_BUILD = False FEATURE_GITHUB_BUILD = False
# Feature Flag: Dockerfile build support.
FEATURE_BUILD_SUPPORT = True
DISTRIBUTED_STORAGE_CONFIG = { DISTRIBUTED_STORAGE_CONFIG = {
'local_eu': ['LocalStorage', {'storage_path': 'test/data/registry/eu'}], 'local_eu': ['LocalStorage', {'storage_path': 'test/data/registry/eu'}],
'local_us': ['LocalStorage', {'storage_path': 'test/data/registry/us'}], 'local_us': ['LocalStorage', {'storage_path': 'test/data/registry/us'}],

View file

@ -17,6 +17,8 @@ SCHEME_DRIVERS = {
'mysql': MySQLDatabase, 'mysql': MySQLDatabase,
'mysql+pymysql': MySQLDatabase, 'mysql+pymysql': MySQLDatabase,
'sqlite': SqliteDatabase, 'sqlite': SqliteDatabase,
'postgresql': PostgresqlDatabase,
'postgresql+psycopg2': PostgresqlDatabase,
} }
db = Proxy() db = Proxy()
@ -32,7 +34,7 @@ def _db_from_url(url, db_kwargs):
if parsed_url.username: if parsed_url.username:
db_kwargs['user'] = parsed_url.username db_kwargs['user'] = parsed_url.username
if parsed_url.password: if parsed_url.password:
db_kwargs['passwd'] = parsed_url.password db_kwargs['password'] = parsed_url.password
return SCHEME_DRIVERS[parsed_url.drivername](parsed_url.database, **db_kwargs) return SCHEME_DRIVERS[parsed_url.drivername](parsed_url.database, **db_kwargs)

View file

@ -0,0 +1,37 @@
"""add log kind for regenerating robot tokens
Revision ID: 43e943c0639f
Revises: 82297d834ad
Create Date: 2014-08-25 17:14:42.784518
"""
# revision identifiers, used by Alembic.
revision = '43e943c0639f'
down_revision = '82297d834ad'
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
from data.model.sqlalchemybridge import gen_sqlalchemy_metadata
from data.database import all_models
def upgrade():
schema = gen_sqlalchemy_metadata(all_models)
op.bulk_insert(schema.tables['logentrykind'],
[
{'id': 41, 'name':'regenerate_robot_token'},
])
def downgrade():
schema = gen_sqlalchemy_metadata(all_models)
logentrykind = schema.tables['logentrykind']
op.execute(
(logentrykind.delete()
.where(logentrykind.c.name == op.inline_literal('regenerate_robot_token')))
)

View file

@ -20,12 +20,12 @@ def get_id(query):
def upgrade(): def upgrade():
conn = op.get_bind() conn = op.get_bind()
event_id = get_id('Select id From externalnotificationevent Where name="repo_push" Limit 1') event_id = get_id('Select id From externalnotificationevent Where name=\'repo_push\' Limit 1')
method_id = get_id('Select id From externalnotificationmethod Where name="webhook" Limit 1') method_id = get_id('Select id From externalnotificationmethod Where name=\'webhook\' Limit 1')
conn.execute('Insert Into repositorynotification (uuid, repository_id, event_id, method_id, config_json) Select public_id, repository_id, %s, %s, parameters FROM webhook' % (event_id, method_id)) conn.execute('Insert Into repositorynotification (uuid, repository_id, event_id, method_id, config_json) Select public_id, repository_id, %s, %s, parameters FROM webhook' % (event_id, method_id))
def downgrade(): def downgrade():
conn = op.get_bind() conn = op.get_bind()
event_id = get_id('Select id From externalnotificationevent Where name="repo_push" Limit 1') event_id = get_id('Select id From externalnotificationevent Where name=\'repo_push\' Limit 1')
method_id = get_id('Select id From externalnotificationmethod Where name="webhook" Limit 1') method_id = get_id('Select id From externalnotificationmethod Where name=\'webhook\' Limit 1')
conn.execute('Insert Into webhook (public_id, repository_id, parameters) Select uuid, repository_id, config_json FROM repositorynotification Where event_id=%s And method_id=%s' % (event_id, method_id)) conn.execute('Insert Into webhook (public_id, repository_id, parameters) Select uuid, repository_id, config_json FROM repositorynotification Where event_id=%s And method_id=%s' % (event_id, method_id))

View file

@ -203,7 +203,7 @@ def upgrade():
sa.Column('id', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False), sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('service_id', sa.Integer(), nullable=False), sa.Column('service_id', sa.Integer(), nullable=False),
sa.Column('service_ident', sa.String(length=255, collation='utf8_general_ci'), nullable=False), sa.Column('service_ident', sa.String(length=255), nullable=False),
sa.ForeignKeyConstraint(['service_id'], ['loginservice.id'], ), sa.ForeignKeyConstraint(['service_id'], ['loginservice.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
@ -375,7 +375,7 @@ def upgrade():
sa.Column('command', sa.Text(), nullable=True), sa.Column('command', sa.Text(), nullable=True),
sa.Column('repository_id', sa.Integer(), nullable=False), sa.Column('repository_id', sa.Integer(), nullable=False),
sa.Column('image_size', sa.BigInteger(), nullable=True), sa.Column('image_size', sa.BigInteger(), nullable=True),
sa.Column('ancestors', sa.String(length=60535, collation='latin1_swedish_ci'), nullable=True), sa.Column('ancestors', sa.String(length=60535), nullable=True),
sa.Column('storage_id', sa.Integer(), nullable=True), sa.Column('storage_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], ), sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], ),
sa.ForeignKeyConstraint(['storage_id'], ['imagestorage.id'], ), sa.ForeignKeyConstraint(['storage_id'], ['imagestorage.id'], ),

View file

@ -76,8 +76,7 @@ class UserAlreadyInTeam(DataModelException):
def is_create_user_allowed(): def is_create_user_allowed():
return get_active_user_count() < config.app_config['LICENSE_USER_LIMIT'] return True
def create_user(username, password, email): def create_user(username, password, email):
""" Creates a regular user, if allowed. """ """ Creates a regular user, if allowed. """
@ -188,6 +187,19 @@ def create_robot(robot_shortname, parent):
except Exception as ex: except Exception as ex:
raise DataModelException(ex.message) raise DataModelException(ex.message)
def get_robot(robot_shortname, parent):
robot_username = format_robot_username(parent.username, robot_shortname)
robot = lookup_robot(robot_username)
if not robot:
msg = ('Could not find robot with username: %s' %
robot_username)
raise InvalidRobotException(msg)
service = LoginService.get(name='quayrobot')
login = FederatedLogin.get(FederatedLogin.user == robot, FederatedLogin.service == service)
return robot, login.service_ident
def lookup_robot(robot_username): def lookup_robot(robot_username):
joined = User.select().join(FederatedLogin).join(LoginService) joined = User.select().join(FederatedLogin).join(LoginService)
@ -198,7 +210,6 @@ def lookup_robot(robot_username):
return found[0] return found[0]
def verify_robot(robot_username, password): def verify_robot(robot_username, password):
joined = User.select().join(FederatedLogin).join(LoginService) joined = User.select().join(FederatedLogin).join(LoginService)
found = list(joined.where(FederatedLogin.service_ident == password, found = list(joined.where(FederatedLogin.service_ident == password,
@ -211,6 +222,25 @@ def verify_robot(robot_username, password):
return found[0] return found[0]
def regenerate_robot_token(robot_shortname, parent):
robot_username = format_robot_username(parent.username, robot_shortname)
robot = lookup_robot(robot_username)
if not robot:
raise InvalidRobotException('Could not find robot with username: %s' %
robot_username)
password = random_string_generator(length=64)()
robot.email = password
service = LoginService.get(name='quayrobot')
login = FederatedLogin.get(FederatedLogin.user == robot, FederatedLogin.service == service)
login.service_ident = password
login.save()
robot.save()
return robot, password
def delete_robot(robot_username): def delete_robot(robot_username):
try: try:
@ -872,6 +902,34 @@ def get_all_repo_users(namespace_name, repository_name):
Repository.name == repository_name) Repository.name == repository_name)
def get_all_repo_users_transitive_via_teams(namespace_name, repository_name):
select = User.select().distinct()
with_team_member = select.join(TeamMember)
with_team = with_team_member.join(Team)
with_perm = with_team.join(RepositoryPermission)
with_repo = with_perm.join(Repository)
return with_repo.where(Repository.namespace == namespace_name,
Repository.name == repository_name)
def get_all_repo_users_transitive(namespace_name, repository_name):
# Load the users found via teams and directly via permissions.
via_teams = get_all_repo_users_transitive_via_teams(namespace_name, repository_name)
directly = [perm.user for perm in get_all_repo_users(namespace_name, repository_name)]
# Filter duplicates.
user_set = set()
def check_add(u):
if u.username in user_set:
return False
user_set.add(u.username)
return True
return [user for user in list(directly) + list(via_teams) if check_add(user)]
def get_repository_for_resource(resource_key): def get_repository_for_resource(resource_key):
try: try:
return (Repository return (Repository
@ -1706,19 +1764,20 @@ def create_notification(kind_name, target, metadata={}):
def create_unique_notification(kind_name, target, metadata={}): def create_unique_notification(kind_name, target, metadata={}):
with config.app_config['DB_TRANSACTION_FACTORY'](db): with config.app_config['DB_TRANSACTION_FACTORY'](db):
if list_notifications(target, kind_name).count() == 0: if list_notifications(target, kind_name, limit=1).count() == 0:
create_notification(kind_name, target, metadata) create_notification(kind_name, target, metadata)
def lookup_notification(user, uuid): def lookup_notification(user, uuid):
results = list(list_notifications(user, id_filter=uuid, include_dismissed=True)) results = list(list_notifications(user, id_filter=uuid, include_dismissed=True, limit=1))
if not results: if not results:
return None return None
return results[0] return results[0]
def list_notifications(user, kind_name=None, id_filter=None, include_dismissed=False): def list_notifications(user, kind_name=None, id_filter=None, include_dismissed=False,
page=None, limit=None):
Org = User.alias() Org = User.alias()
AdminTeam = Team.alias() AdminTeam = Team.alias()
AdminTeamMember = TeamMember.alias() AdminTeamMember = TeamMember.alias()
@ -1756,6 +1815,11 @@ def list_notifications(user, kind_name=None, id_filter=None, include_dismissed=F
.switch(Notification) .switch(Notification)
.where(Notification.uuid == id_filter)) .where(Notification.uuid == id_filter))
if page:
query = query.paginate(page, limit)
elif limit:
query = query.limit(limit)
return query return query

View file

@ -4,7 +4,7 @@ from flask import request
from app import billing from app import billing
from endpoints.api import (resource, nickname, ApiResource, validate_json_request, log_action, from endpoints.api import (resource, nickname, ApiResource, validate_json_request, log_action,
related_user_resource, internal_only, Unauthorized, NotFound, related_user_resource, internal_only, Unauthorized, NotFound,
require_user_admin, show_if, hide_if) require_user_admin, show_if, hide_if, abort)
from endpoints.api.subscribe import subscribe, subscription_view from endpoints.api.subscribe import subscribe, subscription_view
from auth.permissions import AdministerOrganizationPermission from auth.permissions import AdministerOrganizationPermission
from auth.auth_context import get_authenticated_user from auth.auth_context import get_authenticated_user
@ -23,7 +23,11 @@ def get_card(user):
} }
if user.stripe_id: if user.stripe_id:
try:
cus = billing.Customer.retrieve(user.stripe_id) cus = billing.Customer.retrieve(user.stripe_id)
except stripe.APIConnectionError as e:
abort(503, message='Cannot contact Stripe')
if cus and cus.default_card: if cus and cus.default_card:
# Find the default card. # Find the default card.
default_card = None default_card = None
@ -46,7 +50,11 @@ def get_card(user):
def set_card(user, token): def set_card(user, token):
if user.stripe_id: if user.stripe_id:
try:
cus = billing.Customer.retrieve(user.stripe_id) cus = billing.Customer.retrieve(user.stripe_id)
except stripe.APIConnectionError as e:
abort(503, message='Cannot contact Stripe')
if cus: if cus:
try: try:
cus.card = token cus.card = token
@ -55,6 +63,8 @@ def set_card(user, token):
return carderror_response(exc) return carderror_response(exc)
except stripe.InvalidRequestError as exc: except stripe.InvalidRequestError as exc:
return carderror_response(exc) return carderror_response(exc)
except stripe.APIConnectionError as e:
return carderror_response(e)
return get_card(user) return get_card(user)
@ -75,7 +85,11 @@ def get_invoices(customer_id):
'plan': i.lines.data[0].plan.id if i.lines.data[0].plan else None 'plan': i.lines.data[0].plan.id if i.lines.data[0].plan else None
} }
try:
invoices = billing.Invoice.all(customer=customer_id, count=12) invoices = billing.Invoice.all(customer=customer_id, count=12)
except stripe.APIConnectionError as e:
abort(503, message='Cannot contact Stripe')
return { return {
'invoices': [invoice_view(i) for i in invoices.data] 'invoices': [invoice_view(i) for i in invoices.data]
} }
@ -228,7 +242,10 @@ class UserPlan(ApiResource):
private_repos = model.get_private_repo_count(user.username) private_repos = model.get_private_repo_count(user.username)
if user.stripe_id: if user.stripe_id:
try:
cus = billing.Customer.retrieve(user.stripe_id) cus = billing.Customer.retrieve(user.stripe_id)
except stripe.APIConnectionError as e:
abort(503, message='Cannot contact Stripe')
if cus.subscription: if cus.subscription:
return subscription_view(cus.subscription, private_repos) return subscription_view(cus.subscription, private_repos)
@ -291,7 +308,10 @@ class OrganizationPlan(ApiResource):
private_repos = model.get_private_repo_count(orgname) private_repos = model.get_private_repo_count(orgname)
organization = model.get_organization(orgname) organization = model.get_organization(orgname)
if organization.stripe_id: if organization.stripe_id:
try:
cus = billing.Customer.retrieve(organization.stripe_id) cus = billing.Customer.retrieve(organization.stripe_id)
except stripe.APIConnectionError as e:
abort(503, message='Cannot contact Stripe')
if cus.subscription: if cus.subscription:
return subscription_view(cus.subscription, private_repos) return subscription_view(cus.subscription, private_repos)

View file

@ -35,6 +35,14 @@ class UserRobotList(ApiResource):
@internal_only @internal_only
class UserRobot(ApiResource): class UserRobot(ApiResource):
""" Resource for managing a user's robots. """ """ Resource for managing a user's robots. """
@require_user_admin
@nickname('getUserRobot')
def get(self, robot_shortname):
""" Returns the user's robot with the specified name. """
parent = get_authenticated_user()
robot, password = model.get_robot(robot_shortname, parent)
return robot_view(robot.username, password)
@require_user_admin @require_user_admin
@nickname('createUserRobot') @nickname('createUserRobot')
def put(self, robot_shortname): def put(self, robot_shortname):
@ -79,6 +87,18 @@ class OrgRobotList(ApiResource):
@related_user_resource(UserRobot) @related_user_resource(UserRobot)
class OrgRobot(ApiResource): class OrgRobot(ApiResource):
""" Resource for managing an organization's robots. """ """ Resource for managing an organization's robots. """
@require_scope(scopes.ORG_ADMIN)
@nickname('getOrgRobot')
def get(self, orgname, robot_shortname):
""" Returns the organization's robot with the specified name. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
parent = model.get_organization(orgname)
robot, password = model.get_robot(robot_shortname, parent)
return robot_view(robot.username, password)
raise Unauthorized()
@require_scope(scopes.ORG_ADMIN) @require_scope(scopes.ORG_ADMIN)
@nickname('createOrgRobot') @nickname('createOrgRobot')
def put(self, orgname, robot_shortname): def put(self, orgname, robot_shortname):
@ -103,3 +123,38 @@ class OrgRobot(ApiResource):
return 'Deleted', 204 return 'Deleted', 204
raise Unauthorized() raise Unauthorized()
@resource('/v1/user/robots/<robot_shortname>/regenerate')
@path_param('robot_shortname', 'The short name for the robot, without any user or organization prefix')
@internal_only
class RegenerateUserRobot(ApiResource):
""" Resource for regenerate an organization's robot's token. """
@require_user_admin
@nickname('regenerateUserRobotToken')
def post(self, robot_shortname):
""" Regenerates the token for a user's robot. """
parent = get_authenticated_user()
robot, password = model.regenerate_robot_token(robot_shortname, parent)
log_action('regenerate_robot_token', parent.username, {'robot': robot_shortname})
return robot_view(robot.username, password)
@resource('/v1/organization/<orgname>/robots/<robot_shortname>/regenerate')
@path_param('orgname', 'The name of the organization')
@path_param('robot_shortname', 'The short name for the robot, without any user or organization prefix')
@related_user_resource(RegenerateUserRobot)
class RegenerateOrgRobot(ApiResource):
""" Resource for regenerate an organization's robot's token. """
@require_scope(scopes.ORG_ADMIN)
@nickname('regenerateOrgRobotToken')
def post(self, orgname, robot_shortname):
""" Regenerates the token for an organization robot. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
parent = model.get_organization(orgname)
robot, password = model.regenerate_robot_token(robot_shortname, parent)
log_action('regenerate_robot_token', orgname, {'robot': robot_shortname})
return robot_view(robot.username, password)
raise Unauthorized()

View file

@ -15,6 +15,9 @@ logger = logging.getLogger(__name__)
def carderror_response(exc): def carderror_response(exc):
return {'carderror': exc.message}, 402 return {'carderror': exc.message}, 402
def connection_response(exc):
return {'message': 'Could not contact Stripe. Please try again.'}, 503
def subscription_view(stripe_subscription, used_repos): def subscription_view(stripe_subscription, used_repos):
view = { view = {
@ -74,19 +77,29 @@ def subscribe(user, plan, token, require_business_plan):
log_action('account_change_plan', user.username, {'plan': plan}) log_action('account_change_plan', user.username, {'plan': plan})
except stripe.CardError as e: except stripe.CardError as e:
return carderror_response(e) return carderror_response(e)
except stripe.APIConnectionError as e:
return connection_response(e)
response_json = subscription_view(cus.subscription, private_repos) response_json = subscription_view(cus.subscription, private_repos)
status_code = 201 status_code = 201
else: else:
# Change the plan # Change the plan
try:
cus = billing.Customer.retrieve(user.stripe_id) cus = billing.Customer.retrieve(user.stripe_id)
except stripe.APIConnectionError as e:
return connection_response(e)
if plan_found['price'] == 0: if plan_found['price'] == 0:
if cus.subscription is not None: if cus.subscription is not None:
# We only have to cancel the subscription if they actually have one # We only have to cancel the subscription if they actually have one
try:
cus.cancel_subscription() cus.cancel_subscription()
cus.save() cus.save()
except stripe.APIConnectionError as e:
return connection_response(e)
check_repository_usage(user, plan_found) check_repository_usage(user, plan_found)
log_action('account_change_plan', user.username, {'plan': plan}) log_action('account_change_plan', user.username, {'plan': plan})
@ -101,6 +114,8 @@ def subscribe(user, plan, token, require_business_plan):
cus.save() cus.save()
except stripe.CardError as e: except stripe.CardError as e:
return carderror_response(e) return carderror_response(e)
except stripe.APIConnectionError as e:
return connection_response(e)
response_json = subscription_view(cus.subscription, private_repos) response_json = subscription_view(cus.subscription, private_repos)
check_repository_usage(user, plan_found) check_repository_usage(user, plan_found)

View file

@ -42,24 +42,6 @@ class SuperUserLogs(ApiResource):
abort(403) abort(403)
@resource('/v1/superuser/seats')
@internal_only
@show_if(features.SUPER_USERS)
@hide_if(features.BILLING)
class SeatUsage(ApiResource):
""" Resource for managing the seats granted in the license for the system. """
@nickname('getSeatCount')
def get(self):
""" Returns the current number of seats being used in the system. """
if SuperUserPermission().can():
return {
'count': model.get_active_user_count(),
'allowed': app.config.get('LICENSE_USER_LIMIT', 0)
}
abort(403)
def user_view(user): def user_view(user):
return { return {
'username': user.username, 'username': user.username,

View file

@ -350,8 +350,8 @@ class BuildTriggerAnalyze(RepositoryParamResource):
(robot_namespace, shortname) = parse_robot_username(user.username) (robot_namespace, shortname) = parse_robot_username(user.username)
return AdministerOrganizationPermission(robot_namespace).can() return AdministerOrganizationPermission(robot_namespace).can()
repo_perms = model.get_all_repo_users(base_namespace, base_repository) repo_users = list(model.get_all_repo_users_transitive(base_namespace, base_repository))
read_robots = [robot_view(perm.user) for perm in repo_perms if is_valid_robot(perm.user)] read_robots = [robot_view(user) for user in repo_users if is_valid_robot(user)]
return { return {
'namespace': base_namespace, 'namespace': base_namespace,

View file

@ -7,8 +7,9 @@ from flask.ext.principal import identity_changed, AnonymousIdentity
from app import app, billing as stripe, authentication from app import app, billing as stripe, authentication
from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error, from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error,
log_action, internal_only, NotFound, require_user_admin, log_action, internal_only, NotFound, require_user_admin, parse_args,
InvalidToken, require_scope, format_date, hide_if, show_if, license_error) query_param, InvalidToken, require_scope, format_date, hide_if, show_if,
license_error)
from endpoints.api.subscribe import subscribe from endpoints.api.subscribe import subscribe
from endpoints.common import common_login from endpoints.common import common_login
from data import model from data import model
@ -403,11 +404,24 @@ class Recovery(ApiResource):
@internal_only @internal_only
class UserNotificationList(ApiResource): class UserNotificationList(ApiResource):
@require_user_admin @require_user_admin
@parse_args
@query_param('page', 'Offset page number. (int)', type=int, default=0)
@query_param('limit', 'Limit on the number of results (int)', type=int, default=5)
@nickname('listUserNotifications') @nickname('listUserNotifications')
def get(self): def get(self, args):
notifications = model.list_notifications(get_authenticated_user()) page = args['page']
limit = args['limit']
notifications = list(model.list_notifications(get_authenticated_user(), page=page, limit=limit + 1))
has_more = False
if len(notifications) > limit:
has_more = True
notifications = notifications[0:limit]
return { return {
'notifications': [notification_view(notification) for notification in notifications] 'notifications': [notification_view(notification) for notification in notifications],
'additional': has_more
} }

View file

@ -413,8 +413,39 @@ def put_repository_auth(namespace, repository):
@index.route('/search', methods=['GET']) @index.route('/search', methods=['GET'])
@process_auth
def get_search(): def get_search():
abort(501, 'Not Implemented', issue='not-implemented') def result_view(repo):
return {
"name": repo.namespace + '/' + repo.name,
"description": repo.description
}
query = request.args.get('q')
username = None
user = get_authenticated_user()
if user is not None:
username = user.username
if query:
matching = model.get_matching_repositories(query, username)
else:
matching = []
results = [result_view(repo) for repo in matching
if (repo.visibility.name == 'public' or
ReadRepositoryPermission(repo.namespace, repo.name).can())]
data = {
"query": query,
"num_results": len(results),
"results" : results
}
resp = make_response(json.dumps(data), 200)
resp.mimetype = 'application/json'
return resp
@index.route('/_ping') @index.route('/_ping')

View file

@ -184,6 +184,8 @@ class BuildFailureEvent(NotificationEvent):
return 'build_failure' return 'build_failure'
def get_sample_data(self, repository): def get_sample_data(self, repository):
build_uuid = 'fake-build-id'
return build_event_data(repository, { return build_event_data(repository, {
'build_id': build_uuid, 'build_id': build_uuid,
'build_name': 'some-fake-build', 'build_name': 'some-fake-build',

View file

@ -370,6 +370,7 @@ def generate_ancestry(image_id, uuid, locations, parent_id=None, parent_uuid=Non
if not parent_id: if not parent_id:
store.put_content(locations, store.image_ancestry_path(uuid), json.dumps([image_id])) store.put_content(locations, store.image_ancestry_path(uuid), json.dumps([image_id]))
return return
data = store.get_content(parent_locations, store.image_ancestry_path(parent_uuid)) data = store.get_content(parent_locations, store.image_ancestry_path(parent_uuid))
data = json.loads(data) data = json.loads(data)
data.insert(0, image_id) data.insert(0, image_id)
@ -470,8 +471,13 @@ def put_image_json(namespace, repository, image_id):
store.put_content(repo_image.storage.locations, json_path, request.data) store.put_content(repo_image.storage.locations, json_path, request.data)
profile.debug('Generating image ancestry') profile.debug('Generating image ancestry')
try:
generate_ancestry(image_id, uuid, repo_image.storage.locations, parent_id, parent_uuid, generate_ancestry(image_id, uuid, repo_image.storage.locations, parent_id, parent_uuid,
parent_locations) parent_locations)
except IOError as ioe:
profile.debug('Error when generating ancestry: %s' % ioe.message)
abort(404)
profile.debug('Done') profile.debug('Done')
return make_response('true', 200) return make_response('true', 200)

View file

@ -232,13 +232,15 @@ def initialize_database():
LogEntryKind.create(name='delete_application') LogEntryKind.create(name='delete_application')
LogEntryKind.create(name='reset_application_client_secret') LogEntryKind.create(name='reset_application_client_secret')
# Note: These are deprecated. # Note: These next two are deprecated.
LogEntryKind.create(name='add_repo_webhook') LogEntryKind.create(name='add_repo_webhook')
LogEntryKind.create(name='delete_repo_webhook') LogEntryKind.create(name='delete_repo_webhook')
LogEntryKind.create(name='add_repo_notification') LogEntryKind.create(name='add_repo_notification')
LogEntryKind.create(name='delete_repo_notification') LogEntryKind.create(name='delete_repo_notification')
LogEntryKind.create(name='regenerate_robot_token')
ImageStorageLocation.create(name='local_eu') ImageStorageLocation.create(name='local_eu')
ImageStorageLocation.create(name='local_us') ImageStorageLocation.create(name='local_us')

View file

@ -1,13 +0,0 @@
import pickle
from Crypto.PublicKey import RSA
n = 24311791124264168943780535074639421876317270880681911499019414944027362498498429776192966738844514582251884695124256895677070273097239290537016363098432785034818859765271229653729724078304186025013011992335454557504431888746007324285000011384941749613875855493086506022340155196030616409545906383713728780211095701026770053812741971198465120292345817928060114890913931047021503727972067476586739126160044293621653486418983183727572502888923949587290840425930251185737996066354726953382305020440374552871209809125535533731995494145421279907938079885061852265339259634996180877443852561265066616143910755505151318370667L
e = 65537L
def load_license(license_path):
decryptor = RSA.construct((n, e))
with open(license_path, 'rb') as encrypted_license:
decrypted_data = decryptor.encrypt(encrypted_license.read(), 0)
return pickle.loads(decrypted_data[0])

Binary file not shown.

View file

@ -32,5 +32,7 @@ raven
python-ldap python-ldap
pycrypto pycrypto
logentries logentries
psycopg2
pyyaml
git+https://github.com/DevTable/aniso8601-fake.git git+https://github.com/DevTable/aniso8601-fake.git
git+https://github.com/DevTable/anunidecode.git git+https://github.com/DevTable/anunidecode.git

View file

@ -12,6 +12,7 @@ Pillow==2.5.1
PyGithub==1.25.0 PyGithub==1.25.0
PyMySQL==0.6.2 PyMySQL==0.6.2
PyPDF2==1.22 PyPDF2==1.22
PyYAML==3.11
SQLAlchemy==0.9.7 SQLAlchemy==0.9.7
Werkzeug==0.9.6 Werkzeug==0.9.6
alembic==0.6.5 alembic==0.6.5
@ -44,6 +45,7 @@ python-dateutil==2.2
python-ldap==2.4.15 python-ldap==2.4.15
python-magic==0.4.6 python-magic==0.4.6
pytz==2014.4 pytz==2014.4
psycopg2==2.5.3
raven==5.0.0 raven==5.0.0
redis==2.10.1 redis==2.10.1
reportlab==2.7 reportlab==2.7

View file

@ -473,6 +473,22 @@ i.toggle-icon:hover {
.docker-auth-dialog .token-dialog-body .well { .docker-auth-dialog .token-dialog-body .well {
margin-bottom: 0px; margin-bottom: 0px;
position: relative;
padding-right: 24px;
}
.docker-auth-dialog .token-dialog-body .well i.fa-refresh {
position: absolute;
top: 9px;
right: 9px;
font-size: 20px;
color: gray;
transition: all 0.5s ease-in-out;
cursor: pointer;
}
.docker-auth-dialog .token-dialog-body .well i.fa-refresh:hover {
color: black;
} }
.docker-auth-dialog .token-view { .docker-auth-dialog .token-view {
@ -738,7 +754,7 @@ i.toggle-icon:hover {
} }
.user-notification.notification-animated { .user-notification.notification-animated {
width: 21px; min-width: 21px;
transform: scale(0); transform: scale(0);
-moz-transform: scale(0); -moz-transform: scale(0);
@ -832,7 +848,7 @@ i.toggle-icon:hover {
background-color: red; background-color: red;
} }
.phase-icon.waiting, .phase-icon.starting, .phase-icon.initializing { .phase-icon.waiting, .phase-icon.unpacking, .phase-icon.starting, .phase-icon.initializing {
background-color: #ddd; background-color: #ddd;
} }
@ -2266,6 +2282,14 @@ p.editable:hover i {
position: relative; position: relative;
} }
.copy-box-element.disabled .input-group-addon {
display: none;
}
.copy-box-element.disabled input {
border-radius: 4px !important;
}
.global-zeroclipboard-container embed { .global-zeroclipboard-container embed {
cursor: pointer; cursor: pointer;
} }

View file

@ -1,4 +1,4 @@
<div class="copy-box-element"> <div class="copy-box-element" ng-class="disabled ? 'disabled' : ''">
<div class="id-container"> <div class="id-container">
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control" value="{{ value }}" readonly> <input type="text" class="form-control" value="{{ value }}" readonly>

View file

@ -10,19 +10,33 @@
</div> </div>
<div class="modal-body token-dialog-body"> <div class="modal-body token-dialog-body">
<div class="alert alert-info">The docker <u>username</u> is <b>{{ username }}</b> and the <u>password</u> is the token below. You may use any value for email.</div> <div class="alert alert-info">The docker <u>username</u> is <b>{{ username }}</b> and the <u>password</u> is the token below. You may use any value for email.</div>
<div class="well well-sm">
<div class="well well-sm" ng-show="regenerating">
Regenerating Token...
<i class="fa fa-refresh fa-spin"></i>
</div>
<div class="well well-sm" ng-show="!regenerating">
<input id="token-view" class="token-view" type="text" value="{{ token }}" onClick="this.select();" readonly> <input id="token-view" class="token-view" type="text" value="{{ token }}" onClick="this.select();" readonly>
<i class="fa fa-refresh" ng-show="supportsRegenerate" ng-click="askRegenerate()"
data-title="Regenerate Token"
data-placement="left"
bs-tooltip></i>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer" ng-show="regenerating">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
<div class="modal-footer" ng-show="!regenerating">
<span class="download-cfg" ng-show="isDownloadSupported()"> <span class="download-cfg" ng-show="isDownloadSupported()">
<i class="fa fa-download"></i> <i class="fa fa-download"></i>
<a href="javascript:void(0)" ng-click="downloadCfg(shownRobot)">Download .dockercfg file</a> <a href="javascript:void(0)" ng-click="downloadCfg(shownRobot)">Download .dockercfg file</a>
</span> </span>
<div id="clipboardCopied" style="display: none"> <div class="clipboard-copied-message" style="display: none">
Copied to clipboard Copied
</div> </div>
<button id="copyClipboard" type="button" class="btn btn-primary" data-clipboard-target="token-view">Copy to clipboard</button> <input type="hidden" name="command-data" id="command-data" value="{{ command }}">
<button id="copyClipboard" type="button" class="btn btn-primary" data-clipboard-target="command-data">Copy Login Command</button>
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button> <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div> </div>
</div><!-- /.modal-content --> </div><!-- /.modal-content -->

View file

@ -37,15 +37,7 @@
<a href="javascript:void(0)" class="dropdown-toggle user-dropdown user-view" data-toggle="dropdown"> <a href="javascript:void(0)" class="dropdown-toggle user-dropdown user-view" data-toggle="dropdown">
<img src="//www.gravatar.com/avatar/{{ user.gravatar }}?s=32&d=identicon" /> <img src="//www.gravatar.com/avatar/{{ user.gravatar }}?s=32&d=identicon" />
{{ user.username }} {{ user.username }}
<span class="badge user-notification notification-animated" <span class="notifications-bubble"></span>
ng-show="notificationService.notifications.length"
ng-class="notificationService.notificationClasses"
bs-tooltip=""
data-title="User Notifications"
data-placement="left"
data-container="body">
{{ notificationService.notifications.length }}
</span>
<b class="caret"></b> <b class="caret"></b>
</a> </a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
@ -58,11 +50,7 @@
<a href="javascript:void(0)" data-template="/static/directives/notification-bar.html" <a href="javascript:void(0)" data-template="/static/directives/notification-bar.html"
data-animation="am-slide-right" bs-aside="aside" data-container="body"> data-animation="am-slide-right" bs-aside="aside" data-container="body">
Notifications Notifications
<span class="badge user-notification" <span class="notifications-bubble"></span>
ng-class="notificationService.notificationClasses"
ng-show="notificationService.notifications.length">
{{ notificationService.notifications.length }}
</span>
</a> </a>
</li> </li>
<li><a ng-href="/organizations/" target="{{ appLinkTarget() }}">Organizations</a></li> <li><a ng-href="/organizations/" target="{{ appLinkTarget() }}">Organizations</a></li>

View file

@ -3,7 +3,10 @@
<div class="aside-content"> <div class="aside-content">
<div class="aside-header"> <div class="aside-header">
<button type="button" class="close" ng-click="$hide()">&times;</button> <button type="button" class="close" ng-click="$hide()">&times;</button>
<h4 class="aside-title">Notifications</h4> <h4 class="aside-title">
Notifications
<span class="notifications-bubble"></span>
</h4>
</div> </div>
<div class="aside-body"> <div class="aside-body">
<div ng-repeat="notification in notificationService.notifications"> <div ng-repeat="notification in notificationService.notifications">

View file

@ -0,0 +1,7 @@
<span class="notifications-bubble-element">
<span class="badge user-notification notification-animated"
ng-show="notificationService.notifications.length"
ng-class="notificationService.notificationClasses">
{{ notificationService.notifications.length }}<span ng-if="notificationService.additionalNotifications">+</span>
</span>
</span>

View file

@ -31,7 +31,7 @@
</div> </div>
<div class="docker-auth-dialog" username="shownRobot.name" token="shownRobot.token" <div class="docker-auth-dialog" username="shownRobot.name" token="shownRobot.token"
shown="!!shownRobot" counter="showRobotCounter"> shown="!!shownRobot" counter="showRobotCounter" supports-regenerate="true" regenerate="regenerateToken(username)">
<i class="fa fa-wrench"></i> {{ shownRobot.name }} <i class="fa fa-wrench"></i> {{ shownRobot.name }}
</div> </div>
</div> </div>

View file

@ -1,6 +1,46 @@
var TEAM_PATTERN = '^[a-zA-Z][a-zA-Z0-9]+$'; var TEAM_PATTERN = '^[a-zA-Z][a-zA-Z0-9]+$';
var ROBOT_PATTERN = '^[a-zA-Z][a-zA-Z0-9]{3,29}$'; var ROBOT_PATTERN = '^[a-zA-Z][a-zA-Z0-9]{3,29}$';
$.fn.clipboardCopy = function() {
if (zeroClipboardSupported) {
(new ZeroClipboard($(this)));
return true;
}
this.hide();
return false;
};
var zeroClipboardSupported = true;
ZeroClipboard.config({
'swfPath': 'static/lib/ZeroClipboard.swf'
});
ZeroClipboard.on("error", function(e) {
zeroClipboardSupported = false;
});
ZeroClipboard.on('aftercopy', function(e) {
var container = e.target.parentNode.parentNode.parentNode;
var message = $(container).find('.clipboard-copied-message')[0];
// Resets the animation.
var elem = message;
elem.style.display = 'none';
elem.classList.remove('animated');
// Show the notification.
setTimeout(function() {
elem.style.display = 'inline-block';
elem.classList.add('animated');
}, 10);
// Reset the notification.
setTimeout(function() {
elem.style.display = 'none';
}, 5000);
});
function getRestUrl(args) { function getRestUrl(args) {
var url = ''; var url = '';
for (var i = 0; i < arguments.length; ++i) { for (var i = 0; i < arguments.length; ++i) {
@ -59,18 +99,8 @@ function getFirstTextLine(commentString) {
} }
function createRobotAccount(ApiService, is_org, orgname, name, callback) { function createRobotAccount(ApiService, is_org, orgname, name, callback) {
ApiService.createRobot(is_org ? orgname : null, null, {'robot_shortname': name}).then(callback, function(resp) { ApiService.createRobot(is_org ? orgname : null, null, {'robot_shortname': name})
bootbox.dialog({ .then(callback, ApiService.errorDisplay('Cannot create robot account'));
"message": resp.data ? resp.data['message'] : 'The robot account could not be created',
"title": "Cannot create robot account",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
} }
function createOrganizationTeam(ApiService, orgname, teamname, callback) { function createOrganizationTeam(ApiService, orgname, teamname, callback) {
@ -84,18 +114,8 @@ function createOrganizationTeam(ApiService, orgname, teamname, callback) {
'teamname': teamname 'teamname': teamname
}; };
ApiService.updateOrganizationTeam(data, params).then(callback, function(resp) { ApiService.updateOrganizationTeam(data, params)
bootbox.dialog({ .then(callback, ApiService.errorDisplay('Cannot create team'));
"message": resp.data ? resp.data : 'The team could not be created',
"title": "Cannot create team",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
} }
function getMarkedDown(string) { function getMarkedDown(string) {
@ -870,6 +890,38 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
buildMethodsForEndpointResource(endpointResource, resourceMap); buildMethodsForEndpointResource(endpointResource, resourceMap);
} }
apiService.getErrorMessage = function(resp, defaultMessage) {
var message = defaultMessage;
if (resp['data']) {
message = resp['data']['error_message'] || resp['data']['message'] || resp['data']['error_description'] || message;
}
return message;
};
apiService.errorDisplay = function(defaultMessage, opt_handler) {
return function(resp) {
var message = apiService.getErrorMessage(resp, defaultMessage);
if (opt_handler) {
var handlerMessage = opt_handler(resp);
if (handlerMessage) {
message = handlerMessage;
}
}
bootbox.dialog({
"message": message,
"title": defaultMessage,
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
};
};
return apiService; return apiService;
}]); }]);
@ -1126,7 +1178,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
'user': null, 'user': null,
'notifications': [], 'notifications': [],
'notificationClasses': [], 'notificationClasses': [],
'notificationSummaries': [] 'notificationSummaries': [],
'additionalNotifications': false
}; };
var pollTimerHandle = null; var pollTimerHandle = null;
@ -1244,7 +1297,9 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
'uuid': notification.id 'uuid': notification.id
}; };
ApiService.updateUserNotification(notification, params); ApiService.updateUserNotification(notification, params, function() {
notificationService.update();
}, ApiService.errorDisplay('Could not update notification'));
var index = $.inArray(notification, notificationService.notifications); var index = $.inArray(notification, notificationService.notifications);
if (index >= 0) { if (index >= 0) {
@ -1310,6 +1365,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
ApiService.listUserNotifications().then(function(resp) { ApiService.listUserNotifications().then(function(resp) {
notificationService.notifications = resp['notifications']; notificationService.notifications = resp['notifications'];
notificationService.additionalNotifications = resp['additional'];
notificationService.notificationClasses = notificationService.getClasses(notificationService.notifications); notificationService.notificationClasses = notificationService.getClasses(notificationService.notifications);
}); });
}; };
@ -1541,7 +1597,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
}); });
}; };
planService.changePlan = function($scope, orgname, planId, callbacks) { planService.changePlan = function($scope, orgname, planId, callbacks, opt_async) {
if (!Features.BILLING) { return; } if (!Features.BILLING) { return; }
if (callbacks['started']) { if (callbacks['started']) {
@ -1554,7 +1610,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
planService.getCardInfo(orgname, function(cardInfo) { planService.getCardInfo(orgname, function(cardInfo) {
if (plan.price > 0 && (previousSubscribeFailure || !cardInfo.last4)) { if (plan.price > 0 && (previousSubscribeFailure || !cardInfo.last4)) {
var title = cardInfo.last4 ? 'Subscribe' : 'Start Trial ({{amount}} plan)'; var title = cardInfo.last4 ? 'Subscribe' : 'Start Trial ({{amount}} plan)';
planService.showSubscribeDialog($scope, orgname, planId, callbacks, title); planService.showSubscribeDialog($scope, orgname, planId, callbacks, title, /* async */true);
return; return;
} }
@ -1627,9 +1683,34 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
return email; return email;
}; };
planService.showSubscribeDialog = function($scope, orgname, planId, callbacks, opt_title) { planService.showSubscribeDialog = function($scope, orgname, planId, callbacks, opt_title, opt_async) {
if (!Features.BILLING) { return; } if (!Features.BILLING) { return; }
// If the async parameter is true and this is a browser that does not allow async popup of the
// Stripe dialog (such as Mobile Safari or IE), show a bootbox to show the dialog instead.
var isIE = navigator.appName.indexOf("Internet Explorer") != -1;
var isMobileSafari = navigator.userAgent.match(/(iPod|iPhone|iPad)/) && navigator.userAgent.match(/AppleWebKit/);
if (opt_async && (isIE || isMobileSafari)) {
bootbox.dialog({
"message": "Please click 'Subscribe' to continue",
"buttons": {
"subscribe": {
"label": "Subscribe",
"className": "btn-primary",
"callback": function() {
planService.showSubscribeDialog($scope, orgname, planId, callbacks, opt_title, false);
}
},
"close": {
"label": "Cancel",
"className": "btn-default"
}
}
});
return;
}
if (callbacks['opening']) { if (callbacks['opening']) {
callbacks['opening'](); callbacks['opening']();
} }
@ -2084,18 +2165,7 @@ quayApp.directive('applicationReference', function () {
template: '/static/directives/application-reference-dialog.html', template: '/static/directives/application-reference-dialog.html',
show: true show: true
}); });
}, function() { }, ApiService.errorDisplay('Application could not be found'));
bootbox.dialog({
"message": 'The application could not be found; it might have been deleted.',
"title": "Cannot find application",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
} }
}; };
@ -2176,6 +2246,8 @@ quayApp.directive('copyBox', function () {
'hoveringMessage': '=hoveringMessage' 'hoveringMessage': '=hoveringMessage'
}, },
controller: function($scope, $element, $rootScope) { controller: function($scope, $element, $rootScope) {
$scope.disabled = false;
var number = $rootScope.__copyBoxIdCounter || 0; var number = $rootScope.__copyBoxIdCounter || 0;
$rootScope.__copyBoxIdCounter = number + 1; $rootScope.__copyBoxIdCounter = number + 1;
$scope.inputId = "copy-box-input-" + number; $scope.inputId = "copy-box-input-" + number;
@ -2185,27 +2257,7 @@ quayApp.directive('copyBox', function () {
input.attr('id', $scope.inputId); input.attr('id', $scope.inputId);
button.attr('data-clipboard-target', $scope.inputId); button.attr('data-clipboard-target', $scope.inputId);
$scope.disabled = !button.clipboardCopy();
var clip = new ZeroClipboard($(button), { 'moviePath': 'static/lib/ZeroClipboard.swf' });
clip.on('complete', function(e) {
var message = $(this.parentNode.parentNode.parentNode).find('.clipboard-copied-message')[0];
// Resets the animation.
var elem = message;
elem.style.display = 'none';
elem.classList.remove('animated');
// Show the notification.
setTimeout(function() {
elem.style.display = 'inline-block';
elem.classList.add('animated');
}, 10);
// Reset the notification.
setTimeout(function() {
elem.style.display = 'none';
}, 5000);
});
} }
}; };
return directiveDefinitionObject; return directiveDefinitionObject;
@ -2439,10 +2491,37 @@ quayApp.directive('dockerAuthDialog', function (Config) {
'username': '=username', 'username': '=username',
'token': '=token', 'token': '=token',
'shown': '=shown', 'shown': '=shown',
'counter': '=counter' 'counter': '=counter',
'supportsRegenerate': '@supportsRegenerate',
'regenerate': '&regenerate'
}, },
controller: function($scope, $element) { controller: function($scope, $element) {
var updateCommand = function() {
$scope.command = 'docker login -e="." -u="' + $scope.username +
'" -p="' + $scope.token + '" ' + Config['SERVER_HOSTNAME'];
};
$scope.$watch('username', updateCommand);
$scope.$watch('token', updateCommand);
$scope.regenerating = true;
$scope.askRegenerate = function() {
bootbox.confirm('Are you sure you want to regenerate the token? All existing login credentials will become invalid', function(resp) {
if (resp) {
$scope.regenerating = true;
$scope.regenerate({'username': $scope.username, 'token': $scope.token});
}
});
};
$scope.isDownloadSupported = function() { $scope.isDownloadSupported = function() {
var isSafari = /^((?!chrome).)*safari/i.test(navigator.userAgent);
if (isSafari) {
// Doesn't work properly in Safari, sadly.
return false;
}
try { return !!new Blob(); } catch(e) {} try { return !!new Blob(); } catch(e) {}
return false; return false;
}; };
@ -2461,6 +2540,8 @@ quayApp.directive('dockerAuthDialog', function (Config) {
}; };
var show = function(r) { var show = function(r) {
$scope.regenerating = false;
if (!$scope.shown || !$scope.username || !$scope.token) { if (!$scope.shown || !$scope.username || !$scope.token) {
$('#dockerauthmodal').modal('hide'); $('#dockerauthmodal').modal('hide');
return; return;
@ -2706,6 +2787,8 @@ quayApp.directive('logsView', function () {
return 'Delete notification of event "' + eventData['title'] + '" for repository {repo}'; return 'Delete notification of event "' + eventData['title'] + '" for repository {repo}';
}, },
'regenerate_robot_token': 'Regenerated token for robot {robot}',
// Note: These are deprecated. // Note: These are deprecated.
'add_repo_webhook': 'Add webhook in repository {repo}', 'add_repo_webhook': 'Add webhook in repository {repo}',
'delete_repo_webhook': 'Delete webhook in repository {repo}' 'delete_repo_webhook': 'Delete webhook in repository {repo}'
@ -2752,6 +2835,7 @@ quayApp.directive('logsView', function () {
'reset_application_client_secret': 'Reset Client Secret', 'reset_application_client_secret': 'Reset Client Secret',
'add_repo_notification': 'Add repository notification', 'add_repo_notification': 'Add repository notification',
'delete_repo_notification': 'Delete repository notification', 'delete_repo_notification': 'Delete repository notification',
'regenerate_robot_token': 'Regenerate Robot Token',
// Note: these are deprecated. // Note: these are deprecated.
'add_repo_webhook': 'Add webhook', 'add_repo_webhook': 'Add webhook',
@ -2878,18 +2962,7 @@ quayApp.directive('applicationManager', function () {
ApiService.createOrganizationApplication(data, params).then(function(resp) { ApiService.createOrganizationApplication(data, params).then(function(resp) {
$scope.applications.push(resp); $scope.applications.push(resp);
}, function(resp) { }, ApiService.errorDisplay('Cannot create application'));
bootbox.dialog({
"message": resp['message'] || 'The application could not be created',
"title": "Cannot create application",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
var update = function() { var update = function() {
@ -2934,6 +3007,20 @@ quayApp.directive('robotsManager', function () {
$scope.shownRobot = null; $scope.shownRobot = null;
$scope.showRobotCounter = 0; $scope.showRobotCounter = 0;
$scope.regenerateToken = function(username) {
if (!username) { return; }
var shortName = $scope.getShortenedName(username);
ApiService.regenerateRobotToken($scope.organization, null, {'robot_shortname': shortName}).then(function(updated) {
var index = $scope.findRobotIndexByName(username);
if (index >= 0) {
$scope.robots.splice(index, 1);
$scope.robots.push(updated);
}
$scope.shownRobot = updated;
}, ApiService.errorDisplay('Cannot regenerate robot account token'));
};
$scope.showRobot = function(info) { $scope.showRobot = function(info) {
$scope.shownRobot = info; $scope.shownRobot = info;
$scope.showRobotCounter++; $scope.showRobotCounter++;
@ -2974,18 +3061,7 @@ quayApp.directive('robotsManager', function () {
if (index >= 0) { if (index >= 0) {
$scope.robots.splice(index, 1); $scope.robots.splice(index, 1);
} }
}, function() { }, ApiService.errorDisplay('Cannot delete robot account'));
bootbox.dialog({
"message": 'The selected robot account could not be deleted',
"title": "Cannot delete robot account",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
var update = function() { var update = function() {
@ -3050,18 +3126,7 @@ quayApp.directive('prototypeManager', function () {
ApiService.updateOrganizationPrototypePermission(data, params).then(function(resp) { ApiService.updateOrganizationPrototypePermission(data, params).then(function(resp) {
prototype.role = role; prototype.role = role;
}, function(resp) { }, ApiService.errorDisplay('Cannot modify permission'));
bootbox.dialog({
"message": resp.data ? resp.data : 'The permission could not be modified',
"title": "Cannot modify permission",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
$scope.comparePrototypes = function(p) { $scope.comparePrototypes = function(p) {
@ -3101,23 +3166,16 @@ quayApp.directive('prototypeManager', function () {
data['activating_user'] = $scope.activatingForNew; data['activating_user'] = $scope.activatingForNew;
} }
var errorHandler = ApiService.errorDisplay('Cannot create permission',
function(resp) {
$('#addPermissionDialogModal').modal('hide');
});
ApiService.createOrganizationPrototypePermission(data, params).then(function(resp) { ApiService.createOrganizationPrototypePermission(data, params).then(function(resp) {
$scope.prototypes.push(resp); $scope.prototypes.push(resp);
$scope.loading = false; $scope.loading = false;
$('#addPermissionDialogModal').modal('hide'); $('#addPermissionDialogModal').modal('hide');
}, function(resp) { }, errorHandler);
$('#addPermissionDialogModal').modal('hide');
bootbox.dialog({
"message": resp.data ? resp.data : 'The permission could not be created',
"title": "Cannot create permission",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
$scope.deletePrototype = function(prototype) { $scope.deletePrototype = function(prototype) {
@ -3131,18 +3189,7 @@ quayApp.directive('prototypeManager', function () {
ApiService.deleteOrganizationPrototypePermission(null, params).then(function(resp) { ApiService.deleteOrganizationPrototypePermission(null, params).then(function(resp) {
$scope.prototypes.splice($scope.prototypes.indexOf(prototype), 1); $scope.prototypes.splice($scope.prototypes.indexOf(prototype), 1);
$scope.loading = false; $scope.loading = false;
}, function(resp) { }, ApiService.errorDisplay('Cannot delete permission'));
bootbox.dialog({
"message": resp.data ? resp.data : 'The permission could not be deleted',
"title": "Cannot delete permission",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
var update = function() { var update = function() {
@ -3990,7 +4037,7 @@ quayApp.directive('planManager', function () {
return true; return true;
}; };
$scope.changeSubscription = function(planId) { $scope.changeSubscription = function(planId, opt_async) {
if ($scope.planChanging) { return; } if ($scope.planChanging) { return; }
var callbacks = { var callbacks = {
@ -4004,7 +4051,7 @@ quayApp.directive('planManager', function () {
} }
}; };
PlanService.changePlan($scope, $scope.organization, planId, callbacks); PlanService.changePlan($scope, $scope.organization, planId, callbacks, opt_async);
}; };
$scope.cancelSubscription = function() { $scope.cancelSubscription = function() {
@ -4067,7 +4114,7 @@ quayApp.directive('planManager', function () {
if ($scope.readyForPlan) { if ($scope.readyForPlan) {
var planRequested = $scope.readyForPlan(); var planRequested = $scope.readyForPlan();
if (planRequested && planRequested != PlanService.getFreePlan()) { if (planRequested && planRequested != PlanService.getFreePlan()) {
$scope.changeSubscription(planRequested); $scope.changeSubscription(planRequested, /* async */true);
} }
} }
}); });
@ -4485,26 +4532,17 @@ quayApp.directive('setupTriggerDialog', function () {
$scope.activating = true; $scope.activating = true;
var errorHandler = ApiService.errorDisplay('Cannot activate build trigger', function(resp) {
$scope.hide();
$scope.canceled({'trigger': $scope.trigger});
});
ApiService.activateBuildTrigger(data, params).then(function(resp) { ApiService.activateBuildTrigger(data, params).then(function(resp) {
$scope.hide(); $scope.hide();
$scope.trigger['is_active'] = true; $scope.trigger['is_active'] = true;
$scope.trigger['pull_robot'] = resp['pull_robot']; $scope.trigger['pull_robot'] = resp['pull_robot'];
$scope.activated({'trigger': $scope.trigger}); $scope.activated({'trigger': $scope.trigger});
}, function(resp) { }, errorHandler);
$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() { var check = function() {
@ -4844,6 +4882,9 @@ quayApp.directive('buildMessage', function () {
case 'waiting': case 'waiting':
return 'Waiting for available build worker'; return 'Waiting for available build worker';
case 'unpacking':
return 'Unpacking build package';
case 'pulling': case 'pulling':
return 'Pulling base image'; return 'Pulling base image';
@ -4899,6 +4940,7 @@ quayApp.directive('buildProgress', function () {
case 'starting': case 'starting':
case 'waiting': case 'waiting':
case 'cannot_load': case 'cannot_load':
case 'unpacking':
return 0; return 0;
break; break;
} }
@ -5118,6 +5160,23 @@ quayApp.directive('twitterView', function () {
}); });
quayApp.directive('notificationsBubble', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/notifications-bubble.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
},
controller: function($scope, UserService, NotificationService) {
$scope.notificationService = NotificationService;
}
};
return directiveDefinitionObject;
});
quayApp.directive('notificationView', function () { quayApp.directive('notificationView', function () {
var directiveDefinitionObject = { var directiveDefinitionObject = {
priority: 0, priority: 0,
@ -5705,8 +5764,8 @@ quayApp.run(['$location', '$rootScope', 'Restangular', 'UserService', 'PlanServi
} }
} }
if (!Features.BILLING && response.status == 402) { if (response.status == 503) {
$('#overlicenseModal').modal({}); $('#cannotContactService').modal({});
return false; return false;
} }

View file

@ -1,34 +1,4 @@
$.fn.clipboardCopy = function() {
var clip = new ZeroClipboard($(this), { 'moviePath': 'static/lib/ZeroClipboard.swf' });
clip.on('complete', function() {
// Resets the animation.
var elem = $('#clipboardCopied')[0];
if (!elem) {
return;
}
elem.style.display = 'none';
elem.classList.remove('animated');
// Show the notification.
setTimeout(function() {
if (!elem) { return; }
elem.style.display = 'inline-block';
elem.classList.add('animated');
}, 10);
});
};
function SignInCtrl($scope, $location) { function SignInCtrl($scope, $location) {
var redirect = $location.search()['redirect'];
if (redirect && redirect.indexOf('/') < 0) {
delete $location.search()['redirect'];
$scope.redirectUrl = '/' + redirect;
return;
}
$scope.redirectUrl = '/';
} }
function GuideCtrl() { function GuideCtrl() {
@ -543,23 +513,15 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
'image': image.id 'image': image.id
}; };
var errorHandler = ApiService.errorDisplay('Cannot create or move tag', function(resp) {
$('#addTagModal').modal('hide');
});
ApiService.changeTagImage(data, params).then(function(resp) { ApiService.changeTagImage(data, params).then(function(resp) {
$scope.creatingTag = false; $scope.creatingTag = false;
loadViewInfo(); loadViewInfo();
$('#addTagModal').modal('hide'); $('#addTagModal').modal('hide');
}, function(resp) { }, errorHandler);
$('#addTagModal').modal('hide');
bootbox.dialog({
"message": resp.data ? resp.data : 'Could not create or move tag',
"title": "Cannot create or move tag",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
$scope.deleteTag = function(tagName) { $scope.deleteTag = function(tagName) {
@ -573,18 +535,7 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
ApiService.deleteFullTag(null, params).then(function() { ApiService.deleteFullTag(null, params).then(function() {
loadViewInfo(); loadViewInfo();
}, function(resp) { }, ApiService.errorDisplay('Cannot delete tag'));
bootbox.dialog({
"message": resp.data ? resp.data : 'Could not delete tag',
"title": "Cannot delete tag",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
$scope.getImagesForTagBySize = function(tag) { $scope.getImagesForTagBySize = function(tag) {
@ -763,8 +714,6 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
// Load the builds for this repository. If none are active it will cancel the poll. // Load the builds for this repository. If none are active it will cancel the poll.
startBuildInfoTimer(repo); startBuildInfoTimer(repo);
$('#copyClipboard').clipboardCopy();
}); });
}; };
@ -1373,17 +1322,16 @@ function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, $routeParams
}; };
$scope.deleteRole = function(entityName, kind) { $scope.deleteRole = function(entityName, kind) {
var errorHandler = ApiService.errorDisplay('Cannot change permission', function(resp) {
if (resp.status == 409) {
return 'Cannot change permission as you do not have the authority';
}
});
var permissionDelete = Restangular.one(getRestUrl('repository', namespace, name, 'permissions', kind, entityName)); var permissionDelete = Restangular.one(getRestUrl('repository', namespace, name, 'permissions', kind, entityName));
permissionDelete.customDELETE().then(function() { permissionDelete.customDELETE().then(function() {
delete $scope.permissions[kind][entityName]; delete $scope.permissions[kind][entityName];
}, function(resp) { }, errorHandler);
if (resp.status == 409) {
$scope.changePermError = resp.data || '';
$('#channgechangepermModal').modal({});
} else {
$('#cannotchangeModal').modal({});
}
});
}; };
$scope.addRole = function(entityName, role, kind) { $scope.addRole = function(entityName, role, kind) {
@ -1394,9 +1342,7 @@ function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, $routeParams
var permissionPost = Restangular.one(getRestUrl('repository', namespace, name, 'permissions', kind, entityName)); var permissionPost = Restangular.one(getRestUrl('repository', namespace, name, 'permissions', kind, entityName));
permissionPost.customPUT(permission).then(function(result) { permissionPost.customPUT(permission).then(function(result) {
$scope.permissions[kind][entityName] = result; $scope.permissions[kind][entityName] = result;
}, function(result) { }, ApiService.errorDisplay('Cannot change permission'));
$('#cannotchangeModal').modal({});
});
}; };
$scope.roles = [ $scope.roles = [
@ -1611,18 +1557,7 @@ function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, $routeParams
window.console.log(resp); window.console.log(resp);
var url = '/repository/' + namespace + '/' + name + '/build?current=' + resp['id']; var url = '/repository/' + namespace + '/' + name + '/build?current=' + resp['id'];
document.location = url; document.location = url;
}, function(resp) { }, ApiService.errorDisplay('Could not start build'));
bootbox.dialog({
"message": resp['message'] || 'The build could not be started',
"title": "Could not start build",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
$scope.deleteTrigger = function(trigger) { $scope.deleteTrigger = function(trigger) {
@ -1750,18 +1685,7 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use
ApiService.deleteUserAuthorization(null, params).then(function(resp) { ApiService.deleteUserAuthorization(null, params).then(function(resp) {
$scope.authorizedApps.splice($scope.authorizedApps.indexOf(accessTokenInfo), 1); $scope.authorizedApps.splice($scope.authorizedApps.indexOf(accessTokenInfo), 1);
}, function(resp) { }, ApiService.errorDisplay('Could not revoke authorization'));
bootbox.dialog({
"message": resp.message || 'Could not revoke authorization',
"title": "Cannot revoke authorization",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
$scope.loadLogs = function() { $scope.loadLogs = function() {
@ -1770,7 +1694,6 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use
}; };
$scope.loadInvoices = function() { $scope.loadInvoices = function() {
if (!$scope.hasPaidBusinessPlan) { return; }
$scope.invoicesShown++; $scope.invoicesShown++;
}; };
@ -1956,9 +1879,6 @@ function ImageViewCtrl($scope, $routeParams, $rootScope, $timeout, ApiService, I
// Fetch the image's changes. // Fetch the image's changes.
fetchChanges(); fetchChanges();
$('#copyClipboard').clipboardCopy();
return image; return image;
}); });
}; };
@ -2226,13 +2146,14 @@ function OrgViewCtrl($rootScope, $scope, ApiService, $routeParams) {
'teamname': teamname 'teamname': teamname
}; };
var errorHandler = ApiService.errorDisplay('Cannot delete team', function() {
$scope.currentDeleteTeam = null;
});
ApiService.deleteOrganizationTeam(null, params).then(function() { ApiService.deleteOrganizationTeam(null, params).then(function() {
delete $scope.organization.teams[teamname]; delete $scope.organization.teams[teamname];
$scope.currentDeleteTeam = null; $scope.currentDeleteTeam = null;
}, function() { }, errorHandler);
$('#cannotchangeModal').modal({});
$scope.currentDeleteTeam = null;
});
}; };
var loadOrganization = function() { var loadOrganization = function() {
@ -2575,9 +2496,9 @@ function NewOrgCtrl($scope, $routeParams, $timeout, $location, UserService, Plan
}; };
PlanService.changePlan($scope, org.name, $scope.holder.currentPlan.stripeId, callbacks); PlanService.changePlan($scope, org.name, $scope.holder.currentPlan.stripeId, callbacks);
}, function(result) { }, function(resp) {
$scope.creating = false; $scope.creating = false;
$scope.createError = result.data.error_description || result.data; $scope.createError = ApiService.getErrorMessage(resp);
$timeout(function() { $timeout(function() {
$('#orgName').popover('show'); $('#orgName').popover('show');
}); });
@ -2654,18 +2575,7 @@ function ManageApplicationCtrl($scope, $routeParams, $rootScope, $location, $tim
$timeout(function() { $timeout(function() {
$location.path('/organization/' + orgname + '/admin'); $location.path('/organization/' + orgname + '/admin');
}, 500); }, 500);
}, function(resp) { }, ApiService.errorDisplay('Could not delete application'));
bootbox.dialog({
"message": resp.message || 'Could not delete application',
"title": "Cannot delete application",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
$scope.updateApplication = function() { $scope.updateApplication = function() {
@ -2683,22 +2593,13 @@ function ManageApplicationCtrl($scope, $routeParams, $rootScope, $location, $tim
delete $scope.application['gravatar_email']; delete $scope.application['gravatar_email'];
} }
var errorHandler = ApiService.errorDisplay('Could not update application', function(resp) {
$scope.updating = false;
});
ApiService.updateOrganizationApplication($scope.application, params).then(function(resp) { ApiService.updateOrganizationApplication($scope.application, params).then(function(resp) {
$scope.application = resp; $scope.application = resp;
$scope.updating = false; }, errorHandler);
}, function(resp) {
$scope.updating = false;
bootbox.dialog({
"message": resp.message || 'Could not update application',
"title": "Cannot update application",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
$scope.resetClientSecret = function() { $scope.resetClientSecret = function() {
@ -2711,18 +2612,7 @@ function ManageApplicationCtrl($scope, $routeParams, $rootScope, $location, $tim
ApiService.resetOrganizationApplicationClientSecret(null, params).then(function(resp) { ApiService.resetOrganizationApplicationClientSecret(null, params).then(function(resp) {
$scope.application = resp; $scope.application = resp;
}, function(resp) { }, ApiService.errorDisplay('Could not reset client secret'));
bootbox.dialog({
"message": resp.message || 'Could not reset client secret',
"title": "Cannot reset client secret",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
var loadOrganization = function() { var loadOrganization = function() {
@ -2818,18 +2708,7 @@ function SuperUserAdminCtrl($scope, ApiService, Features, UserService) {
ApiService.changeInstallUser(data, params).then(function(resp) { ApiService.changeInstallUser(data, params).then(function(resp) {
$scope.loadUsersInternal(); $scope.loadUsersInternal();
}, function(resp) { }, ApiService.errorDisplay('Could not change user'));
bootbox.dialog({
"message": resp.data ? resp.data.message : 'Could not change user',
"title": "Cannot change user",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
$scope.deleteUser = function(user) { $scope.deleteUser = function(user) {
@ -2841,49 +2720,10 @@ function SuperUserAdminCtrl($scope, ApiService, Features, UserService) {
ApiService.deleteInstallUser(null, params).then(function(resp) { ApiService.deleteInstallUser(null, params).then(function(resp) {
$scope.loadUsersInternal(); $scope.loadUsersInternal();
}, function(resp) { }, ApiService.errorDisplay('Cannot delete user'));
bootbox.dialog({
"message": resp.data ? resp.data.message : 'Could not delete user',
"title": "Cannot delete user",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
var seatUsageLoaded = function(usage) { $scope.loadUsers();
$scope.usageLoading = false;
if (usage.count > usage.allowed) {
$scope.limit = 'over';
} else if (usage.count == usage.allowed) {
$scope.limit = 'at';
} else if (usage.count >= usage.allowed * 0.7) {
$scope.limit = 'near';
} else {
$scope.limit = 'none';
}
if (!$scope.chart) {
$scope.chart = new UsageChart();
$scope.chart.draw('seat-usage-chart');
}
$scope.chart.update(usage.count, usage.allowed);
};
var loadSeatUsage = function() {
$scope.usageLoading = true;
ApiService.getSeatCount().then(function(resp) {
seatUsageLoaded(resp);
});
};
loadSeatUsage();
} }
function TourCtrl($scope, $location) { function TourCtrl($scope, $location) {

View file

@ -148,6 +148,8 @@ ImageHistoryTree.prototype.updateDimensions_ = function() {
var ch = dimensions.ch; var ch = dimensions.ch;
// Set the height of the container so that it never goes offscreen. // Set the height of the container so that it never goes offscreen.
if (!$('#' + container).removeOverscroll) { return; }
$('#' + container).removeOverscroll(); $('#' + container).removeOverscroll();
var viewportHeight = $(window).height(); var viewportHeight = $(window).height();
var boundingBox = document.getElementById(container).getBoundingClientRect(); var boundingBox = document.getElementById(container).getBoundingClientRect();

7
static/lib/ZeroClipboard.min.js vendored Executable file → Normal file

File diff suppressed because one or more lines are too long

BIN
static/lib/ZeroClipboard.swf Executable file → Normal file

Binary file not shown.

View file

@ -53,7 +53,7 @@
<label for="orgName">Organization Name</label> <label for="orgName">Organization Name</label>
<input id="orgName" name="orgName" type="text" class="form-control" placeholder="Organization Name" <input id="orgName" name="orgName" type="text" class="form-control" placeholder="Organization Name"
ng-model="org.name" required autofocus data-trigger="manual" data-content="{{ createError }}" ng-model="org.name" required autofocus data-trigger="manual" data-content="{{ createError }}"
data-placement="right" data-container="body" ng-pattern="/^[a-z0-9_]{4,30}$/"> data-placement="bottom" data-container="body" ng-pattern="/^[a-z0-9_]{4,30}$/">
<span class="description">This will also be the namespace for your repositories</span> <span class="description">This will also be the namespace for your repositories</span>
</div> </div>

View file

@ -34,6 +34,13 @@
</span> </span>
<i class="fa fa-upload visible-lg"></i> <i class="fa fa-upload visible-lg"></i>
</div> </div>
<div class="feature">
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
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"> <div class="feature">
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right" <span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
data-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">
@ -48,13 +55,6 @@
</span> </span>
<i class="fa fa-bar-chart-o visible-lg"></i> <i class="fa fa-bar-chart-o visible-lg"></i>
</div> </div>
<div class="feature">
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
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"> <div class="feature">
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right" <span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
data-title="All plans have a free trial"> data-title="All plans have a free trial">
@ -81,7 +81,7 @@
<div class="feature present"></div> <div class="feature present"></div>
<div class="feature present"></div> <div class="feature present"></div>
<div class="feature present"></div> <div class="feature present"></div>
<div class="feature" ng-class="plan.bus_features ? 'present' : ''"></div> <div class="feature present"></div>
<div class="feature" ng-class="plan.bus_features ? 'present' : ''"></div> <div class="feature" ng-class="plan.bus_features ? 'present' : ''"></div>
<div class="feature" ng-class="plan.bus_features ? 'present' : ''"></div> <div class="feature" ng-class="plan.bus_features ? 'present' : ''"></div>
<div class="feature present"></div> <div class="feature present"></div>
@ -93,9 +93,9 @@
<div class="feature present">SSL Encryption</div> <div class="feature present">SSL Encryption</div>
<div class="feature present">Robot accounts</div> <div class="feature present">Robot accounts</div>
<div class="feature present">Dockerfile Build</div> <div class="feature present">Dockerfile Build</div>
<div class="feature present">Invoice History</div>
<div class="feature" ng-class="plan.bus_features ? 'present' : 'notpresent'">Teams</div> <div class="feature" ng-class="plan.bus_features ? 'present' : 'notpresent'">Teams</div>
<div class="feature" ng-class="plan.bus_features ? 'present' : 'notpresent'">Logging</div> <div class="feature" ng-class="plan.bus_features ? 'present' : 'notpresent'">Logging</div>
<div class="feature" ng-class="plan.bus_features ? 'present' : 'notpresent'">Invoice History</div>
<div class="feature present">Free Trial</div> <div class="feature present">Free Trial</div>
</div> </div>

View file

@ -18,7 +18,8 @@
<div class="col-md-2"> <div class="col-md-2">
<ul class="nav nav-pills nav-stacked"> <ul class="nav nav-pills nav-stacked">
<li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#permissions">Permissions</a></li> <li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#permissions">Permissions</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#trigger" ng-click="loadTriggers()">Build Triggers</a></li> <li><a href="javascript:void(0)" data-toggle="tab" data-target="#trigger" ng-click="loadTriggers()"
quay-require="['BUILD_SUPPORT']">Build Triggers</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#badge">Status Badge</a></li> <li><a href="javascript:void(0)" data-toggle="tab" data-target="#badge">Status Badge</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#notification" ng-click="loadNotifications()">Notifications</a></li> <li><a href="javascript:void(0)" data-toggle="tab" data-target="#notification" ng-click="loadNotifications()">Notifications</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#publicprivate">Public/Private</a></li> <li><a href="javascript:void(0)" data-toggle="tab" data-target="#publicprivate">Public/Private</a></li>
@ -225,7 +226,7 @@
</div> </div>
<!-- Triggers tab --> <!-- Triggers tab -->
<div id="trigger" class="tab-pane"> <div id="trigger" class="tab-pane" quay-require="['BUILD_SUPPORT']">
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading">Build Triggers <div class="panel-heading">Build Triggers
<i class="info-icon fa fa-info-circle" data-placement="left" data-content="Triggers from various services (such as GitHub) which tell the repository to be built and updated."></i> <i class="info-icon fa fa-info-circle" data-placement="left" data-content="Triggers from various services (such as GitHub) which tell the repository to be built and updated."></i>
@ -377,24 +378,6 @@
counter="showNewNotificationCounter" counter="showNewNotificationCounter"
notification-created="handleNotificationCreated(notification)"></div> notification-created="handleNotificationCreated(notification)"></div>
<!-- Modal message dialog -->
<div class="modal fade" id="cannotchangeModal">
<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">Cannot change</h4>
</div>
<div class="modal-body">
The selected action could not be performed because you do not have that authority.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- Modal message dialog --> <!-- Modal message dialog -->
<div class="modal fade" id="makepublicModal"> <div class="modal fade" id="makepublicModal">
<div class="modal-dialog"> <div class="modal-dialog">
@ -441,26 +424,6 @@
</div><!-- /.modal --> </div><!-- /.modal -->
<!-- Modal message dialog -->
<div class="modal fade" id="channgechangepermModal">
<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">Cannot change permissions</h4>
</div>
<div class="modal-body">
<span ng-show="!changePermError">You do not have permission to change the permissions on the repository.</span>
<span ng-show="changePermError">{{ changePermError }}</span>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- Modal message dialog --> <!-- Modal message dialog -->
<div class="modal fade" id="confirmdeleteModal"> <div class="modal fade" id="confirmdeleteModal">
<div class="modal-dialog"> <div class="modal-dialog">

View file

@ -8,9 +8,6 @@
<div class="col-md-2"> <div class="col-md-2">
<ul class="nav nav-pills nav-stacked"> <ul class="nav nav-pills nav-stacked">
<li class="active"> <li class="active">
<a href="javascript:void(0)" data-toggle="tab" data-target="#license">License and Usage</a>
</li>
<li>
<a href="javascript:void(0)" data-toggle="tab" data-target="#users" ng-click="loadUsers()">Users</a> <a href="javascript:void(0)" data-toggle="tab" data-target="#users" ng-click="loadUsers()">Users</a>
</li> </li>
</ul> </ul>
@ -19,19 +16,8 @@
<!-- Content --> <!-- Content -->
<div class="col-md-10"> <div class="col-md-10">
<div class="tab-content"> <div class="tab-content">
<!-- License tab -->
<div id="license" class="tab-pane active">
<div class="quay-spinner 3x" ng-show="usageLoading"></div>
<!-- Chart -->
<div>
<div id="seat-usage-chart" class="usage-chart limit-{{limit}}"></div>
<span class="usage-caption" ng-show="chart">Seat Usage</span>
</div>
</div>
<!-- Users tab --> <!-- Users tab -->
<div id="users" class="tab-pane"> <div id="users" class="tab-pane active">
<div class="quay-spinner" ng-show="!users"></div> <div class="quay-spinner" ng-show="!users"></div>
<div class="alert alert-error" ng-show="usersError"> <div class="alert alert-error" ng-show="usersError">
{{ usersError }} {{ usersError }}

View file

@ -25,7 +25,7 @@
<li ng-show="hasPaidPlan" quay-require="['BILLING']"> <li ng-show="hasPaidPlan" quay-require="['BILLING']">
<a href="javascript:void(0)" data-toggle="tab" data-target="#billingoptions">Billing Options</a> <a href="javascript:void(0)" data-toggle="tab" data-target="#billingoptions">Billing Options</a>
</li> </li>
<li ng-show="hasPaidBusinessPlan" quay-require="['BILLING']"> <li ng-show="hasPaidPlan" quay-require="['BILLING']">
<a href="javascript:void(0)" data-toggle="tab" data-target="#billing" ng-click="loadInvoices()">Billing History</a> <a href="javascript:void(0)" data-toggle="tab" data-target="#billing" ng-click="loadInvoices()">Billing History</a>
</li> </li>

View file

@ -18,7 +18,7 @@
<div class="dropdown" data-placement="top" style="display: inline-block" <div class="dropdown" data-placement="top" style="display: inline-block"
bs-tooltip="" bs-tooltip=""
data-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"> quay-show="Features.BUILD_SUPPORT && (repo.can_write || buildHistory.length)">
<button class="btn btn-default dropdown-toggle" data-toggle="dropdown"> <button class="btn btn-default dropdown-toggle" data-toggle="dropdown">
<i class="fa fa-tasks fa-lg"></i> <i class="fa fa-tasks fa-lg"></i>
<span class="count" ng-class="runningBuilds.length ? 'visible' : ''"><span>{{ runningBuilds.length ? runningBuilds.length : '' }}</span></span> <span class="count" ng-class="runningBuilds.length ? 'visible' : ''"><span>{{ runningBuilds.length ? runningBuilds.length : '' }}</span></span>
@ -58,16 +58,9 @@
<span class="pull-command visible-md-inline"> <span class="pull-command visible-md-inline">
<div class="pull-container" data-title="Pull repository" bs-tooltip="tooltip.title"> <div class="pull-container" data-title="Pull repository" bs-tooltip="tooltip.title">
<div class="input-group"> <div class="input-group">
<input id="pull-text" type="text" class="form-control" value="{{ 'docker pull ' + Config.getDomain() + '/' + repo.namespace + '/' + repo.name }}" readonly> <div class="copy-box" hovering-message="true" value="'docker pull ' + Config.getDomain() + '/' + repo.namespace + '/' + repo.name"></div>
<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> </div>
</div> </div>
<div id="clipboardCopied" class="hovering" style="display: none">
Copied to clipboard
</div>
</span> </span>
</div> </div>
</div> </div>

View file

@ -35,23 +35,18 @@
</div><!-- /.modal-dialog --> </div><!-- /.modal-dialog -->
</div><!-- /.modal --> </div><!-- /.modal -->
{% if not has_billing %}
<!-- Modal message dialog --> <!-- Modal message dialog -->
<div class="modal fade" id="overlicenseModal" data-backdrop="static"> <div class="modal fade" id="cannotContactService" data-backdrop="static">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title">Cannot create user</h4> <h4 class="modal-title">Cannot Contact External Service</h4>
</div> </div>
<div class="modal-body"> <div class="modal-body">
A new user cannot be created as this organization has reached its licensed seat count. Please contact your administrator. A connection to an external service has failed. Please reload the page to try again.
</div>
<div class="modal-footer">
<a href="javascript:void(0)" class="btn btn-primary" data-dismiss="modal" onclick="location = '/signin'">Sign In</a>
</div> </div>
</div><!-- /.modal-content --> </div><!-- /.modal-content -->
</div><!-- /.modal-dialog --> </div><!-- /.modal-dialog -->
</div><!-- /.modal --> </div><!-- /.modal -->
{% endif %}
{% endblock %} {% endblock %}

Binary file not shown.

View file

@ -196,7 +196,7 @@ def build_index_specs():
IndexTestSpec(url_for('index.put_repository_auth', repository=PUBLIC_REPO), IndexTestSpec(url_for('index.put_repository_auth', repository=PUBLIC_REPO),
NO_REPO, 501, 501, 501, 501).set_method('PUT'), NO_REPO, 501, 501, 501, 501).set_method('PUT'),
IndexTestSpec(url_for('index.get_search'), NO_REPO, 501, 501, 501, 501), IndexTestSpec(url_for('index.get_search'), NO_REPO, 200, 200, 200, 200),
IndexTestSpec(url_for('index.ping'), NO_REPO, 200, 200, 200, 200), IndexTestSpec(url_for('index.ping'), NO_REPO, 200, 200, 200, 200),

View file

@ -14,7 +14,9 @@ from endpoints.api.search import FindRepositories, EntitySearch
from endpoints.api.image import RepositoryImageChanges, RepositoryImage, RepositoryImageList from endpoints.api.image import RepositoryImageChanges, RepositoryImage, RepositoryImageList
from endpoints.api.build import (FileDropResource, RepositoryBuildStatus, RepositoryBuildLogs, from endpoints.api.build import (FileDropResource, RepositoryBuildStatus, RepositoryBuildLogs,
RepositoryBuildList) RepositoryBuildList)
from endpoints.api.robot import UserRobotList, OrgRobot, OrgRobotList, UserRobot from endpoints.api.robot import (UserRobotList, OrgRobot, OrgRobotList, UserRobot,
RegenerateOrgRobot, RegenerateUserRobot)
from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs, from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs,
TriggerBuildList, ActivateBuildTrigger, BuildTrigger, TriggerBuildList, ActivateBuildTrigger, BuildTrigger,
BuildTriggerList, BuildTriggerAnalyze) BuildTriggerList, BuildTriggerAnalyze)
@ -37,7 +39,7 @@ from endpoints.api.repository import RepositoryList, RepositoryVisibility, Repos
from endpoints.api.permission import (RepositoryUserPermission, RepositoryTeamPermission, from endpoints.api.permission import (RepositoryUserPermission, RepositoryTeamPermission,
RepositoryTeamPermissionList, RepositoryUserPermissionList) RepositoryTeamPermissionList, RepositoryUserPermissionList)
from endpoints.api.superuser import SuperUserLogs, SeatUsage, SuperUserList, SuperUserManagement from endpoints.api.superuser import SuperUserLogs, SuperUserList, SuperUserManagement
try: try:
@ -1632,6 +1634,19 @@ class TestOrgRobotBuynlargeZ7pd(ApiTestCase):
ApiTestCase.setUp(self) ApiTestCase.setUp(self)
self._set_url(OrgRobot, orgname="buynlarge", robot_shortname="Z7PD") self._set_url(OrgRobot, orgname="buynlarge", robot_shortname="Z7PD")
def test_get_anonymous(self):
self._run_test('GET', 401, None, None)
def test_get_freshuser(self):
self._run_test('GET', 403, 'freshuser', None)
def test_get_reader(self):
self._run_test('GET', 403, 'reader', None)
def test_get_devtable(self):
self._run_test('GET', 400, 'devtable', None)
def test_put_anonymous(self): def test_put_anonymous(self):
self._run_test('PUT', 401, None, None) self._run_test('PUT', 401, None, None)
@ -1644,6 +1659,7 @@ class TestOrgRobotBuynlargeZ7pd(ApiTestCase):
def test_put_devtable(self): def test_put_devtable(self):
self._run_test('PUT', 400, 'devtable', None) self._run_test('PUT', 400, 'devtable', None)
def test_delete_anonymous(self): def test_delete_anonymous(self):
self._run_test('DELETE', 401, None, None) self._run_test('DELETE', 401, None, None)
@ -3040,6 +3056,19 @@ class TestUserRobot5vdy(ApiTestCase):
ApiTestCase.setUp(self) ApiTestCase.setUp(self)
self._set_url(UserRobot, robot_shortname="robotname") self._set_url(UserRobot, robot_shortname="robotname")
def test_get_anonymous(self):
self._run_test('GET', 401, None, None)
def test_get_freshuser(self):
self._run_test('GET', 400, 'freshuser', None)
def test_get_reader(self):
self._run_test('GET', 400, 'reader', None)
def test_get_devtable(self):
self._run_test('GET', 400, 'devtable', None)
def test_put_anonymous(self): def test_put_anonymous(self):
self._run_test('PUT', 401, None, None) self._run_test('PUT', 401, None, None)
@ -3052,6 +3081,7 @@ class TestUserRobot5vdy(ApiTestCase):
def test_put_devtable(self): def test_put_devtable(self):
self._run_test('PUT', 201, 'devtable', None) self._run_test('PUT', 201, 'devtable', None)
def test_delete_anonymous(self): def test_delete_anonymous(self):
self._run_test('DELETE', 401, None, None) self._run_test('DELETE', 401, None, None)
@ -3065,6 +3095,42 @@ class TestUserRobot5vdy(ApiTestCase):
self._run_test('DELETE', 400, 'devtable', None) self._run_test('DELETE', 400, 'devtable', None)
class TestRegenerateUserRobot(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(RegenerateUserRobot, robot_shortname="robotname")
def test_post_anonymous(self):
self._run_test('POST', 401, None, None)
def test_post_freshuser(self):
self._run_test('POST', 400, 'freshuser', None)
def test_post_reader(self):
self._run_test('POST', 400, 'reader', None)
def test_post_devtable(self):
self._run_test('POST', 400, 'devtable', None)
class TestRegenerateOrgRobot(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(RegenerateOrgRobot, orgname="buynlarge", robot_shortname="robotname")
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', 400, 'devtable', None)
class TestOrganizationBuynlarge(ApiTestCase): class TestOrganizationBuynlarge(ApiTestCase):
def setUp(self): def setUp(self):
ApiTestCase.setUp(self) ApiTestCase.setUp(self)

View file

@ -16,7 +16,8 @@ from endpoints.api.tag import RepositoryTagImages, RepositoryTag
from endpoints.api.search import FindRepositories, EntitySearch from endpoints.api.search import FindRepositories, EntitySearch
from endpoints.api.image import RepositoryImage, RepositoryImageList from endpoints.api.image import RepositoryImage, RepositoryImageList
from endpoints.api.build import RepositoryBuildStatus, RepositoryBuildLogs, RepositoryBuildList from endpoints.api.build import RepositoryBuildStatus, RepositoryBuildLogs, RepositoryBuildList
from endpoints.api.robot import UserRobotList, OrgRobot, OrgRobotList, UserRobot from endpoints.api.robot import (UserRobotList, OrgRobot, OrgRobotList, UserRobot,
RegenerateUserRobot, RegenerateOrgRobot)
from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs, from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs,
TriggerBuildList, ActivateBuildTrigger, BuildTrigger, TriggerBuildList, ActivateBuildTrigger, BuildTrigger,
BuildTriggerList, BuildTriggerAnalyze) BuildTriggerList, BuildTriggerAnalyze)
@ -40,7 +41,7 @@ from endpoints.api.organization import (OrganizationList, OrganizationMember,
from endpoints.api.repository import RepositoryList, RepositoryVisibility, Repository from endpoints.api.repository import RepositoryList, RepositoryVisibility, Repository
from endpoints.api.permission import (RepositoryUserPermission, RepositoryTeamPermission, from endpoints.api.permission import (RepositoryUserPermission, RepositoryTeamPermission,
RepositoryTeamPermissionList, RepositoryUserPermissionList) RepositoryTeamPermissionList, RepositoryUserPermissionList)
from endpoints.api.superuser import SuperUserLogs, SeatUsage, SuperUserList, SuperUserManagement from endpoints.api.superuser import SuperUserLogs, SuperUserList, SuperUserManagement
try: try:
app.register_blueprint(api_bp, url_prefix='/api') app.register_blueprint(api_bp, url_prefix='/api')
@ -1751,6 +1752,30 @@ class TestUserRobots(ApiTestCase):
robots = self.getRobotNames() robots = self.getRobotNames()
assert not NO_ACCESS_USER + '+bender' in robots assert not NO_ACCESS_USER + '+bender' in robots
def test_regenerate(self):
self.login(NO_ACCESS_USER)
# Create a robot.
json = self.putJsonResponse(UserRobot,
params=dict(robot_shortname='bender'),
expected_code=201)
token = json['token']
# Regenerate the robot.
json = self.postJsonResponse(RegenerateUserRobot,
params=dict(robot_shortname='bender'),
expected_code=200)
# Verify the token changed.
self.assertNotEquals(token, json['token'])
json2 = self.getJsonResponse(UserRobot,
params=dict(robot_shortname='bender'),
expected_code=200)
self.assertEquals(json['token'], json2['token'])
class TestOrgRobots(ApiTestCase): class TestOrgRobots(ApiTestCase):
def getRobotNames(self): def getRobotNames(self):
@ -1780,6 +1805,31 @@ class TestOrgRobots(ApiTestCase):
assert not ORGANIZATION + '+bender' in robots assert not ORGANIZATION + '+bender' in robots
def test_regenerate(self):
self.login(ADMIN_ACCESS_USER)
# Create a robot.
json = self.putJsonResponse(OrgRobot,
params=dict(orgname=ORGANIZATION, robot_shortname='bender'),
expected_code=201)
token = json['token']
# Regenerate the robot.
json = self.postJsonResponse(RegenerateOrgRobot,
params=dict(orgname=ORGANIZATION, robot_shortname='bender'),
expected_code=200)
# Verify the token changed.
self.assertNotEquals(token, json['token'])
json2 = self.getJsonResponse(OrgRobot,
params=dict(orgname=ORGANIZATION, robot_shortname='bender'),
expected_code=200)
self.assertEquals(json['token'], json2['token'])
class TestLogs(ApiTestCase): class TestLogs(ApiTestCase):
def test_user_logs(self): def test_user_logs(self):
self.login(ADMIN_ACCESS_USER) self.login(ADMIN_ACCESS_USER)

View file

@ -1,38 +0,0 @@
import argparse
import pickle
from Crypto.PublicKey import RSA
from datetime import datetime, timedelta
def encrypt(message, output_filename):
private_key_file = 'conf/stack/license_key'
with open(private_key_file, 'r') as private_key:
encryptor = RSA.importKey(private_key)
encrypted_data = encryptor.decrypt(message)
with open(output_filename, 'wb') as encrypted_file:
encrypted_file.write(encrypted_data)
parser = argparse.ArgumentParser(description='Create a license file.')
parser.add_argument('--users', type=int, default=20,
help='Number of users allowed by the license')
parser.add_argument('--days', type=int, default=30,
help='Number of days for which the license is valid')
parser.add_argument('--warn', type=int, default=7,
help='Number of days prior to expiration to warn users')
parser.add_argument('--output', type=str, required=True,
help='File in which to store the license')
if __name__ == "__main__":
args = parser.parse_args()
print ('Creating license for %s users for %s days in file: %s' %
(args.users, args.days, args.output))
license_data = {
'LICENSE_EXPIRATION': datetime.utcnow() + timedelta(days=args.days),
'LICENSE_USER_LIMIT': args.users,
'LICENSE_EXPIRATION_WARNING': datetime.utcnow() + timedelta(days=(args.days - args.warn)),
}
encrypt(pickle.dumps(license_data, 2), args.output)

View file

@ -1,4 +1,4 @@
from app import stripe import stripe
from app import app from app import app
from util.invoice import renderInvoiceToHtml from util.invoice import renderInvoiceToHtml

View file

@ -0,0 +1,31 @@
FROM phusion/baseimage:0.9.9
ENV DEBIAN_FRONTEND noninteractive
ENV HOME /root
ENV UPDATE_APT 2
RUN apt-get update
# Install LAMP
RUN apt-get install -y lamp-server^
# Install phpMyAdmin
RUN mysqld & \
service apache2 start; \
sleep 5; \
printf y\\n\\n\\n1\\n | apt-get install -y phpmyadmin; \
sleep 15; \
mysqladmin -u root shutdown
# Setup phpmyadmin to run
RUN echo "Include /etc/phpmyadmin/apache.conf" >> /etc/apache2/apache2.conf
RUN rm /etc/phpmyadmin/config.inc.php
ADD config.inc.php /etc/phpmyadmin/config.inc.php
ADD run-admin.sh /etc/service/phpadmin/run
EXPOSE 80
CMD ["/sbin/my_init"]

View file

@ -0,0 +1,59 @@
<?php
/**
* Debian local configuration file
*
* This file overrides the settings made by phpMyAdmin interactive setup
* utility.
*
* For example configuration see
* /usr/share/doc/phpmyadmin/examples/config.sample.inc.php
* or
* /usr/share/doc/phpmyadmin/examples/config.manyhosts.inc.php
*
* NOTE: do not add security sensitive data to this file (like passwords)
* unless you really know what you're doing. If you do, any user that can
* run PHP or CGI on your webserver will be able to read them. If you still
* want to do this, make sure to properly secure the access to this file
* (also on the filesystem level).
*/
// Load secret generated on postinst
include('/var/lib/phpmyadmin/blowfish_secret.inc.php');
// Load autoconf local config
include('/var/lib/phpmyadmin/config.inc.php');
/**
* Server(s) configuration
*/
$i = 0;
// The $cfg['Servers'] array starts with $cfg['Servers'][1]. Do not use $cfg['Servers'][0].
// You can disable a server config entry by setting host to ''.
$i++;
/**
* Read configuration from dbconfig-common
* You can regenerate it using: dpkg-reconfigure -plow phpmyadmin
*/
if (is_readable('/etc/phpmyadmin/config-db.php')) {
require('/etc/phpmyadmin/config-db.php');
} else {
error_log('phpmyadmin: Failed to load /etc/phpmyadmin/config-db.php.'
. ' Check group www-data has read access.');
}
$cfg['Servers'][$i]['auth_type'] = 'HTTP';
$cfg['Servers'][$i]['hide_db'] = '(mysql|information_schema|phpmyadmin)';
/* Server parameters */
$cfg['Servers'][$i]['host'] = 'db1.quay.io';
$cfg['Servers'][$i]['ssl'] = true;
/*
* End of servers configuration
*/
/*
* Directories for saving/loading files from server
*/
$cfg['UploadDir'] = '';
$cfg['SaveDir'] = '';

4
tools/phpmyadmin/run-admin.sh Executable file
View file

@ -0,0 +1,4 @@
#! /bin/bash
service apache2 start
mysqld

View file

@ -1,4 +1,3 @@
from app import stripe
from app import app from app import app
from util.useremails import send_confirmation_email from util.useremails import send_confirmation_email

View file

@ -30,7 +30,11 @@ class SendToMixpanel(Process):
while True: while True:
mp_request = self._mp_queue.get() mp_request = self._mp_queue.get()
logger.debug('Got queued mixpanel reqeust.') logger.debug('Got queued mixpanel reqeust.')
try:
self._consumer.send(*json.loads(mp_request)) self._consumer.send(*json.loads(mp_request))
except:
# Make sure we don't crash if Mixpanel request fails.
pass
class FakeMixpanel(object): class FakeMixpanel(object):

View file

@ -1,55 +0,0 @@
import calendar
import sys
from email.utils import formatdate
from apscheduler.schedulers.background import BackgroundScheduler
from datetime import datetime, timedelta
from data import model
class ExpirationScheduler(object):
def __init__(self, utc_create_notifications_date, utc_terminate_processes_date):
self._scheduler = BackgroundScheduler()
self._termination_date = utc_terminate_processes_date
soon = datetime.now() + timedelta(seconds=1)
if utc_create_notifications_date > datetime.utcnow():
self._scheduler.add_job(model.delete_all_notifications_by_kind, 'date', run_date=soon,
args=['expiring_license'])
local_notifications_date = self._utc_to_local(utc_create_notifications_date)
self._scheduler.add_job(self._generate_notifications, 'date',
run_date=local_notifications_date)
else:
self._scheduler.add_job(self._generate_notifications, 'date', run_date=soon)
local_termination_date = self._utc_to_local(utc_terminate_processes_date)
self._scheduler.add_job(self._terminate, 'date', run_date=local_termination_date)
@staticmethod
def _format_date(date):
""" Output an RFC822 date format. """
if date is None:
return None
return formatdate(calendar.timegm(date.utctimetuple()))
@staticmethod
def _utc_to_local(utc_dt):
# get integer timestamp to avoid precision lost
timestamp = calendar.timegm(utc_dt.timetuple())
local_dt = datetime.fromtimestamp(timestamp)
return local_dt.replace(microsecond=utc_dt.microsecond)
def _generate_notifications(self):
for user in model.get_active_users():
model.create_unique_notification('expiring_license', user,
{'expires_at': self._format_date(self._termination_date)})
@staticmethod
def _terminate():
sys.exit(1)
def start(self):
self._scheduler.start()

View file

@ -44,9 +44,9 @@ def matches_system_error(status_str):
KNOWN_MATCHES = ['lxc-start: invalid', 'lxc-start: failed to', 'lxc-start: Permission denied'] KNOWN_MATCHES = ['lxc-start: invalid', 'lxc-start: failed to', 'lxc-start: Permission denied']
for match in KNOWN_MATCHES: for match in KNOWN_MATCHES:
# 4 because we might have a Unix control code at the start. # 10 because we might have a Unix control code at the start.
found = status_str.find(match[0:len(match) + 4]) found = status_str.find(match[0:len(match) + 10])
if found >= 0 and found <= 4: if found >= 0 and found <= 10:
return True return True
return False return False
@ -477,6 +477,7 @@ class DockerfileBuildWorker(Worker):
container['Id'], container['Command']) container['Id'], container['Command'])
docker_cl.kill(container['Id']) docker_cl.kill(container['Id'])
self._timeout.set() self._timeout.set()
except ConnectionError as exc: except ConnectionError as exc:
raise WorkerUnhealthyException(exc.message) raise WorkerUnhealthyException(exc.message)
@ -521,16 +522,6 @@ class DockerfileBuildWorker(Worker):
logger.info(filetype_msg) logger.info(filetype_msg)
log_appender(filetype_msg) log_appender(filetype_msg)
if c_type not in self._mime_processors:
log_appender('error', build_logs.PHASE)
repository_build.phase = 'error'
repository_build.save()
message = 'Unknown mime-type: %s' % c_type
log_appender(message, build_logs.ERROR)
raise JobException(message)
build_dir = self._mime_processors[c_type](docker_resource)
# Spawn a notification that the build has started. # Spawn a notification that the build has started.
event_data = { event_data = {
'build_id': repository_build.uuid, 'build_id': repository_build.uuid,
@ -544,6 +535,7 @@ class DockerfileBuildWorker(Worker):
subpage='build?current=%s' % repository_build.uuid, subpage='build?current=%s' % repository_build.uuid,
pathargs=['build', repository_build.uuid]) pathargs=['build', repository_build.uuid])
# Setup a handler for spawning failure messages. # Setup a handler for spawning failure messages.
def spawn_failure(message, event_data): def spawn_failure(message, event_data):
event_data['error_message'] = message event_data['error_message'] = message
@ -551,6 +543,29 @@ class DockerfileBuildWorker(Worker):
subpage='build?current=%s' % repository_build.uuid, subpage='build?current=%s' % repository_build.uuid,
pathargs=['build', repository_build.uuid]) pathargs=['build', repository_build.uuid])
if c_type not in self._mime_processors:
log_appender('error', build_logs.PHASE)
repository_build.phase = 'error'
repository_build.save()
message = 'Unknown mime-type: %s' % c_type
log_appender(message, build_logs.ERROR)
spawn_failure(message, event_data)
raise JobException(message)
# Try to build the build directory package from the buildpack.
log_appender('unpacking', build_logs.PHASE)
repository_build.phase = 'unpacking'
repository_build.save()
build_dir = None
try:
build_dir = self._mime_processors[c_type](docker_resource)
except Exception as ex:
cur_message = ex.message or 'Error while unpacking build package'
log_appender(cur_message, build_logs.ERROR)
spawn_failure(cur_message, event_data)
raise JobException(cur_message)
# Start the build process. # Start the build process.
try: try:
with DockerfileBuildContext(build_dir, build_subdir, repo, tag_names, access_token, with DockerfileBuildContext(build_dir, build_subdir, repo, tag_names, access_token,
@ -599,6 +614,7 @@ class DockerfileBuildWorker(Worker):
except WorkerUnhealthyException as exc: except WorkerUnhealthyException as exc:
# Spawn a notification that the build has failed. # Spawn a notification that the build has failed.
log_appender('Worker has become unhealthy. Will retry shortly.', build_logs.ERROR)
spawn_failure(exc.message, event_data) spawn_failure(exc.message, event_data)
# Raise the exception to the queue. # Raise the exception to the queue.

View file

@ -1,41 +0,0 @@
import logging
import argparse
import requests
import json
from app import webhook_queue
from workers.worker import Worker
root_logger = logging.getLogger('')
root_logger.setLevel(logging.DEBUG)
FORMAT = '%(asctime)-15s - %(levelname)s - %(pathname)s - %(funcName)s - %(message)s'
formatter = logging.Formatter(FORMAT)
logger = logging.getLogger(__name__)
class WebhookWorker(Worker):
def process_queue_item(self, job_details):
url = job_details['url']
payload = job_details['payload']
headers = {'Content-type': 'application/json'}
try:
resp = requests.post(url, data=json.dumps(payload), headers=headers)
if resp.status_code/100 != 2:
logger.error('%s response for webhook to url: %s' % (resp.status_code,
url))
return False
except requests.exceptions.RequestException as ex:
logger.exception('Webhook was unable to be sent: %s' % ex.message)
return False
return True
logging.config.fileConfig('conf/logging.conf', disable_existing_loggers=False)
worker = WebhookWorker(webhook_queue, poll_period_seconds=15,
reservation_seconds=3600)
worker.start()

View file

@ -102,8 +102,8 @@ class Worker(object):
logger.debug('Running watchdog.') logger.debug('Running watchdog.')
try: try:
self.watchdog() self.watchdog()
except WorkerUnhealthyException: except WorkerUnhealthyException as exc:
logger.error('The worker has encountered an error and will not take new jobs.') logger.error('The worker has encountered an error via watchdog and will not take new jobs: %s' % exc.message)
self.mark_current_incomplete(restore_retry=True) self.mark_current_incomplete(restore_retry=True)
self._stop.set() self._stop.set()
@ -133,10 +133,10 @@ class Worker(object):
logger.warning('An error occurred processing request: %s', current_queue_item.body) logger.warning('An error occurred processing request: %s', current_queue_item.body)
self.mark_current_incomplete(restore_retry=False) self.mark_current_incomplete(restore_retry=False)
except WorkerUnhealthyException: except WorkerUnhealthyException as exc:
logger.error('The worker has encountered an error and will not take new jobs. Job is being requeued.') logger.error('The worker has encountered an error via the job and will not take new jobs: %s' % exc.message)
self._stop.set()
self.mark_current_incomplete(restore_retry=True) self.mark_current_incomplete(restore_retry=True)
self._stop.set()
finally: finally:
# Close the db handle periodically # Close the db handle periodically