Merge branch 'grunt-js-folder' of https://github.com/coreos-inc/quay into ackbar
This commit is contained in:
commit
30b895b795
42 changed files with 573 additions and 240 deletions
42
app.py
42
app.py
|
@ -4,30 +4,31 @@ import json
|
||||||
|
|
||||||
from flask import Flask, Config, request, Request
|
from flask import Flask, Config, request, Request
|
||||||
from flask.ext.principal import Principal
|
from flask.ext.principal import Principal
|
||||||
from flask.ext.login import LoginManager
|
from flask.ext.login import LoginManager, UserMixin
|
||||||
from flask.ext.mail import Mail
|
from flask.ext.mail import Mail
|
||||||
|
|
||||||
import features
|
import features
|
||||||
|
|
||||||
from storage import Storage
|
from storage import Storage
|
||||||
|
|
||||||
|
from avatars.avatars import Avatar
|
||||||
|
|
||||||
from data import model
|
from data import model
|
||||||
from data import database
|
from data import database
|
||||||
from data.userfiles import Userfiles
|
from data.userfiles import Userfiles
|
||||||
from data.users import UserAuthentication
|
from data.users import UserAuthentication
|
||||||
|
from data.billing import Billing
|
||||||
from data.buildlogs import BuildLogs
|
from data.buildlogs import BuildLogs
|
||||||
from data.archivedlogs import LogArchive
|
from data.archivedlogs import LogArchive
|
||||||
from data.queue import WorkQueue
|
|
||||||
from data.userevent import UserEventsBuilderModule
|
from data.userevent import UserEventsBuilderModule
|
||||||
|
from data.queue import WorkQueue
|
||||||
from avatars.avatars import Avatar
|
|
||||||
|
|
||||||
from util.analytics import Analytics
|
from util.analytics import Analytics
|
||||||
from data.billing import Billing
|
|
||||||
from util.config.provider import FileConfigProvider, TestConfigProvider
|
|
||||||
from util.exceptionlog import Sentry
|
from util.exceptionlog import Sentry
|
||||||
from util.names import urn_generator
|
from util.names import urn_generator
|
||||||
from util.oauth import GoogleOAuthConfig, GithubOAuthConfig
|
from util.oauth import GoogleOAuthConfig, GithubOAuthConfig
|
||||||
from util.queuemetrics import QueueMetrics
|
from util.queuemetrics import QueueMetrics
|
||||||
|
from util.config.provider import FileConfigProvider, TestConfigProvider
|
||||||
from util.config.configutil import generate_secret_key
|
from util.config.configutil import generate_secret_key
|
||||||
from util.config.superusermanager import SuperUserManager
|
from util.config.superusermanager import SuperUserManager
|
||||||
|
|
||||||
|
@ -103,17 +104,18 @@ analytics = Analytics(app)
|
||||||
billing = Billing(app)
|
billing = Billing(app)
|
||||||
sentry = Sentry(app)
|
sentry = Sentry(app)
|
||||||
build_logs = BuildLogs(app)
|
build_logs = BuildLogs(app)
|
||||||
queue_metrics = QueueMetrics(app)
|
|
||||||
authentication = UserAuthentication(app)
|
authentication = UserAuthentication(app)
|
||||||
userevents = UserEventsBuilderModule(app)
|
userevents = UserEventsBuilderModule(app)
|
||||||
superusers = SuperUserManager(app)
|
superusers = SuperUserManager(app)
|
||||||
|
queue_metrics = QueueMetrics(app)
|
||||||
|
|
||||||
|
tf = app.config['DB_TRANSACTION_FACTORY']
|
||||||
|
|
||||||
github_login = GithubOAuthConfig(app.config, 'GITHUB_LOGIN_CONFIG')
|
github_login = GithubOAuthConfig(app.config, 'GITHUB_LOGIN_CONFIG')
|
||||||
github_trigger = GithubOAuthConfig(app.config, 'GITHUB_TRIGGER_CONFIG')
|
github_trigger = GithubOAuthConfig(app.config, 'GITHUB_TRIGGER_CONFIG')
|
||||||
google_login = GoogleOAuthConfig(app.config, 'GOOGLE_LOGIN_CONFIG')
|
google_login = GoogleOAuthConfig(app.config, 'GOOGLE_LOGIN_CONFIG')
|
||||||
oauth_apps = [github_login, github_trigger, google_login]
|
oauth_apps = [github_login, github_trigger, google_login]
|
||||||
|
|
||||||
tf = app.config['DB_TRANSACTION_FACTORY']
|
|
||||||
image_diff_queue = WorkQueue(app.config['DIFFS_QUEUE_NAME'], tf)
|
image_diff_queue = WorkQueue(app.config['DIFFS_QUEUE_NAME'], tf)
|
||||||
dockerfile_build_queue = WorkQueue(app.config['DOCKERFILE_BUILD_QUEUE_NAME'], tf,
|
dockerfile_build_queue = WorkQueue(app.config['DOCKERFILE_BUILD_QUEUE_NAME'], tf,
|
||||||
reporter=queue_metrics.report)
|
reporter=queue_metrics.report)
|
||||||
|
@ -128,5 +130,29 @@ if app.config['SECRET_KEY'] is None:
|
||||||
logger.debug('Generating in-memory secret key')
|
logger.debug('Generating in-memory secret key')
|
||||||
app.config['SECRET_KEY'] = generate_secret_key()
|
app.config['SECRET_KEY'] = generate_secret_key()
|
||||||
|
|
||||||
|
@login_manager.user_loader
|
||||||
|
def load_user(user_uuid):
|
||||||
|
logger.debug('User loader loading deferred user with uuid: %s' % user_uuid)
|
||||||
|
return LoginWrappedDBUser(user_uuid)
|
||||||
|
|
||||||
|
class LoginWrappedDBUser(UserMixin):
|
||||||
|
def __init__(self, user_uuid, db_user=None):
|
||||||
|
self._uuid = user_uuid
|
||||||
|
self._db_user = db_user
|
||||||
|
|
||||||
|
def db_user(self):
|
||||||
|
if not self._db_user:
|
||||||
|
self._db_user = model.get_user_by_uuid(self._uuid)
|
||||||
|
return self._db_user
|
||||||
|
|
||||||
|
def is_authenticated(self):
|
||||||
|
return self.db_user() is not None
|
||||||
|
|
||||||
|
def is_active(self):
|
||||||
|
return self.db_user().verified
|
||||||
|
|
||||||
|
def get_id(self):
|
||||||
|
return unicode(self._uuid)
|
||||||
|
|
||||||
def get_app_url():
|
def get_app_url():
|
||||||
return '%s://%s' % (app.config['PREFERRED_URL_SCHEME'], app.config['SERVER_HOSTNAME'])
|
return '%s://%s' % (app.config['PREFERRED_URL_SCHEME'], app.config['SERVER_HOSTNAME'])
|
||||||
|
|
|
@ -89,6 +89,8 @@ class QuayDeferredPermissionUser(Identity):
|
||||||
if not self._permissions_loaded:
|
if not self._permissions_loaded:
|
||||||
logger.debug('Loading user permissions after deferring.')
|
logger.debug('Loading user permissions after deferring.')
|
||||||
user_object = model.get_user_by_uuid(self.id)
|
user_object = model.get_user_by_uuid(self.id)
|
||||||
|
if user_object is None:
|
||||||
|
return super(QuayDeferredPermissionUser, self).can(permission)
|
||||||
|
|
||||||
if user_object is None:
|
if user_object is None:
|
||||||
return super(QuayDeferredPermissionUser, self).can(permission)
|
return super(QuayDeferredPermissionUser, self).can(permission)
|
||||||
|
|
Binary file not shown.
BIN
binary_dependencies/tengine_2.1.0-1_amd64.deb
Normal file
BIN
binary_dependencies/tengine_2.1.0-1_amd64.deb
Normal file
Binary file not shown.
|
@ -1,3 +1,5 @@
|
||||||
|
# vim: ft=nginx
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80 default_server;
|
listen 80 default_server;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
# vim: ft=nginx
|
||||||
|
|
||||||
types_hash_max_size 2048;
|
types_hash_max_size 2048;
|
||||||
include /usr/local/nginx/conf/mime.types.default;
|
include /usr/local/nginx/conf/mime.types.default;
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,12 @@
|
||||||
|
# vim: ft=nginx
|
||||||
|
|
||||||
include root-base.conf;
|
include root-base.conf;
|
||||||
|
|
||||||
http {
|
http {
|
||||||
include http-base.conf;
|
include http-base.conf;
|
||||||
|
|
||||||
|
include rate-limiting.conf;
|
||||||
|
|
||||||
server {
|
server {
|
||||||
include server-base.conf;
|
include server-base.conf;
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
# vim: ft=nginx
|
||||||
|
|
||||||
include root-base.conf;
|
include root-base.conf;
|
||||||
|
|
||||||
http {
|
http {
|
||||||
|
@ -5,6 +7,8 @@ http {
|
||||||
|
|
||||||
include hosted-http-base.conf;
|
include hosted-http-base.conf;
|
||||||
|
|
||||||
|
include rate-limiting.conf;
|
||||||
|
|
||||||
server {
|
server {
|
||||||
include server-base.conf;
|
include server-base.conf;
|
||||||
|
|
||||||
|
|
6
conf/rate-limiting.conf
Normal file
6
conf/rate-limiting.conf
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
# vim: ft=nginx
|
||||||
|
|
||||||
|
limit_req_zone $binary_remote_addr zone=webapp:10m rate=10r/s;
|
||||||
|
limit_req_zone $binary_remote_addr zone=api:10m rate=1r/s;
|
||||||
|
limit_req_status 429;
|
||||||
|
limit_req_log_level warn;
|
|
@ -1,3 +1,5 @@
|
||||||
|
# vim: ft=nginx
|
||||||
|
|
||||||
pid /tmp/nginx.pid;
|
pid /tmp/nginx.pid;
|
||||||
error_log /var/log/nginx/nginx.error.log;
|
error_log /var/log/nginx/nginx.error.log;
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
# vim: ft=nginx
|
||||||
|
|
||||||
client_body_temp_path /var/log/nginx/client_body 1 2;
|
client_body_temp_path /var/log/nginx/client_body 1 2;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
|
@ -19,6 +21,8 @@ proxy_set_header Transfer-Encoding $http_transfer_encoding;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://web_app_server;
|
proxy_pass http://web_app_server;
|
||||||
|
|
||||||
|
#limit_req zone=webapp burst=10 nodelay;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /realtime {
|
location /realtime {
|
||||||
|
@ -37,6 +41,8 @@ location /v1/ {
|
||||||
proxy_temp_path /var/log/nginx/proxy_temp 1 2;
|
proxy_temp_path /var/log/nginx/proxy_temp 1 2;
|
||||||
|
|
||||||
client_max_body_size 20G;
|
client_max_body_size 20G;
|
||||||
|
|
||||||
|
#limit_req zone=api burst=5 nodelay;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /c1/ {
|
location /c1/ {
|
||||||
|
@ -47,6 +53,8 @@ location /c1/ {
|
||||||
proxy_pass http://verbs_app_server;
|
proxy_pass http://verbs_app_server;
|
||||||
proxy_read_timeout 2000;
|
proxy_read_timeout 2000;
|
||||||
proxy_temp_path /var/log/nginx/proxy_temp 1 2;
|
proxy_temp_path /var/log/nginx/proxy_temp 1 2;
|
||||||
|
|
||||||
|
#limit_req zone=api burst=5 nodelay;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /static/ {
|
location /static/ {
|
||||||
|
|
|
@ -70,13 +70,14 @@ read_slave = Proxy()
|
||||||
db_random_func = CallableProxy()
|
db_random_func = CallableProxy()
|
||||||
|
|
||||||
|
|
||||||
def validate_database_url(url):
|
def validate_database_url(url, connect_timeout=5):
|
||||||
driver = _db_from_url(url, {
|
driver = _db_from_url(url, {
|
||||||
'connect_timeout': 5
|
'connect_timeout': connect_timeout
|
||||||
})
|
})
|
||||||
driver.connect()
|
driver.connect()
|
||||||
driver.close()
|
driver.close()
|
||||||
|
|
||||||
|
|
||||||
def _db_from_url(url, db_kwargs):
|
def _db_from_url(url, db_kwargs):
|
||||||
parsed_url = make_url(url)
|
parsed_url = make_url(url)
|
||||||
|
|
||||||
|
@ -89,6 +90,10 @@ def _db_from_url(url, db_kwargs):
|
||||||
if parsed_url.password:
|
if parsed_url.password:
|
||||||
db_kwargs['password'] = parsed_url.password
|
db_kwargs['password'] = parsed_url.password
|
||||||
|
|
||||||
|
# Note: sqlite does not support connect_timeout.
|
||||||
|
if parsed_url.drivername == 'sqlite' and 'connect_timeout' in db_kwargs:
|
||||||
|
del db_kwargs['connect_timeout']
|
||||||
|
|
||||||
return SCHEME_DRIVERS[parsed_url.drivername](parsed_url.database, **db_kwargs)
|
return SCHEME_DRIVERS[parsed_url.drivername](parsed_url.database, **db_kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@ -129,8 +134,9 @@ def close_db_filter(_):
|
||||||
|
|
||||||
|
|
||||||
class QuayUserField(ForeignKeyField):
|
class QuayUserField(ForeignKeyField):
|
||||||
def __init__(self, allows_robots=False, *args, **kwargs):
|
def __init__(self, allows_robots=False, robot_null_delete=False, *args, **kwargs):
|
||||||
self.allows_robots = allows_robots
|
self.allows_robots = allows_robots
|
||||||
|
self.robot_null_delete = robot_null_delete
|
||||||
if not 'rel_model' in kwargs:
|
if not 'rel_model' in kwargs:
|
||||||
kwargs['rel_model'] = User
|
kwargs['rel_model'] = User
|
||||||
|
|
||||||
|
@ -164,6 +170,10 @@ class User(BaseModel):
|
||||||
for query, fk in self.dependencies(search_nullable=True):
|
for query, fk in self.dependencies(search_nullable=True):
|
||||||
if isinstance(fk, QuayUserField) and fk.allows_robots:
|
if isinstance(fk, QuayUserField) and fk.allows_robots:
|
||||||
model = fk.model_class
|
model = fk.model_class
|
||||||
|
|
||||||
|
if fk.robot_null_delete:
|
||||||
|
model.update(**{fk.name: None}).where(query).execute()
|
||||||
|
else:
|
||||||
model.delete().where(query).execute()
|
model.delete().where(query).execute()
|
||||||
|
|
||||||
# Delete the instance itself.
|
# Delete the instance itself.
|
||||||
|
@ -466,7 +476,7 @@ class LogEntry(BaseModel):
|
||||||
kind = ForeignKeyField(LogEntryKind, index=True)
|
kind = ForeignKeyField(LogEntryKind, index=True)
|
||||||
account = QuayUserField(index=True, related_name='account')
|
account = QuayUserField(index=True, related_name='account')
|
||||||
performer = QuayUserField(allows_robots=True, index=True, null=True,
|
performer = QuayUserField(allows_robots=True, index=True, null=True,
|
||||||
related_name='performer')
|
related_name='performer', robot_null_delete=True)
|
||||||
repository = ForeignKeyField(Repository, index=True, null=True)
|
repository = ForeignKeyField(Repository, index=True, null=True)
|
||||||
datetime = DateTimeField(default=datetime.now, index=True)
|
datetime = DateTimeField(default=datetime.now, index=True)
|
||||||
ip = CharField(null=True)
|
ip = CharField(null=True)
|
||||||
|
|
|
@ -14,7 +14,7 @@ from data.database import (User, Repository, Image, AccessToken, Role, Repositor
|
||||||
ExternalNotificationEvent, ExternalNotificationMethod,
|
ExternalNotificationEvent, ExternalNotificationMethod,
|
||||||
RepositoryNotification, RepositoryAuthorizedEmail, TeamMemberInvite,
|
RepositoryNotification, RepositoryAuthorizedEmail, TeamMemberInvite,
|
||||||
DerivedImageStorage, ImageStorageTransformation, random_string_generator,
|
DerivedImageStorage, ImageStorageTransformation, random_string_generator,
|
||||||
db, BUILD_PHASE, QuayUserField)
|
db, BUILD_PHASE, QuayUserField, validate_database_url)
|
||||||
from peewee import JOIN_LEFT_OUTER, fn
|
from peewee import JOIN_LEFT_OUTER, fn
|
||||||
from util.validation import (validate_username, validate_email, validate_password,
|
from util.validation import (validate_username, validate_email, validate_password,
|
||||||
INVALID_PASSWORD_MESSAGE)
|
INVALID_PASSWORD_MESSAGE)
|
||||||
|
@ -2257,11 +2257,20 @@ def delete_user(user):
|
||||||
# TODO: also delete any repository data associated
|
# TODO: also delete any repository data associated
|
||||||
|
|
||||||
|
|
||||||
def check_health():
|
def check_health(app_config):
|
||||||
|
# Attempt to connect to the database first. If the DB is not responding,
|
||||||
|
# using the validate_database_url will timeout quickly, as opposed to
|
||||||
|
# making a normal connect which will just hang (thus breaking the health
|
||||||
|
# check).
|
||||||
|
try:
|
||||||
|
validate_database_url(app_config['DB_URI'], connect_timeout=3)
|
||||||
|
except Exception:
|
||||||
|
logger.exception('Could not connect to the database')
|
||||||
|
return False
|
||||||
|
|
||||||
# We will connect to the db, check that it contains some log entry kinds
|
# We will connect to the db, check that it contains some log entry kinds
|
||||||
try:
|
try:
|
||||||
found_count = LogEntryKind.select().count()
|
return bool(list(LogEntryKind.select().limit(1)))
|
||||||
return found_count > 0
|
|
||||||
except:
|
except:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,12 @@
|
||||||
<meta name="viewport" content="width=device-width" />
|
<meta name="viewport" content="width=device-width" />
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
<title>{{ subject }}</title>
|
<title>{{ subject }}</title>
|
||||||
|
|
||||||
|
{% if action_metadata %}
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{{ action_metadata }}
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
</head>
|
</head>
|
||||||
<body bgcolor="#FFFFFF" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; width: 100% !important; height: 100%; margin: 0; padding: 0;"><style type="text/css">
|
<body bgcolor="#FFFFFF" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; width: 100% !important; height: 100%; margin: 0; padding: 0;"><style type="text/css">
|
||||||
@media only screen and (max-width: 600px) {
|
@media only screen and (max-width: 600px) {
|
||||||
|
|
|
@ -4,14 +4,17 @@ import json
|
||||||
import string
|
import string
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
# Register the various exceptions via decorators.
|
||||||
|
import endpoints.decorated
|
||||||
|
|
||||||
from flask import make_response, render_template, request, abort, session
|
from flask import make_response, render_template, request, abort, session
|
||||||
from flask.ext.login import login_user, UserMixin
|
from flask.ext.login import login_user
|
||||||
from flask.ext.principal import identity_changed
|
from flask.ext.principal import identity_changed
|
||||||
from random import SystemRandom
|
from random import SystemRandom
|
||||||
|
|
||||||
from data import model
|
from data import model
|
||||||
from data.database import db
|
from data.database import db
|
||||||
from app import app, login_manager, dockerfile_build_queue, notification_queue, oauth_apps
|
from app import app, oauth_apps, dockerfile_build_queue, LoginWrappedDBUser
|
||||||
|
|
||||||
from auth.permissions import QuayDeferredPermissionUser
|
from auth.permissions import QuayDeferredPermissionUser
|
||||||
from auth import scopes
|
from auth import scopes
|
||||||
|
@ -21,7 +24,6 @@ from functools import wraps
|
||||||
from config import getFrontendVisibleConfig
|
from config import getFrontendVisibleConfig
|
||||||
from external_libraries import get_external_javascript, get_external_css
|
from external_libraries import get_external_javascript, get_external_css
|
||||||
from endpoints.notificationhelper import spawn_notification
|
from endpoints.notificationhelper import spawn_notification
|
||||||
from util.useremails import CannotSendEmailException
|
|
||||||
|
|
||||||
import features
|
import features
|
||||||
|
|
||||||
|
@ -84,34 +86,8 @@ def param_required(param_name):
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
@login_manager.user_loader
|
|
||||||
def load_user(user_uuid):
|
|
||||||
logger.debug('User loader loading deferred user with uuid: %s' % user_uuid)
|
|
||||||
return _LoginWrappedDBUser(user_uuid)
|
|
||||||
|
|
||||||
|
|
||||||
class _LoginWrappedDBUser(UserMixin):
|
|
||||||
def __init__(self, user_uuid, db_user=None):
|
|
||||||
self._uuid = user_uuid
|
|
||||||
self._db_user = db_user
|
|
||||||
|
|
||||||
def db_user(self):
|
|
||||||
if not self._db_user:
|
|
||||||
self._db_user = model.get_user_by_uuid(self._uuid)
|
|
||||||
return self._db_user
|
|
||||||
|
|
||||||
def is_authenticated(self):
|
|
||||||
return self.db_user() is not None
|
|
||||||
|
|
||||||
def is_active(self):
|
|
||||||
return self.db_user().verified
|
|
||||||
|
|
||||||
def get_id(self):
|
|
||||||
return unicode(self._uuid)
|
|
||||||
|
|
||||||
|
|
||||||
def common_login(db_user):
|
def common_login(db_user):
|
||||||
if login_user(_LoginWrappedDBUser(db_user.uuid, db_user)):
|
if login_user(LoginWrappedDBUser(db_user.uuid, db_user)):
|
||||||
logger.debug('Successfully signed in as: %s (%s)' % (db_user.username, db_user.uuid))
|
logger.debug('Successfully signed in as: %s (%s)' % (db_user.username, db_user.uuid))
|
||||||
new_identity = QuayDeferredPermissionUser(db_user.uuid, 'user_uuid', {scopes.DIRECT_LOGIN})
|
new_identity = QuayDeferredPermissionUser(db_user.uuid, 'user_uuid', {scopes.DIRECT_LOGIN})
|
||||||
identity_changed.send(app, identity=new_identity)
|
identity_changed.send(app, identity=new_identity)
|
||||||
|
@ -121,17 +97,6 @@ def common_login(db_user):
|
||||||
logger.debug('User could not be logged in, inactive?.')
|
logger.debug('User could not be logged in, inactive?.')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@app.errorhandler(model.DataModelException)
|
|
||||||
def handle_dme(ex):
|
|
||||||
logger.exception(ex)
|
|
||||||
return make_response(json.dumps({'message': ex.message}), 400)
|
|
||||||
|
|
||||||
@app.errorhandler(CannotSendEmailException)
|
|
||||||
def handle_emailexception(ex):
|
|
||||||
message = 'Could not send email. Please contact an administrator and report this problem.'
|
|
||||||
return make_response(json.dumps({'message': message}), 400)
|
|
||||||
|
|
||||||
def random_string():
|
def random_string():
|
||||||
random = SystemRandom()
|
random = SystemRandom()
|
||||||
return ''.join([random.choice(string.ascii_uppercase + string.digits) for _ in range(8)])
|
return ''.join([random.choice(string.ascii_uppercase + string.digits) for _ in range(8)])
|
||||||
|
|
19
endpoints/decorated.py
Normal file
19
endpoints/decorated.py
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
|
||||||
|
from flask import make_response
|
||||||
|
from app import app
|
||||||
|
from util.useremails import CannotSendEmailException
|
||||||
|
from data import model
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@app.errorhandler(model.DataModelException)
|
||||||
|
def handle_dme(ex):
|
||||||
|
logger.exception(ex)
|
||||||
|
return make_response(json.dumps({'message': ex.message}), 400)
|
||||||
|
|
||||||
|
@app.errorhandler(CannotSendEmailException)
|
||||||
|
def handle_emailexception(ex):
|
||||||
|
message = 'Could not send email. Please contact an administrator and report this problem.'
|
||||||
|
return make_response(json.dumps({'message': message}), 400)
|
|
@ -380,6 +380,11 @@ def get_search():
|
||||||
resp.mimetype = 'application/json'
|
resp.mimetype = 'application/json'
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
# Note: This is *not* part of the Docker index spec. This is here for our own health check,
|
||||||
|
# since we have nginx handle the _ping below.
|
||||||
|
@index.route('/_internal_ping')
|
||||||
|
def internal_ping():
|
||||||
|
return make_response('true', 200)
|
||||||
|
|
||||||
@index.route('/_ping')
|
@index.route('/_ping')
|
||||||
@index.route('/_ping')
|
@index.route('/_ping')
|
||||||
|
|
|
@ -226,7 +226,7 @@ class GithubBuildTrigger(BuildTrigger):
|
||||||
'personal': False,
|
'personal': False,
|
||||||
'repos': repo_list,
|
'repos': repo_list,
|
||||||
'info': {
|
'info': {
|
||||||
'name': org.name,
|
'name': org.name or org.login,
|
||||||
'avatar_url': org.avatar_url
|
'avatar_url': org.avatar_url
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -6,7 +6,7 @@ from flask import (abort, redirect, request, url_for, make_response, Response,
|
||||||
from avatar_generator import Avatar
|
from avatar_generator import Avatar
|
||||||
from flask.ext.login import current_user
|
from flask.ext.login import current_user
|
||||||
from urlparse import urlparse
|
from urlparse import urlparse
|
||||||
from health.healthcheck import HealthCheck
|
from health.healthcheck import get_healthchecker
|
||||||
|
|
||||||
from data import model
|
from data import model
|
||||||
from data.model.oauth import DatabaseAuthorizationProvider
|
from data.model.oauth import DatabaseAuthorizationProvider
|
||||||
|
@ -30,6 +30,9 @@ import features
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Capture the unverified SSL errors.
|
||||||
|
logging.captureWarnings(True)
|
||||||
|
|
||||||
web = Blueprint('web', __name__)
|
web = Blueprint('web', __name__)
|
||||||
|
|
||||||
STATUS_TAGS = app.config['STATUS_TAGS']
|
STATUS_TAGS = app.config['STATUS_TAGS']
|
||||||
|
@ -164,33 +167,27 @@ def v1():
|
||||||
return index('')
|
return index('')
|
||||||
|
|
||||||
|
|
||||||
|
# TODO(jschorr): Remove this mirrored endpoint once we migrate ELB.
|
||||||
@web.route('/health', methods=['GET'])
|
@web.route('/health', methods=['GET'])
|
||||||
|
@web.route('/health/instance', methods=['GET'])
|
||||||
@no_cache
|
@no_cache
|
||||||
def health():
|
def instance_health():
|
||||||
db_healthy = model.check_health()
|
checker = get_healthchecker(app)
|
||||||
buildlogs_healthy = build_logs.check_health()
|
(data, status_code) = checker.check_instance()
|
||||||
|
response = jsonify(dict(data=data, status_code=status_code))
|
||||||
check = HealthCheck.get_check(app.config['HEALTH_CHECKER'][0], app.config['HEALTH_CHECKER'][1])
|
response.status_code = status_code
|
||||||
(data, is_healthy) = check.conduct_healthcheck(db_healthy, buildlogs_healthy)
|
|
||||||
|
|
||||||
response = jsonify(dict(data=data, is_healthy=is_healthy))
|
|
||||||
response.status_code = 200 if is_healthy else 503
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# TODO(jschorr): Remove this mirrored endpoint once we migrate pingdom.
|
||||||
@web.route('/status', methods=['GET'])
|
@web.route('/status', methods=['GET'])
|
||||||
|
@web.route('/health/endtoend', methods=['GET'])
|
||||||
@no_cache
|
@no_cache
|
||||||
def status():
|
def endtoend_health():
|
||||||
db_healthy = model.check_health()
|
checker = get_healthchecker(app)
|
||||||
buildlogs_healthy = build_logs.check_health()
|
(data, status_code) = checker.check_endtoend()
|
||||||
|
response = jsonify(dict(data=data, status_code=status_code))
|
||||||
response = jsonify({
|
response.status_code = status_code
|
||||||
'db_healthy': db_healthy,
|
|
||||||
'buildlogs_healthy': buildlogs_healthy,
|
|
||||||
'is_testing': app.config['TESTING'],
|
|
||||||
})
|
|
||||||
response.status_code = 200 if db_healthy and buildlogs_healthy else 503
|
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -25,7 +25,7 @@ module.exports = function(grunt) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
src: ['../static/lib/**/*.js', '../static/js/*.js', '../static/dist/template-cache.js'],
|
src: ['../static/lib/**/*.js', '../static/js/**/*.js', '../static/dist/template-cache.js'],
|
||||||
dest: '../static/dist/<%= pkg.name %>.js'
|
dest: '../static/dist/<%= pkg.name %>.js'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,47 +1,84 @@
|
||||||
import boto.rds2
|
import boto.rds2
|
||||||
import logging
|
import logging
|
||||||
|
from health.services import check_all_services
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class HealthCheck(object):
|
def get_healthchecker(app):
|
||||||
def __init__(self):
|
""" Returns a HealthCheck instance for the given app. """
|
||||||
pass
|
return HealthCheck.get_checker(app)
|
||||||
|
|
||||||
def conduct_healthcheck(self, db_healthy, buildlogs_healthy):
|
|
||||||
|
class HealthCheck(object):
|
||||||
|
def __init__(self, app):
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
def check_instance(self):
|
||||||
"""
|
"""
|
||||||
Conducts any custom healthcheck work, returning a dict representing the HealthCheck
|
Conducts a check on this specific instance, returning a dict representing the HealthCheck
|
||||||
output and a boolean indicating whether the instance is healthy.
|
output and a number indicating the health check response code.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
service_statuses = check_all_services(self.app)
|
||||||
|
return self.get_instance_health(service_statuses)
|
||||||
|
|
||||||
|
def check_endtoend(self):
|
||||||
|
"""
|
||||||
|
Conducts a check on all services, returning a dict representing the HealthCheck
|
||||||
|
output and a number indicating the health check response code.
|
||||||
|
"""
|
||||||
|
service_statuses = check_all_services(self.app)
|
||||||
|
return self.calculate_overall_health(service_statuses)
|
||||||
|
|
||||||
|
def get_instance_health(self, service_statuses):
|
||||||
|
"""
|
||||||
|
For the given service statuses, returns a dict representing the HealthCheck
|
||||||
|
output and a number indicating the health check response code. By default,
|
||||||
|
this simply ensures that all services are reporting as healthy.
|
||||||
|
"""
|
||||||
|
return self.calculate_overall_health(service_statuses)
|
||||||
|
|
||||||
|
def calculate_overall_health(self, service_statuses, skip=None, notes=None):
|
||||||
|
""" Returns true if and only if all the given service statuses report as healthy. """
|
||||||
|
is_healthy = True
|
||||||
|
notes = notes or []
|
||||||
|
|
||||||
|
for service_name in service_statuses:
|
||||||
|
if skip and service_name in skip:
|
||||||
|
notes.append('%s skipped in compute health' % service_name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_healthy = is_healthy and service_statuses[service_name]
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'services': service_statuses,
|
||||||
|
'notes': notes,
|
||||||
|
'is_testing': self.app.config['TESTING']
|
||||||
|
}
|
||||||
|
|
||||||
|
return (data, 200 if is_healthy else 503)
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_check(cls, name, parameters):
|
def get_checker(cls, app):
|
||||||
|
name = app.config['HEALTH_CHECKER'][0]
|
||||||
|
parameters = app.config['HEALTH_CHECKER'][1] or {}
|
||||||
|
|
||||||
for subc in cls.__subclasses__():
|
for subc in cls.__subclasses__():
|
||||||
if subc.check_name() == name:
|
if subc.check_name() == name:
|
||||||
return subc(**parameters)
|
return subc(app, **parameters)
|
||||||
|
|
||||||
raise Exception('Unknown health check with name %s' % name)
|
raise Exception('Unknown health check with name %s' % name)
|
||||||
|
|
||||||
|
|
||||||
class LocalHealthCheck(HealthCheck):
|
class LocalHealthCheck(HealthCheck):
|
||||||
def __init__(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def check_name(cls):
|
def check_name(cls):
|
||||||
return 'LocalHealthCheck'
|
return 'LocalHealthCheck'
|
||||||
|
|
||||||
def conduct_healthcheck(self, db_healthy, buildlogs_healthy):
|
|
||||||
data = {
|
|
||||||
'db_healthy': db_healthy,
|
|
||||||
'buildlogs_healthy': buildlogs_healthy
|
|
||||||
}
|
|
||||||
|
|
||||||
return (data, db_healthy and buildlogs_healthy)
|
|
||||||
|
|
||||||
|
|
||||||
class ProductionHealthCheck(HealthCheck):
|
class ProductionHealthCheck(HealthCheck):
|
||||||
def __init__(self, access_key, secret_key):
|
def __init__(self, app, access_key, secret_key):
|
||||||
|
super(ProductionHealthCheck, self).__init__(app)
|
||||||
self.access_key = access_key
|
self.access_key = access_key
|
||||||
self.secret_key = secret_key
|
self.secret_key = secret_key
|
||||||
|
|
||||||
|
@ -49,19 +86,30 @@ class ProductionHealthCheck(HealthCheck):
|
||||||
def check_name(cls):
|
def check_name(cls):
|
||||||
return 'ProductionHealthCheck'
|
return 'ProductionHealthCheck'
|
||||||
|
|
||||||
def conduct_healthcheck(self, db_healthy, buildlogs_healthy):
|
def get_instance_health(self, service_statuses):
|
||||||
data = {
|
# Note: We skip the redis check because if redis is down, we don't want ELB taking the
|
||||||
'db_healthy': db_healthy,
|
# machines out of service. Redis is not considered a high avaliability-required service.
|
||||||
'buildlogs_healthy': buildlogs_healthy
|
skip = ['redis']
|
||||||
}
|
notes = []
|
||||||
|
|
||||||
# Only report unhealthy if the machine cannot connect to the DB. Redis isn't required for
|
|
||||||
# mission critical/high avaliability operations.
|
|
||||||
if not db_healthy:
|
|
||||||
# If the database is marked as unhealthy, check the status of RDS directly. If RDS is
|
# If the database is marked as unhealthy, check the status of RDS directly. If RDS is
|
||||||
# reporting as available, then the problem is with this instance. Otherwise, the problem is
|
# reporting as available, then the problem is with this instance. Otherwise, the problem is
|
||||||
# with RDS, and we can keep this machine as 'healthy'.
|
# with RDS, and so we skip the DB status so we can keep this machine as 'healthy'.
|
||||||
is_rds_working = False
|
db_healthy = service_statuses['database']
|
||||||
|
if not db_healthy:
|
||||||
|
rds_status = self._get_rds_status()
|
||||||
|
notes.append('DB reports unhealthy; RDS status: %s' % rds_status)
|
||||||
|
|
||||||
|
# If the RDS is in any state but available, then we skip the DB check since it will
|
||||||
|
# fail and bring down the instance.
|
||||||
|
if rds_status != 'available':
|
||||||
|
skip.append('database')
|
||||||
|
|
||||||
|
return self.calculate_overall_health(service_statuses, skip=skip, notes=notes)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_rds_status(self):
|
||||||
|
""" Returns the status of the RDS instance as reported by AWS. """
|
||||||
try:
|
try:
|
||||||
region = boto.rds2.connect_to_region('us-east-1',
|
region = boto.rds2.connect_to_region('us-east-1',
|
||||||
aws_access_key_id=self.access_key, aws_secret_access_key=self.secret_key)
|
aws_access_key_id=self.access_key, aws_secret_access_key=self.secret_key)
|
||||||
|
@ -69,16 +117,7 @@ class ProductionHealthCheck(HealthCheck):
|
||||||
result = response['DescribeDBInstancesResult']
|
result = response['DescribeDBInstancesResult']
|
||||||
instances = result['DBInstances']
|
instances = result['DBInstances']
|
||||||
status = instances[0]['DBInstanceStatus']
|
status = instances[0]['DBInstanceStatus']
|
||||||
is_rds_working = status == 'available'
|
return status
|
||||||
except:
|
except:
|
||||||
logger.exception("Exception while checking RDS status")
|
logger.exception("Exception while checking RDS status")
|
||||||
pass
|
return 'error'
|
||||||
|
|
||||||
data['db_available_checked'] = True
|
|
||||||
data['db_available_status'] = is_rds_working
|
|
||||||
|
|
||||||
# If RDS is down, then we still report the machine as healthy, so that it can handle
|
|
||||||
# requests once RDS comes back up.
|
|
||||||
return (data, not is_rds_working)
|
|
||||||
|
|
||||||
return (data, db_healthy)
|
|
||||||
|
|
46
health/services.py
Normal file
46
health/services.py
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import logging
|
||||||
|
from data import model
|
||||||
|
from app import build_logs
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def _check_registry_gunicorn(app):
|
||||||
|
""" Returns the status of the registry gunicorn workers. """
|
||||||
|
# Compute the URL for checking the registry endpoint. We append a port if and only if the
|
||||||
|
# hostname contains one.
|
||||||
|
client = app.config['HTTPCLIENT']
|
||||||
|
hostname_parts = app.config['SERVER_HOSTNAME'].split(':')
|
||||||
|
port = ''
|
||||||
|
if len(hostname_parts) == 2:
|
||||||
|
port = ':' + hostname_parts[1]
|
||||||
|
|
||||||
|
registry_url = '%s://localhost%s/v1/_internal_ping' % (app.config['PREFERRED_URL_SCHEME'], port)
|
||||||
|
try:
|
||||||
|
return client.get(registry_url, verify=False, timeout=2).status_code == 200
|
||||||
|
except Exception:
|
||||||
|
logger.exception('Exception when checking registry health: %s', registry_url)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _check_database(app):
|
||||||
|
""" Returns the status of the database, as accessed from this instance. """
|
||||||
|
return model.check_health(app.config)
|
||||||
|
|
||||||
|
def _check_redis(app):
|
||||||
|
""" Returns the status of Redis, as accessed from this instance. """
|
||||||
|
return build_logs.check_health()
|
||||||
|
|
||||||
|
|
||||||
|
_SERVICES = {
|
||||||
|
'registry_gunicorn': _check_registry_gunicorn,
|
||||||
|
'database': _check_database,
|
||||||
|
'redis': _check_redis
|
||||||
|
}
|
||||||
|
|
||||||
|
def check_all_services(app):
|
||||||
|
""" Returns a dictionary containing the status of all the services defined. """
|
||||||
|
status = {}
|
||||||
|
for name in _SERVICES:
|
||||||
|
status[name] = _SERVICES[name](app)
|
||||||
|
|
||||||
|
return status
|
|
@ -7,7 +7,6 @@ from endpoints.index import index
|
||||||
from endpoints.tags import tags
|
from endpoints.tags import tags
|
||||||
from endpoints.registry import registry
|
from endpoints.registry import registry
|
||||||
|
|
||||||
|
|
||||||
application.register_blueprint(index, url_prefix='/v1')
|
application.register_blueprint(index, url_prefix='/v1')
|
||||||
application.register_blueprint(tags, url_prefix='/v1')
|
application.register_blueprint(tags, url_prefix='/v1')
|
||||||
application.register_blueprint(registry, url_prefix='/v1')
|
application.register_blueprint(registry, url_prefix='/v1')
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
autobahn
|
autobahn==0.9.3-3
|
||||||
aiowsgi
|
aiowsgi
|
||||||
trollius
|
trollius
|
||||||
peewee
|
peewee
|
||||||
|
|
|
@ -8,24 +8,21 @@ Jinja2==2.7.3
|
||||||
LogentriesLogger==0.2.1
|
LogentriesLogger==0.2.1
|
||||||
Mako==1.0.0
|
Mako==1.0.0
|
||||||
MarkupSafe==0.23
|
MarkupSafe==0.23
|
||||||
Pillow==2.6.1
|
Pillow==2.7.0
|
||||||
PyMySQL==0.6.2
|
PyMySQL==0.6.3
|
||||||
PyPDF2==1.23
|
PyPDF2==1.24
|
||||||
PyYAML==3.11
|
PyYAML==3.11
|
||||||
SQLAlchemy==0.9.8
|
SQLAlchemy==0.9.8
|
||||||
|
WebOb==1.4
|
||||||
Werkzeug==0.9.6
|
Werkzeug==0.9.6
|
||||||
alembic==0.7.0
|
|
||||||
git+https://github.com/DevTable/aniso8601-fake.git
|
|
||||||
git+https://github.com/DevTable/anunidecode.git
|
|
||||||
git+https://github.com/DevTable/avatar-generator.git
|
|
||||||
git+https://github.com/DevTable/pygithub.git
|
|
||||||
aiowsgi==0.3
|
aiowsgi==0.3
|
||||||
|
alembic==0.7.4
|
||||||
autobahn==0.9.3-3
|
autobahn==0.9.3-3
|
||||||
backports.ssl-match-hostname==3.4.0.2
|
backports.ssl-match-hostname==3.4.0.2
|
||||||
beautifulsoup4==4.3.2
|
beautifulsoup4==4.3.2
|
||||||
blinker==1.3
|
blinker==1.3
|
||||||
boto==2.34.0
|
boto==2.35.1
|
||||||
docker-py==0.6.0
|
docker-py==0.7.1
|
||||||
ecdsa==0.11
|
ecdsa==0.11
|
||||||
futures==2.2.0
|
futures==2.2.0
|
||||||
gevent==1.0.1
|
gevent==1.0.1
|
||||||
|
@ -36,26 +33,31 @@ hiredis==0.1.5
|
||||||
html5lib==0.999
|
html5lib==0.999
|
||||||
itsdangerous==0.24
|
itsdangerous==0.24
|
||||||
jsonschema==2.4.0
|
jsonschema==2.4.0
|
||||||
marisa-trie==0.6
|
marisa-trie==0.7
|
||||||
mixpanel-py==3.2.0
|
mixpanel-py==3.2.1
|
||||||
git+https://github.com/NateFerrero/oauth2lib.git
|
paramiko==1.15.2
|
||||||
paramiko==1.15.1
|
peewee==2.4.5
|
||||||
peewee==2.4.3
|
|
||||||
psycopg2==2.5.4
|
psycopg2==2.5.4
|
||||||
py-bcrypt==0.4
|
py-bcrypt==0.4
|
||||||
pycrypto==2.6.1
|
pycrypto==2.6.1
|
||||||
python-dateutil==2.2
|
python-dateutil==2.4.0
|
||||||
python-ldap==2.4.18
|
python-ldap==2.4.19
|
||||||
python-magic==0.4.6
|
python-magic==0.4.6
|
||||||
pytz==2014.9
|
pytz==2014.10
|
||||||
raven==5.1.1
|
raven==5.1.1
|
||||||
redis==2.10.3
|
redis==2.10.3
|
||||||
reportlab==2.7
|
reportlab==2.7
|
||||||
requests==2.4.3
|
requests==2.5.1
|
||||||
six==1.8.0
|
six==1.9.0
|
||||||
stripe==1.19.1
|
stripe==1.20.1
|
||||||
trollius==1.0.3
|
trollius==1.0.4
|
||||||
tzlocal==1.1.2
|
tzlocal==1.1.2
|
||||||
websocket-client==0.21.0
|
waitress==0.8.9
|
||||||
|
websocket-client==0.23.0
|
||||||
wsgiref==0.1.2
|
wsgiref==0.1.2
|
||||||
xhtml2pdf==0.0.6
|
xhtml2pdf==0.0.6
|
||||||
|
git+https://github.com/DevTable/aniso8601-fake.git
|
||||||
|
git+https://github.com/DevTable/anunidecode.git
|
||||||
|
git+https://github.com/DevTable/avatar-generator.git
|
||||||
|
git+https://github.com/DevTable/pygithub.git
|
||||||
|
git+https://github.com/NateFerrero/oauth2lib.git
|
||||||
|
|
|
@ -1068,12 +1068,6 @@ i.toggle-icon:hover {
|
||||||
border: 1px dashed #ccc;
|
border: 1px dashed #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-repo .initialize-repo .init-description {
|
|
||||||
color: #444;
|
|
||||||
font-size: 12px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-repo .initialize-repo .file-drop {
|
.new-repo .initialize-repo .file-drop {
|
||||||
margin: 10px;
|
margin: 10px;
|
||||||
}
|
}
|
||||||
|
@ -1319,13 +1313,16 @@ i.toggle-icon:hover {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-price:after {
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.plan-price:after {
|
||||||
content: "/ mo";
|
content: "/ mo";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0px;
|
bottom: 0px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #aaa;
|
color: #aaa;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.plans-list .plan .count {
|
.plans-list .plan .count {
|
||||||
|
@ -1488,9 +1485,6 @@ i.toggle-icon:hover {
|
||||||
right: 0px;
|
right: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-filter.signedin {
|
|
||||||
}
|
|
||||||
|
|
||||||
.landing-content {
|
.landing-content {
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
@ -1500,7 +1494,6 @@ i.toggle-icon:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing .call-to-action {
|
.landing .call-to-action {
|
||||||
height: 40px;
|
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
padding-left: 14px;
|
padding-left: 14px;
|
||||||
padding-right: 14px;
|
padding-right: 14px;
|
||||||
|
@ -1639,7 +1632,7 @@ i.toggle-icon:hover {
|
||||||
padding-left: 70px;
|
padding-left: 70px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-page .twitter-tweet .avatar img {
|
.landing-page .twitter-tweet .twitter-avatar img {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 2px solid rgb(70, 70, 70);
|
border: 2px solid rgb(70, 70, 70);
|
||||||
width: 50px;
|
width: 50px;
|
||||||
|
@ -3519,6 +3512,22 @@ p.editable:hover i {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plans-table ul {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.plans-table ul li {
|
||||||
|
padding: 4px;
|
||||||
|
margin: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans-table ul li .plan-info {
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.repo-breadcrumb-element .crumb {
|
.repo-breadcrumb-element .crumb {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
@ -4930,3 +4939,19 @@ i.slack-icon {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
margin-bottom: -16px;
|
margin-bottom: -16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dockerfile-build-form table td {
|
||||||
|
vertical-align: top;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dockerfile-build-form input[type="file"] {
|
||||||
|
margin: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dockerfile-build-form .help-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #aaa;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="dockerfile-build-form" repository="repository" upload-failed="handleBuildFailed(message)"
|
<div class="dockerfile-build-form" repository="repository" upload-failed="handleBuildFailed(message)"
|
||||||
build-started="handleBuildStarted(build)" build-failed="handleBuildFailed(message)" start-now="startCounter"
|
build-started="handleBuildStarted(build)" build-failed="handleBuildFailed(message)" start-now="startCounter"
|
||||||
has-dockerfile="hasDockerfile" uploading="uploading" building="building"></div>
|
is-ready="hasDockerfile" uploading="uploading" building="building"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-primary" ng-click="startBuild()" ng-disabled="building || uploading || !hasDockerfile">Start Build</button>
|
<button type="button" class="btn btn-primary" ng-click="startBuild()" ng-disabled="building || uploading || !hasDockerfile">Start Build</button>
|
||||||
|
|
|
@ -11,9 +11,44 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container" ng-show="!uploading && !building">
|
<div class="container" ng-show="!uploading && !building">
|
||||||
<div class="init-description">
|
<table>
|
||||||
Upload a <b>Dockerfile</b> or an archive (<code>.zip</code> or <code>.tar.gz</code>) containing a Dockerfile <b>in the root directory</b>
|
<tr>
|
||||||
|
<td style="vertical-align: middle;">Dockerfile or <code>.tar.gz</code> or <code>.zip</code>:</td>
|
||||||
|
<td><input id="file-drop" class="file-drop" type="file" file-present="internal.hasDockerfile">
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>
|
||||||
|
<div class="help-text">If an archive, the Dockerfile must be at the root</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Base Image Pull Credentials:</td>
|
||||||
|
<td>
|
||||||
|
<!-- Select credentials -->
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button type="button" class="btn btn-default"
|
||||||
|
ng-class="is_public ? 'active btn-info' : ''"
|
||||||
|
ng-click="is_public = true">
|
||||||
|
None
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-default"
|
||||||
|
ng-class="is_public ? '' : 'active btn-info'"
|
||||||
|
ng-click="is_public = false">
|
||||||
|
<i class="fa fa-wrench"></i>
|
||||||
|
Robot account
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<input id="file-drop" class="file-drop" type="file" file-present="internal.hasDockerfile">
|
|
||||||
|
<!-- Robot Select -->
|
||||||
|
<div ng-show="!is_public" style="margin-top: 10px">
|
||||||
|
<div class="entity-search" namespace="repository.namespace"
|
||||||
|
placeholder="'Select robot account for pulling...'"
|
||||||
|
current-entity="pull_entity"
|
||||||
|
allowed-entities="['robot']"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -19,8 +19,21 @@
|
||||||
<li><a ng-href="{{ user.organizations.length ? '/organizations/' : '/tour/organizations/' }}" target="{{ appLinkTarget() }}" quay-section="organization">Organizations</a></li>
|
<li><a ng-href="{{ user.organizations.length ? '/organizations/' : '/tour/organizations/' }}" target="{{ appLinkTarget() }}" quay-section="organization">Organizations</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<!-- Phone -->
|
||||||
|
<ul class="nav navbar-nav navbar-right visible-xs" ng-switch on="user.anonymous">
|
||||||
|
<li ng-switch-when="false">
|
||||||
|
<a href="/user/" class="user-view" target="{{ appLinkTarget() }}">
|
||||||
|
<span class="avatar" size="32" hash="user.avatar"></span>
|
||||||
|
{{ user.username }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li ng-switch-default>
|
||||||
|
<a class="user-view" href="/signin/" target="{{ appLinkTarget() }}">Sign in</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<ul class="nav navbar-nav navbar-right" ng-switch on="user.anonymous">
|
<!-- Normal -->
|
||||||
|
<ul class="nav navbar-nav navbar-right hidden-xs" ng-switch on="user.anonymous">
|
||||||
<li>
|
<li>
|
||||||
<form class="navbar-form navbar-left" role="search">
|
<form class="navbar-form navbar-left" role="search">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
|
@ -31,11 +31,16 @@
|
||||||
ng-show="!planLoading"></div>
|
ng-show="!planLoading"></div>
|
||||||
|
|
||||||
<!-- Plans Table -->
|
<!-- Plans Table -->
|
||||||
|
<div class="visible-xs" style="margin-top: 10px"></div>
|
||||||
|
|
||||||
<table class="table table-hover plans-list-table" ng-show="!planLoading">
|
<table class="table table-hover plans-list-table" ng-show="!planLoading">
|
||||||
<thead>
|
<thead>
|
||||||
<td>Plan</td>
|
<td>Plan</td>
|
||||||
<td>Private Repositories</td>
|
<td>
|
||||||
<td style="min-width: 64px">Price</td>
|
<span class="hidden-xs">Private Repositories</span>
|
||||||
|
<span class="visible-xs"><i class="fa fa-hdd-o"></i></span>
|
||||||
|
</td>
|
||||||
|
<td style="min-width: 64px"><span class="hidden-xs">Price</span><span class="visible-xs">$/mo</span></td>
|
||||||
<td></td>
|
<td></td>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,21 @@
|
||||||
<div class="plans-table-element">
|
<div class="plans-table-element">
|
||||||
|
<ul class="plans-table-list visible-xs">
|
||||||
|
<li ng-repeat="plan in plans" ng-class="currentPlan == plan ? 'active' : ''">
|
||||||
|
|
||||||
|
<a class="btn" href="javascript:void(0)"
|
||||||
|
ng-class="currentPlan == plan ? 'btn-primary' : 'btn-default'"
|
||||||
|
ng-click="setPlan(plan)">
|
||||||
|
{{ plan.title }}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="plan-info">
|
||||||
|
${{ plan.price / 100 }} / month -
|
||||||
|
{{ plan.privateRepos }} repositories
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="hidden-xs">
|
||||||
<table class="table table-hover plans-table-table" ng-show="plans">
|
<table class="table table-hover plans-table-table" ng-show="plans">
|
||||||
<thead>
|
<thead>
|
||||||
<th>Plan</th>
|
<th>Plan</th>
|
||||||
|
@ -20,4 +37,5 @@
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
</p>
|
</p>
|
||||||
<div class="attribute">
|
<div class="attribute">
|
||||||
<span class="info-wrap">
|
<span class="info-wrap">
|
||||||
<span class="avatar"><img ng-src="{{ avatarUrl }}" fallback-src="/static/img/default-twitter.png"></span>
|
<span class="twitter-avatar"><img ng-src="{{ avatarUrl }}" fallback-src="/static/img/default-twitter.png"></span>
|
||||||
<span class="info">
|
<span class="info">
|
||||||
<span class="author">{{ authorName }} (@{{authorUser}})</span>
|
<span class="author">{{ authorName }} (@{{authorUser}})</span>
|
||||||
<a class="reference" ng-href="{{ messageUrl }}">{{ messageDate }}</a>
|
<a class="reference" ng-href="{{ messageUrl }}">{{ messageDate }}</a>
|
||||||
|
|
|
@ -2647,7 +2647,7 @@ quayApp.directive('focusablePopoverContent', ['$timeout', '$popover', function (
|
||||||
|
|
||||||
if (!scope) { return; }
|
if (!scope) { return; }
|
||||||
scope.$apply(function() {
|
scope.$apply(function() {
|
||||||
if (!scope) { return; }
|
if (!scope || !$scope.$hide) { return; }
|
||||||
scope.$hide();
|
scope.$hide();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -4358,6 +4358,8 @@ quayApp.directive('entitySearch', function () {
|
||||||
|
|
||||||
if (classes.length > 1) {
|
if (classes.length > 1) {
|
||||||
classes[classes.length - 1] = 'or ' + classes[classes.length - 1];
|
classes[classes.length - 1] = 'or ' + classes[classes.length - 1];
|
||||||
|
} else if (classes.length == 0) {
|
||||||
|
return '<div class="tt-empty">No matching entities found</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
var class_string = '';
|
var class_string = '';
|
||||||
|
@ -4443,7 +4445,6 @@ quayApp.directive('entitySearch', function () {
|
||||||
|
|
||||||
$scope.$watch('namespace', function(namespace) {
|
$scope.$watch('namespace', function(namespace) {
|
||||||
if (!namespace) { return; }
|
if (!namespace) { return; }
|
||||||
|
|
||||||
$scope.isAdmin = UserService.isNamespaceAdmin(namespace);
|
$scope.isAdmin = UserService.isNamespaceAdmin(namespace);
|
||||||
$scope.isOrganization = !!UserService.getOrganization(namespace);
|
$scope.isOrganization = !!UserService.getOrganization(namespace);
|
||||||
});
|
});
|
||||||
|
@ -6233,7 +6234,7 @@ quayApp.directive('dockerfileBuildForm', function () {
|
||||||
scope: {
|
scope: {
|
||||||
'repository': '=repository',
|
'repository': '=repository',
|
||||||
'startNow': '=startNow',
|
'startNow': '=startNow',
|
||||||
'hasDockerfile': '=hasDockerfile',
|
'isReady': '=isReady',
|
||||||
'uploadFailed': '&uploadFailed',
|
'uploadFailed': '&uploadFailed',
|
||||||
'uploadStarted': '&uploadStarted',
|
'uploadStarted': '&uploadStarted',
|
||||||
'buildStarted': '&buildStarted',
|
'buildStarted': '&buildStarted',
|
||||||
|
@ -6244,6 +6245,8 @@ quayApp.directive('dockerfileBuildForm', function () {
|
||||||
},
|
},
|
||||||
controller: function($scope, $element, ApiService) {
|
controller: function($scope, $element, ApiService) {
|
||||||
$scope.internal = {'hasDockerfile': false};
|
$scope.internal = {'hasDockerfile': false};
|
||||||
|
$scope.pull_entity = null;
|
||||||
|
$scope.is_public = true;
|
||||||
|
|
||||||
var handleBuildFailed = function(message) {
|
var handleBuildFailed = function(message) {
|
||||||
message = message || 'Dockerfile build failed to start';
|
message = message || 'Dockerfile build failed to start';
|
||||||
|
@ -6317,8 +6320,12 @@ quayApp.directive('dockerfileBuildForm', function () {
|
||||||
'file_id': fileId
|
'file_id': fileId
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!$scope.is_public && $scope.pull_entity) {
|
||||||
|
data['pull_robot'] = $scope.pull_entity['name'];
|
||||||
|
}
|
||||||
|
|
||||||
var params = {
|
var params = {
|
||||||
'repository': repo.namespace + '/' + repo.name
|
'repository': repo.namespace + '/' + repo.name,
|
||||||
};
|
};
|
||||||
|
|
||||||
ApiService.requestRepoBuild(data, params).then(function(resp) {
|
ApiService.requestRepoBuild(data, params).then(function(resp) {
|
||||||
|
@ -6396,9 +6403,13 @@ quayApp.directive('dockerfileBuildForm', function () {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.$watch('internal.hasDockerfile', function(d) {
|
var checkIsReady = function() {
|
||||||
$scope.hasDockerfile = d;
|
$scope.isReady = $scope.internal.hasDockerfile && ($scope.is_public || $scope.pull_entity);
|
||||||
});
|
};
|
||||||
|
|
||||||
|
$scope.$watch('pull_entity', checkIsReady);
|
||||||
|
$scope.$watch('is_public', checkIsReady);
|
||||||
|
$scope.$watch('internal.hasDockerfile', checkIsReady);
|
||||||
|
|
||||||
$scope.$watch('startNow', function() {
|
$scope.$watch('startNow', function() {
|
||||||
if ($scope.startNow && $scope.repository && !$scope.uploading && !$scope.building) {
|
if ($scope.startNow && $scope.repository && !$scope.uploading && !$scope.building) {
|
||||||
|
|
|
@ -207,7 +207,7 @@
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<div class="twitter-view" avatar-url="https://pbs.twimg.com/profile_images/2578175278/ykn3l9ktfdy1hia5odij_bigger.jpeg"
|
<div class="twitter-view" avatar-url="https://pbs.twimg.com/profile_images/483391930147954688/pvJAHzy__bigger.jpeg"
|
||||||
author-name="Frank Macreery" author-user="fancyremarker" message-url="https://twitter.com/fancyremarker/statuses/448528623692025857"
|
author-name="Frank Macreery" author-user="fancyremarker" message-url="https://twitter.com/fancyremarker/statuses/448528623692025857"
|
||||||
message-date="March 25, 2014">
|
message-date="March 25, 2014">
|
||||||
<a href="https://twitter.com/quayio">@quayio</a> releases Docker build flair! <a href="http://t.co/72ULgveLj4">pic.twitter.com/72ULgveLj4</a>
|
<a href="https://twitter.com/quayio">@quayio</a> releases Docker build flair! <a href="http://t.co/72ULgveLj4">pic.twitter.com/72ULgveLj4</a>
|
||||||
|
|
|
@ -54,14 +54,14 @@
|
||||||
<input id="orgName" name="orgName" type="text" class="form-control" placeholder="Organization Name"
|
<input id="orgName" name="orgName" type="text" class="form-control" placeholder="Organization Name"
|
||||||
ng-model="org.name" required autofocus data-trigger="manual" data-content="{{ createError }}"
|
ng-model="org.name" required autofocus data-trigger="manual" data-content="{{ createError }}"
|
||||||
data-placement="bottom" data-container="body" ng-pattern="/^[a-z0-9_]{4,30}$/">
|
data-placement="bottom" data-container="body" ng-pattern="/^[a-z0-9_]{4,30}$/">
|
||||||
<span class="description">This will also be the namespace for your repositories</span>
|
<span class="description">This will also be the namespace for your repositories. Must be alphanumeric and all lowercase.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group nested">
|
<div class="form-group nested">
|
||||||
<label for="orgName">Organization Email</label>
|
<label for="orgName">Organization Email</label>
|
||||||
<input id="orgEmail" name="orgEmail" type="email" class="form-control" placeholder="Organization Email"
|
<input id="orgEmail" name="orgEmail" type="email" class="form-control" placeholder="Organization Email"
|
||||||
ng-model="org.email" required>
|
ng-model="org.email" required>
|
||||||
<span class="description">This address must be different from your account's email</span>
|
<span class="description">This address must be different from your account's email.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Plans Table -->
|
<!-- Plans Table -->
|
||||||
|
|
|
@ -143,9 +143,9 @@
|
||||||
<div class="section-title">Upload <span ng-if="repo.initialize == 'dockerfile'">Dockerfile</span><span ng-if="repo.initialize == 'zipfile'">Archive</span></div>
|
<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;">
|
<div style="padding-top: 20px;">
|
||||||
<div class="initialize-repo">
|
<div class="initialize-repo">
|
||||||
<div class="dockerfile-build-form" repository="createdForBuild" upload-failed="handleBuildFailed(message)"
|
<div class="dockerfile-build-form" repository="createdForBuild || repo" upload-failed="handleBuildFailed(message)"
|
||||||
build-started="handleBuildStarted()" build-failed="handleBuildFailed(message)" start-now="createdForBuild"
|
build-started="handleBuildStarted()" build-failed="handleBuildFailed(message)" start-now="createdForBuild"
|
||||||
has-dockerfile="hasDockerfile" uploading="uploading" building="building"></div>
|
is-ready="hasDockerfile" uploading="uploading" building="building"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
<div class="team-view container">
|
<div class="team-view container">
|
||||||
<div class="organization-header" organization="organization" team-name="teamname">
|
<div class="organization-header" organization="organization" team-name="teamname">
|
||||||
<div ng-show="canEditMembers" class="side-controls">
|
<div ng-show="canEditMembers" class="side-controls">
|
||||||
<div class="hidden-sm hidden-xs">
|
<div class="hidden-xs">
|
||||||
<button class="btn btn-success"
|
<button class="btn btn-success"
|
||||||
id="showAddMember"
|
id="showAddMember"
|
||||||
data-title="Add Team Member"
|
data-title="Add Team Member"
|
||||||
|
@ -82,7 +82,7 @@
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div ng-show="canEditMembers">
|
<div ng-show="canEditMembers">
|
||||||
<div ng-if-media="'(max-width: 560px)'">
|
<div ng-if-media="'(max-width: 767px)'">
|
||||||
<div ng-include="'/static/directives/team-view-add.html'"></div>
|
<div ng-include="'/static/directives/team-view-add.html'"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1965,6 +1965,9 @@ class TestOrgRobots(ApiTestCase):
|
||||||
pull_robot = model.get_user(membername)
|
pull_robot = model.get_user(membername)
|
||||||
model.create_build_trigger(repo, 'fakeservice', 'sometoken', user, pull_robot=pull_robot)
|
model.create_build_trigger(repo, 'fakeservice', 'sometoken', user, pull_robot=pull_robot)
|
||||||
|
|
||||||
|
# Add some log entries for the robot.
|
||||||
|
model.log_action('pull_repo', ORGANIZATION, performer=pull_robot, repository=repo)
|
||||||
|
|
||||||
# Delete the robot and verify it works.
|
# Delete the robot and verify it works.
|
||||||
self.deleteResponse(OrgRobot,
|
self.deleteResponse(OrgRobot,
|
||||||
params=dict(orgname=ORGANIZATION, robot_shortname='bender'))
|
params=dict(orgname=ORGANIZATION, robot_shortname='bender'))
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from multiprocessing import Process, Queue
|
from Queue import Queue
|
||||||
from mixpanel import Consumer, Mixpanel
|
from threading import Thread
|
||||||
|
from mixpanel import BufferedConsumer, Mixpanel
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -17,24 +18,23 @@ class MixpanelQueingConsumer(object):
|
||||||
self._mp_queue.put(json.dumps([endpoint, json_message]))
|
self._mp_queue.put(json.dumps([endpoint, json_message]))
|
||||||
|
|
||||||
|
|
||||||
class SendToMixpanel(Process):
|
class SendToMixpanel(Thread):
|
||||||
def __init__(self, request_queue):
|
def __init__(self, request_queue):
|
||||||
Process.__init__(self)
|
Thread.__init__(self)
|
||||||
|
self.daemon = True
|
||||||
|
|
||||||
self._mp_queue = request_queue
|
self._mp_queue = request_queue
|
||||||
self._consumer = Consumer()
|
self._consumer = BufferedConsumer()
|
||||||
self.daemon = True
|
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
logger.debug('Starting mixpanel sender process.')
|
logger.debug('Starting mixpanel sender process.')
|
||||||
while True:
|
while True:
|
||||||
mp_request = self._mp_queue.get()
|
mp_request = self._mp_queue.get()
|
||||||
logger.debug('Got queued mixpanel reqeust.')
|
logger.debug('Got queued mixpanel request.')
|
||||||
try:
|
try:
|
||||||
self._consumer.send(*json.loads(mp_request))
|
self._consumer.send(*json.loads(mp_request))
|
||||||
except:
|
except:
|
||||||
# Make sure we don't crash if Mixpanel request fails.
|
logger.exception('Failed to send Mixpanel request.')
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class FakeMixpanel(object):
|
class FakeMixpanel(object):
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import logging
|
import logging
|
||||||
import boto
|
import boto
|
||||||
|
|
||||||
from multiprocessing import Process, Queue
|
from Queue import Queue
|
||||||
|
from threading import Thread
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -12,6 +14,7 @@ class NullReporter(object):
|
||||||
|
|
||||||
|
|
||||||
class QueueingCloudWatchReporter(object):
|
class QueueingCloudWatchReporter(object):
|
||||||
|
""" QueueingCloudWatchReporter reports metrics to the "SendToCloudWatch" process """
|
||||||
def __init__(self, request_queue, namespace, need_capacity_name, build_percent_name):
|
def __init__(self, request_queue, namespace, need_capacity_name, build_percent_name):
|
||||||
self._namespace = namespace
|
self._namespace = namespace
|
||||||
self._need_capacity_name = need_capacity_name
|
self._need_capacity_name = need_capacity_name
|
||||||
|
@ -34,26 +37,37 @@ class QueueingCloudWatchReporter(object):
|
||||||
unit='Percent')
|
unit='Percent')
|
||||||
|
|
||||||
|
|
||||||
class SendToCloudWatch(Process):
|
class SendToCloudWatch(Thread):
|
||||||
|
""" SendToCloudWatch loops indefinitely and pulls metrics off of a queue then sends it to
|
||||||
|
CloudWatch. """
|
||||||
def __init__(self, request_queue, aws_access_key, aws_secret_key):
|
def __init__(self, request_queue, aws_access_key, aws_secret_key):
|
||||||
Process.__init__(self)
|
Thread.__init__(self)
|
||||||
|
self.daemon = True
|
||||||
|
|
||||||
self._aws_access_key = aws_access_key
|
self._aws_access_key = aws_access_key
|
||||||
self._aws_secret_key = aws_secret_key
|
self._aws_secret_key = aws_secret_key
|
||||||
self._put_metrics_queue = request_queue
|
self._put_metrics_queue = request_queue
|
||||||
self.daemon = True
|
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
logger.debug('Starting cloudwatch sender process.')
|
try:
|
||||||
|
logger.debug('Starting CloudWatch sender process.')
|
||||||
connection = boto.connect_cloudwatch(self._aws_access_key, self._aws_secret_key)
|
connection = boto.connect_cloudwatch(self._aws_access_key, self._aws_secret_key)
|
||||||
|
except:
|
||||||
|
logger.exception('Failed to connect to CloudWatch.')
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
put_metric_args, kwargs = self._put_metrics_queue.get()
|
put_metric_args, kwargs = self._put_metrics_queue.get()
|
||||||
logger.debug('Got queued put metrics reqeust.')
|
logger.debug('Got queued put metrics request.')
|
||||||
|
try:
|
||||||
connection.put_metric_data(*put_metric_args, **kwargs)
|
connection.put_metric_data(*put_metric_args, **kwargs)
|
||||||
|
except:
|
||||||
|
logger.exception('Failed to write to CloudWatch')
|
||||||
|
|
||||||
|
|
||||||
class QueueMetrics(object):
|
class QueueMetrics(object):
|
||||||
def __init__(self, app=None):
|
def __init__(self, app=None):
|
||||||
self.app = app
|
self.app = app
|
||||||
|
self.sender = None
|
||||||
if app is not None:
|
if app is not None:
|
||||||
self.state = self.init_app(app)
|
self.state = self.init_app(app)
|
||||||
else:
|
else:
|
||||||
|
@ -72,8 +86,7 @@ class QueueMetrics(object):
|
||||||
request_queue = Queue()
|
request_queue = Queue()
|
||||||
reporter = QueueingCloudWatchReporter(request_queue, namespace, req_capacity_name,
|
reporter = QueueingCloudWatchReporter(request_queue, namespace, req_capacity_name,
|
||||||
build_percent_name)
|
build_percent_name)
|
||||||
sender = SendToCloudWatch(request_queue, access_key, secret_key)
|
self.sender = SendToCloudWatch(request_queue, access_key, secret_key)
|
||||||
sender.start()
|
|
||||||
else:
|
else:
|
||||||
reporter = NullReporter()
|
reporter = NullReporter()
|
||||||
|
|
||||||
|
@ -82,5 +95,11 @@ class QueueMetrics(object):
|
||||||
app.extensions['queuemetrics'] = reporter
|
app.extensions['queuemetrics'] = reporter
|
||||||
return reporter
|
return reporter
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
logger.debug('Asked to start CloudWatch reporter')
|
||||||
|
if self.sender is not None:
|
||||||
|
logger.debug('Starting CloudWatch reporter')
|
||||||
|
self.sender.start()
|
||||||
|
|
||||||
def __getattr__(self, name):
|
def __getattr__(self, name):
|
||||||
return getattr(self.state, name, None)
|
return getattr(self.state, name, None)
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import logging
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
|
import json
|
||||||
|
|
||||||
from flask.ext.mail import Message
|
from flask.ext.mail import Message
|
||||||
|
|
||||||
|
@ -13,7 +14,42 @@ template_env = get_template_env("emails")
|
||||||
class CannotSendEmailException(Exception):
|
class CannotSendEmailException(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def send_email(recipient, subject, template_file, parameters):
|
class GmailAction(object):
|
||||||
|
""" Represents an action that can be taken in Gmail in response to the email. """
|
||||||
|
def __init__(self, metadata):
|
||||||
|
self.metadata = metadata
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def confirm(name, url, description):
|
||||||
|
return GmailAction({
|
||||||
|
"@context": "http://schema.org",
|
||||||
|
"@type": "EmailMessage",
|
||||||
|
"action": {
|
||||||
|
"@type": 'ConfirmAction',
|
||||||
|
"name": name,
|
||||||
|
"handler": {
|
||||||
|
"@type": "HttpActionHandler",
|
||||||
|
"url": get_app_url() + '/' + url
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": description
|
||||||
|
})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def view(name, url, description):
|
||||||
|
return GmailAction({
|
||||||
|
"@context": "http://schema.org",
|
||||||
|
"@type": "EmailMessage",
|
||||||
|
"action": {
|
||||||
|
"@type": 'ViewAction',
|
||||||
|
"name": name,
|
||||||
|
"url": get_app_url() + '/' + url
|
||||||
|
},
|
||||||
|
"description": description
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def send_email(recipient, subject, template_file, parameters, action=None):
|
||||||
app_title = app.config['REGISTRY_TITLE_SHORT']
|
app_title = app.config['REGISTRY_TITLE_SHORT']
|
||||||
app_url = get_app_url()
|
app_url = get_app_url()
|
||||||
|
|
||||||
|
@ -29,7 +65,8 @@ def send_email(recipient, subject, template_file, parameters):
|
||||||
'app_logo': 'https://quay.io/static/img/quay-logo.png', # TODO: make this pull from config
|
'app_logo': 'https://quay.io/static/img/quay-logo.png', # TODO: make this pull from config
|
||||||
'app_url': app_url,
|
'app_url': app_url,
|
||||||
'app_title': app_title,
|
'app_title': app_title,
|
||||||
'app_link': app_link_handler
|
'app_link': app_link_handler,
|
||||||
|
'action_metadata': json.dumps(action.metadata) if action else None
|
||||||
})
|
})
|
||||||
|
|
||||||
rendered_html = template_env.get_template(template_file + '.html').render(parameters)
|
rendered_html = template_env.get_template(template_file + '.html').render(parameters)
|
||||||
|
@ -61,25 +98,34 @@ def send_change_email(username, email, token):
|
||||||
})
|
})
|
||||||
|
|
||||||
def send_confirmation_email(username, email, token):
|
def send_confirmation_email(username, email, token):
|
||||||
|
action = GmailAction.confirm('Confirm E-mail', 'confirm?code=' + token,
|
||||||
|
'Verification of e-mail address')
|
||||||
|
|
||||||
send_email(email, 'Please confirm your e-mail address', 'confirmemail', {
|
send_email(email, 'Please confirm your e-mail address', 'confirmemail', {
|
||||||
'username': username,
|
'username': username,
|
||||||
'token': token
|
'token': token
|
||||||
})
|
}, action=action)
|
||||||
|
|
||||||
def send_repo_authorization_email(namespace, repository, email, token):
|
def send_repo_authorization_email(namespace, repository, email, token):
|
||||||
|
action = GmailAction.confirm('Verify E-mail', 'authrepoemail?code=' + token,
|
||||||
|
'Verification of e-mail address')
|
||||||
|
|
||||||
subject = 'Please verify your e-mail address for repository %s/%s' % (namespace, repository)
|
subject = 'Please verify your e-mail address for repository %s/%s' % (namespace, repository)
|
||||||
send_email(email, subject, 'repoauthorizeemail', {
|
send_email(email, subject, 'repoauthorizeemail', {
|
||||||
'namespace': namespace,
|
'namespace': namespace,
|
||||||
'repository': repository,
|
'repository': repository,
|
||||||
'token': token
|
'token': token
|
||||||
})
|
}, action=action)
|
||||||
|
|
||||||
def send_recovery_email(email, token):
|
def send_recovery_email(email, token):
|
||||||
|
action = GmailAction.view('Recover Account', 'recovery?code=' + token,
|
||||||
|
'Recovery of an account')
|
||||||
|
|
||||||
subject = 'Account recovery'
|
subject = 'Account recovery'
|
||||||
send_email(email, subject, 'recovery', {
|
send_email(email, subject, 'recovery', {
|
||||||
'email': email,
|
'email': email,
|
||||||
'token': token
|
'token': token
|
||||||
})
|
}, action=action)
|
||||||
|
|
||||||
def send_payment_failed(email, username):
|
def send_payment_failed(email, username):
|
||||||
send_email(email, 'Subscription Payment Failure', 'paymentfailure', {
|
send_email(email, 'Subscription Payment Failure', 'paymentfailure', {
|
||||||
|
@ -87,12 +133,15 @@ def send_payment_failed(email, username):
|
||||||
})
|
})
|
||||||
|
|
||||||
def send_org_invite_email(member_name, member_email, orgname, team, adder, code):
|
def send_org_invite_email(member_name, member_email, orgname, team, adder, code):
|
||||||
|
action = GmailAction.view('Join %s' % team, 'confirminvite?code=' + code,
|
||||||
|
'Invitation to join a team')
|
||||||
|
|
||||||
send_email(member_email, 'Invitation to join team', 'teaminvite', {
|
send_email(member_email, 'Invitation to join team', 'teaminvite', {
|
||||||
'inviter': adder,
|
'inviter': adder,
|
||||||
'token': code,
|
'token': code,
|
||||||
'organization': orgname,
|
'organization': orgname,
|
||||||
'teamname': team
|
'teamname': team
|
||||||
})
|
}, action=action)
|
||||||
|
|
||||||
|
|
||||||
def send_invoice_email(email, contents):
|
def send_invoice_email(email, contents):
|
||||||
|
|
4
web.py
4
web.py
|
@ -1,7 +1,7 @@
|
||||||
import logging
|
import logging
|
||||||
import logging.config
|
import logging.config
|
||||||
|
|
||||||
from app import app as application
|
from app import app as application, queue_metrics
|
||||||
|
|
||||||
from endpoints.api import api_bp
|
from endpoints.api import api_bp
|
||||||
from endpoints.web import web
|
from endpoints.web import web
|
||||||
|
@ -9,6 +9,8 @@ from endpoints.webhooks import webhooks
|
||||||
from endpoints.realtime import realtime
|
from endpoints.realtime import realtime
|
||||||
from endpoints.callbacks import callback
|
from endpoints.callbacks import callback
|
||||||
|
|
||||||
|
# Start the cloudwatch reporting.
|
||||||
|
queue_metrics.run()
|
||||||
|
|
||||||
application.register_blueprint(web)
|
application.register_blueprint(web)
|
||||||
application.register_blueprint(callback, url_prefix='/oauth2')
|
application.register_blueprint(callback, url_prefix='/oauth2')
|
||||||
|
|
Reference in a new issue