Merge master into branch

This commit is contained in:
Joseph Schorr 2014-09-04 18:08:18 -04:00
commit e028d4ae0a
103 changed files with 2319 additions and 1187 deletions

View file

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

View file

@ -4,10 +4,10 @@ ENV DEBIAN_FRONTEND noninteractive
ENV HOME /root
# 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
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
ADD requirements.txt requirements.txt

View file

@ -4,10 +4,10 @@ ENV DEBIAN_FRONTEND noninteractive
ENV HOME /root
# 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
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
ADD requirements.txt requirements.txt
@ -30,6 +30,7 @@ RUN cd grunt && npm install
RUN cd grunt && grunt
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/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/notificationworker /etc/service/notificationworker
# TODO: Remove this after the prod CL push
ADD conf/init/webhookworker /etc/service/webhookworker
# Download any external libs.
RUN mkdir static/fonts static/ldn
RUN venv/bin/python -m external_libraries

52
app.py
View file

@ -1,8 +1,9 @@
import logging
import os
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.login import LoginManager
from flask.ext.mail import Mail
@ -21,11 +22,37 @@ from data.billing import Billing
from data.buildlogs import BuildLogs
from data.queue import WorkQueue
from data.userevent import UserEventsBuilderModule
from license import load_license
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'
LICENSE_FILENAME = 'conf/stack/license.enc'
@ -43,22 +70,17 @@ else:
logger.debug('Loading default config.')
app.config.from_object(DefaultConfig())
if os.path.exists(OVERRIDE_CONFIG_FILENAME):
logger.debug('Applying config file: %s', OVERRIDE_CONFIG_FILENAME)
app.config.from_pyfile(OVERRIDE_CONFIG_FILENAME)
if os.path.exists(OVERRIDE_CONFIG_PY_FILENAME):
logger.debug('Applying config file: %s', OVERRIDE_CONFIG_PY_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, '{}'))
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('No license file found, please check your configuration')
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)
Principal(app, use_sessions=False)

View file

@ -1,5 +1,5 @@
bind = 'unix:/tmp/gunicorn.sock'
workers = 8
workers = 16
worker_class = 'gevent'
timeout = 2000
logconfig = 'conf/logging.conf'

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;
server_name _;

View file

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

View file

@ -41,6 +41,15 @@ PLANS = [
'bus_features': False,
'deprecated': True,
},
{
'title': 'Yacht',
'price': 5000,
'privateRepos': 20,
'stripeId': 'bus-small',
'audience': 'For small businesses',
'bus_features': True,
'deprecated': True,
},
# Active plans
{
@ -74,7 +83,7 @@ PLANS = [
'title': 'Yacht',
'price': 5000,
'privateRepos': 20,
'stripeId': 'bus-small',
'stripeId': 'bus-coreos-trial',
'audience': 'For small businesses',
'bus_features': True,
'deprecated': False,

View file

@ -17,6 +17,8 @@ SCHEME_DRIVERS = {
'mysql': MySQLDatabase,
'mysql+pymysql': MySQLDatabase,
'sqlite': SqliteDatabase,
'postgresql': PostgresqlDatabase,
'postgresql+psycopg2': PostgresqlDatabase,
}
db = Proxy()
@ -32,7 +34,7 @@ def _db_from_url(url, db_kwargs):
if parsed_url.username:
db_kwargs['user'] = parsed_url.username
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)
@ -74,6 +76,8 @@ class User(BaseModel):
organization = BooleanField(default=False, index=True)
robot = BooleanField(default=False, index=True)
invoice_email = BooleanField(default=False)
invalid_login_attempts = IntegerField(default=0)
last_invalid_login = DateTimeField(default=datetime.utcnow)
class TeamRole(BaseModel):

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():
conn = op.get_bind()
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')
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')
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():
conn = op.get_bind()
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')
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')
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

@ -0,0 +1,28 @@
"""Add brute force prevention metadata to the user table.
Revision ID: 4fdb65816b8d
Revises: 43e943c0639f
Create Date: 2014-09-03 12:35:33.722435
"""
# revision identifiers, used by Alembic.
revision = '4fdb65816b8d'
down_revision = '43e943c0639f'
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.add_column('user', sa.Column('invalid_login_attempts', sa.Integer(), nullable=False, server_default="0"))
op.add_column('user', sa.Column('last_invalid_login', sa.DateTime(), nullable=False, server_default=sa.func.now()))
### end Alembic commands ###
def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_column('user', 'last_invalid_login')
op.drop_column('user', 'invalid_login_attempts')
### end Alembic commands ###

View file

@ -203,7 +203,7 @@ def upgrade():
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_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(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
@ -375,7 +375,7 @@ def upgrade():
sa.Column('command', sa.Text(), nullable=True),
sa.Column('repository_id', sa.Integer(), nullable=False),
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.ForeignKeyConstraint(['repository_id'], ['repository.id'], ),
sa.ForeignKeyConstraint(['storage_id'], ['imagestorage.id'], ),

View file

@ -0,0 +1,36 @@
"""add US West location
Revision ID: 82297d834ad
Revises: 47670cbeced
Create Date: 2014-08-15 13:35:23.834079
"""
# revision identifiers, used by Alembic.
revision = '82297d834ad'
down_revision = '47670cbeced'
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['imagestoragelocation'],
[
{'id':8, 'name':'s3_us_west_1'},
])
def downgrade():
schema = gen_sqlalchemy_metadata(all_models)
op.execute(
(imagestoragelocation.delete()
.where(imagestoragelocation.c.name == op.inline_literal('s3_us_west_1')))
)

View file

@ -35,6 +35,7 @@ def upgrade():
{'id':4, 'name':'s3_ap_southeast_2'},
{'id':5, 'name':'s3_ap_northeast_1'},
{'id':6, 'name':'s3_sa_east_1'},
{'id':7, 'name':'local'},
])
op.create_table('imagestorageplacement',

View file

@ -0,0 +1,35 @@
"""Remove the old webhooks table.
Revision ID: f42b0ea7a4d
Revises: 4fdb65816b8d
Create Date: 2014-09-03 13:43:23.391464
"""
# revision identifiers, used by Alembic.
revision = 'f42b0ea7a4d'
down_revision = '4fdb65816b8d'
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_table('webhook')
### end Alembic commands ###
def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.create_table('webhook',
sa.Column('id', mysql.INTEGER(display_width=11), nullable=False),
sa.Column('public_id', mysql.VARCHAR(length=255), nullable=False),
sa.Column('repository_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('parameters', mysql.LONGTEXT(), nullable=False),
sa.ForeignKeyConstraint(['repository_id'], [u'repository.id'], name=u'fk_webhook_repository_repository_id'),
sa.PrimaryKeyConstraint('id'),
mysql_default_charset=u'latin1',
mysql_engine=u'InnoDB'
)
### end Alembic commands ###

View file

@ -1,12 +1,17 @@
import bcrypt
import logging
import datetime
import dateutil.parser
import json
from datetime import datetime, timedelta
from data.database import *
from util.validation import *
from util.names import format_robot_username
from util.backoff import exponential_backoff
EXPONENTIAL_BACKOFF_SCALE = timedelta(seconds=1)
logger = logging.getLogger(__name__)
@ -68,10 +73,15 @@ class TooManyUsersException(DataModelException):
pass
def is_create_user_allowed():
return get_active_user_count() < config.app_config['LICENSE_USER_LIMIT']
class TooManyLoginAttemptsException(Exception):
def __init__(self, message, retry_after):
super(TooManyLoginAttemptsException, self).__init__(message)
self.retry_after = retry_after
def is_create_user_allowed():
return True
def create_user(username, password, email):
""" Creates a regular user, if allowed. """
if not validate_password(password):
@ -181,6 +191,19 @@ def create_robot(robot_shortname, parent):
except Exception as ex:
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):
joined = User.select().join(FederatedLogin).join(LoginService)
@ -191,7 +214,6 @@ def lookup_robot(robot_username):
return found[0]
def verify_robot(robot_username, password):
joined = User.select().join(FederatedLogin).join(LoginService)
found = list(joined.where(FederatedLogin.service_ident == password,
@ -204,6 +226,25 @@ def verify_robot(robot_username, password):
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):
try:
@ -524,11 +565,30 @@ def verify_user(username_or_email, password):
except User.DoesNotExist:
return None
now = datetime.utcnow()
if fetched.invalid_login_attempts > 0:
can_retry_at = exponential_backoff(fetched.invalid_login_attempts, EXPONENTIAL_BACKOFF_SCALE,
fetched.last_invalid_login)
if can_retry_at > now:
retry_after = can_retry_at - now
raise TooManyLoginAttemptsException('Too many login attempts.', retry_after.total_seconds())
if (fetched.password_hash and
bcrypt.hashpw(password, fetched.password_hash) ==
fetched.password_hash):
if fetched.invalid_login_attempts > 0:
fetched.invalid_login_attempts = 0
fetched.save()
return fetched
fetched.invalid_login_attempts += 1
fetched.last_invalid_login = now
fetched.save()
# We weren't able to authorize the user
return None
@ -828,6 +888,34 @@ def get_all_repo_users(namespace_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):
try:
return (Repository
@ -982,7 +1070,8 @@ def find_create_or_link_image(docker_image_id, repository, username, translation
.join(Repository)
.join(Visibility)
.switch(Repository)
.join(RepositoryPermission, JOIN_LEFT_OUTER))
.join(RepositoryPermission, JOIN_LEFT_OUTER)
.where(ImageStorage.uploading == False))
query = (_filter_to_repos_for_user(query, username)
.where(Image.docker_image_id == docker_image_id))
@ -1662,19 +1751,20 @@ def create_notification(kind_name, target, metadata={}):
def create_unique_notification(kind_name, target, metadata={}):
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)
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:
return None
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()
AdminTeam = Team.alias()
AdminTeamMember = TeamMember.alias()
@ -1712,6 +1802,11 @@ def list_notifications(user, kind_name=None, id_filter=None, include_dismissed=F
.switch(Notification)
.where(Notification.uuid == id_filter))
if page:
query = query.paginate(page, limit)
elif limit:
query = query.limit(limit)
return query

View file

@ -1,7 +1,8 @@
import logging
import json
import datetime
from flask import Blueprint, request, make_response, jsonify
from flask import Blueprint, request, make_response, jsonify, session
from flask.ext.restful import Resource, abort, Api, reqparse
from flask.ext.restful.utils.cors import crossdomain
from werkzeug.exceptions import HTTPException
@ -66,6 +67,11 @@ class Unauthorized(ApiException):
ApiException.__init__(self, 'insufficient_scope', 403, 'Unauthorized', payload)
class FreshLoginRequired(ApiException):
def __init__(self, payload=None):
ApiException.__init__(self, 'fresh_login_required', 401, "Requires fresh login", payload)
class ExceedsLicenseException(ApiException):
def __init__(self, payload=None):
ApiException.__init__(self, None, 402, 'Payment Required', payload)
@ -87,6 +93,14 @@ def handle_api_error(error):
return response
@api_bp.app_errorhandler(model.TooManyLoginAttemptsException)
@crossdomain(origin='*', headers=['Authorization', 'Content-Type'])
def handle_too_many_login_attempts(error):
response = make_response('Too many login attempts', 429)
response.headers['Retry-After'] = int(error.retry_after)
return response
def resource(*urls, **kwargs):
def wrapper(api_resource):
if not api_resource:
@ -256,6 +270,26 @@ def require_user_permission(permission_class, scope=None):
require_user_read = require_user_permission(UserReadPermission, scopes.READ_USER)
require_user_admin = require_user_permission(UserAdminPermission, None)
require_fresh_user_admin = require_user_permission(UserAdminPermission, None)
def require_fresh_login(func):
@add_method_metadata('requires_fresh_login', True)
@wraps(func)
def wrapped(*args, **kwargs):
user = get_authenticated_user()
if not user:
raise Unauthorized()
logger.debug('Checking fresh login for user %s', user.username)
last_login = session.get('login_time', datetime.datetime.now() - datetime.timedelta(minutes=60))
valid_span = datetime.datetime.now() - datetime.timedelta(minutes=10)
if last_login >= valid_span:
return func(*args, **kwargs)
raise FreshLoginRequired()
return wrapped
def require_scope(scope_object):

View file

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

View file

@ -119,6 +119,11 @@ def swagger_route_data(include_internal=False, compact=False):
if internal is not None:
new_operation['internal'] = True
if include_internal:
requires_fresh_login = method_metadata(method, 'requires_fresh_login')
if requires_fresh_login is not None:
new_operation['requires_fresh_login'] = True
if not internal or (internal and include_internal):
operations.append(new_operation)

View file

@ -24,6 +24,7 @@ def image_view(image):
'dbid': image.id,
'size': extended_props.image_size,
'locations': list(image.storage.locations),
'uploading': image.storage.uploading,
}

View file

@ -35,6 +35,14 @@ class UserRobotList(ApiResource):
@internal_only
class UserRobot(ApiResource):
""" 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
@nickname('createUserRobot')
def put(self, robot_shortname):
@ -79,6 +87,18 @@ class OrgRobotList(ApiResource):
@related_user_resource(UserRobot)
class OrgRobot(ApiResource):
""" 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)
@nickname('createOrgRobot')
def put(self, orgname, robot_shortname):
@ -103,3 +123,38 @@ class OrgRobot(ApiResource):
return 'Deleted', 204
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):
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):
view = {
@ -74,19 +77,29 @@ def subscribe(user, plan, token, require_business_plan):
log_action('account_change_plan', user.username, {'plan': plan})
except stripe.CardError as e:
return carderror_response(e)
except stripe.APIConnectionError as e:
return connection_response(e)
response_json = subscription_view(cus.subscription, private_repos)
status_code = 201
else:
# Change the plan
cus = billing.Customer.retrieve(user.stripe_id)
try:
cus = billing.Customer.retrieve(user.stripe_id)
except stripe.APIConnectionError as e:
return connection_response(e)
if plan_found['price'] == 0:
if cus.subscription is not None:
# We only have to cancel the subscription if they actually have one
cus.cancel_subscription()
cus.save()
try:
cus.cancel_subscription()
cus.save()
except stripe.APIConnectionError as e:
return connection_response(e)
check_repository_usage(user, plan_found)
log_action('account_change_plan', user.username, {'plan': plan})
@ -101,6 +114,8 @@ def subscribe(user, plan, token, require_business_plan):
cus.save()
except stripe.CardError as e:
return carderror_response(e)
except stripe.APIConnectionError as e:
return connection_response(e)
response_json = subscription_view(cus.subscription, private_repos)
check_repository_usage(user, plan_found)

View file

@ -42,24 +42,6 @@ class SuperUserLogs(ApiResource):
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):
return {
'username': user.username,

View file

@ -350,8 +350,8 @@ class BuildTriggerAnalyze(RepositoryParamResource):
(robot_namespace, shortname) = parse_robot_username(user.username)
return AdministerOrganizationPermission(robot_namespace).can()
repo_perms = model.get_all_repo_users(base_namespace, base_repository)
read_robots = [robot_view(perm.user) for perm in repo_perms if is_valid_robot(perm.user)]
repo_users = list(model.get_all_repo_users_transitive(base_namespace, base_repository))
read_robots = [robot_view(user) for user in repo_users if is_valid_robot(user)]
return {
'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 endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error,
log_action, internal_only, NotFound, require_user_admin,
InvalidToken, require_scope, format_date, hide_if, show_if, license_error)
log_action, internal_only, NotFound, require_user_admin, parse_args,
query_param, InvalidToken, require_scope, format_date, hide_if, show_if,
license_error, require_fresh_login)
from endpoints.api.subscribe import subscribe
from endpoints.common import common_login
from data import model
@ -150,6 +151,7 @@ class User(ApiResource):
return user_view(user)
@require_user_admin
@require_fresh_login
@nickname('changeUserDetails')
@internal_only
@validate_json_request('UpdateUser')
@ -158,7 +160,7 @@ class User(ApiResource):
user = get_authenticated_user()
user_data = request.get_json()
try:
try:
if 'password' in user_data:
logger.debug('Changing password for user: %s', user.username)
log_action('account_change_password', user.username)
@ -363,6 +365,37 @@ class Signin(ApiResource):
return conduct_signin(username, password)
@resource('/v1/signin/verify')
@internal_only
class VerifyUser(ApiResource):
""" Operations for verifying the existing user. """
schemas = {
'VerifyUser': {
'id': 'VerifyUser',
'type': 'object',
'description': 'Information required to verify the signed in user.',
'required': [
'password',
],
'properties': {
'password': {
'type': 'string',
'description': 'The user\'s password',
},
},
},
}
@require_user_admin
@nickname('verifyUser')
@validate_json_request('VerifyUser')
def post(self):
""" Verifies the signed in the user with the specified credentials. """
signin_data = request.get_json()
password = signin_data['password']
return conduct_signin(get_authenticated_user().username, password)
@resource('/v1/signout')
@internal_only
class Signout(ApiResource):
@ -410,11 +443,24 @@ class Recovery(ApiResource):
@internal_only
class UserNotificationList(ApiResource):
@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')
def get(self):
notifications = model.list_notifications(get_authenticated_user())
def get(self, args):
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 {
'notifications': [notification_view(notification) for notification in notifications]
'notifications': [notification_view(notification) for notification in notifications],
'additional': has_more
}

View file

@ -2,8 +2,9 @@ import logging
import urlparse
import json
import string
import datetime
from flask import make_response, render_template, request, abort
from flask import make_response, render_template, request, abort, session
from flask.ext.login import login_user, UserMixin
from flask.ext.principal import identity_changed
from random import SystemRandom
@ -112,6 +113,7 @@ def common_login(db_user):
logger.debug('Successfully signed in as: %s' % db_user.username)
new_identity = QuayDeferredPermissionUser(db_user.username, 'username', {scopes.DIRECT_LOGIN})
identity_changed.send(app, identity=new_identity)
session['login_time'] = datetime.datetime.now()
return True
else:
logger.debug('User could not be logged in, inactive?.')

View file

@ -413,8 +413,39 @@ def put_repository_auth(namespace, repository):
@index.route('/search', methods=['GET'])
@process_auth
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')

View file

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

View file

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

View file

@ -60,7 +60,8 @@ module.exports = function(grunt) {
removeEmptyAttributes: true,
removeRedundantAttributes: true,
removeScriptTypeAttributes: true,
removeStyleLinkTypeAttributes: true
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true // For inline SVG
}
},
quay: {

View file

@ -231,13 +231,15 @@ def initialize_database():
LogEntryKind.create(name='delete_application')
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='delete_repo_webhook')
LogEntryKind.create(name='add_repo_notification')
LogEntryKind.create(name='delete_repo_notification')
LogEntryKind.create(name='regenerate_robot_token')
ImageStorageLocation.create(name='local_eu')
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
pycrypto
logentries
psycopg2
pyyaml
git+https://github.com/DevTable/aniso8601-fake.git
git+https://github.com/DevTable/anunidecode.git

View file

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

View file

@ -9,33 +9,131 @@
}
}
.repo-search {
display: none;
}
@media (min-width: 1200px) {
.repo-search {
display: inline-block;
}
}
#quay-logo {
width: 80px;
margin-right: 30px;
}
#padding-container {
padding: 20px;
padding-top: 20px;
padding-bottom: 6px;
}
.d3-tip {
position: absolute;
left: -100000px;
}
#co-l-footer-wrapper {
clear: both;
min-height: 100%;
height: auto !important;
height: 100%;
margin-bottom: -64px;
}
#co-l-footer-wrapper #co-l-footer-push {
height: 64px;
}
#co-l-footer img {
height: 50px;
margin-top: 8px;
}
#co-l-footer .col-md-4 {
text-align: right;
}
#co-l-footer {
clear: both;
position: relative;
background-color: white;
height: 64px;
min-height: 64px;
overflow: hidden;
margin: 0;
}
.co-img-bg-network {
background: url('/static/img/network-tile.png') left top repeat, linear-gradient(30deg, #2277ad, #144768) no-repeat left top fixed;
background-color: #2277ad;
background-size: auto, 100% 100%;
}
.co-m-navbar {
background-color: white;
margin: 0;
padding-left: 10px;
}
.co-fx-box-shadow {
-webkit-box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
-moz-box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
-ms-box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
-o-box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
}
.co-fx-box-shadow-heavy {
-webkit-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4);
-moz-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4);
-ms-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4);
-o-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4);
box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4);
}
.main-panel {
margin-bottom: 20px;
background-color: #fff;
border: 1px solid transparent;
padding: 30px;
-webkit-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4);
-moz-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4);
-ms-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4);
-o-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4);
box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4);
}
.container {
max-width: none !important;
}
nav.navbar {
border: 0px;
border-radius: 0px;
background-image: linear-gradient(to top, #535C66 0%,#6E8194 100%);
}
nav.navbar-default .navbar-brand {
padding-left: 14px;
border-right: 1px solid rgb(134, 140, 163);
padding-right: 14px;
padding-top: 5px;
height: 50px;
}
nav.navbar-default .navbar-nav>li>a {
color: white;
letter-spacing: 0.5px;
color: #428bca;
font-size: 16px;
}
nav.navbar-default .navbar-nav>li>a.active {
color: #f04c5c;
}
.navbar-default .navbar-nav>li>a:hover, .navbar-default .navbar-nav>li>a:focus {
color: #BEE1FF;
background: #eee;
}
.navbar-default .navbar-nav>.open>a, .navbar-default .navbar-nav>.open>a:hover, .navbar-default .navbar-nav>.open>a:focus {
cursor: pointer;
background: rgba(255, 255, 255, 0.4) !important;
color: white;
}
.notification-view-element {
@ -366,6 +464,22 @@ i.toggle-icon:hover {
.docker-auth-dialog .token-dialog-body .well {
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 {
@ -399,17 +513,6 @@ i.toggle-icon:hover {
margin: 0 auto -176px;
}
.footer-container, .push {
height: 100px;
}
.footer-container.fixed {
position: fixed;
bottom: 16px;
left: 0px;
right: 0px;
}
.button-hidden {
visibility: hidden;
}
@ -489,6 +592,14 @@ i.toggle-icon:hover {
line-height: 25px;
}
.logs-view-element .log .log-description code {
max-width: 300px;
display: inline-block;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
}
.logs-view-element .log-performer {
white-space: nowrap;
}
@ -634,7 +745,7 @@ i.toggle-icon:hover {
}
.user-notification.notification-animated {
width: 21px;
min-width: 21px;
transform: scale(0);
-moz-transform: scale(0);
@ -671,12 +782,12 @@ i.toggle-icon:hover {
.user-tools .user-tool {
font-size: 24px;
margin-top: 14px;
color: white;
color: #428bca;
}
.user-tools i.user-tool:hover {
cursor: pointer;
color: #BEE1FF;
color: #333;
}
.status-box a {
@ -728,7 +839,7 @@ i.toggle-icon:hover {
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;
}
@ -1000,12 +1111,12 @@ i.toggle-icon:hover {
font-size: 1.4em;
}
.plans-list .plan.bus-small {
.plans-list .plan.bus-small, .plans-list .plan.bus-coreos-trial {
border-top: 6px solid #46ac39;
margin-top: -10px;
}
.plans-list .plan.bus-small .plan-box {
.plans-list .plan.bus-small .plan-box, .plans-list .plan.bus-coreos-trial .plan-box {
background: black !important;
}
@ -1040,6 +1151,10 @@ i.toggle-icon:hover {
display: inline;
}
.hidden-xs-inline {
display: inline;
}
@media (min-width: 991px) {
.visible-md-inline {
display: inline;
@ -1056,6 +1171,13 @@ i.toggle-icon:hover {
}
}
@media (max-width: 700px) {
.hidden-xs-inline {
display: none;
}
}
.visible-xl {
display: none;
}
@ -1085,7 +1207,7 @@ i.toggle-icon:hover {
margin-bottom: 6px;
}
.plans-list .plan.bus-small button {
.plans-list .plan.bus-small button, .plans-list .plan.bus-coreos-trial button {
font-size: 1em;
}
@ -1230,14 +1352,64 @@ i.toggle-icon:hover {
margin-right: 5px;
}
.plans .plan-faq dd{
.plans .plan-faq dd {
margin-bottom: 20px;
}
.enterprise-plan .plan-combine {
text-align: center;
padding: 10px;
margin-bottom: 20px;
}
.enterprise-plan .plan-combine .plus {
font-size: 22px;
display: inline-block;
margin-left: 10px;
margin-right: 10px;
}
.enterprise-plan a {
margin-top: 20px;
}
.enterprise-plan .plan-combine img {
height: 45px;
}
.enterprise-tour .tour-section {
margin-bottom: 50px !important;
padding-bottom: 50px !important;
border-bottom: 1px solid #eee !important;
}
.enterprise-tour .tour-section p {
margin: 0 !important;
}
.enterprise-tour .btn {
font-size: 20px;
}
.feature-illustration {
text-align: center;
}
.feature-illustration svg {
margin: 0 auto;
width: auto !important;
max-width: 100%;
max-height: 110px;
}
.loading {
padding: 20px;
}
.jumbotron {
background: transparent;
}
.jumbotron .disclaimer-link {
font-size: .3em;
vertical-align: 23px;
@ -1255,25 +1427,19 @@ i.toggle-icon:hover {
color: #555;
}
.landing-page .wrapper > nav {
display: none;
.landing-page #padding-container {
padding: 0px;
}
.landing-page .nav > li > a {
border-radius: 4px;
.landing-page .main-panel {
padding: 0px;
border: 0px;
padding-bottom: 10px;
}
.landing-page .nav > li > a:hover, .landing-page .nav > li > a:focus {
background: rgba(255, 255, 255, 0.4);
}
.landing-page .nav .user-view {
color: white !important;
font-weight: bold;
}
.landing-page .user-tool {
color: white;
.landing-page.signedin .main-panel {
background: transparent;
box-shadow: none;
}
.landing {
@ -1284,10 +1450,6 @@ i.toggle-icon:hover {
font-size: 14px;
}
.landing-content {
}
.landing-background {
z-index: 0;
@ -1297,10 +1459,13 @@ i.toggle-icon:hover {
left: 0px;
right: 0px;
background-color: #1d1d1d;
background-image: url(../img/landing-back-opt.jpg);
background-repeat: no-repeat;
background-size: cover;
background: url('/static/img/network-tile.png') left top repeat, linear-gradient(30deg, #2277ad, #144768) no-repeat left top fixed;
background-color: #2277ad;
background-size: auto, 100% 100%;
}
.landing-page.signedin .landing-background {
background: transparent;
}
.landing-filter {
@ -1320,6 +1485,55 @@ i.toggle-icon:hover {
z-index: 2;
}
.landing .call-to-action i.fa {
margin-left: 10px;
}
.landing .call-to-action {
height: 40px;
font-size: 18px;
padding-left: 14px;
padding-right: 14px;
padding-top: 2px;
padding-bottom: 2px;
background: rgba(15, 131, 203, 0.6);
display: inline-block;
margin-top: 20px;
}
.landing .announcement {
position: absolute;
z-index: 9;
top: 0px;
left: 0px;
right: 0px;
display: block;
background: rgba(8, 61, 95, 0.6);
min-height: 45px;
text-align: center;
font-size: 14px;
line-height: 45px;
}
.landing .announcement .spacer {
display: inline-block;
width: 45px;
}
.landing .announcement img {
height: 45px;
padding-top: 6px;
padding-bottom: 6px;
}
.landing .announcement .plus {
display: inline-block;
margin-left: 10px;
margin-right: 10px;
}
.landing {
color: white;
@ -1346,104 +1560,6 @@ i.toggle-icon:hover {
font-size: 40px;
}
.landing .header-bar .navbar-brand {
display: none;
}
.landing .header-bar form {
display: none;
}
.landing .header-bar {
font-size: 16px;
position: absolute;
right: 20px;
top: 20px;
color: white;
z-index: 100;
}
.landing .header-bar .user-tools a {
padding-left: 10px;
padding-right: 10px;
background: transparent !important;
}
.landing .header-bar .user-tools i {
margin-top: 0px;
padding: 12px;
border-radius: 4px;
color: white !important;
}
.landing .header-bar .user-tools i:hover {
background: rgba(255, 255, 255, 0.2) !important;
}
.landing .navbar-links a {
font-weight: 600;
color: white;
border-radius: 6px;
letter-spacing: 0.5px;
}
.landing .navbar-links li a:hover {
background: rgba(255, 255, 255, 0.2);
}
.landing .nav .dropdown-menu a {
color: black;
font-weight: normal;
}
@media (max-width: 971px) {
.landing .navbar-collapse {
background: rgba(0, 0, 0, 0.8);
border-radius: 2px;
}
.navbar-header {
float: none;
}
.navbar-toggle {
display: block;
color: white;
font-size: 48px;
line-height: 28px;
padding: 4px;
}
.navbar-collapse {
border-top: 1px solid transparent;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.1);
}
.navbar-collapse.collapse {
display: none!important;
}
.navbar-nav {
float: none!important;
margin: 7.5px -15px;
}
.navbar-nav>li {
float: none;
}
.navbar-nav>li>a {
padding-top: 10px;
padding-bottom: 10px;
}
.navbar-collapse.collapse.in { display: block!important; }
}
.landing .messages b {
color: #59B2FF;
}
.landing .messages h1 {
font-size: 48px;
}
@ -1651,10 +1767,15 @@ form input.ng-valid.ng-dirty,
font-size: 26px;
}
.page-footer {
padding: 10px;
padding-bottom: 0px;
border-top: 1px solid #eee;
.footer-container, .push {
height: 100px;
}
.footer-container.fixed {
position: fixed;
bottom: 16px;
left: 0px;
right: 0px;
}
.page-footer-padder {
@ -1838,6 +1959,7 @@ p.editable:hover i {
}
.right-tag-controls {
cursor: default;
display: inline-block;
float: right;
padding: 4px;
@ -2151,6 +2273,14 @@ p.editable:hover i {
position: relative;
}
.copy-box-element.disabled .input-group-addon {
display: none;
}
.copy-box-element.disabled input {
border-radius: 4px !important;
}
.global-zeroclipboard-container embed {
cursor: pointer;
}
@ -3141,6 +3271,10 @@ p.editable:hover i {
display: inline-block;
}
.table-container {
max-width: 100%;
}
.billing-invoices-element .invoice-title {
padding: 6px;
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="input-group">
<input type="text" class="form-control" value="{{ value }}" readonly>

View file

@ -10,19 +10,33 @@
</div>
<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="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>
<i class="fa fa-refresh" ng-show="supportsRegenerate" ng-click="askRegenerate()"
data-title="Regenerate Token"
data-placement="left"
bs-tooltip></i>
</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()">
<i class="fa fa-download"></i>
<a href="javascript:void(0)" ng-click="downloadCfg(shownRobot)">Download .dockercfg file</a>
</span>
<div id="clipboardCopied" style="display: none">
Copied to clipboard
<div class="clipboard-copied-message" style="display: none">
Copied
</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>
</div>
</div><!-- /.modal-content -->

View file

@ -4,19 +4,19 @@
&equiv;
</button>
<a class="navbar-brand" href="/" target="{{ appLinkTarget() }}">
<img src="/static/img/quay-box-white.png" data-title="Quay.io" data-placement="bottom" bs-tooltip>
<img id="quay-logo" src="/static/img/black-horizontal.svg">
</a>
</div>
<!-- Collapsable stuff -->
<div class="collapse navbar-collapse navbar-ex1-collapse">
<ul class="nav navbar-nav navbar-links">
<li><a ng-href="/tour/" target="{{ appLinkTarget() }}">Tour</a></li>
<li><a ng-href="/repository/" target="{{ appLinkTarget() }}">Repositories</a></li>
<li><a ng-href="/tour/" target="{{ appLinkTarget() }}" quay-section="tour">Tour</a></li>
<li><a ng-href="/repository/" target="{{ appLinkTarget() }}" quay-section="repository">Repositories</a></li>
<li><a href="http://docs.quay.io/" target="_blank">Docs</a></li>
<li><a ng-href="/tutorial/" target="{{ appLinkTarget() }}">Tutorial</a></li>
<li quay-require="['BILLING']"><a ng-href="/plans/" target="{{ appLinkTarget() }}">Pricing</a></li>
<li><a ng-href="{{ user.organizations.length ? '/organizations/' : '/tour/organizations/' }}" target="{{ appLinkTarget() }}">Organizations</a></li>
<li><a ng-href="/tutorial/" target="{{ appLinkTarget() }}" quay-section="tutorial">Tutorial</a></li>
<li quay-require="['BILLING']"><a ng-href="/plans/" target="{{ appLinkTarget() }}" quay-section="plans">Pricing</a></li>
<li><a ng-href="{{ user.organizations.length ? '/organizations/' : '/tour/organizations/' }}" target="{{ appLinkTarget() }}" quay-section="organization">Organizations</a></li>
</ul>
@ -37,15 +37,7 @@
<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" />
{{ user.username }}
<span class="badge user-notification notification-animated"
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>
<span class="notifications-bubble"></span>
<b class="caret"></b>
</a>
<ul class="dropdown-menu">
@ -58,11 +50,7 @@
<a href="javascript:void(0)" data-template="/static/directives/notification-bar.html"
data-animation="am-slide-right" bs-aside="aside" data-container="body">
Notifications
<span class="badge user-notification"
ng-class="notificationService.notificationClasses"
ng-show="notificationService.notifications.length">
{{ notificationService.notifications.length }}
</span>
<span class="notifications-bubble"></span>
</a>
</li>
<li><a ng-href="/organizations/" target="{{ appLinkTarget() }}">Organizations</a></li>

View file

@ -37,7 +37,8 @@
<input id="log-filter" class="form-control" placeholder="Filter Logs" type="text" ng-model="search.$">
</div>
</div>
<div class="table-container">
<table class="table">
<thead>
<th>Description</th>
@ -77,5 +78,6 @@
</tr>
</tbody>
</table>
</div>
</div>
</div>

View file

@ -3,7 +3,10 @@
<div class="aside-content">
<div class="aside-header">
<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 class="aside-body">
<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 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 }}
</div>
</div>

View file

@ -4,16 +4,23 @@
placeholder="Username or E-mail Address" ng-model="user.username" autofocus>
<input type="password" class="form-control input-lg" name="password"
placeholder="Password" ng-model="user.password">
<button class="btn btn-lg btn-primary btn-block" type="submit">Sign In</button>
<span class="social-alternate" quay-require="['GITHUB_LOGIN']">
<i class="fa fa-circle"></i>
<span class="inner-text">OR</span>
</span>
<div class="external-login-button" provider="github" redirect-url="redirectUrl" sign-in-started="markStarted()"></div>
<div class="external-login-button" provider="google" redirect-url="redirectUrl" sign-in-started="markStarted()"></div>
</form>
<div class="alert alert-warning" ng-show="tryAgainSoon > 0">
Too many attempts have been made to login. Please try again in {{ tryAgainSoon }} second<span ng-if="tryAgainSoon != 1">s</span>.
</div>
<span ng-show="tryAgainSoon == 0">
<button class="btn btn-lg btn-primary btn-block" type="submit">Sign In</button>
<span class="social-alternate" quay-show="Features.GITHUB_LOGIN || Features.GOOGLE_LOGIN">
<i class="fa fa-circle"></i>
<span class="inner-text">OR</span>
</span>
<div class="external-login-button" provider="github" redirect-url="redirectUrl" sign-in-started="markStarted()"></div>
<div class="external-login-button" provider="google" redirect-url="redirectUrl" sign-in-started="markStarted()"></div>
</span>
</form>
<div class="alert alert-danger" ng-show="invalidCredentials">Invalid username or password.</div>
<div class="alert alert-danger" ng-show="needsEmailVerification">

View file

@ -20,7 +20,6 @@
</span>
<div class="external-login-button" provider="github"></div>
<div class="external-login-button" provider="google"></div>
<p class="help-block" quay-require="['BILLING']">No credit card required.</p>
</div>
</form>
<div ng-show="registering" style="text-align: center">

View file

@ -226,71 +226,208 @@
<!-- Enterprise -->
<div class="product-tour" ng-if="kind == 'enterprise'">
<div class="product-tour enterprise-tour" ng-if="kind == 'enterprise'">
<div class="tour-section row tour-header">
<div class="col-md-12">
<div class="tour-section-title">Quay.io in your data center</div>
<div class="tour-section-title">Run Quay.io Behind Your Firewall</div>
<div class="tour-section-description">
All of the power of Quay.io, easily deployed to your data center via docker.
</div>
<div class="tour-section-description">
<div class="row">
<div class="alert alert-info col-md-4 col-md-offset-4"><strong>docker run quay.io/quay/enterprise</strong></div>
<div class="col-lg-6 enterprise-plan col-lg-offset-3">
<div class="plan-combine">
<img src="/static/img/quay-logo.png">
<span class="plus">+</span>
<img src="/static/img/coreos.svg" style="height: 50px">
</div>
Quay.io has partnered with CoreOS to offer <a href="https://coreos.com/products/enterprise-registry">Enterprise Registry</a>, a version
of Quay.io that can be hosted behind your firewall.
</div>
</div>
</div>
</div>
<div class="tour-section row">
<div class="col-sm-3 enterprise-icon">
<span class="fa-stack fa-5x">
<i class="fa fa-circle fa-stack-2x"></i>
<i class="fa fa-lock fa-stack-1x fa-inverse"></i>
</span>
</div>
<div class="col-sm-9">
<div class="tour-section-title">Take control of your own security</div>
<div class="tour-section-description">
The nature of machine images is that they often contain keys and passwords. We're pretty proud of the <a href="/security/">security</a> of our hosted offering, but we recognize that there are situations, such as compliance or auditing, where you must control your own end to end security. Quay.io Enterprise Edition runs in your own data centers, inside your firewall.
<div class="tour-section row features">
<div class="col-lg-4 col-md-4 col-sm-4 feature-desc">
<div class="feature-illustration">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
preserveAspectRatio="xMinYMin" width="100px" viewBox="0 0 144 144" enable-background="new 0 0 144 144" xml:space="preserve">
<path fill="none" stroke="#010101" stroke-width="2" stroke-miterlimit="10" d="M124.979,62.559
c0.695,3.413,1.058,6.944,1.058,10.56c0,29.104-23.591,52.695-52.695,52.695c-17.612,0-33.207-8.641-42.776-21.913"/>
<path fill="none" stroke="#010101" stroke-width="2" stroke-miterlimit="10" d="M21.319,81.551c-0.44-2.744-0.671-5.561-0.671-8.431
c0-29.104,23.591-52.695,52.695-52.695c17.442,0,32.906,8.474,42.497,21.532"/>
<polygon fill="#010101" points="122.993,57.123 119.268,67.523 131.435,63.599 "/>
<polygon fill="#010101" points="23.006,87.718 26.73,77.318 14.565,81.242 "/>
<g enable-background="new ">
<path d="M41.5,75.337v-5.679c0-0.204,0.071-0.403,0.215-0.598c0.143-0.194,0.307-0.302,0.491-0.322l4.757-0.737
c0.225-0.716,0.552-1.493,0.982-2.332c-0.696-0.982-1.617-2.159-2.762-3.53c-0.144-0.225-0.215-0.43-0.215-0.613
c0-0.246,0.071-0.45,0.215-0.614c0.45-0.613,1.289-1.523,2.517-2.731c1.228-1.207,2.036-1.811,2.425-1.811
c0.225,0,0.439,0.072,0.645,0.215l3.529,2.762c0.696-0.368,1.483-0.695,2.363-0.981c0.225-2.21,0.46-3.785,0.706-4.727
c0.143-0.491,0.45-0.737,0.921-0.737h5.708c0.225,0,0.43,0.077,0.614,0.23c0.185,0.153,0.287,0.333,0.307,0.537l0.706,4.696
c0.695,0.204,1.462,0.521,2.302,0.951l3.622-2.731c0.164-0.143,0.368-0.215,0.614-0.215c0.225,0,0.44,0.082,0.645,0.245
c2.946,2.722,4.42,4.358,4.42,4.911c0,0.184-0.072,0.379-0.215,0.583c-0.245,0.327-0.675,0.88-1.289,1.657
c-0.614,0.778-1.074,1.392-1.382,1.842c0.471,0.982,0.818,1.821,1.044,2.517l4.665,0.706c0.205,0.041,0.379,0.148,0.521,0.322
c0.144,0.174,0.215,0.373,0.215,0.599v5.678c0,0.205-0.071,0.404-0.215,0.599c-0.143,0.194-0.307,0.302-0.49,0.322l-4.758,0.736
c-0.225,0.717-0.553,1.494-0.982,2.333c0.695,0.981,1.617,2.159,2.763,3.529c0.144,0.205,0.215,0.409,0.215,0.614
c0,0.245-0.071,0.439-0.215,0.583c-0.471,0.613-1.315,1.529-2.532,2.747c-1.217,1.217-2.021,1.826-2.409,1.826
c-0.225,0-0.44-0.072-0.645-0.215l-3.529-2.763c-0.757,0.389-1.545,0.706-2.363,0.951c-0.226,2.21-0.46,3.796-0.706,4.758
c-0.144,0.491-0.45,0.736-0.921,0.736h-5.708c-0.226,0-0.43-0.077-0.614-0.23s-0.287-0.332-0.307-0.537l-0.706-4.695
c-0.696-0.204-1.463-0.522-2.302-0.952l-3.622,2.732c-0.144,0.143-0.348,0.215-0.614,0.215c-0.226,0-0.44-0.082-0.645-0.246
c-2.946-2.721-4.42-4.358-4.42-4.91c0-0.185,0.072-0.379,0.215-0.584c0.205-0.286,0.624-0.828,1.259-1.626
c0.634-0.798,1.115-1.422,1.442-1.872c-0.471-0.9-0.829-1.739-1.074-2.518l-4.665-0.736c-0.205-0.02-0.379-0.117-0.522-0.291
C41.571,75.762,41.5,75.562,41.5,75.337z M55.587,66.988c-1.534,1.535-2.302,3.387-2.302,5.556s0.768,4.021,2.302,5.555
c1.535,1.535,3.386,2.302,5.556,2.302c2.168,0,4.021-0.767,5.555-2.302C68.233,76.564,69,74.713,69,72.544
s-0.767-4.021-2.302-5.556c-1.535-1.534-3.387-2.302-5.555-2.302C58.974,64.687,57.122,65.454,55.587,66.988z M76.857,58.978
v-4.297c0-0.327,1.523-0.645,4.572-0.951c0.266-0.593,0.573-1.125,0.922-1.596c-1.044-2.312-1.566-3.724-1.566-4.235
c0-0.082,0.041-0.154,0.123-0.215c0.082-0.041,0.439-0.246,1.074-0.614s1.238-0.716,1.811-1.044c0.573-0.326,0.88-0.49,0.922-0.49
c0.163,0,0.633,0.476,1.411,1.427c0.777,0.951,1.31,1.642,1.597,2.071c0.408-0.04,0.715-0.061,0.92-0.061s0.512,0.021,0.921,0.061
c1.044-1.452,1.984-2.598,2.823-3.438l0.185-0.061c0.081,0,1.351,0.716,3.806,2.148c0.082,0.061,0.123,0.133,0.123,0.215
c0,0.512-0.521,1.923-1.565,4.235c0.348,0.471,0.655,1.003,0.921,1.596c3.049,0.307,4.573,0.624,4.573,0.951v4.297
c0,0.328-1.524,0.645-4.573,0.952c-0.246,0.552-0.553,1.084-0.921,1.596c1.044,2.312,1.565,3.725,1.565,4.235
c0,0.082-0.041,0.153-0.123,0.215c-2.496,1.453-3.765,2.179-3.806,2.179c-0.164,0-0.634-0.48-1.411-1.442
c-0.778-0.961-1.311-1.657-1.597-2.087c-0.409,0.041-0.716,0.062-0.921,0.062s-0.512-0.021-0.92-0.062
c-0.287,0.43-0.819,1.126-1.597,2.087c-0.778,0.962-1.248,1.442-1.411,1.442c-0.042,0-1.311-0.726-3.807-2.179
c-0.082-0.062-0.123-0.133-0.123-0.215c0-0.511,0.522-1.923,1.566-4.235c-0.369-0.512-0.676-1.044-0.922-1.596
C78.381,59.622,76.857,59.306,76.857,58.978z M76.857,90.406v-4.297c0-0.327,1.523-0.645,4.572-0.951
c0.266-0.594,0.573-1.125,0.922-1.596c-1.044-2.312-1.566-3.725-1.566-4.236c0-0.081,0.041-0.153,0.123-0.215
c0.082-0.04,0.439-0.245,1.074-0.613s1.238-0.716,1.811-1.044c0.573-0.327,0.88-0.491,0.922-0.491c0.163,0,0.633,0.477,1.411,1.428
c0.777,0.951,1.31,1.642,1.597,2.071c0.408-0.04,0.715-0.062,0.92-0.062s0.512,0.021,0.921,0.062
c1.044-1.452,1.984-2.598,2.823-3.438l0.185-0.062c0.081,0,1.351,0.717,3.806,2.148c0.082,0.062,0.123,0.134,0.123,0.215
c0,0.512-0.521,1.924-1.565,4.236c0.348,0.471,0.655,1.002,0.921,1.596c3.049,0.307,4.573,0.624,4.573,0.951v4.297
c0,0.328-1.524,0.645-4.573,0.951c-0.246,0.553-0.553,1.085-0.921,1.597c1.044,2.312,1.565,3.724,1.565,4.235
c0,0.081-0.041,0.153-0.123,0.215c-2.496,1.452-3.765,2.179-3.806,2.179c-0.164,0-0.634-0.48-1.411-1.442
c-0.778-0.962-1.311-1.657-1.597-2.087c-0.409,0.041-0.716,0.062-0.921,0.062s-0.512-0.021-0.92-0.062
c-0.287,0.43-0.819,1.125-1.597,2.087c-0.778,0.962-1.248,1.442-1.411,1.442c-0.042,0-1.311-0.727-3.807-2.179
c-0.082-0.062-0.123-0.134-0.123-0.215c0-0.512,0.522-1.924,1.566-4.235c-0.369-0.512-0.676-1.044-0.922-1.597
C78.381,91.051,76.857,90.734,76.857,90.406z M84.715,56.829c0,1.085,0.383,2.011,1.15,2.778c0.768,0.767,1.693,1.15,2.777,1.15
s2.011-0.384,2.777-1.15c0.768-0.768,1.151-1.693,1.151-2.778c0-1.063-0.389-1.984-1.166-2.762
c-0.778-0.777-1.698-1.167-2.763-1.167s-1.984,0.39-2.762,1.167C85.103,54.845,84.715,55.766,84.715,56.829z M84.715,88.258
c0,1.085,0.383,2.011,1.15,2.777c0.768,0.768,1.693,1.151,2.777,1.151s2.011-0.384,2.777-1.151
c0.768-0.767,1.151-1.692,1.151-2.777c0-1.063-0.389-1.984-1.166-2.762c-0.778-0.777-1.698-1.167-2.763-1.167
s-1.984,0.39-2.762,1.167C85.103,86.273,84.715,87.194,84.715,88.258z"/>
</g>
</svg>
</div>
<h4>Deployment Made Easy</h4>
<p>Trigger container builds when your code is checked into Github and passes tests. Automatically pushed into your repository for immediate access by your servers.</p>
</div>
<div class="col-lg-4 col-md-4 col-sm-4 feature-desc">
<div class="feature-illustration">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
preserveAspectRatio="xMinYMin" width="140px" viewBox="0 0 202.5 144" enable-background="new 0 0 202.5 144" xml:space="preserve">
<circle fill="none" stroke="#010101" stroke-width="1.9753" stroke-miterlimit="10" cx="72" cy="72" r="52.567"/>
<circle fill="none" stroke="#010101" stroke-width="1.9753" stroke-miterlimit="10" cx="132.006" cy="72" r="52.567"/>
<g>
<path fill="#00A3A0" d="M90.699,77.453c0-0.552,0.019-1.091,0.055-1.617s0.109-1.094,0.219-1.703s0.247-1.175,0.414-1.696
c0.167-0.521,0.391-1.028,0.672-1.523c0.281-0.495,0.604-0.917,0.969-1.266c0.364-0.349,0.81-0.627,1.336-0.836
c0.526-0.208,1.106-0.312,1.742-0.312c0.094,0,0.312,0.112,0.656,0.336c0.344,0.224,0.731,0.474,1.164,0.75
c0.433,0.276,0.995,0.526,1.688,0.75c0.692,0.224,1.388,0.336,2.086,0.336s1.394-0.112,2.086-0.336
c0.692-0.224,1.255-0.474,1.688-0.75c0.433-0.276,0.82-0.526,1.164-0.75c0.344-0.224,0.562-0.336,0.656-0.336
c0.636,0,1.216,0.104,1.742,0.312c0.526,0.208,0.972,0.487,1.336,0.836c0.364,0.349,0.688,0.771,0.969,1.266
c0.281,0.495,0.505,1.003,0.672,1.523c0.167,0.521,0.305,1.086,0.414,1.696s0.183,1.177,0.219,1.703s0.055,1.065,0.055,1.617
c0,1.25-0.38,2.236-1.141,2.961c-0.761,0.724-1.771,1.086-3.031,1.086H94.871c-1.261,0-2.271-0.362-3.031-1.086
C91.079,79.689,90.699,78.703,90.699,77.453z M97.457,67.742c-1.172-1.172-1.758-2.586-1.758-4.242s0.586-3.07,1.758-4.242
s2.586-1.758,4.242-1.758s3.07,0.586,4.242,1.758s1.758,2.586,1.758,4.242s-0.586,3.07-1.758,4.242s-2.586,1.758-4.242,1.758
S98.629,68.914,97.457,67.742z"/>
</g>
<g>
<path fill="#4BA0D8" d="M42.782,57.118c0-0.552,0.018-1.091,0.055-1.617c0.036-0.526,0.109-1.094,0.219-1.703
s0.247-1.174,0.414-1.695c0.167-0.521,0.391-1.028,0.672-1.523c0.281-0.495,0.604-0.917,0.969-1.266
c0.364-0.349,0.81-0.627,1.336-0.836c0.526-0.208,1.106-0.312,1.742-0.312c0.094,0,0.312,0.112,0.656,0.336
c0.344,0.224,0.731,0.474,1.164,0.75c0.432,0.276,0.995,0.526,1.688,0.75c0.692,0.224,1.388,0.336,2.086,0.336
c0.698,0,1.393-0.112,2.086-0.336c0.692-0.224,1.255-0.474,1.688-0.75c0.432-0.276,0.82-0.526,1.164-0.75
c0.344-0.224,0.562-0.336,0.656-0.336c0.635,0,1.216,0.104,1.742,0.312c0.526,0.208,0.971,0.487,1.336,0.836
c0.364,0.349,0.688,0.771,0.969,1.266c0.281,0.495,0.505,1.003,0.672,1.523c0.167,0.521,0.305,1.086,0.414,1.695
s0.182,1.177,0.219,1.703c0.036,0.526,0.055,1.065,0.055,1.617c0,1.25-0.38,2.237-1.141,2.961
c-0.761,0.724-1.771,1.086-3.031,1.086H46.954c-1.261,0-2.271-0.362-3.031-1.086C43.162,59.355,42.782,58.368,42.782,57.118z
M49.54,47.407c-1.172-1.172-1.758-2.586-1.758-4.242s0.586-3.07,1.758-4.242s2.586-1.758,4.242-1.758s3.07,0.586,4.242,1.758
s1.758,2.586,1.758,4.242s-0.586,3.07-1.758,4.242s-2.586,1.758-4.242,1.758S50.712,48.579,49.54,47.407z"/>
</g>
<g>
<path fill="#4BA0D8" d="M27.422,90.324c0-0.552,0.018-1.091,0.055-1.617c0.036-0.526,0.109-1.094,0.219-1.703
s0.247-1.175,0.414-1.695c0.167-0.521,0.391-1.028,0.672-1.523s0.604-0.917,0.969-1.266c0.364-0.349,0.81-0.628,1.336-0.836
c0.526-0.208,1.106-0.312,1.742-0.312c0.094,0,0.312,0.112,0.656,0.336s0.731,0.474,1.164,0.75c0.432,0.276,0.995,0.526,1.688,0.75
c0.692,0.224,1.388,0.336,2.086,0.336c0.698,0,1.393-0.112,2.086-0.336c0.692-0.224,1.255-0.474,1.688-0.75
c0.432-0.276,0.82-0.526,1.164-0.75s0.562-0.336,0.656-0.336c0.635,0,1.216,0.104,1.742,0.312c0.526,0.208,0.971,0.487,1.336,0.836
c0.364,0.349,0.688,0.771,0.969,1.266s0.505,1.003,0.672,1.523c0.167,0.521,0.305,1.086,0.414,1.695s0.182,1.177,0.219,1.703
c0.036,0.526,0.055,1.065,0.055,1.617c0,1.25-0.38,2.236-1.141,2.961c-0.761,0.724-1.771,1.086-3.031,1.086H31.594
c-1.261,0-2.271-0.362-3.031-1.086C27.802,92.561,27.422,91.574,27.422,90.324z M34.18,80.613
c-1.172-1.172-1.758-2.586-1.758-4.242s0.586-3.07,1.758-4.242s2.586-1.758,4.242-1.758s3.07,0.586,4.242,1.758
s1.758,2.586,1.758,4.242s-0.586,3.07-1.758,4.242s-2.586,1.758-4.242,1.758S35.352,81.785,34.18,80.613z"/>
</g>
<g>
<path fill="#4BA0D8" d="M54.999,106.043c0-0.552,0.018-1.091,0.055-1.617c0.036-0.525,0.109-1.094,0.219-1.703
s0.247-1.174,0.414-1.695c0.167-0.521,0.391-1.028,0.672-1.523c0.281-0.494,0.604-0.916,0.969-1.266
c0.364-0.349,0.81-0.627,1.336-0.836c0.526-0.208,1.106-0.312,1.742-0.312c0.094,0,0.312,0.112,0.656,0.336
c0.344,0.225,0.731,0.475,1.164,0.75c0.432,0.276,0.995,0.526,1.688,0.75c0.692,0.225,1.388,0.336,2.086,0.336
c0.698,0,1.393-0.111,2.086-0.336c0.692-0.224,1.255-0.474,1.688-0.75c0.432-0.275,0.82-0.525,1.164-0.75
c0.344-0.224,0.562-0.336,0.656-0.336c0.635,0,1.216,0.104,1.742,0.312c0.525,0.209,0.971,0.487,1.336,0.836
c0.364,0.35,0.688,0.771,0.969,1.266c0.281,0.495,0.505,1.003,0.672,1.523c0.166,0.521,0.305,1.086,0.414,1.695
s0.182,1.178,0.219,1.703c0.036,0.526,0.055,1.065,0.055,1.617c0,1.25-0.381,2.237-1.141,2.961
c-0.761,0.724-1.771,1.086-3.031,1.086H59.171c-1.261,0-2.271-0.362-3.031-1.086C55.379,108.28,54.999,107.293,54.999,106.043z
M61.757,96.332c-1.172-1.172-1.758-2.586-1.758-4.242s0.586-3.07,1.758-4.242s2.586-1.758,4.242-1.758s3.07,0.586,4.242,1.758
s1.758,2.586,1.758,4.242s-0.586,3.07-1.758,4.242s-2.586,1.758-4.242,1.758S62.929,97.504,61.757,96.332z"/>
</g>
<g>
<path fill="#00A651" d="M141.867,57.118c0-0.552,0.018-1.091,0.055-1.617c0.035-0.526,0.109-1.094,0.219-1.703
s0.246-1.174,0.414-1.695c0.166-0.521,0.391-1.028,0.672-1.523c0.281-0.495,0.604-0.917,0.969-1.266
c0.363-0.349,0.809-0.627,1.336-0.836c0.525-0.208,1.105-0.312,1.742-0.312c0.094,0,0.312,0.112,0.656,0.336
c0.344,0.224,0.73,0.474,1.164,0.75c0.432,0.276,0.994,0.526,1.688,0.75c0.691,0.224,1.387,0.336,2.086,0.336
c0.697,0,1.393-0.112,2.086-0.336c0.691-0.224,1.254-0.474,1.688-0.75c0.432-0.276,0.82-0.526,1.164-0.75
c0.344-0.224,0.562-0.336,0.656-0.336c0.635,0,1.215,0.104,1.742,0.312c0.525,0.208,0.971,0.487,1.336,0.836
c0.363,0.349,0.688,0.771,0.969,1.266c0.281,0.495,0.504,1.003,0.672,1.523c0.166,0.521,0.305,1.086,0.414,1.695
s0.182,1.177,0.219,1.703c0.035,0.526,0.055,1.065,0.055,1.617c0,1.25-0.381,2.237-1.141,2.961
c-0.762,0.724-1.771,1.086-3.031,1.086h-13.656c-1.262,0-2.271-0.362-3.031-1.086C142.246,59.354,141.867,58.368,141.867,57.118z
M148.625,47.407c-1.172-1.172-1.758-2.586-1.758-4.242s0.586-3.07,1.758-4.242s2.586-1.758,4.242-1.758s3.07,0.586,4.242,1.758
s1.758,2.586,1.758,4.242s-0.586,3.07-1.758,4.242s-2.586,1.758-4.242,1.758S149.797,48.579,148.625,47.407z"/>
</g>
<g>
<path fill="#00A651" d="M132.005,102.458c0-0.552,0.019-1.091,0.055-1.617s0.109-1.094,0.219-1.703s0.247-1.175,0.414-1.695
s0.391-1.028,0.672-1.523s0.604-0.917,0.969-1.266s0.81-0.628,1.336-0.836s1.106-0.312,1.742-0.312
c0.094,0,0.312,0.112,0.656,0.336s0.731,0.474,1.164,0.75s0.995,0.526,1.688,0.75s1.388,0.336,2.086,0.336s1.394-0.112,2.086-0.336
s1.255-0.474,1.688-0.75s0.82-0.526,1.164-0.75s0.562-0.336,0.656-0.336c0.636,0,1.216,0.104,1.742,0.312s0.972,0.487,1.336,0.836
s0.688,0.771,0.969,1.266s0.505,1.003,0.672,1.523s0.305,1.086,0.414,1.695s0.183,1.177,0.219,1.703s0.055,1.065,0.055,1.617
c0,1.25-0.38,2.236-1.141,2.961c-0.761,0.724-1.771,1.086-3.031,1.086h-13.656c-1.261,0-2.271-0.362-3.031-1.086
C132.385,104.694,132.005,103.708,132.005,102.458z M138.763,92.747c-1.172-1.172-1.758-2.586-1.758-4.242s0.586-3.07,1.758-4.242
s2.586-1.758,4.242-1.758s3.07,0.586,4.242,1.758s1.758,2.586,1.758,4.242s-0.586,3.07-1.758,4.242s-2.586,1.758-4.242,1.758
S139.935,93.919,138.763,92.747z"/>
</g>
</svg>
</div>
<h4>Teamwork Optimized</h4>
<p>Control how the different teams and projects within your enterprise collaborate on repositories.</p>
</div>
<div class="col-lg-4 col-md-4 col-sm-4 feature-desc">
<div class="feature-illustration">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
preserveAspectRatio="xMinYMin" width="100px" viewBox="0 0 144 144" enable-background="new 0 0 144 144" xml:space="preserve">
<path d="M103.5,123.357V110.5c0-0.595,0.208-1.101,0.625-1.518c0.416-0.416,0.922-0.625,1.518-0.625h0.715v-4.286
c0-2.737,0.982-5.089,2.946-7.053c1.964-1.965,4.315-2.947,7.054-2.947c2.737,0,5.089,0.982,7.054,2.947
c1.964,1.964,2.946,4.315,2.946,7.053v4.286h0.714c0.595,0,1.102,0.209,1.518,0.625c0.417,0.417,0.625,0.923,0.625,1.518v12.857
c0,0.596-0.208,1.102-0.625,1.518c-0.416,0.417-0.923,0.625-1.518,0.625h-21.429c-0.596,0-1.102-0.208-1.518-0.625
C103.708,124.459,103.5,123.953,103.5,123.357z M110.643,108.357h11.429v-4.286c0-1.577-0.558-2.924-1.674-4.04
s-2.463-1.674-4.04-1.674c-1.578,0-2.924,0.558-4.041,1.674c-1.115,1.116-1.674,2.463-1.674,4.04V108.357z"/>
<polyline fill="none" stroke="#000000" stroke-width="2" stroke-miterlimit="10" points="99.889,122.5 21.5,122.5 21.5,20.5
123.5,20.5 123.5,92.469 "/>
<rect x="32.214" y="61.422" fill="#4CA0D8" width="20.156" height="20.156"/>
<rect x="92.63" y="61.422" fill="#4CA0D8" width="20.156" height="20.156"/>
<rect x="62.422" y="61.422" fill="#4CA0D8" width="20.156" height="20.156"/>
<rect x="32.214" y="31.874" fill="#4CA0D8" width="20.156" height="20.156"/>
<rect x="92.63" y="31.874" fill="#4CA0D8" width="20.156" height="20.156"/>
<rect x="62.422" y="31.874" fill="#4CA0D8" width="20.156" height="20.156"/>
<rect x="32.214" y="91.909" fill="#4CA0D8" width="20.156" height="20.156"/>
<g>
<path fill="#4CA0D8" d="M101.579,106.437c0.361-0.361,0.756-0.666,1.179-0.913v-1.452c0-3.714,1.346-6.943,4-9.599
c1.073-1.073,2.244-1.921,3.499-2.564H92.63v20.156H99.9V110.5C99.9,108.94,100.48,107.535,101.579,106.437z"/>
</g>
<rect x="62.422" y="91.909" fill="#4CA0D8" width="20.156" height="20.156"/>
</svg>
</div>
<h4>Secure, Private Storage</h4>
<p>Containers often contain keys and passwords &mdash; take control of your registry by running it behind your firewall on <a href="https://coreos.com/products/managed-linux" data-category="Products: Managed Linux" data-event="Product: Managed Linux">CoreOS Managed Linux</a>.</p>
</div>
</div>
</div>
<div class="tour-section row">
<div class="col-sm-3 col-sm-push-9 enterprise-icon">
<span class="fa-stack fa-5x">
<i class="fa fa-circle fa-stack-2x"></i>
<i class="fa fa-bar-chart-o fa-stack-1x fa-inverse"></i>
</span>
</div>
<div class="col-sm-9 col-sm-pull-3">
<div class="tour-section-title">Auditing and insight done right</div>
<div class="tour-section-description">
Our platform has built in logging and insight tools, allowing you to see who performed every action on the contents and access control settings of your repositories. This will help you pass internal auditing and compliance requirements more easily.
</div>
</div>
</div>
<div class="tour-section row">
<div class="col-sm-3 enterprise-icon">
<span class="fa-stack fa-5x">
<i class="fa fa-circle fa-stack-2x"></i>
<i class="fa fa-users fa-stack-1x fa-inverse"></i>
</span>
</div>
<div class="col-sm-9">
<div class="tour-section-title">Organize your repositories like you organize your business</div>
<div class="tour-section-description">
The built in teams and organizations features of <span class="registry-name"></span> allow you to finely control how the different teams and projects within your enterprise collaborate on repositories.
</div>
</div>
</div>
<div class="tour-action">
<a href="mailto:support@devtable.com?Subject=Enterprise%20Quay.io%20Inquiry" data-title="Email us to find out more." bs-tooltip="tooltip.title">
<button class="btn btn-success">
Contact Us
</button>
</a>
<div class="row" style="text-align: center; margin-bottom: 20px;">
<a href="https://coreos.com/products/enterprise-registry" class="btn btn-primary">Learn more about Enterprise Registry</a>
</div>
</div>

View file

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 17.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="325.68px" height="112.574px" viewBox="0 0 325.68 112.574" enable-background="new 0 0 325.68 112.574"
xml:space="preserve">
<path d="M95.701,60.164c0,27.658,9.385,30.316,24.336,30.316c14.95-0.083,24.502-3.488,24.502-30.316
c0-28.489-10.133-31.229-24.502-31.229C105.916,28.934,95.701,31.675,95.701,60.164 M107.329,60.164
c0-19.352,3.738-20.515,12.708-20.515c9.22,0,12.874,1.578,12.874,20.515c0,16.363-3.073,19.602-12.874,19.602
C109.654,79.766,107.329,76.775,107.329,60.164z M116.465,92.889c0.665,10.632,11.711,13.538,20.1,11.711v-9.385
c-4.9,1.329-10.881,2.076-13.538-2.326H116.465z"/>
<path d="M150.438,75.364c0,8.472,2.907,15.532,13.123,15.532c6.727,0,9.635-2.824,14.037-5.399l2.243,4.651h9.386V46.044h-11.628
v31.645c-3.405,2.658-5.98,3.821-10.631,3.821c-2.907,0-4.901-1.495-4.901-5.897V46.044h-11.628V75.364z"/>
<path d="M195.457,78.935c0,5.731,3.987,11.462,12.459,11.711c6.728,0.166,12.957-4.402,13.455-5.316l2.409,4.818h9.22V59.417
c0-10.964-6.479-14.203-15.947-14.203c-8.223,0-14.286,0.665-20.1,2.907v7.226c3.571-0.249,14.618-0.914,18.522-0.914
c3.654,0,5.897,0.665,5.897,5.399c0.083,0.083,0,2.824,0,2.824c0,0.083-12.957,0-12.957,0c-9.469,0-12.957,5.648-12.957,12.127
V78.935z M207.085,77.274v-2.16c0-1.91,0.499-4.236,3.572-4.485l10.714-0.831v8.057c0,0-5.316,3.488-10.133,3.488
C209.078,81.344,207.085,80.929,207.085,77.274z"/>
<path d="M236.573,46.044l8.638,32.891c2.159,8.14,6.977,11.213,13.123,11.213l-6.229,22.426h8.306l6.063-8.472l15.283-58.058H269.63
l-8.804,34.137c-0.664-0.083-3.489,0.249-4.402-3.239l-7.641-30.898H236.573z"/>
<path d="M283.668,89.321c0,0.621,0.443,1.021,1.065,1.021h3.815c0.621,0,1.109-0.222,1.109-1.021v-3.992
c0-0.71-0.311-1.109-1.109-1.109h-3.815c-0.71,0-1.065,0.444-1.065,1.109V89.321z"/>
<path d="M294.138,63.281c0,0.621,0.444,1.021,1.065,1.021h4.658c0.621,0,0.976-0.355,0.976-1.021v-3.904
c0-0.621-0.311-1.153-0.976-1.153h-4.658c-0.71,0-1.065,0.444-1.065,1.153V63.281z M294.36,90.342h6.211V66.785h-6.211V90.342z"/>
<path d="M304.208,78.896c0,8.429,2.617,11.667,10.736,11.667c8.251,0,10.735-2.928,10.735-11.667
c0-9.361-2.617-12.333-10.735-12.333C307.003,66.563,304.208,69.27,304.208,78.896 M310.419,78.896c0-6.61,0.843-7.364,4.525-7.364
c3.726,0,4.525,0.754,4.525,7.364c0,6.21-1.02,6.654-4.525,6.654C311.661,85.55,310.419,84.974,310.419,78.896z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.505,0c13.241,0,24.037,13.64,24.588,30.737h-8.876
c0-11.805-7.035-21.374-15.712-21.374c-8.678,0-15.713,9.569-15.713,21.374h-8.876C14.467,13.64,25.264,0,38.505,0"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.504,57.156c2.483,0,4.495,2.013,4.495,4.495c0,0.946-0.292,1.823-0.791,2.547
l2.294,12.511H32.507l2.294-12.511c-0.499-0.724-0.791-1.601-0.791-2.547C34.009,59.168,36.022,57.156,38.504,57.156"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.394,34.801h4.724c1.317,0,2.394,1.077,2.394,2.394v59.474
c0,1.317-1.077,2.394-2.394,2.394H2.394C1.077,99.063,0,97.986,0,96.669V37.195C0,35.878,1.077,34.801,2.394,34.801"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.314,34.801h4.724c1.316,0,2.394,1.077,2.394,2.394v59.474
c0,1.317-1.077,2.394-2.394,2.394h-4.724c-1.316,0-2.394-1.077-2.394-2.394V37.195C13.92,35.878,14.997,34.801,16.314,34.801"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.183,34.801h4.724c1.317,0,2.394,1.077,2.394,2.394V51.96
c0,1.317-1.077,2.394-2.394,2.394h-4.724c-1.317,0-2.394-1.077-2.394-2.394V37.195C26.789,35.878,27.866,34.801,29.183,34.801"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M43.103,34.801h4.724c1.316,0,2.393,1.077,2.393,2.394V51.96
c0,1.317-1.077,2.394-2.393,2.394h-4.724c-1.316,0-2.394-1.077-2.394-2.394V37.195C40.709,35.878,41.786,34.801,43.103,34.801"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M55.971,34.801h4.724c1.317,0,2.394,1.077,2.394,2.394v59.474
c0,1.317-1.077,2.394-2.394,2.394h-4.724c-1.316,0-2.393-1.077-2.393-2.394V37.195C53.578,35.878,54.655,34.801,55.971,34.801"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M69.891,34.801h4.724c1.317,0,2.394,1.077,2.394,2.394v59.474
c0,1.317-1.077,2.394-2.394,2.394h-4.724c-1.317,0-2.394-1.077-2.394-2.394V37.195C67.498,35.878,68.575,34.801,69.891,34.801"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.183,79.51h4.724c1.317,0,2.394,1.077,2.394,2.394V96.67
c0,1.317-1.077,2.394-2.394,2.394h-4.724c-1.317,0-2.394-1.077-2.394-2.394V81.904C26.789,80.588,27.866,79.51,29.183,79.51"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M43.103,79.51h4.724c1.316,0,2.393,1.077,2.393,2.394V96.67
c0,1.317-1.077,2.394-2.393,2.394h-4.724c-1.316,0-2.394-1.077-2.394-2.394V81.904C40.709,80.588,41.786,79.51,43.103,79.51"/>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View file

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 17.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="792px" height="306px" viewBox="0 0 792 306" enable-background="new 0 0 792 306" xml:space="preserve">
<g>
<g>
<g>
<path fill="#FFFFFF" d="M136.168,45.527C76.898,45.527,28.689,93.739,28.689,153c0,59.265,48.209,107.474,107.479,107.474
c59.252,0,107.465-48.209,107.465-107.474C243.633,93.739,195.42,45.527,136.168,45.527z M176.542,180.428
c-0.889,0.129-1.767,0.254-2.659,0.368c-8.111,1.049-16.77,1.761-25.819,2.099c-3.895,0.145-7.872,0.233-11.895,0.233
c-4.035,0-8-0.087-11.907-0.233c-9.038-0.338-17.703-1.05-25.807-2.099c-0.723-6.047-1.213-12.339-1.458-18.811
c-0.117-2.956-0.175-5.956-0.175-8.985c0-3.032,0.058-6.026,0.175-8.988c0.245-6.473,0.735-12.761,1.458-18.808
c0.145-1.259,0.309-2.51,0.478-3.746c5.294-38.424,19.952-66.069,37.235-66.069c53.903,0,97.608,43.699,97.609,97.611
C233.777,165.196,210.296,175.688,176.542,180.428z"/>
</g>
<path fill="#FFFFFF" d="M176.541,125.569c-0.979-1.428-2.029-2.796-3.148-4.11c-8.956-10.557-22.297-17.265-37.224-17.265
c-4.839,0-9.148,7.407-11.907,18.909c-1.096,4.586-1.947,9.819-2.495,15.498c-0.432,4.551-0.665,9.391-0.665,14.399
s0.233,9.849,0.665,14.396c4.554,0.432,9.387,0.664,14.402,0.664c5.009,0,9.842-0.232,14.396-0.664
c10.011-0.95,18.653-2.875,24.775-5.411c6.046-2.501,9.624-5.615,9.624-8.985C184.963,142.832,181.858,133.388,176.541,125.569z"
/>
</g>
<g>
<path fill="#FFFFFF" d="M344.891,100.053c12.585,0,22.816,6.138,29.262,13.062l-10.064,11.326
c-5.353-5.192-11.175-8.495-19.041-8.495c-16.839,0-28.953,14.16-28.953,37.291c0,23.448,11.169,37.608,28.32,37.608
c9.128,0,15.895-3.775,21.717-10.228l10.067,11.169c-8.335,9.598-19.038,14.95-32.099,14.95c-26.119,0-46.731-18.88-46.731-53.025
C297.37,120.036,318.454,100.053,344.891,100.053z"/>
<path fill="#FFFFFF" d="M416.961,125.701c19.352,0,36.822,14.793,36.822,40.597c0,25.647-17.471,40.439-36.822,40.439
c-19.197,0-36.66-14.792-36.66-40.439C380.301,140.494,397.764,125.701,416.961,125.701z M416.961,191.945
c11.33,0,18.25-10.228,18.25-25.647c0-15.577-6.92-25.804-18.25-25.804s-18.094,10.227-18.094,25.804
C398.867,181.717,405.631,191.945,416.961,191.945z"/>
<path fill="#FFFFFF" d="M459.771,127.589h14.943l1.26,13.688h0.629c5.506-10.07,13.691-15.577,21.871-15.577
c3.938,0,6.455,0.472,8.811,1.574l-3.148,15.734c-2.67-0.784-4.717-1.257-8.018-1.257c-6.139,0-13.539,4.245-18.256,15.893v47.203
h-18.092L459.771,127.589L459.771,127.589z"/>
<path fill="#FFFFFF" d="M541.121,125.701c20.928,0,31.941,15.107,31.941,36.667c0,3.458-0.314,6.604-0.787,8.495h-49.09
c1.57,14.003,10.379,21.869,22.811,21.869c6.613,0,12.273-2.041,17.941-5.662l6.135,11.326
c-7.395,4.878-16.676,8.341-26.432,8.341c-21.404,0-38.08-14.95-38.08-40.439C505.561,141.12,523.023,125.701,541.121,125.701z
M557.326,159.376c0-12.277-5.189-19.671-15.732-19.671c-9.125,0-16.996,6.768-18.57,19.671H557.326z"/>
<path fill="#FFFFFF" d="M600.602,152.607c0-32.729,17.785-53.344,42.799-53.344c24.863,0,42.641,20.615,42.641,53.344
c0,32.889-17.777,54.13-42.641,54.13C618.387,206.737,600.602,185.496,600.602,152.607z M678.49,152.607
c0-28.639-14.158-46.731-35.09-46.731c-21.084,0-35.248,18.093-35.248,46.731c0,28.796,14.164,47.521,35.248,47.521
C664.332,200.128,678.49,181.403,678.49,152.607z"/>
<path fill="#FFFFFF" d="M699.738,186.125c7.557,8.495,18.412,14.003,30.529,14.003c15.732,0,25.807-8.499,25.807-20.767
c0-12.904-8.494-17.154-18.723-21.717l-15.736-7.082c-8.969-3.936-20.934-10.385-20.934-25.808
c0-14.947,12.904-25.492,30.059-25.492c12.588,0,22.658,5.665,28.949,12.435l-4.244,4.878c-5.982-6.452-14.32-10.7-24.705-10.7
c-13.691,0-22.816,7.239-22.816,18.565c0,11.962,10.385,16.521,17.936,19.985l15.738,6.921
c11.486,5.195,21.713,11.647,21.713,27.539s-13.061,27.851-33.201,27.851c-15.107,0-26.75-6.451-34.932-15.576L699.738,186.125z"
/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

49
static/img/coreos.svg Normal file
View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="792px" height="306px" viewBox="0 0 792 306" enable-background="new 0 0 792 306" xml:space="preserve">
<g>
<g>
<path fill="#53A3DA" d="M136.168,45.527C76.898,45.527,28.689,93.739,28.689,153c0,59.265,48.209,107.474,107.479,107.474
c59.252,0,107.465-48.209,107.465-107.474C243.633,93.739,195.42,45.527,136.168,45.527z"/>
<path fill="#F1606D" d="M136.168,55.389c-17.283,0-31.941,27.645-37.235,66.069c-0.169,1.236-0.333,2.487-0.478,3.746
c-0.723,6.047-1.213,12.335-1.458,18.808c-0.117,2.962-0.175,5.956-0.175,8.988c0,3.029,0.058,6.029,0.175,8.985
c0.245,6.472,0.735,12.764,1.458,18.811c8.104,1.049,16.769,1.761,25.807,2.099c3.907,0.146,7.872,0.233,11.907,0.233
c4.023,0,8-0.088,11.895-0.233c9.049-0.338,17.708-1.05,25.819-2.099c0.892-0.114,1.77-0.239,2.659-0.368
c33.754-4.74,57.235-15.232,57.235-27.428C233.776,99.088,190.071,55.389,136.168,55.389z"/>
<path fill="#FFFFFF" d="M176.541,125.569c-0.979-1.428-2.029-2.796-3.148-4.11c-8.956-10.557-22.297-17.265-37.224-17.265
c-4.839,0-9.148,7.407-11.907,18.909c-1.096,4.586-1.947,9.819-2.495,15.498c-0.432,4.551-0.665,9.391-0.665,14.399
s0.233,9.849,0.665,14.396c4.554,0.432,9.387,0.664,14.402,0.664c5.009,0,9.842-0.232,14.396-0.664
c10.011-0.95,18.653-2.875,24.775-5.411c6.046-2.501,9.624-5.615,9.624-8.985C184.963,142.832,181.858,133.388,176.541,125.569z"
/>
</g>
<g>
<path fill="#231F20" d="M344.891,100.053c12.585,0,22.816,6.138,29.262,13.062l-10.064,11.326
c-5.353-5.192-11.175-8.495-19.041-8.495c-16.839,0-28.953,14.16-28.953,37.291c0,23.448,11.169,37.608,28.32,37.608
c9.128,0,15.895-3.775,21.717-10.228l10.067,11.169c-8.335,9.598-19.038,14.95-32.099,14.95c-26.119,0-46.731-18.88-46.731-53.025
C297.37,120.036,318.454,100.053,344.891,100.053z"/>
<path fill="#231F20" d="M416.961,125.701c19.352,0,36.822,14.793,36.822,40.597c0,25.647-17.471,40.439-36.822,40.439
c-19.197,0-36.66-14.792-36.66-40.439C380.301,140.494,397.764,125.701,416.961,125.701z M416.961,191.945
c11.33,0,18.25-10.228,18.25-25.647c0-15.577-6.92-25.804-18.25-25.804s-18.094,10.227-18.094,25.804
C398.867,181.717,405.631,191.945,416.961,191.945z"/>
<path fill="#231F20" d="M459.771,127.589h14.943l1.26,13.688h0.629c5.506-10.07,13.691-15.577,21.871-15.577
c3.938,0,6.455,0.472,8.811,1.574l-3.148,15.734c-2.67-0.784-4.717-1.257-8.018-1.257c-6.139,0-13.539,4.245-18.256,15.893v47.203
h-18.092V127.589z"/>
<path fill="#231F20" d="M541.121,125.701c20.928,0,31.941,15.107,31.941,36.667c0,3.458-0.314,6.604-0.787,8.495h-49.09
c1.57,14.003,10.379,21.869,22.811,21.869c6.613,0,12.273-2.041,17.941-5.662l6.135,11.326
c-7.395,4.878-16.676,8.341-26.432,8.341c-21.404,0-38.08-14.95-38.08-40.439C505.561,141.12,523.023,125.701,541.121,125.701z
M557.326,159.376c0-12.277-5.189-19.671-15.732-19.671c-9.125,0-16.996,6.768-18.57,19.671H557.326z"/>
<path fill="#F1606D" d="M600.602,152.607c0-32.729,17.785-53.344,42.799-53.344c24.863,0,42.641,20.615,42.641,53.344
c0,32.889-17.777,54.13-42.641,54.13C618.387,206.737,600.602,185.496,600.602,152.607z M678.49,152.607
c0-28.639-14.158-46.731-35.09-46.731c-21.084,0-35.248,18.093-35.248,46.731c0,28.796,14.164,47.521,35.248,47.521
C664.332,200.128,678.49,181.403,678.49,152.607z"/>
<path fill="#53A4D9" d="M699.738,186.125c7.557,8.495,18.412,14.003,30.529,14.003c15.732,0,25.807-8.499,25.807-20.767
c0-12.904-8.494-17.154-18.723-21.717l-15.736-7.082c-8.969-3.936-20.934-10.385-20.934-25.808
c0-14.947,12.904-25.492,30.059-25.492c12.588,0,22.658,5.665,28.949,12.435l-4.244,4.878c-5.982-6.452-14.32-10.7-24.705-10.7
c-13.691,0-22.816,7.239-22.816,18.565c0,11.962,10.385,16.521,17.936,19.985l15.738,6.921
c11.486,5.195,21.713,11.647,21.713,27.539s-13.061,27.851-33.201,27.851c-15.107,0-26.75-6.451-34.932-15.576L699.738,186.125z"
/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
static/img/image-view.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

BIN
static/img/network-tile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View file

@ -1,6 +1,46 @@
var TEAM_PATTERN = '^[a-zA-Z][a-zA-Z0-9]+$';
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) {
var url = '';
for (var i = 0; i < arguments.length; ++i) {
@ -59,18 +99,8 @@ function getFirstTextLine(commentString) {
}
function createRobotAccount(ApiService, is_org, orgname, name, callback) {
ApiService.createRobot(is_org ? orgname : null, null, {'robot_shortname': name}).then(callback, function(resp) {
bootbox.dialog({
"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"
}
}
});
});
ApiService.createRobot(is_org ? orgname : null, null, {'robot_shortname': name})
.then(callback, ApiService.errorDisplay('Cannot create robot account'));
}
function createOrganizationTeam(ApiService, orgname, teamname, callback) {
@ -84,18 +114,8 @@ function createOrganizationTeam(ApiService, orgname, teamname, callback) {
'teamname': teamname
};
ApiService.updateOrganizationTeam(data, params).then(callback, function(resp) {
bootbox.dialog({
"message": resp.data ? resp.data : 'The team could not be created',
"title": "Cannot create team",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
ApiService.updateOrganizationTeam(data, params)
.then(callback, ApiService.errorDisplay('Cannot create team'));
}
function getMarkedDown(string) {
@ -121,7 +141,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
* pauses in the UI for ngRepeat's when the array is significant in size.
*/
$provide.factory('AngularViewArray', ['$interval', function($interval) {
var ADDTIONAL_COUNT = 50;
var ADDTIONAL_COUNT = 20;
function _ViewArray() {
this.isVisible = false;
@ -364,7 +384,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
var uiService = {};
uiService.hidePopover = function(elem) {
var popover = $('#signupButton').data('bs.popover');
var popover = $(elem).data('bs.popover');
if (popover) {
popover.hide();
}
@ -398,15 +418,19 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
$provide.factory('UtilService', ['$sanitize', function($sanitize) {
var utilService = {};
utilService.textToSafeHtml = function(text) {
utilService.escapeHtmlString = function(text) {
var adjusted = text.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
return $sanitize(adjusted);
return adjusted;
};
utilService.textToSafeHtml = function(text) {
return $sanitize(utilService.escapeHtmlString(text));
};
return utilService;
@ -417,6 +441,29 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
var pingService = {};
var pingCache = {};
var invokeCallback = function($scope, pings, callback) {
if (pings[0] == -1) {
setTimeout(function() {
$scope.$apply(function() {
callback(-1, false, -1);
});
}, 0);
return;
}
var sum = 0;
for (var i = 0; i < pings.length; ++i) {
sum += pings[i];
}
// Report the average ping.
setTimeout(function() {
$scope.$apply(function() {
callback(Math.floor(sum / pings.length), true, pings.length);
});
}, 0);
};
var reportPingResult = function($scope, url, ping, callback) {
// Lookup the cached ping data, if any.
var cached = pingCache[url];
@ -429,28 +476,15 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
// If an error occurred, report it and done.
if (ping < 0) {
cached['pings'] = [-1];
setTimeout(function() {
$scope.$apply(function() {
callback(-1, false, -1);
});
}, 0);
invokeCallback($scope, pings, callback);
return;
}
// Otherwise, add the current ping and determine the average.
cached['pings'].push(ping);
var sum = 0;
for (var i = 0; i < cached['pings'].length; ++i) {
sum += cached['pings'][i];
}
// Report the average ping.
setTimeout(function() {
$scope.$apply(function() {
callback(Math.floor(sum / cached['pings'].length), true, cached['pings'].length);
});
}, 0);
// Invoke the callback.
invokeCallback($scope, cached['pings'], callback);
// Schedule another check if we've done less than three.
if (cached['pings'].length < 3) {
@ -486,12 +520,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
pingService.pingUrl = function($scope, url, callback) {
if (pingCache[url]) {
cached = pingCache[url];
setTimeout(function() {
$scope.$apply(function() {
callback(cached.result, cached.success);
});
}, 0);
invokeCallback($scope, pingCache[url]['pings'], callback);
return;
}
@ -526,7 +555,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
return builderService;
}]);
$provide.factory('StringBuilderService', ['$sce', function($sce) {
$provide.factory('StringBuilderService', ['$sce', 'UtilService', function($sce, UtilService) {
var stringBuilderService = {};
stringBuilderService.buildString = function(value_or_func, metadata) {
@ -581,6 +610,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
if (key.indexOf('image') >= 0) {
value = value.substr(0, 12);
}
var safe = UtilService.escapeHtmlString(value);
var markedDown = getMarkedDown(value);
markedDown = markedDown.substr('<p>'.length, markedDown.length - '<p></p>'.length);
@ -589,7 +620,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
markedDown = '<i class="fa fa-' + icon + '"></i>' + markedDown;
}
description = description.replace('{' + key + '}', '<code>' + markedDown + '</code>');
description = description.replace('{' + key + '}', '<code title="' + safe + '">' + markedDown + '</code>');
}
}
return $sce.trustAsHtml(description.replace('\n', '<br>'));
@ -682,7 +713,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
return config;
}]);
$provide.factory('ApiService', ['Restangular', function(Restangular) {
$provide.factory('ApiService', ['Restangular', '$q', function(Restangular, $q) {
var apiService = {};
var getResource = function(path, opt_background) {
@ -779,6 +810,65 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
}
};
var freshLoginFailCheck = function(opName, opArgs) {
return function(resp) {
var deferred = $q.defer();
// If the error is a fresh login required, show the dialog.
if (resp.status == 401 && resp.data['error_type'] == 'fresh_login_required') {
bootbox.dialog({
"message": 'It has been more than a few minutes since you last logged in, ' +
'so please verify your password to perform this sensitive operation:' +
'<form style="margin-top: 10px" action="javascript:void(0)">' +
'<input id="freshPassword" class="form-control" type="password" placeholder="Current Password">' +
'</form>',
"title": 'Please Verify',
"buttons": {
"verify": {
"label": "Verify",
"className": "btn-success",
"callback": function() {
var info = {
'password': $('#freshPassword').val()
};
$('#freshPassword').val('');
// Conduct the sign in of the user.
apiService.verifyUser(info).then(function() {
// On success, retry the operation. if it succeeds, then resolve the
// deferred promise with the result. Otherwise, reject the same.
apiService[opName].apply(apiService, opArgs).then(function(resp) {
deferred.resolve(resp);
}, function(resp) {
deferred.reject(resp);
});
}, function(resp) {
// Reject with the sign in error.
deferred.reject({'data': {'message': 'Invalid verification credentials'}});
});
}
},
"close": {
"label": "Cancel",
"className": "btn-default",
"callback": function() {
deferred.reject(resp);
}
}
}
});
// Return a new promise. We'll accept or reject it based on the result
// of the login.
return deferred.promise;
}
// Otherwise, we just 'raise' the error via the reject method on the promise.
return $q.reject(resp);
};
};
var buildMethodsForOperation = function(operation, resource, resourceMap) {
var method = operation['method'].toLowerCase();
var operationName = operation['nickname'];
@ -792,7 +882,15 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
'ignoreLoadingBar': true
});
}
return one['custom' + method.toUpperCase()](opt_options);
var opObj = one['custom' + method.toUpperCase()](opt_options);
// If the operation requires_fresh_login, then add a specialized error handler that
// will defer the operation's result if sudo is requested.
if (operation['requires_fresh_login']) {
opObj = opObj.catch(freshLoginFailCheck(operationName, arguments));
}
return opObj;
};
// If the method for the operation is a GET, add an operationAsResource method.
@ -841,6 +939,38 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
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;
}]);
@ -1097,7 +1227,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
'user': null,
'notifications': [],
'notificationClasses': [],
'notificationSummaries': []
'notificationSummaries': [],
'additionalNotifications': false
};
var pollTimerHandle = null;
@ -1193,7 +1324,9 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
'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);
if (index >= 0) {
@ -1250,6 +1383,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
ApiService.listUserNotifications().then(function(resp) {
notificationService.notifications = resp['notifications'];
notificationService.additionalNotifications = resp['additional'];
notificationService.notificationClasses = notificationService.getClasses(notificationService.notifications);
});
};
@ -1512,7 +1646,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 (callbacks['started']) {
@ -1525,7 +1659,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
planService.getCardInfo(orgname, function(cardInfo) {
if (plan.price > 0 && (previousSubscribeFailure || !cardInfo.last4)) {
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;
}
@ -1598,9 +1732,34 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
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 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']) {
callbacks['opening']();
}
@ -1693,7 +1852,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
when('/repository/:namespace/:name/build', {templateUrl: '/static/partials/repo-build.html', controller:RepoBuildCtrl, reloadOnSearch: false}).
when('/repository/:namespace/:name/build/:buildid/buildpack', {templateUrl: '/static/partials/build-package.html', controller:BuildPackageCtrl, reloadOnSearch: false}).
when('/repository/', {title: 'Repositories', description: 'Public and private docker repositories list',
templateUrl: '/static/partials/repo-list.html', controller: RepoListCtrl}).
templateUrl: '/static/partials/repo-list.html', controller: RepoListCtrl, reloadOnSearch: false}).
when('/user/', {title: 'Account Settings', description:'Account settings for ' + title, templateUrl: '/static/partials/user-admin.html',
reloadOnSearch: false, controller: UserAdminCtrl}).
when('/superuser/', {title: 'Superuser Admin Panel', description:'Admin panel for ' + title, templateUrl: '/static/partials/super-user.html',
@ -1819,6 +1978,26 @@ quayApp.directive('quayShow', function($animate, Features, Config) {
});
quayApp.directive('quaySection', function($animate, $location, $rootScope) {
return {
priority: 590,
restrict: 'A',
link: function($scope, $element, $attr, ctrl, $transclude) {
var update = function() {
var result = $location.path().indexOf('/' + $attr.quaySection) == 0;
$animate[!result ? 'removeClass' : 'addClass']($element, 'active');
};
$scope.$watch(function(){
return $location.path();
}, update);
$scope.$watch($attr.quaySection, update);
}
};
});
quayApp.directive('quayClasses', function(Features, Config) {
return {
priority: 580,
@ -2018,18 +2197,7 @@ quayApp.directive('applicationReference', function () {
template: '/static/directives/application-reference-dialog.html',
show: true
});
}, function() {
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"
}
}
});
});
}, ApiService.errorDisplay('Application could not be found'));
};
}
};
@ -2110,6 +2278,8 @@ quayApp.directive('copyBox', function () {
'hoveringMessage': '=hoveringMessage'
},
controller: function($scope, $element, $rootScope) {
$scope.disabled = false;
var number = $rootScope.__copyBoxIdCounter || 0;
$rootScope.__copyBoxIdCounter = number + 1;
$scope.inputId = "copy-box-input-" + number;
@ -2119,27 +2289,7 @@ quayApp.directive('copyBox', function () {
input.attr('id', $scope.inputId);
button.attr('data-clipboard-target', $scope.inputId);
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);
});
$scope.disabled = !button.clipboardCopy();
}
};
return directiveDefinitionObject;
@ -2194,7 +2344,7 @@ quayApp.directive('externalLoginButton', function () {
'provider': '@provider',
'action': '@action'
},
controller: function($scope, $location, $timeout, ApiService, KeyService, UserService, CookieService, Features, Config) {
controller: function($scope, $timeout, $interval, ApiService, KeyService, CookieService, Features, Config) {
$scope.startSignin = function(service) {
$scope.signInStarted({'service': service});
@ -2228,15 +2378,39 @@ quayApp.directive('signinForm', function () {
'signInStarted': '&signInStarted',
'signedIn': '&signedIn'
},
controller: function($scope, $location, $timeout, ApiService, KeyService, UserService, CookieService, Features, Config) {
controller: function($scope, $location, $timeout, $interval, ApiService, KeyService, UserService, CookieService, Features, Config) {
$scope.tryAgainSoon = 0;
$scope.tryAgainInterval = null;
$scope.markStarted = function() {
if ($scope.signInStarted != null) {
$scope.signInStarted();
}
};
$scope.cancelInterval = function() {
$scope.tryAgainSoon = 0;
if ($scope.tryAgainInterval) {
$interval.cancel($scope.tryAgainInterval);
}
$scope.tryAgainInterval = null;
};
$scope.$watch('user.username', function() {
$scope.cancelInterval();
});
$scope.$on('$destroy', function() {
$scope.cancelInterval();
});
$scope.signin = function() {
if ($scope.tryAgainSoon > 0) { return; }
$scope.markStarted();
$scope.cancelInterval();
ApiService.signinUser($scope.user).then(function() {
$scope.needsEmailVerification = false;
@ -2258,8 +2432,23 @@ quayApp.directive('signinForm', function () {
$location.path($scope.redirectUrl ? $scope.redirectUrl : '/');
}, 500);
}, function(result) {
$scope.needsEmailVerification = result.data.needsEmailVerification;
$scope.invalidCredentials = result.data.invalidCredentials;
if (result.status == 429 /* try again later */) {
$scope.needsEmailVerification = false;
$scope.invalidCredentials = false;
$scope.cancelInterval();
$scope.tryAgainSoon = result.headers('Retry-After');
$scope.tryAgainInterval = $interval(function() {
$scope.tryAgainSoon--;
if ($scope.tryAgainSoon <= 0) {
$scope.cancelInterval();
}
}, 1000, $scope.tryAgainSoon);
} else {
$scope.needsEmailVerification = result.data.needsEmailVerification;
$scope.invalidCredentials = result.data.invalidCredentials;
}
});
};
}
@ -2370,11 +2559,42 @@ quayApp.directive('dockerAuthDialog', function (Config) {
'username': '=username',
'token': '=token',
'shown': '=shown',
'counter': '=counter'
'counter': '=counter',
'supportsRegenerate': '@supportsRegenerate',
'regenerate': '&regenerate'
},
controller: function($scope, $element) {
controller: function($scope, $element) {
var updateCommand = function() {
var escape = function(v) {
if (!v) { return v; }
return v.replace('$', '\\$');
};
$scope.command = 'docker login -e="." -u="' + escape($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() {
try { return !!new Blob(); } catch(e){}
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) {}
return false;
};
@ -2392,6 +2612,8 @@ quayApp.directive('dockerAuthDialog', function (Config) {
};
var show = function(r) {
$scope.regenerating = false;
if (!$scope.shown || !$scope.username || !$scope.token) {
$('#dockerauthmodal').modal('hide');
return;
@ -2632,6 +2854,8 @@ quayApp.directive('logsView', function () {
return 'Delete notification of event "' + eventData['title'] + '" for repository {repo}';
},
'regenerate_robot_token': 'Regenerated token for robot {robot}',
// Note: These are deprecated.
'add_repo_webhook': 'Add webhook in repository {repo}',
'delete_repo_webhook': 'Delete webhook in repository {repo}'
@ -2675,6 +2899,7 @@ quayApp.directive('logsView', function () {
'reset_application_client_secret': 'Reset Client Secret',
'add_repo_notification': 'Add repository notification',
'delete_repo_notification': 'Delete repository notification',
'regenerate_robot_token': 'Regenerate Robot Token',
// Note: these are deprecated.
'add_repo_webhook': 'Add webhook',
@ -2801,18 +3026,7 @@ quayApp.directive('applicationManager', function () {
ApiService.createOrganizationApplication(data, params).then(function(resp) {
$scope.applications.push(resp);
}, function(resp) {
bootbox.dialog({
"message": resp['message'] || 'The application could not be created',
"title": "Cannot create application",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}, ApiService.errorDisplay('Cannot create application'));
};
var update = function() {
@ -2857,6 +3071,20 @@ quayApp.directive('robotsManager', function () {
$scope.shownRobot = null;
$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.shownRobot = info;
$scope.showRobotCounter++;
@ -2897,18 +3125,7 @@ quayApp.directive('robotsManager', function () {
if (index >= 0) {
$scope.robots.splice(index, 1);
}
}, function() {
bootbox.dialog({
"message": 'The selected robot account could not be deleted',
"title": "Cannot delete robot account",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}, ApiService.errorDisplay('Cannot delete robot account'));
};
var update = function() {
@ -2973,18 +3190,7 @@ quayApp.directive('prototypeManager', function () {
ApiService.updateOrganizationPrototypePermission(data, params).then(function(resp) {
prototype.role = role;
}, function(resp) {
bootbox.dialog({
"message": resp.data ? resp.data : 'The permission could not be modified',
"title": "Cannot modify permission",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}, ApiService.errorDisplay('Cannot modify permission'));
};
$scope.comparePrototypes = function(p) {
@ -3024,23 +3230,16 @@ quayApp.directive('prototypeManager', function () {
data['activating_user'] = $scope.activatingForNew;
}
var errorHandler = ApiService.errorDisplay('Cannot create permission',
function(resp) {
$('#addPermissionDialogModal').modal('hide');
});
ApiService.createOrganizationPrototypePermission(data, params).then(function(resp) {
$scope.prototypes.push(resp);
$scope.loading = false;
$('#addPermissionDialogModal').modal('hide');
}, function(resp) {
$('#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"
}
}
});
});
}, errorHandler);
};
$scope.deletePrototype = function(prototype) {
@ -3054,18 +3253,7 @@ quayApp.directive('prototypeManager', function () {
ApiService.deleteOrganizationPrototypePermission(null, params).then(function(resp) {
$scope.prototypes.splice($scope.prototypes.indexOf(prototype), 1);
$scope.loading = false;
}, function(resp) {
bootbox.dialog({
"message": resp.data ? resp.data : 'The permission could not be deleted',
"title": "Cannot delete permission",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}, ApiService.errorDisplay('Cannot delete permission'));
};
var update = function() {
@ -3836,9 +4024,11 @@ quayApp.directive('billingOptions', function () {
var save = function() {
$scope.working = true;
var errorHandler = ApiService.errorDisplay('Could not change user details');
ApiService.changeDetails($scope.organization, $scope.obj).then(function(resp) {
$scope.working = false;
});
}, errorHandler);
};
var checkSave = function() {
@ -3890,7 +4080,7 @@ quayApp.directive('planManager', function () {
return true;
};
$scope.changeSubscription = function(planId) {
$scope.changeSubscription = function(planId, opt_async) {
if ($scope.planChanging) { return; }
var callbacks = {
@ -3904,7 +4094,7 @@ quayApp.directive('planManager', function () {
}
};
PlanService.changePlan($scope, $scope.organization, planId, callbacks);
PlanService.changePlan($scope, $scope.organization, planId, callbacks, opt_async);
};
$scope.cancelSubscription = function() {
@ -3967,7 +4157,7 @@ quayApp.directive('planManager', function () {
if ($scope.readyForPlan) {
var planRequested = $scope.readyForPlan();
if (planRequested && planRequested != PlanService.getFreePlan()) {
$scope.changeSubscription(planRequested);
$scope.changeSubscription(planRequested, /* async */true);
}
}
});
@ -3998,7 +4188,7 @@ quayApp.directive('namespaceSelector', function () {
'namespace': '=namespace',
'requireCreate': '=requireCreate'
},
controller: function($scope, $element, $routeParams, CookieService) {
controller: function($scope, $element, $routeParams, $location, CookieService) {
$scope.namespaces = {};
$scope.initialize = function(user) {
@ -4035,6 +4225,10 @@ quayApp.directive('namespaceSelector', function () {
if (newNamespace) {
CookieService.putPermanent('quay.namespace', newNamespace);
if ($routeParams['namespace'] && $routeParams['namespace'] != newNamespace) {
$location.search({'namespace': newNamespace});
}
}
};
@ -4385,26 +4579,17 @@ quayApp.directive('setupTriggerDialog', function () {
$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) {
$scope.hide();
$scope.trigger['is_active'] = true;
$scope.trigger['pull_robot'] = resp['pull_robot'];
$scope.activated({'trigger': $scope.trigger});
}, function(resp) {
$scope.hide();
$scope.canceled({'trigger': $scope.trigger});
bootbox.dialog({
"message": resp['data']['message'] || 'The build trigger setup could not be completed',
"title": "Could not activate build trigger",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}, errorHandler);
};
var check = function() {
@ -4744,6 +4929,9 @@ quayApp.directive('buildMessage', function () {
case 'waiting':
return 'Waiting for available build worker';
case 'unpacking':
return 'Unpacking build package';
case 'pulling':
return 'Pulling base image';
@ -4799,6 +4987,7 @@ quayApp.directive('buildProgress', function () {
case 'starting':
case 'waiting':
case 'cannot_load':
case 'unpacking':
return 0;
break;
}
@ -5018,6 +5207,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 () {
var directiveDefinitionObject = {
priority: 0,
@ -5329,7 +5535,9 @@ quayApp.directive('locationView', function () {
'local_us': { 'country': 'US', 'data': 'quay-registry.s3.amazonaws.com', 'title': 'United States' },
'local_eu': { 'country': 'EU', 'data': 'quay-registry-eu.s3-eu-west-1.amazonaws.com', 'title': 'Europe' },
's3_us_east_1': { 'country': 'US', 'data': 'quay-registry.s3.amazonaws.com', 'title': 'United States' },
's3_us_east_1': { 'country': 'US', 'data': 'quay-registry.s3.amazonaws.com', 'title': 'United States (East)' },
's3_us_west_1': { 'country': 'US', 'data': 'quay-registry-cali.s3.amazonaws.com', 'title': 'United States (West)' },
's3_eu_west_1': { 'country': 'EU', 'data': 'quay-registry-eu.s3-eu-west-1.amazonaws.com', 'title': 'Europe' },
's3_ap_southeast_1': { 'country': 'SG', 'data': 'quay-registry-singapore.s3-ap-southeast-1.amazonaws.com', 'title': 'Singapore' },
@ -5344,7 +5552,9 @@ quayApp.directive('locationView', function () {
$scope.getLocationTooltip = function(location, ping) {
var tip = $scope.getLocationTitle(location) + '<br>';
if (ping < 0) {
if (ping == null) {
tip += '(Loading)';
} else if (ping < 0) {
tip += '<br><b>Note: Could not contact server</b>';
} else {
tip += 'Estimated Ping: ' + (ping ? ping + 'ms' : '(Loading)');
@ -5367,7 +5577,7 @@ quayApp.directive('locationView', function () {
};
$scope.getLocationPing = function(location) {
var url = 'http://' + LOCATIONS[location]['data'] + '/okay.txt';
var url = 'https://' + LOCATIONS[location]['data'] + '/okay.txt';
PingService.pingUrl($scope, url, function(ping, success, count) {
if (count == 3 || !success) {
$scope.locationPing = success ? ping : -1;
@ -5424,7 +5634,8 @@ quayApp.directive('tagSpecificImagesView', function () {
scope: {
'repository': '=repository',
'tag': '=tag',
'images': '=images'
'images': '=images',
'imageCutoff': '=imageCutoff'
},
controller: function($scope, $element) {
$scope.getFirstTextLine = getFirstTextLine;
@ -5446,7 +5657,7 @@ quayApp.directive('tagSpecificImagesView', function () {
return classes;
};
var forAllTagImages = function(tag, callback) {
var forAllTagImages = function(tag, callback, opt_cutoff) {
if (!tag) { return; }
if (!$scope.imageByDBID) {
@ -5464,10 +5675,14 @@ quayApp.directive('tagSpecificImagesView', function () {
callback(tag_image);
var ancestors = tag_image.ancestors.split('/');
var ancestors = tag_image.ancestors.split('/').reverse();
for (var i = 0; i < ancestors.length; ++i) {
var image = $scope.imageByDBID[ancestors[i]];
if (image) {
if (image == opt_cutoff) {
return;
}
callback(image);
}
}
@ -5489,7 +5704,7 @@ quayApp.directive('tagSpecificImagesView', function () {
var ids = {};
forAllTagImages(currentTag, function(image) {
ids[image.dbid] = true;
});
}, $scope.imageCutoff);
return ids;
};
@ -5587,15 +5802,14 @@ quayApp.run(['$location', '$rootScope', 'Restangular', 'UserService', 'PlanServi
// Handle session expiration.
Restangular.setErrorInterceptor(function(response) {
if (response.status == 401) {
if (response.data['session_required'] == null || response.data['session_required'] === true) {
$('#sessionexpiredModal').modal({});
return false;
}
if (response.status == 401 && response.data['error_type'] == 'invalid_token' &&
response.data['session_required'] !== false) {
$('#sessionexpiredModal').modal({});
return false;
}
if (!Features.BILLING && response.status == 402) {
$('#overlicenseModal').modal({});
if (response.status == 503) {
$('#cannotContactService').modal({});
return false;
}

View file

@ -1,25 +1,3 @@
$.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 GuideCtrl() {
}
@ -431,6 +409,27 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
$location.search('current', buildInfo.id);
};
$scope.isPushing = function(images) {
if (!images) { return false; }
var cached = images.__isPushing;
if (cached !== undefined) {
return cached;
}
return images.__isPushing = $scope.isPushingInternal(images);
};
$scope.isPushingInternal = function(images) {
if (!images) { return false; }
for (var i = 0; i < images.length; ++i) {
if (images[i].uploading) { return true; }
}
return false;
};
$scope.getTooltipCommand = function(image) {
var sanitized = ImageMetadataService.getEscapedFormattedCommand(image);
return '<span class=\'codetooltip\'>' + sanitized + '</span>';
@ -511,48 +510,37 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
'image': image.id
};
var errorHandler = ApiService.errorDisplay('Cannot create or move tag', function(resp) {
$('#addTagModal').modal('hide');
});
ApiService.changeTagImage(data, params).then(function(resp) {
$scope.creatingTag = false;
loadViewInfo();
$('#addTagModal').modal('hide');
}, function(resp) {
$('#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"
}
}
});
});
}, errorHandler);
};
$scope.deleteTag = function(tagName) {
if (!$scope.repo.can_admin) { return; }
$('#confirmdeleteTagModal').modal('hide');
var params = {
'repository': namespace + '/' + name,
'tag': tagName
};
var errorHandler = ApiService.errorDisplay('Cannot delete tag', function() {
$('#confirmdeleteTagModal').modal('hide');
$scope.deletingTag = false;
});
$scope.deletingTag = true;
ApiService.deleteFullTag(null, params).then(function() {
loadViewInfo();
}, function(resp) {
bootbox.dialog({
"message": resp.data ? resp.data : 'Could not delete tag',
"title": "Cannot delete tag",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
$('#confirmdeleteTagModal').modal('hide');
$scope.deletingTag = false;
}, errorHandler);
};
$scope.getImagesForTagBySize = function(tag) {
@ -731,8 +719,6 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
// Load the builds for this repository. If none are active it will cancel the poll.
startBuildInfoTimer(repo);
$('#copyClipboard').clipboardCopy();
});
};
@ -1341,17 +1327,16 @@ function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, $routeParams
};
$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));
permissionDelete.customDELETE().then(function() {
delete $scope.permissions[kind][entityName];
}, function(resp) {
if (resp.status == 409) {
$scope.changePermError = resp.data || '';
$('#channgechangepermModal').modal({});
} else {
$('#cannotchangeModal').modal({});
}
});
}, errorHandler);
};
$scope.addRole = function(entityName, role, kind) {
@ -1362,9 +1347,7 @@ function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, $routeParams
var permissionPost = Restangular.one(getRestUrl('repository', namespace, name, 'permissions', kind, entityName));
permissionPost.customPUT(permission).then(function(result) {
$scope.permissions[kind][entityName] = result;
}, function(result) {
$('#cannotchangeModal').modal({});
});
}, ApiService.errorDisplay('Cannot change permission'));
};
$scope.roles = [
@ -1579,18 +1562,7 @@ function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, $routeParams
window.console.log(resp);
var url = '/repository/' + namespace + '/' + name + '/build?current=' + resp['id'];
document.location = url;
}, function(resp) {
bootbox.dialog({
"message": resp['message'] || 'The build could not be started',
"title": "Could not start build",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}, ApiService.errorDisplay('Could not start build'));
};
$scope.deleteTrigger = function(trigger) {
@ -1720,18 +1692,7 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use
ApiService.deleteUserAuthorization(null, params).then(function(resp) {
$scope.authorizedApps.splice($scope.authorizedApps.indexOf(accessTokenInfo), 1);
}, function(resp) {
bootbox.dialog({
"message": resp.message || 'Could not revoke authorization',
"title": "Cannot revoke authorization",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}, ApiService.errorDisplay('Could not revoke authorization'));
};
$scope.loadLogs = function() {
@ -1740,7 +1701,6 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use
};
$scope.loadInvoices = function() {
if (!$scope.hasPaidBusinessPlan) { return; }
$scope.invoicesShown++;
};
@ -1809,7 +1769,7 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use
$scope.changeEmailForm.$setPristine();
}, function(result) {
$scope.updatingUser = false;
UIService.showFormError('#changeEmailForm', result);
UIService.showFormError('#changeEmailForm', result);
});
};
@ -1819,7 +1779,8 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use
$scope.updatingUser = true;
$scope.changePasswordSuccess = false;
ApiService.changeUserDetails($scope.cuser).then(function() {
ApiService.changeUserDetails($scope.cuser).then(function(resp) {
$scope.updatingUser = false;
$scope.changePasswordSuccess = true;
@ -1926,9 +1887,6 @@ function ImageViewCtrl($scope, $routeParams, $rootScope, $timeout, ApiService, I
// Fetch the image's changes.
fetchChanges();
$('#copyClipboard').clipboardCopy();
return image;
});
};
@ -2196,13 +2154,14 @@ function OrgViewCtrl($rootScope, $scope, ApiService, $routeParams) {
'teamname': teamname
};
var errorHandler = ApiService.errorDisplay('Cannot delete team', function() {
$scope.currentDeleteTeam = null;
});
ApiService.deleteOrganizationTeam(null, params).then(function() {
delete $scope.organization.teams[teamname];
$scope.currentDeleteTeam = null;
}, function() {
$('#cannotchangeModal').modal({});
$scope.currentDeleteTeam = null;
});
}, errorHandler);
};
var loadOrganization = function() {
@ -2496,9 +2455,9 @@ function NewOrgCtrl($scope, $routeParams, $timeout, $location, UserService, Plan
};
PlanService.changePlan($scope, org.name, $scope.holder.currentPlan.stripeId, callbacks);
}, function(result) {
}, function(resp) {
$scope.creating = false;
$scope.createError = result.data.error_description || result.data;
$scope.createError = ApiService.getErrorMessage(resp);
$timeout(function() {
$('#orgName').popover('show');
});
@ -2575,18 +2534,7 @@ function ManageApplicationCtrl($scope, $routeParams, $rootScope, $location, $tim
$timeout(function() {
$location.path('/organization/' + orgname + '/admin');
}, 500);
}, function(resp) {
bootbox.dialog({
"message": resp.message || 'Could not delete application',
"title": "Cannot delete application",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}, ApiService.errorDisplay('Could not delete application'));
};
$scope.updateApplication = function() {
@ -2604,22 +2552,13 @@ function ManageApplicationCtrl($scope, $routeParams, $rootScope, $location, $tim
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) {
$scope.application = resp;
$scope.updating = false;
}, 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"
}
}
});
});
}, errorHandler);
};
$scope.resetClientSecret = function() {
@ -2632,18 +2571,7 @@ function ManageApplicationCtrl($scope, $routeParams, $rootScope, $location, $tim
ApiService.resetOrganizationApplicationClientSecret(null, params).then(function(resp) {
$scope.application = resp;
}, function(resp) {
bootbox.dialog({
"message": resp.message || 'Could not reset client secret',
"title": "Cannot reset client secret",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}, ApiService.errorDisplay('Could not reset client secret'));
};
var loadOrganization = function() {
@ -2739,18 +2667,7 @@ function SuperUserAdminCtrl($scope, ApiService, Features, UserService) {
ApiService.changeInstallUser(data, params).then(function(resp) {
$scope.loadUsersInternal();
}, function(resp) {
bootbox.dialog({
"message": resp.data ? resp.data.message : 'Could not change user',
"title": "Cannot change user",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}, ApiService.errorDisplay('Could not change user'));
};
$scope.deleteUser = function(user) {
@ -2762,49 +2679,10 @@ function SuperUserAdminCtrl($scope, ApiService, Features, UserService) {
ApiService.deleteInstallUser(null, params).then(function(resp) {
$scope.loadUsersInternal();
}, function(resp) {
bootbox.dialog({
"message": resp.data ? resp.data.message : 'Could not delete user',
"title": "Cannot delete user",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}, ApiService.errorDisplay('Cannot delete user'));
};
var seatUsageLoaded = function(usage) {
$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();
$scope.loadUsers();
}
function TourCtrl($scope, $location) {

View file

@ -148,6 +148,8 @@ ImageHistoryTree.prototype.updateDimensions_ = function() {
var ch = dimensions.ch;
// Set the height of the container so that it never goes offscreen.
if (!$('#' + container).removeOverscroll) { return; }
$('#' + container).removeOverscroll();
var viewportHeight = $(window).height();
var boundingBox = document.getElementById(container).getBoundingClientRect();
@ -402,6 +404,10 @@ ImageHistoryTree.prototype.buildRoot_ = function() {
var roots = [];
for (var i = 0; i < this.images_.length; ++i) {
var image = this.images_[i];
// Skip images that are currently uploading.
if (image.uploading) { continue; }
var imageNode = imageByDBID[image.dbid];
var ancestors = this.getAncestors_(image);
var immediateParent = ancestors[ancestors.length - 1] * 1;
@ -432,6 +438,10 @@ ImageHistoryTree.prototype.buildRoot_ = function() {
var maxChildCount = roots.length;
for (var i = 0; i < this.images_.length; ++i) {
var image = this.images_[i];
// Skip images that are currently uploading.
if (image.uploading) { continue; }
var imageNode = imageByDBID[image.dbid];
maxChildCount = Math.max(maxChildCount, this.determineMaximumChildCount_(imageNode));
}
@ -582,6 +592,10 @@ ImageHistoryTree.prototype.setTag_ = function(tagName) {
// Ensure that the children are in the correct order.
for (var i = 0; i < this.images_.length; ++i) {
var image = this.images_[i];
// Skip images that are currently uploading.
if (image.uploading) { continue; }
var imageNode = this.imageByDBID_[image.dbid];
var ancestors = this.getAncestors_(image);
var immediateParent = ancestors[ancestors.length - 1] * 1;

17
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

@ -7,41 +7,49 @@
<div class="col-sm-12 about-basic-info">
<h3>The Basics</h3>
</div>
<div class="col-sm-4">
<div class="col-sm-3">
<div class="about-basic-icon"><i class="fa fa-3x fa-calendar"></i></div>
<div class="about-basic-text">
<b> Founded</b><br>
2012
</div>
</div>
<div class="col-sm-4">
<div class="col-sm-3">
<div class="about-basic-icon"><i class="fa fa-3x fa-globe"></i></div>
<div class="about-basic-text">
<b> Location</b><br>
New York City, NY
</div>
</div>
<div class="col-sm-4">
<div class="col-sm-3">
<div class="about-basic-icon"><i class="fa fa-3x fa-users"></i></div>
<div class="about-basic-text">
<b> Worker Bees</b><br>
<b> Local Worker Bees</b><br>
2
</div>
</div>
<div class="col-sm-3">
<div class="about-basic-icon"><img src="/static/img/coreos-globe-color-lg.png" style="height: 42px; vertical-align: bottom;"></div>
<div class="about-basic-text">
<b> CoreOS</b><br>
August, 2014
</div>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<h3>Our Story</h3>
<p>Quay.io was originally created out of necessesity when we wanted to use Docker containers with DevTable IDE. We were using Docker containers to host and isolate server processes invoked on behalf of our users and often running their code. We started by building the Docker image dynamically whenever we spun up a new host node. The image was monolithic. It was too large, took too long to build, and was hard to manage conflicts. It was everything that Docker wasn't supposed to be. When we decided to split it up into pre-built images and host them somewhere, we noticed that there wasn't a good place to host images securely. Determined to scratch our own itch, we built Quay.io, and officially launched it as an aside in our presentation to the <a href="http://www.meetup.com/Docker-NewYorkCity/events/142142762/">Docker New York City Meetup</a> on October 2nd, 2013.</p>
<p>Since that time, our users have demanded that Quay.io become our main focus. Our customers rely on us to make sure they can store and distribute their Docker images, and we understand that solemn responsibility. Our customers have been fantastic with giving us great feedback and suggestions. We are working as hard as we can to deliver on the promise and execute our vision of what a top notch Docker registry should be. We thank you for taking this journey with us.</p>
<p>Quay.io was originally created out of necessesity when we wanted to use Docker containers with our original IDE product. We were using Docker containers to host and isolate server processes invoked on behalf of our users and often running their code. We started by building the Docker image dynamically whenever we spun up a new host node. The image was monolithic. It was too large, took too long to build, and was hard to manage conflicts. It was everything that Docker wasn't supposed to be. When we decided to split it up into pre-built images and host them somewhere, we noticed that there wasn't a good place to host images securely. Determined to scratch our own itch, we built Quay.io, and officially launched it as an aside in our presentation to the <a href="http://www.meetup.com/Docker-NewYorkCity/events/142142762/">Docker New York City Meetup</a> on October 2nd, 2013.</p>
<p>After launch, our customers demanded that Quay.io become our main focus. They rely on us to make sure they can store and distribute their Docker images, and we understand that solemn responsibility. Our customers have been fantastic with giving us great feedback and suggestions.</p>
<p>In August, 2014, Quay.io joined <a href="https://coreos.com">CoreOS</a> to provide registry support for the enterprise. As ever, we are working as hard as we can to deliver on the promise and execute our vision of what a top notch Docker registry should be.
</div>
</div>
<div class="row">
<div class="col-sm-12">
<h3>The Team</h3>
Our team is composed of two software engineers turned entrepreneurs:
<h3>The Quay.io Team at CoreOS</h3>
The Quay.io team is composed of two software engineers:
</div>
</div>
<div class="row">
@ -67,12 +75,7 @@
<img class="img-rounded founder-photo" src="http://www.gravatar.com/avatar/9fc3232622773fb2e8f71c0027601bc5?s=128&d=mm">
</div>
<div class="col-sm-7 col-md-10">
<p>Joseph graduated from University of Pennsylvania with a Bachelors and Masters in Computer Science. After a record setting (probably) five internships with Google, he took a full time position there to continue his work on exciting products such as Google Spreadsheets, the Google Closure Compiler, and Google APIs. Joseph was one of the original duo responsible for inventing the language and framework on which DevTable is built.</p>
<p>Joseph graduated from University of Pennsylvania with a Bachelors and Masters in Computer Science. After a record setting (probably) five internships with Google, he took a full time position there to continue his work on exciting products such as Google Spreadsheets, the Google Closure Compiler, and Google APIs. </p>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<p>With a combined 10 years experience building tools for software engineers, our founding team knows what it takes to make software engineers happy doing their work. Combined with our love for the web, we are ready to make a difference in the way people think about software development in the cloud.</p>
</div>
</div>
</div>
</div>

View file

@ -2,10 +2,6 @@
<div class="landing-background" ng-class="user.anonymous ? 'landing': 'signedin'"></div>
<div class="landing-filter" ng-class="user.anonymous ? 'landing': 'signedin'"></div>
<div class="landing-content">
<img class="logo" src="/static/img/white_horizontal.png">
<div class="header-bar"></div>
<div class="container">
<div class="row messages">
<div class="col-md-7">
@ -28,7 +24,7 @@
<a ng-href="/repository/{{ repository.namespace }}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a>
<div class="markdown-view description" content="repository.description" first-line-only="true"></div>
</div>
<a href="/repository/?namespace={{ user.username }}">See All Repositories</a>
<a href="/repository/?namespace={{ namespace }}">See All Repositories</a>
</div>
<!-- No Repos -->

View file

@ -1,18 +1,25 @@
<div class="jumbotron landing">
<div class="announcement">
<span class="hidden-xs-inline">
<img src="/static/img/white_horizontal.png" style="height: 40px">
<span class="plus">+</span>
<img src="/static/img/coreos-wordmark-horiz-white.svg">
</span>
<span class="spacer"></span>
Quay.io is now part of CoreOS! <a href="http://blog.devtable.com/" target="_blank">Read the blog post.</a>
</div>
<div class="landing-background" ng-class="user.anonymous ? 'landing': 'signedin'"></div>
<div class="landing-filter" ng-class="user.anonymous ? 'landing': 'signedin'"></div>
<div class="landing-content">
<img class="logo" src="/static/img/white_horizontal.png">
<div class="header-bar"></div>
<div class="landing-content">
<div class="container">
<div class="row messages">
<div class="col-md-7">
<div ng-show="user.anonymous">
<h1>Secure hosting for <b>private</b> Docker<a class="disclaimer-link" href="/disclaimer" target="_self">*</a> repositories</h1>
<h3>Use the Docker images <b>your team</b> needs with the safety of <b>private</b> repositories</h3>
<div class="sellcall"><a href="/plans/">Private repository plans starting at $12/mo</a></div>
<a href="/plans/?trial-plan=bus-coreos-trial" class="call-to-action">Get 20 free private repos for 6 months<i class="fa fa-arrow-right"></i></a>
</div>
<div ng-show="!user.anonymous">
@ -27,7 +34,7 @@
<a ng-href="/repository/{{ repository.namespace }}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a>
<div class="markdown-view description" content="repository.description" first-line-only="true"></div>
</div>
<a href="/repository/?namespace={{ user.username }}">See All Repositories</a>
<a href="/repository/?namespace={{ namespace }}">See All Repositories</a>
</div>
<!-- No Repos -->
@ -265,7 +272,7 @@
</div>
<div class="container">
<div class="container" ng-if="user.anonymous">
<div class="row">
<div style="border-top: 1px solid #eee; padding-top: 20px;" class="col-md-12">
<a href="https://mixpanel.com/f/partner"><img src="//cdn.mxpnl.com/site_media/images/partner/badge_light.png" alt="Mobile Analytics" /></a>

View file

@ -5,7 +5,7 @@
<div class="container create-org" ng-show="!creating">
<div class="row header-row">
<div class="col-md-8 col-md-offset-1">
<div class="col-md-12">
<h2>Create Organization</h2>
<div class="steps-container" ng-show="false">
@ -44,8 +44,7 @@
<!-- Step 2 -->
<div class="row" ng-show="user && !user.anonymous && !created">
<div class="col-md-1"></div>
<div class="col-md-8">
<div class="col-md-12">
<div class="step-container">
<h3>Setup the new organization</h3>
@ -54,7 +53,7 @@
<label for="orgName">Organization Name</label>
<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 }}"
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>
</div>
@ -85,8 +84,7 @@
<!-- Step 3 -->
<div class="row" ng-show="user && !user.anonymous && created">
<div class="col-md-1"></div>
<div class="col-md-8">
<div class="col-md-12">
<div class="step-container">
<h3>Organization Created</h3>
<h4><a href="/organization/{{ org.name }}">Manage Teams Now</a></h4>

View file

@ -17,8 +17,8 @@
<!-- Header -->
<div class="row">
<div class="col-md-1"></div>
<div class="col-md-8">
<div class="col-md-12">
<div class="section">
<div class="new-header">
<span style="color: #444;">
@ -45,8 +45,8 @@
<!-- Private/public -->
<div class="row">
<div class="col-md-1"></div>
<div class="col-md-8">
<div class="col-md-12">
<div class="section-title">Repository Visibility</div>
<div class="section">
<div class="repo-option">
@ -98,8 +98,8 @@
</div>
<div class="row">
<div class="col-md-1"></div>
<div class="col-md-8">
<div class="col-md-12">
<div class="section">
<div class="section-title">Initialize repository</div>
@ -137,8 +137,8 @@
</div>
<div class="row" ng-show="repo.initialize == 'dockerfile' || repo.initialize == 'zipfile'">
<div class="col-md-1"></div>
<div class="col-md-8">
<div class="col-md-12">
<div class="section">
<div class="section-title">Upload <span ng-if="repo.initialize == 'dockerfile'">Dockerfile</span><span ng-if="repo.initialize == 'zipfile'">Archive</span></div>
<div style="padding-top: 20px;">
@ -153,8 +153,8 @@
</div>
<div class="row" ng-show="repo.initialize == 'github'">
<div class="col-md-1"></div>
<div class="col-md-8">
<div class="col-md-12">
<div class="alert alert-info">
You will be redirected to authorize via GitHub once the repository has been created
</div>
@ -162,8 +162,8 @@
</div>
<div class="row">
<div class="col-md-1"></div>
<div class="col-md-8">
<div class="col-md-12">
<button class="btn btn-large btn-success" type="submit"
ng-disabled="uploading || building || newRepoForm.$invalid || (repo.is_public == '0' && (planRequired || checkingPlan)) || ((repo.initialize == 'dockerfile' || repo.initialize == 'zipfile') && !hasDockerfile)">
<i class="fa fa-large" ng-class="repo.is_public == '1' ? 'fa-unlock' : 'fa-lock'" style="margin-right: 4px"></i>

View file

@ -34,6 +34,13 @@
</span>
<i class="fa fa-upload visible-lg"></i>
</div>
<div class="feature">
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
data-title="Administrators can view and download the full invoice history for their organization">
Invoice History
</span>
<i class="fa fa-calendar visible-lg"></i>
</div>
<div class="feature">
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
data-title="Grant subsets of users in an organization their own permissions, either on a global basis or a per-repository basis">
@ -50,16 +57,9 @@
</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">
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
data-title="All plans have a 14-day free trial">
<span class="hidden-sm-inline">14-Day Free Trial</span>
<span class="visible-sm-inline">14-Day Trial</span>
data-title="All plans have a free trial">
<span class="hidden-sm-inline">Free Trial</span>
<span class="visible-sm-inline">Free Trial</span>
</span>
<i class="fa fa-clock-o visible-lg"></i>
</div>
@ -81,7 +81,7 @@
<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 present"></div>
@ -93,10 +93,10 @@
<div class="feature present">SSL Encryption</div>
<div class="feature present">Robot accounts</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'">Logging</div>
<div class="feature" ng-class="plan.bus_features ? 'present' : 'notpresent'">Invoice History</div>
<div class="feature present">14-Day Free Trial</div>
<div class="feature present">Free Trial</div>
</div>
<button class="btn btn-block" ng-class="plan.bus_features ? 'btn-success' : 'btn-primary'"
@ -128,11 +128,19 @@
</dl>
</div>
</div>
<div class="row">
<div class="col-md-12">
<h3>Enterprise</h3>
<dt>I work in an enterprise and we need to run Quay.io on our servers. Can I do so?</dt>
<dd>Please contact us at our <a href="mailto:support@quay.io">support email address</a> to discuss enterprise plans.</dd>
<div class="row enterprise-plan">
<div class="col-md-6">
<h2>Run Quay.io Behind Your Firewall</h2>
<div class="plan-combine">
<img src="/static/img/quay-logo.png">
<span class="plus">+</span>
<img src="/static/img/coreos.svg" style="height: 50px">
</div>
<div>Quay.io has partnered with CoreOS to offer Enterprise Registry, a version
of Quay.io that can be hosted behind your firewall. More information
can be found on the <a href="https://coreos.com/products/enterprise-registry">CoreOS website</a>.</div>
<a href="https://coreos.com/products/enterprise-registry" class="btn btn-default">Learn more about Enterprise Registry</a>
</div>
</div>
</div>

View file

@ -18,7 +18,8 @@
<div class="col-md-2">
<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><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="#notification" ng-click="loadNotifications()">Notifications</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#publicprivate">Public/Private</a></li>
@ -225,7 +226,7 @@
</div>
<!-- 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-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>
@ -377,24 +378,6 @@
counter="showNewNotificationCounter"
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 -->
<div class="modal fade" id="makepublicModal">
<div class="modal-dialog">
@ -441,26 +424,6 @@
</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 -->
<div class="modal fade" id="confirmdeleteModal">
<div class="modal-dialog">

View file

@ -8,9 +8,6 @@
<div class="col-md-2">
<ul class="nav nav-pills nav-stacked">
<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>
</li>
</ul>
@ -19,19 +16,8 @@
<!-- Content -->
<div class="col-md-10">
<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 -->
<div id="users" class="tab-pane">
<div id="users" class="tab-pane active">
<div class="quay-spinner" ng-show="!users"></div>
<div class="alert alert-error" ng-show="usersError">
{{ usersError }}

View file

@ -25,7 +25,7 @@
<li ng-show="hasPaidPlan" quay-require="['BILLING']">
<a href="javascript:void(0)" data-toggle="tab" data-target="#billingoptions">Billing Options</a>
</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>
</li>
@ -138,13 +138,14 @@
<!-- Change password tab -->
<div id="password" class="tab-pane">
<div class="loading" ng-show="updatingUser">
<div class="quay-spinner 3x"></div>
</div>
<div class="row">
<div class="panel">
<div class="panel-title">Change Password</div>
<div class="loading" ng-show="updatingUser">
<div class="quay-spinner 3x"></div>
</div>
<span class="help-block" ng-show="changePasswordSuccess">Password changed successfully</span>
<div ng-show="!updatingUser" class="panel-body">

View file

@ -18,7 +18,7 @@
<div class="dropdown" data-placement="top" style="display: inline-block"
bs-tooltip=""
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">
<i class="fa fa-tasks fa-lg"></i>
<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">
<div class="pull-container" data-title="Pull repository" bs-tooltip="tooltip.title">
<div class="input-group">
<input id="pull-text" type="text" class="form-control" value="{{ 'docker pull ' + Config.getDomain() + '/' + repo.namespace + '/' + repo.name }}" readonly>
<span id="copyClipboard" class="input-group-addon" data-title="Copy to Clipboard" data-clipboard-target="pull-text">
<i class="fa fa-copy"></i>
</span>
<div class="copy-box" hovering-message="true" value="'docker pull ' + Config.getDomain() + '/' + repo.namespace + '/' + repo.name"></div>
</div>
</div>
<div id="clipboardCopied" class="hovering" style="display: none">
Copied to clipboard
</div>
</span>
</div>
</div>
@ -76,33 +69,43 @@
<div class="description markdown-input" content="repo.description" can-write="repo.can_write"
content-changed="updateForDescription" field-title="'repository description'"></div>
<!-- Empty message -->
<div class="repo-content" ng-show="!currentTag.image_id && !currentImage && !repo.is_building">
<div class="empty-message">
This repository is empty
</div>
<!-- Empty messages -->
<div ng-if="!currentTag.image_id && !currentImage">
<!-- !building && !pushing -->
<div class="repo-content" ng-show="!repo.is_building && !isPushing(images)">
<div class="empty-message">
This repository is empty
</div>
<div class="empty-description" ng-show="repo.can_write">
<div class="panel-default">
<div class="panel-heading">How to push a new image to this repository:</div>
<div class="panel-body">
First login to the registry (if you have not done so already):
<pre class="command">sudo docker login {{ Config.getDomain() }}</pre>
<div class="empty-description" ng-show="repo.can_write">
<div class="panel-default">
<div class="panel-heading">How to push a new image to this repository:</div>
<div class="panel-body">
First login to the registry (if you have not done so already):
<pre class="command">sudo docker login {{ Config.getDomain() }}</pre>
Tag an image to this repository:
<pre class="command">sudo docker tag <i>0u123imageidgoeshere</i> {{ Config.getDomain() }}/{{repo.namespace}}/{{repo.name}}</pre>
Tag an image to this repository:
<pre class="command">sudo docker tag <i>0u123imageidgoeshere</i> {{ Config.getDomain() }}/{{repo.namespace}}/{{repo.name}}</pre>
Push the image to this repository:
<pre class="command">sudo docker push {{ Config.getDomain() }}/{{repo.namespace}}/{{repo.name}}</pre>
Push the image to this repository:
<pre class="command">sudo docker push {{ Config.getDomain() }}/{{repo.namespace}}/{{repo.name}}</pre>
</div>
</div>
</div>
</div>
</div>
<!-- building -->
<div class="repo-content" ng-show="repo.is_building">
<div class="empty-message">
A build is currently processing. If this takes longer than an hour, please <a href="/contact">contact us</a>
</div>
</div>
<div class="repo-content" ng-show="!currentTag.image_id && repo.is_building">
<div class="empty-message">
A build is currently processing. If this takes longer than an hour, please <a href="/contact">contact us</a>
<!-- pushing -->
<div class="repo-content" ng-show="!repo.is_building && isPushing(images)">
<div class="empty-message">
A push to this repository is in progress.
</div>
</div>
</div>
@ -357,7 +360,7 @@
<div style="margin: 10px; margin-top: 20px;" ng-show="isAnotherImageTag(toTagImage, tagToCreate)">
Note: <span class="label tag label-default">{{ tagToCreate }}</span> is already applied to another image. This will <b>move</b> the tag.
</div>
<div class="tag-specific-images-view" tag="tagToCreate" repository="repo" images="images"
<div class="tag-specific-images-view" tag="tagToCreate" repository="repo" images="images" image-cutoff="toTagImage"
style="margin: 10px; margin-top: 20px; margin-bottom: -10px;" ng-show="isAnotherImageTag(toTagImage, tagToCreate)">
This will also delete any unattach images and delete the following images:
</div>
@ -388,7 +391,10 @@
</span>?
</h4>
</div>
<div class="modal-body">
<div class="modal-body" ng-show="deletingTag">
<div class="quay-spinner"></div>
</div>
<div class="modal-body" ng-show="!deletingTag">
Are you sure you want to delete tag
<span class="label tag" ng-class="tagToDelete == currentTag.name ? 'label-success' : 'label-default'">
{{ tagToDelete }}
@ -398,7 +404,7 @@
The following images and any other images not referenced by a tag will be deleted:
</div>
</div>
<div class="modal-footer">
<div class="modal-footer" ng-show="!deletingTag">
<button type="button" class="btn btn-primary" ng-click="deleteTag(tagToDelete)">Delete Tag</button>
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
</div>

View file

@ -1,9 +1,16 @@
from storage.local import LocalStorage
from storage.s3 import S3Storage
from storage.cloud import S3Storage, GoogleCloudStorage
from storage.fakestorage import FakeStorage
from storage.distributedstorage import DistributedStorage
STORAGE_DRIVER_CLASSES = {
'LocalStorage': LocalStorage,
'S3Storage': S3Storage,
'GoogleCloudStorage': GoogleCloudStorage,
}
class Storage(object):
def __init__(self, app=None):
self.app = app
@ -18,13 +25,8 @@ class Storage(object):
driver = storage_params[0]
parameters = storage_params[1]
if driver == 'LocalStorage':
storage = LocalStorage(**parameters)
elif driver == 'S3Storage':
storage = S3Storage(**parameters)
else:
storage = FakeStorage()
driver_class = STORAGE_DRIVER_CLASSES.get(driver, FakeStorage)
storage = driver_class(**parameters)
storages[location] = storage
preference = app.config.get('DISTRIBUTED_STORAGE_PREFERENCE', None)

View file

@ -3,7 +3,9 @@ import os
import logging
import boto.s3.connection
import boto.gs.connection
import boto.s3.key
import boto.gs.key
from storage.basestorage import BaseStorage
@ -32,22 +34,24 @@ class StreamReadKeyAsFile(object):
return resp
class S3Storage(BaseStorage):
def __init__(self, storage_path, s3_access_key, s3_secret_key, s3_bucket):
class _CloudStorage(BaseStorage):
def __init__(self, connection_class, key_class, upload_params, storage_path, access_key,
secret_key, bucket_name):
self._initialized = False
self._bucket_name = s3_bucket
self._access_key = s3_access_key
self._secret_key = s3_secret_key
self._bucket_name = bucket_name
self._access_key = access_key
self._secret_key = secret_key
self._root_path = storage_path
self._s3_conn = None
self._s3_bucket = None
self._connection_class = connection_class
self._key_class = key_class
self._upload_params = upload_params
self._cloud_conn = None
self._cloud_bucket = None
def _initialize_s3(self):
def _initialize_cloud_conn(self):
if not self._initialized:
self._s3_conn = boto.s3.connection.S3Connection(self._access_key,
self._secret_key)
self._s3_bucket = self._s3_conn.get_bucket(self._bucket_name)
self._cloud_conn = self._connection_class(self._access_key, self._secret_key)
self._cloud_bucket = self._cloud_conn.get_bucket(self._bucket_name)
self._initialized = True
def _debug_key(self, key):
@ -69,33 +73,33 @@ class S3Storage(BaseStorage):
return path
def get_content(self, path):
self._initialize_s3()
self._initialize_cloud_conn()
path = self._init_path(path)
key = boto.s3.key.Key(self._s3_bucket, path)
key = self._key_class(self._cloud_bucket, path)
if not key.exists():
raise IOError('No such key: \'{0}\''.format(path))
return key.get_contents_as_string()
def put_content(self, path, content):
self._initialize_s3()
self._initialize_cloud_conn()
path = self._init_path(path)
key = boto.s3.key.Key(self._s3_bucket, path)
key.set_contents_from_string(content, encrypt_key=True)
key = self._key_class(self._cloud_bucket, path)
key.set_contents_from_string(content, **self._upload_params)
return path
def get_supports_resumeable_downloads(self):
return True
def get_direct_download_url(self, path, expires_in=60):
self._initialize_s3()
self._initialize_cloud_conn()
path = self._init_path(path)
k = boto.s3.key.Key(self._s3_bucket, path)
k = self._key_class(self._cloud_bucket, path)
return k.generate_url(expires_in)
def stream_read(self, path):
self._initialize_s3()
self._initialize_cloud_conn()
path = self._init_path(path)
key = boto.s3.key.Key(self._s3_bucket, path)
key = self._key_class(self._cloud_bucket, path)
if not key.exists():
raise IOError('No such key: \'{0}\''.format(path))
while True:
@ -105,21 +109,21 @@ class S3Storage(BaseStorage):
yield buf
def stream_read_file(self, path):
self._initialize_s3()
self._initialize_cloud_conn()
path = self._init_path(path)
key = boto.s3.key.Key(self._s3_bucket, path)
key = self._key_class(self._cloud_bucket, path)
if not key.exists():
raise IOError('No such key: \'{0}\''.format(path))
return StreamReadKeyAsFile(key)
def stream_write(self, path, fp):
# Minimum size of upload part size on S3 is 5MB
self._initialize_s3()
self._initialize_cloud_conn()
buffer_size = 5 * 1024 * 1024
if self.buffer_size > buffer_size:
buffer_size = self.buffer_size
path = self._init_path(path)
mp = self._s3_bucket.initiate_multipart_upload(path, encrypt_key=True)
mp = self._cloud_bucket.initiate_multipart_upload(path, **self._upload_params)
num_part = 1
while True:
try:
@ -135,7 +139,7 @@ class S3Storage(BaseStorage):
mp.complete_upload()
def list_directory(self, path=None):
self._initialize_s3()
self._initialize_cloud_conn()
path = self._init_path(path)
if not path.endswith('/'):
path += '/'
@ -143,7 +147,7 @@ class S3Storage(BaseStorage):
if self._root_path != '/':
ln = len(self._root_path)
exists = False
for key in self._s3_bucket.list(prefix=path, delimiter='/'):
for key in self._cloud_bucket.list(prefix=path, delimiter='/'):
exists = True
name = key.name
if name.endswith('/'):
@ -156,15 +160,15 @@ class S3Storage(BaseStorage):
raise OSError('No such directory: \'{0}\''.format(path))
def exists(self, path):
self._initialize_s3()
self._initialize_cloud_conn()
path = self._init_path(path)
key = boto.s3.key.Key(self._s3_bucket, path)
key = self._key_class(self._cloud_bucket, path)
return key.exists()
def remove(self, path):
self._initialize_s3()
self._initialize_cloud_conn()
path = self._init_path(path)
key = boto.s3.key.Key(self._s3_bucket, path)
key = self._key_class(self._cloud_bucket, path)
if key.exists():
# It's a file
key.delete()
@ -172,5 +176,28 @@ class S3Storage(BaseStorage):
# We assume it's a directory
if not path.endswith('/'):
path += '/'
for key in self._s3_bucket.list(prefix=path):
for key in self._cloud_bucket.list(prefix=path):
key.delete()
class S3Storage(_CloudStorage):
def __init__(self, storage_path, s3_access_key, s3_secret_key, s3_bucket):
upload_params = {
'encrypt_key': True,
}
super(S3Storage, self).__init__(boto.s3.connection.S3Connection, boto.s3.key.Key,
upload_params, storage_path, s3_access_key, s3_secret_key,
s3_bucket)
class GoogleCloudStorage(_CloudStorage):
def __init__(self, storage_path, access_key, secret_key, bucket_name):
super(GoogleCloudStorage, self).__init__(boto.gs.connection.GSConnection, boto.gs.key.Key, {},
storage_path, access_key, secret_key, bucket_name)
def stream_write(self, path, fp):
# Minimum size of upload part size on S3 is 5MB
self._initialize_cloud_conn()
path = self._init_path(path)
key = self._key_class(self._cloud_bucket, path)
key.set_contents_from_stream(fp)

View file

@ -91,42 +91,40 @@ mixpanel.init("{{ mixpanel_key }}", { track_pageview : false, debug: {{ is_debug
{% endif %}
</head>
<body ng-class="pageClass">
<div ng-class="!fixFooter ? 'wrapper' : ''">
<nav class="navbar navbar-default header-bar" role="navigation"></nav>
<body ng-class="pageClass + ' ' + (user.anonymous ? 'anon' : 'signedin')" class="co-img-bg-network">
<div id="co-l-footer-wrapper">
<nav class="navbar navbar-default header-bar co-m-navbar co-fx-box-shadow" role="navigation"></nav>
{% block body_content %}
<div id="padding-container">
<div id="co-l-view-container">
<div class="main-panel co-fx-box-shadow-heavy">
{% block body_content %}
{% endblock %}
<div ng-class="!fixFooter ? 'push' : ''"></div>
</div>
<div class="footer-container" ng-class="fixFooter ? 'fixed' : ''">
<div class="page-footer-padder">
<nav class="page-footer hidden-xs">
<div class="row">
<div class="col-md-8">
<ul>
<li><span class="copyright">&copy;2014 DevTable, LLC</span></li>
<li quay-require="['BILLING']"><a href="http://blog.devtable.com/" target="_blank">Blog</a></li>
<li quay-require="['BILLING']"><a href="/tos" target="_self">Terms</a></li>
<li quay-require="['BILLING']"><a href="/privacy" target="_self">Privacy</a></li>
<li quay-require="['BILLING']"><a href="/security/" target="_self">Security</a></li>
<li quay-require="['BILLING']"><a href="/about/" target="_self">About</a></li>
<li><b><a href="/contact/" target="_self">Contact</a></b></li>
<li quay-require="['BILLING']"><b><a href="http://status.quay.io" target="_self">Service Status</a></b></li>
</ul>
</div>
<div class="col-md-4 logo-container">
<a href="https://devtable.com"><img class="dt-logo" src="/static/img/dt-logo.png"></a>
</div>
</div> <!-- row -->
</nav>
{% endblock %}
</div>
</div>
</div>
<div id="co-l-footer-push"></div>
</div>
<nav id="co-l-footer" class="page-footer hidden-xs">
<div class="col-md-8">
<ul>
<li><span class="copyright">&copy;2014 CoreOS, Inc.</span></li>
<li quay-require="['BILLING']"><a href="/tos" target="_self">Terms</a></li>
<li quay-require="['BILLING']"><a href="/privacy" target="_self">Privacy</a></li>
<li quay-require="['BILLING']"><a href="/security/" target="_self">Security</a></li>
<li quay-require="['BILLING']"><a href="/about/" target="_self">About</a></li>
<li><b><a href="/contact/" target="_self">Contact</a></b></li>
<li quay-require="['BILLING']"><b><a href="http://status.quay.io" target="_self">Service Status</a></b></li>
</ul>
</div>
<div class="col-md-4">
<a href="https://coreos.com" target="_blank"><img src="/static/img/coreos.svg"></a>
</div>
</nav>
<!-- Modal message dialog -->
<div class="modal fade" id="couldnotloadModal" data-backdrop="static">
<div class="modal-dialog">

View file

@ -15,7 +15,7 @@
{% block body_content %}
<!-- Note: Must be in the <body> tag -->
<script src="static/standalonelib/jquery.overscroll.min.js"></script>
<div ng-view></div>
<!-- Modal message dialog -->
@ -35,23 +35,18 @@
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
{% if not has_billing %}
<!-- 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-content">
<div class="modal-header">
<h4 class="modal-title">Cannot create user</h4>
<h4 class="modal-title">Cannot Contact External Service</h4>
</div>
<div class="modal-body">
A new user cannot be created as this organization has reached its licensed seat count. Please contact your administrator.
</div>
<div class="modal-footer">
<a href="javascript:void(0)" class="btn btn-primary" data-dismiss="modal" onclick="location = '/signin'">Sign In</a>
A connection to an external service has failed. Please reload the page to try again.
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
{% endif %}
{% 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),
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),

View file

@ -14,14 +14,17 @@ from endpoints.api.search import FindRepositories, EntitySearch
from endpoints.api.image import RepositoryImageChanges, RepositoryImage, RepositoryImageList
from endpoints.api.build import (FileDropResource, RepositoryBuildStatus, RepositoryBuildLogs,
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,
TriggerBuildList, ActivateBuildTrigger, BuildTrigger,
BuildTriggerList, BuildTriggerAnalyze)
from endpoints.api.repoemail import RepositoryAuthorizedEmail
from endpoints.api.repositorynotification import RepositoryNotification, RepositoryNotificationList
from endpoints.api.user import (PrivateRepositories, ConvertToOrganization, Recovery, Signout,
Signin, User, UserAuthorizationList, UserAuthorization, UserNotification)
Signin, User, UserAuthorizationList, UserAuthorization, UserNotification,
VerifyUser)
from endpoints.api.repotoken import RepositoryToken, RepositoryTokenList
from endpoints.api.prototype import PermissionPrototype, PermissionPrototypeList
from endpoints.api.logs import UserLogs, OrgLogs, RepositoryLogs
@ -37,7 +40,7 @@ from endpoints.api.repository import RepositoryList, RepositoryVisibility, Repos
from endpoints.api.permission import (RepositoryUserPermission, RepositoryTeamPermission,
RepositoryTeamPermissionList, RepositoryUserPermissionList)
from endpoints.api.superuser import SuperUserLogs, SeatUsage, SuperUserList, SuperUserManagement
from endpoints.api.superuser import SuperUserLogs, SuperUserList, SuperUserManagement
try:
@ -432,6 +435,24 @@ class TestSignin(ApiTestCase):
self._run_test('POST', 403, 'devtable', {u'username': 'E9RY', u'password': 'LQ0N'})
class TestVerifyUser(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(VerifyUser)
def test_post_anonymous(self):
self._run_test('POST', 401, None, {u'password': 'LQ0N'})
def test_post_freshuser(self):
self._run_test('POST', 403, 'freshuser', {u'password': 'LQ0N'})
def test_post_reader(self):
self._run_test('POST', 403, 'reader', {u'password': 'LQ0N'})
def test_post_devtable(self):
self._run_test('POST', 200, 'devtable', {u'password': 'password'})
class TestListPlans(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
@ -471,13 +492,13 @@ class TestUser(ApiTestCase):
self._run_test('PUT', 401, None, {})
def test_put_freshuser(self):
self._run_test('PUT', 200, 'freshuser', {})
self._run_test('PUT', 401, 'freshuser', {})
def test_put_reader(self):
self._run_test('PUT', 200, 'reader', {})
self._run_test('PUT', 401, 'reader', {})
def test_put_devtable(self):
self._run_test('PUT', 200, 'devtable', {})
self._run_test('PUT', 401, 'devtable', {})
def test_post_anonymous(self):
self._run_test('POST', 400, None, {u'username': 'T946', u'password': '0SG4', u'email': 'MENT'})
@ -1632,6 +1653,19 @@ class TestOrgRobotBuynlargeZ7pd(ApiTestCase):
ApiTestCase.setUp(self)
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):
self._run_test('PUT', 401, None, None)
@ -1644,6 +1678,7 @@ class TestOrgRobotBuynlargeZ7pd(ApiTestCase):
def test_put_devtable(self):
self._run_test('PUT', 400, 'devtable', None)
def test_delete_anonymous(self):
self._run_test('DELETE', 401, None, None)
@ -3040,6 +3075,19 @@ class TestUserRobot5vdy(ApiTestCase):
ApiTestCase.setUp(self)
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):
self._run_test('PUT', 401, None, None)
@ -3052,6 +3100,7 @@ class TestUserRobot5vdy(ApiTestCase):
def test_put_devtable(self):
self._run_test('PUT', 201, 'devtable', None)
def test_delete_anonymous(self):
self._run_test('DELETE', 401, None, None)
@ -3065,6 +3114,42 @@ class TestUserRobot5vdy(ApiTestCase):
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):
def 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.image import RepositoryImage, RepositoryImageList
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,
TriggerBuildList, ActivateBuildTrigger, BuildTrigger,
BuildTriggerList, BuildTriggerAnalyze)
@ -40,7 +41,7 @@ from endpoints.api.organization import (OrganizationList, OrganizationMember,
from endpoints.api.repository import RepositoryList, RepositoryVisibility, Repository
from endpoints.api.permission import (RepositoryUserPermission, RepositoryTeamPermission,
RepositoryTeamPermissionList, RepositoryUserPermissionList)
from endpoints.api.superuser import SuperUserLogs, SeatUsage, SuperUserList, SuperUserManagement
from endpoints.api.superuser import SuperUserLogs, SuperUserList, SuperUserManagement
try:
app.register_blueprint(api_bp, url_prefix='/api')
@ -326,7 +327,13 @@ class TestChangeUserDetails(ApiTestCase):
self.putJsonResponse(User,
data=dict(password='newpasswordiscool'))
self.login(READ_ACCESS_USER, password='newpasswordiscool')
def test_changeeemail(self):
self.login(READ_ACCESS_USER)
self.putJsonResponse(User,
data=dict(email='test+foo@devtable.com'))
def test_changeinvoiceemail(self):
self.login(READ_ACCESS_USER)
@ -1572,6 +1579,30 @@ class TestUserRobots(ApiTestCase):
robots = self.getRobotNames()
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):
def getRobotNames(self):
@ -1601,6 +1632,31 @@ class TestOrgRobots(ApiTestCase):
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):
def test_user_logs(self):
self.login(ADMIN_ACCESS_USER)

View file

@ -46,25 +46,30 @@ class TestImageSharing(unittest.TestCase):
preferred = storage.preferred_locations[0]
image = model.find_create_or_link_image(docker_image_id, repository_obj, username, {},
preferred)
return image.storage.id
image.storage.uploading = False
image.storage.save()
return image.storage
def assertSameStorage(self, docker_image_id, storage_id, repository=REPO, username=ADMIN_ACCESS_USER):
new_storage_id = self.createStorage(docker_image_id, repository, username)
self.assertEquals(storage_id, new_storage_id)
def assertSameStorage(self, docker_image_id, existing_storage, repository=REPO,
username=ADMIN_ACCESS_USER):
new_storage = self.createStorage(docker_image_id, repository, username)
self.assertEquals(existing_storage.id, new_storage.id)
def assertDifferentStorage(self, docker_image_id, storage_id, repository=REPO, username=ADMIN_ACCESS_USER):
new_storage_id = self.createStorage(docker_image_id, repository, username)
self.assertNotEquals(storage_id, new_storage_id)
def assertDifferentStorage(self, docker_image_id, existing_storage, repository=REPO,
username=ADMIN_ACCESS_USER):
new_storage = self.createStorage(docker_image_id, repository, username)
self.assertNotEquals(existing_storage.id, new_storage.id)
def test_same_user(self):
""" The same user creates two images, each which should be shared in the same repo. This is a sanity check. """
""" The same user creates two images, each which should be shared in the same repo. This is a
sanity check. """
# Create a reference to a new docker ID => new image.
first_storage_id = self.createStorage('first-image')
first_storage = self.createStorage('first-image')
# Create a reference to the same docker ID => same image.
self.assertSameStorage('first-image', first_storage_id)
self.assertSameStorage('first-image', first_storage)
# Create a reference to another new docker ID => new image.
second_storage_id = self.createStorage('second-image')
@ -73,68 +78,68 @@ class TestImageSharing(unittest.TestCase):
self.assertSameStorage('second-image', second_storage_id)
# Make sure the images are different.
self.assertNotEquals(first_storage_id, second_storage_id)
self.assertNotEquals(first_storage, second_storage_id)
def test_no_user_private_repo(self):
""" If no user is specified (token case usually), then no sharing can occur on a private repo. """
# Create a reference to a new docker ID => new image.
first_storage_id = self.createStorage('the-image', username=None, repository=SHARED_REPO)
first_storage = self.createStorage('the-image', username=None, repository=SHARED_REPO)
# Create a areference to the same docker ID, but since no username => new image.
self.assertDifferentStorage('the-image', first_storage_id, username=None, repository=RANDOM_REPO)
self.assertDifferentStorage('the-image', first_storage, username=None, repository=RANDOM_REPO)
def test_no_user_public_repo(self):
""" If no user is specified (token case usually), then no sharing can occur on a private repo except when the image is first public. """
# Create a reference to a new docker ID => new image.
first_storage_id = self.createStorage('the-image', username=None, repository=PUBLIC_REPO)
first_storage = self.createStorage('the-image', username=None, repository=PUBLIC_REPO)
# Create a areference to the same docker ID. Since no username, we'd expect different but the first image is public so => shaed image.
self.assertSameStorage('the-image', first_storage_id, username=None, repository=RANDOM_REPO)
self.assertSameStorage('the-image', first_storage, username=None, repository=RANDOM_REPO)
def test_different_user_same_repo(self):
""" Two different users create the same image in the same repo. """
# Create a reference to a new docker ID under the first user => new image.
first_storage_id = self.createStorage('the-image', username=PUBLIC_USER, repository=SHARED_REPO)
first_storage = self.createStorage('the-image', username=PUBLIC_USER, repository=SHARED_REPO)
# Create a reference to the *same* docker ID under the second user => same image.
self.assertSameStorage('the-image', first_storage_id, username=ADMIN_ACCESS_USER, repository=SHARED_REPO)
self.assertSameStorage('the-image', first_storage, username=ADMIN_ACCESS_USER, repository=SHARED_REPO)
def test_different_repo_no_shared_access(self):
""" Neither user has access to the other user's repository. """
# Create a reference to a new docker ID under the first user => new image.
first_storage_id = self.createStorage('the-image', username=RANDOM_USER, repository=RANDOM_REPO)
first_storage = self.createStorage('the-image', username=RANDOM_USER, repository=RANDOM_REPO)
# Create a reference to the *same* docker ID under the second user => new image.
second_storage_id = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=REPO)
# Verify that the users do not share storage.
self.assertNotEquals(first_storage_id, second_storage_id)
self.assertNotEquals(first_storage, second_storage_id)
def test_public_than_private(self):
""" An image is created publicly then used privately, so it should be shared. """
# Create a reference to a new docker ID under the first user => new image.
first_storage_id = self.createStorage('the-image', username=PUBLIC_USER, repository=PUBLIC_REPO)
first_storage = self.createStorage('the-image', username=PUBLIC_USER, repository=PUBLIC_REPO)
# Create a reference to the *same* docker ID under the second user => same image, since the first was public.
self.assertSameStorage('the-image', first_storage_id, username=ADMIN_ACCESS_USER, repository=REPO)
self.assertSameStorage('the-image', first_storage, username=ADMIN_ACCESS_USER, repository=REPO)
def test_private_than_public(self):
""" An image is created privately then used publicly, so it should *not* be shared. """
# Create a reference to a new docker ID under the first user => new image.
first_storage_id = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=REPO)
first_storage = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=REPO)
# Create a reference to the *same* docker ID under the second user => new image, since the first was private.
self.assertDifferentStorage('the-image', first_storage_id, username=PUBLIC_USER, repository=PUBLIC_REPO)
self.assertDifferentStorage('the-image', first_storage, username=PUBLIC_USER, repository=PUBLIC_REPO)
def test_different_repo_with_access(self):
@ -143,64 +148,71 @@ class TestImageSharing(unittest.TestCase):
be shared since the user has access.
"""
# Create the image in the shared repo => new image.
first_storage_id = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=SHARED_REPO)
first_storage = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=SHARED_REPO)
# Create the image in the other user's repo, but since the user (PUBLIC) still has access to the shared
# repository, they should reuse the storage.
self.assertSameStorage('the-image', first_storage_id, username=PUBLIC_USER, repository=PUBLIC_REPO)
self.assertSameStorage('the-image', first_storage, username=PUBLIC_USER, repository=PUBLIC_REPO)
def test_org_access(self):
""" An image is accessible by being a member of the organization. """
# Create the new image under the org's repo => new image.
first_storage_id = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ORG_REPO)
first_storage = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ORG_REPO)
# Create an image under the user's repo, but since the user has access to the organization => shared image.
self.assertSameStorage('the-image', first_storage_id, username=ADMIN_ACCESS_USER, repository=REPO)
self.assertSameStorage('the-image', first_storage, username=ADMIN_ACCESS_USER, repository=REPO)
# Ensure that the user's robot does not have access, since it is not on the permissions list for the repo.
self.assertDifferentStorage('the-image', first_storage_id, username=ADMIN_ROBOT_USER, repository=SHARED_REPO)
self.assertDifferentStorage('the-image', first_storage, username=ADMIN_ROBOT_USER, repository=SHARED_REPO)
def test_org_access_different_user(self):
""" An image is accessible by being a member of the organization. """
# Create the new image under the org's repo => new image.
first_storage_id = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ORG_REPO)
first_storage = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ORG_REPO)
# Create an image under a user's repo, but since the user has access to the organization => shared image.
self.assertSameStorage('the-image', first_storage_id, username=PUBLIC_USER, repository=PUBLIC_REPO)
self.assertSameStorage('the-image', first_storage, username=PUBLIC_USER, repository=PUBLIC_REPO)
# Also verify for reader.
self.assertSameStorage('the-image', first_storage_id, username=READ_ACCESS_USER, repository=PUBLIC_REPO)
self.assertSameStorage('the-image', first_storage, username=READ_ACCESS_USER, repository=PUBLIC_REPO)
def test_org_no_access(self):
""" An image is not accessible if not a member of the organization. """
# Create the new image under the org's repo => new image.
first_storage_id = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ORG_REPO)
first_storage = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ORG_REPO)
# Create an image under a user's repo. Since the user is not a member of the organization => new image.
self.assertDifferentStorage('the-image', first_storage_id, username=RANDOM_USER, repository=RANDOM_REPO)
self.assertDifferentStorage('the-image', first_storage, username=RANDOM_USER, repository=RANDOM_REPO)
def test_org_not_team_member_with_access(self):
""" An image is accessible to a user specifically listed as having permission on the org repo. """
# Create the new image under the org's repo => new image.
first_storage_id = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ORG_REPO)
first_storage = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ORG_REPO)
# Create an image under a user's repo. Since the user has read access on that repo, they can see the image => shared image.
self.assertSameStorage('the-image', first_storage_id, username=OUTSIDE_ORG_USER, repository=OUTSIDE_ORG_REPO)
self.assertSameStorage('the-image', first_storage, username=OUTSIDE_ORG_USER, repository=OUTSIDE_ORG_REPO)
def test_org_not_team_member_with_no_access(self):
""" A user that has access to one org repo but not another and is not a team member. """
# Create the new image under the org's repo => new image.
first_storage_id = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ANOTHER_ORG_REPO)
first_storage = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ANOTHER_ORG_REPO)
# Create an image under a user's repo. The user doesn't have access to the repo (ANOTHER_ORG_REPO) so => new image.
self.assertDifferentStorage('the-image', first_storage_id, username=OUTSIDE_ORG_USER, repository=OUTSIDE_ORG_REPO)
self.assertDifferentStorage('the-image', first_storage, username=OUTSIDE_ORG_USER, repository=OUTSIDE_ORG_REPO)
def test_no_link_to_uploading(self):
still_uploading = self.createStorage('an-image', repository=PUBLIC_REPO)
still_uploading.uploading = True
still_uploading.save()
self.assertDifferentStorage('an-image', still_uploading)

View file

@ -20,7 +20,7 @@ query = (Image
.join(ImageStorage)
.switch(Image)
.join(Repository)
.where(Repository.name == 'userportal', Repository.namespace == 'crsinc'))
.where(ImageStorage.uploading == False))
bad_count = 0
good_count = 0

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 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 util.useremails import send_confirmation_email

View file

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

5
util/backoff.py Normal file
View file

@ -0,0 +1,5 @@
def exponential_backoff(attempts, scaling_factor, base):
backoff = 5 * (pow(2, attempts) - 1)
backoff_time = backoff * scaling_factor
retry_at = backoff_time/10 + base
return retry_at

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

Some files were not shown because too many files have changed in this diff Show more