Merge branch 'master' into git

This commit is contained in:
Jimmy Zelinskie 2015-04-16 17:38:35 -04:00
commit ba2cb08904
268 changed files with 7008 additions and 1535 deletions

View file

@ -38,19 +38,13 @@ ADD . .
# Run grunt # Run grunt
RUN cd grunt && grunt RUN cd grunt && grunt
ADD conf/init/svlogd_config /svlogd_config
ADD conf/init/doupdatelimits.sh /etc/my_init.d/ ADD conf/init/doupdatelimits.sh /etc/my_init.d/
ADD conf/init/preplogsdir.sh /etc/my_init.d/ ADD conf/init/copy_syslog_config.sh /etc/my_init.d/
ADD conf/init/runmigration.sh /etc/my_init.d/ ADD conf/init/runmigration.sh /etc/my_init.d/
ADD conf/init/gunicorn_web /etc/service/gunicorn_web ADD conf/init/service/ /etc/service/
ADD conf/init/gunicorn_registry /etc/service/gunicorn_registry
ADD conf/init/gunicorn_verbs /etc/service/gunicorn_verbs RUN rm -rf /etc/service/syslog-forwarder
ADD conf/init/nginx /etc/service/nginx
ADD conf/init/diffsworker /etc/service/diffsworker
ADD conf/init/notificationworker /etc/service/notificationworker
ADD conf/init/buildlogsarchiver /etc/service/buildlogsarchiver
ADD conf/init/buildmanager /etc/service/buildmanager
# Download any external libs. # Download any external libs.
RUN mkdir static/fonts static/ldn RUN mkdir static/fonts static/ldn

1
app.py
View file

@ -39,7 +39,6 @@ OVERRIDE_CONFIG_YAML_FILENAME = 'conf/stack/config.yaml'
OVERRIDE_CONFIG_PY_FILENAME = 'conf/stack/config.py' OVERRIDE_CONFIG_PY_FILENAME = 'conf/stack/config.py'
OVERRIDE_CONFIG_KEY = 'QUAY_OVERRIDE_CONFIG' OVERRIDE_CONFIG_KEY = 'QUAY_OVERRIDE_CONFIG'
LICENSE_FILENAME = 'conf/stack/license.enc'
CONFIG_PROVIDER = FileConfigProvider(OVERRIDE_CONFIG_DIRECTORY, 'config.yaml', 'config.py') CONFIG_PROVIDER = FileConfigProvider(OVERRIDE_CONFIG_DIRECTORY, 'config.yaml', 'config.py')

View file

@ -114,7 +114,8 @@ def _process_basic_auth(auth):
logger.debug('Invalid robot or password for robot: %s' % credentials[0]) logger.debug('Invalid robot or password for robot: %s' % credentials[0])
else: else:
authenticated = authentication.verify_user(credentials[0], credentials[1]) (authenticated, error_message) = authentication.verify_user(credentials[0], credentials[1],
basic_auth=True)
if authenticated: if authenticated:
logger.debug('Successfully validated user: %s' % authenticated.username) logger.debug('Successfully validated user: %s' % authenticated.username)

View file

@ -1,4 +1,5 @@
import hashlib import hashlib
import math
class Avatar(object): class Avatar(object):
def __init__(self, app=None): def __init__(self, app=None):
@ -7,8 +8,7 @@ class Avatar(object):
def _init_app(self, app): def _init_app(self, app):
return AVATAR_CLASSES[app.config.get('AVATAR_KIND', 'Gravatar')]( return AVATAR_CLASSES[app.config.get('AVATAR_KIND', 'Gravatar')](
app.config['SERVER_HOSTNAME'], app.config['PREFERRED_URL_SCHEME'], app.config['AVATAR_COLORS'], app.config['HTTPCLIENT'])
app.config['PREFERRED_URL_SCHEME'])
def __getattr__(self, name): def __getattr__(self, name):
return getattr(self.state, name, None) return getattr(self.state, name, None)
@ -16,48 +16,83 @@ class Avatar(object):
class BaseAvatar(object): class BaseAvatar(object):
""" Base class for all avatar implementations. """ """ Base class for all avatar implementations. """
def __init__(self, server_hostname, preferred_url_scheme): def __init__(self, preferred_url_scheme, colors, http_client):
self.server_hostname = server_hostname
self.preferred_url_scheme = preferred_url_scheme self.preferred_url_scheme = preferred_url_scheme
self.colors = colors
self.http_client = http_client
def get_url(self, email, size=16, name=None): def get_mail_html(self, name, email_or_id, size=16, kind='user'):
""" Returns the full URL for viewing the avatar of the given email address, with """ Returns the full HTML and CSS for viewing the avatar of the given name and email address,
an optional size. with an optional size.
""" """
raise NotImplementedError data = self.get_data(name, email_or_id, kind)
url = self._get_url(data['hash'], size) if kind != 'team' else None
font_size = size - 6
def compute_hash(self, email, name=None): if url is not None:
""" Computes the avatar hash for the given email address. If the name is given and a default # Try to load the gravatar. If we get a non-404 response, then we use it in place of
avatar is being computed, the name can be used in place of the email address. """ # the CSS avatar.
raise NotImplementedError response = self.http_client.get(url)
if response.status_code == 200:
return """<img src="%s" width="%s" height="%s" alt="%s"
style="vertical-align: middle;">""" % (url, size, size, kind)
radius = '50%' if kind == 'team' else '0%'
letter = '&Omega;' if kind == 'team' and data['name'] == 'owners' else data['name'].upper()[0]
return """
<span style="width: %spx; height: %spx; background-color: %s; font-size: %spx;
line-height: %spx; margin-left: 2px; margin-right: 2px; display: inline-block;
vertical-align: middle; text-align: center; color: white; border-radius: %s">
%s
</span>
""" % (size, size, data['color'], font_size, size, radius, letter)
def get_data_for_user(self, user):
return self.get_data(user.username, user.email, 'robot' if user.robot else 'user')
def get_data_for_team(self, team):
return self.get_data(team.name, team.name, 'team')
def get_data_for_org(self, org):
return self.get_data(org.username, org.email, 'org')
def get_data(self, name, email_or_id, kind='user'):
""" Computes and returns the full data block for the avatar:
{
'name': name,
'hash': The gravatar hash, if any.
'color': The color for the avatar
}
"""
colors = self.colors
hash_value = hashlib.md5(email_or_id.strip().lower()).hexdigest()
byte_count = int(math.ceil(math.log(len(colors), 16)))
byte_data = hash_value[0:byte_count]
hash_color = colors[int(byte_data, 16) % len(colors)]
return {
'name': name,
'hash': hash_value,
'color': hash_color,
'kind': kind
}
def _get_url(self, hash_value, size):
""" Returns the URL for displaying the overlay avatar. """
return None
class GravatarAvatar(BaseAvatar): class GravatarAvatar(BaseAvatar):
""" Avatar system that uses gravatar for generating avatars. """ """ Avatar system that uses gravatar for generating avatars. """
def compute_hash(self, email, name=None): def _get_url(self, hash_value, size=16):
email = email or "" return '%s://www.gravatar.com/avatar/%s?d=404&size=%s' % (self.preferred_url_scheme,
return hashlib.md5(email.strip().lower()).hexdigest() hash_value, size)
def get_url(self, email, size=16, name=None):
computed = self.compute_hash(email, name=name)
return '%s://www.gravatar.com/avatar/%s?d=identicon&size=%s' % (self.preferred_url_scheme,
computed, size)
class LocalAvatar(BaseAvatar): class LocalAvatar(BaseAvatar):
""" Avatar system that uses the local system for generating avatars. """ """ Avatar system that uses the local system for generating avatars. """
def compute_hash(self, email, name=None): pass
email = email or ""
if not name and not email:
return ''
prefix = name if name else email
return prefix[0] + hashlib.md5(email.strip().lower()).hexdigest()
def get_url(self, email, size=16, name=None):
computed = self.compute_hash(email, name=name)
return '%s://%s/avatar/%s?size=%s' % (self.preferred_url_scheme, self.server_hostname,
computed, size)
AVATAR_CLASSES = { AVATAR_CLASSES = {
'gravatar': GravatarAvatar, 'gravatar': GravatarAvatar,

View file

@ -157,8 +157,12 @@ class EphemeralBuilderManager(BaseManager):
etcd_host = self._manager_config.get('ETCD_HOST', '127.0.0.1') etcd_host = self._manager_config.get('ETCD_HOST', '127.0.0.1')
etcd_port = self._manager_config.get('ETCD_PORT', 2379) etcd_port = self._manager_config.get('ETCD_PORT', 2379)
etcd_auth = self._manager_config.get('ETCD_CERT_AND_KEY', None)
etcd_ca_cert = self._manager_config.get('ETCD_CA_CERT', None) etcd_ca_cert = self._manager_config.get('ETCD_CA_CERT', None)
etcd_auth = self._manager_config.get('ETCD_CERT_AND_KEY', None)
if etcd_auth is not None:
etcd_auth = tuple(etcd_auth) # Convert YAML list to a tuple
etcd_protocol = 'http' if etcd_auth is None else 'https' etcd_protocol = 'http' if etcd_auth is None else 'https'
logger.debug('Connecting to etcd on %s:%s', etcd_host, etcd_port) logger.debug('Connecting to etcd on %s:%s', etcd_host, etcd_port)

View file

@ -69,6 +69,7 @@ class BuilderExecutor(object):
manager_hostname=manager_hostname, manager_hostname=manager_hostname,
coreos_channel=coreos_channel, coreos_channel=coreos_channel,
worker_tag=self.executor_config['WORKER_TAG'], worker_tag=self.executor_config['WORKER_TAG'],
logentries_token=self.executor_config.get('LOGENTRIES_TOKEN', None),
) )

View file

@ -12,6 +12,9 @@ write_files:
REALM={{ realm }} REALM={{ realm }}
TOKEN={{ token }} TOKEN={{ token }}
SERVER=wss://{{ manager_hostname }} SERVER=wss://{{ manager_hostname }}
{% if logentries_token -%}
LOGENTRIES_TOKEN={{ logentries_token }}
{%- endif %}
coreos: coreos:
update: update:
@ -19,6 +22,17 @@ coreos:
group: {{ coreos_channel }} group: {{ coreos_channel }}
units: units:
- name: systemd-journal-gatewayd.socket
command: start
enable: yes
content: |
[Unit]
Description=Journal Gateway Service Socket
[Socket]
ListenStream=/var/run/journald.sock
Service=systemd-journal-gatewayd.service
[Install]
WantedBy=sockets.target
{{ dockersystemd('quay-builder', {{ dockersystemd('quay-builder',
'quay.io/coreos/registry-build-worker', 'quay.io/coreos/registry-build-worker',
quay_username, quay_username,
@ -29,3 +43,10 @@ coreos:
flattened=True, flattened=True,
restart_policy='no' restart_policy='no'
) | indent(4) }} ) | indent(4) }}
{% if logentries_token -%}
{{ dockersystemd('builder-logs',
'quay.io/kelseyhightower/journal-2-logentries',
extra_args='--env-file /root/overrides.list -v /run/journald.sock:/run/journald.sock',
after_units=['quay-builder.service']
) | indent(4) }}
{%- endif %}

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="146" height="18"><linearGradient id="a" x2="0" y2="100%"><stop offset="0" stop-color="#fff" stop-opacity=".7"/><stop offset=".1" stop-color="#aaa" stop-opacity=".1"/><stop offset=".9" stop-opacity=".3"/><stop offset="1" stop-opacity=".5"/></linearGradient><rect rx="4" width="146" height="18" fill="#555"/><rect rx="4" x="92" width="54" height="18" fill="#dfb317"/><path fill="#dfb317" d="M92 0h4v18h-4z"/><rect rx="4" width="146" height="18" fill="url(#a)"/><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"><text x="47" y="13" fill="#010101" fill-opacity=".3">Docker Image</text><text x="47" y="12">Docker Image</text><text x="118" y="13" fill="#010101" fill-opacity=".3">building</text><text x="118" y="12">building</text></g></svg> <svg xmlns="http://www.w3.org/2000/svg" width="117" height="20"><linearGradient id="b" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><mask id="a"><rect width="117" height="20" rx="3" fill="#fff"/></mask><g mask="url(#a)"><path fill="#555" d="M0 0h63v20H0z"/><path fill="#dfb317" d="M63 0h54v20H63z"/><path fill="url(#b)" d="M0 0h117v20H0z"/></g><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"><text x="32.5" y="15" fill="#010101" fill-opacity=".3">container</text><text x="32.5" y="14">container</text><text x="89" y="15" fill="#010101" fill-opacity=".3">building</text><text x="89" y="14">building</text></g></svg>

Before

Width:  |  Height:  |  Size: 836 B

After

Width:  |  Height:  |  Size: 748 B

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="164" height="18"><linearGradient id="a" x2="0" y2="100%"><stop offset="0" stop-color="#fff" stop-opacity=".7"/><stop offset=".1" stop-color="#aaa" stop-opacity=".1"/><stop offset=".9" stop-opacity=".3"/><stop offset="1" stop-opacity=".5"/></linearGradient><rect rx="4" width="164" height="18" fill="#555"/><rect rx="4" x="92" width="72" height="18" fill="#e05d44"/><path fill="#e05d44" d="M92 0h4v18h-4z"/><rect rx="4" width="164" height="18" fill="url(#a)"/><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"><text x="47" y="13" fill="#010101" fill-opacity=".3">Docker Image</text><text x="47" y="12">Docker Image</text><text x="127" y="13" fill="#010101" fill-opacity=".3">build failed</text><text x="127" y="12">build failed</text></g></svg> <svg xmlns="http://www.w3.org/2000/svg" width="104" height="20"><linearGradient id="b" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><mask id="a"><rect width="104" height="20" rx="3" fill="#fff"/></mask><g mask="url(#a)"><path fill="#555" d="M0 0h63v20H0z"/><path fill="#e05d44" d="M63 0h41v20H63z"/><path fill="url(#b)" d="M0 0h104v20H0z"/></g><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"><text x="32.5" y="15" fill="#010101" fill-opacity=".3">container</text><text x="32.5" y="14">container</text><text x="82.5" y="15" fill="#010101" fill-opacity=".3">failed</text><text x="82.5" y="14">failed</text></g></svg>

Before

Width:  |  Height:  |  Size: 844 B

After

Width:  |  Height:  |  Size: 748 B

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="130" height="18"><linearGradient id="a" x2="0" y2="100%"><stop offset="0" stop-color="#fff" stop-opacity=".7"/><stop offset=".1" stop-color="#aaa" stop-opacity=".1"/><stop offset=".9" stop-opacity=".3"/><stop offset="1" stop-opacity=".5"/></linearGradient><rect rx="4" width="130" height="18" fill="#555"/><rect rx="4" x="92" width="38" height="18" fill="#9f9f9f"/><path fill="#9f9f9f" d="M92 0h4v18h-4z"/><rect rx="4" width="130" height="18" fill="url(#a)"/><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"><text x="47" y="13" fill="#010101" fill-opacity=".3">Docker Image</text><text x="47" y="12">Docker Image</text><text x="110" y="13" fill="#010101" fill-opacity=".3">none</text><text x="110" y="12">none</text></g></svg> <svg xmlns="http://www.w3.org/2000/svg" width="101" height="20"><linearGradient id="b" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><mask id="a"><rect width="101" height="20" rx="3" fill="#fff"/></mask><g mask="url(#a)"><path fill="#555" d="M0 0h63v20H0z"/><path fill="#9f9f9f" d="M63 0h38v20H63z"/><path fill="url(#b)" d="M0 0h101v20H0z"/></g><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"><text x="32.5" y="15" fill="#010101" fill-opacity=".3">container</text><text x="32.5" y="14">container</text><text x="81" y="15" fill="#010101" fill-opacity=".3">none</text><text x="81" y="14">none</text></g></svg>

Before

Width:  |  Height:  |  Size: 828 B

After

Width:  |  Height:  |  Size: 740 B

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="135" height="18"><linearGradient id="a" x2="0" y2="100%"><stop offset="0" stop-color="#fff" stop-opacity=".7"/><stop offset=".1" stop-color="#aaa" stop-opacity=".1"/><stop offset=".9" stop-opacity=".3"/><stop offset="1" stop-opacity=".5"/></linearGradient><rect rx="4" width="135" height="18" fill="#555"/><rect rx="4" x="92" width="43" height="18" fill="#4c1"/><path fill="#4c1" d="M92 0h4v18h-4z"/><rect rx="4" width="135" height="18" fill="url(#a)"/><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"><text x="47" y="13" fill="#010101" fill-opacity=".3">Docker Image</text><text x="47" y="12">Docker Image</text><text x="112.5" y="13" fill="#010101" fill-opacity=".3">ready</text><text x="112.5" y="12">ready</text></g></svg> <svg xmlns="http://www.w3.org/2000/svg" width="106" height="20"><linearGradient id="b" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><mask id="a"><rect width="106" height="20" rx="3" fill="#fff"/></mask><g mask="url(#a)"><path fill="#555" d="M0 0h63v20H0z"/><path fill="#97CA00" d="M63 0h43v20H63z"/><path fill="url(#b)" d="M0 0h106v20H0z"/></g><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"><text x="32.5" y="15" fill="#010101" fill-opacity=".3">container</text><text x="32.5" y="14">container</text><text x="83.5" y="15" fill="#010101" fill-opacity=".3">ready</text><text x="83.5" y="14">ready</text></g></svg>

Before

Width:  |  Height:  |  Size: 828 B

After

Width:  |  Height:  |  Size: 746 B

View file

@ -4,7 +4,7 @@ types_hash_max_size 2048;
include /usr/local/nginx/conf/mime.types.default; include /usr/local/nginx/conf/mime.types.default;
default_type application/octet-stream; default_type application/octet-stream;
access_log /var/log/nginx/nginx.access.log; access_log /dev/stdout;
sendfile on; sendfile on;
gzip on; gzip on;

View file

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

View file

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

View file

@ -0,0 +1,6 @@
#! /bin/sh
if [ -e /conf/stack/syslog-ng-extra.conf ]
then
cp /conf/stack/syslog-ng-extra.conf /etc/syslog-ng/conf.d/
fi

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,10 +0,0 @@
#! /bin/sh
echo 'Linking config files to logs directory'
for svc in `ls /etc/service/`
do
if [ ! -d /var/log/$svc ]; then
mkdir -p /var/log/$svc
ln -s /svlogd_config /var/log/$svc/config
fi
done

View file

@ -0,0 +1,2 @@
#!/bin/sh
exec logger -i -t buildlogsarchiver

View file

@ -0,0 +1,2 @@
#!/bin/sh
exec logger -i -t buildmanager

View file

@ -0,0 +1,2 @@
#!/bin/sh
exec logger -i -t diffsworker

View file

@ -0,0 +1,2 @@
#!/bin/sh
exec logger -i -t gunicorn_registry

View file

@ -0,0 +1,2 @@
#!/bin/sh
exec logger -i -t gunicorn_verbs

View file

@ -0,0 +1,2 @@
#!/bin/sh
exec logger -i -t gunicorn_web

View file

@ -0,0 +1,2 @@
#!/bin/sh
exec logger -i -t nginx

View file

@ -0,0 +1,2 @@
#!/bin/sh
exec logger -i -t notificationworker

View file

@ -1,3 +0,0 @@
s100000000
t86400
n4

View file

@ -5,4 +5,4 @@ real_ip_header proxy_protocol;
log_format elb_pp '$proxy_protocol_addr - $remote_user [$time_local] ' log_format elb_pp '$proxy_protocol_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent ' '"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent"'; '"$http_referer" "$http_user_agent"';
access_log /var/log/nginx/nginx.access.log elb_pp; access_log /dev/stdout elb_pp;

View file

@ -1,7 +1,7 @@
# vim: ft=nginx # vim: ft=nginx
pid /tmp/nginx.pid; pid /tmp/nginx.pid;
error_log /var/log/nginx/nginx.error.log; error_log /dev/stdout;
worker_processes 2; worker_processes 2;
worker_priority -10; worker_priority -10;

View file

@ -1,6 +1,5 @@
# vim: ft=nginx # vim: ft=nginx
client_body_temp_path /var/log/nginx/client_body 1 2;
server_name _; server_name _;
keepalive_timeout 5; keepalive_timeout 5;
@ -36,7 +35,7 @@ location /v1/repositories/ {
proxy_pass http://registry_app_server; proxy_pass http://registry_app_server;
proxy_read_timeout 2000; proxy_read_timeout 2000;
proxy_temp_path /var/log/nginx/proxy_temp 1 2; proxy_temp_path /tmp 1 2;
limit_req zone=repositories burst=10; limit_req zone=repositories burst=10;
} }
@ -47,7 +46,7 @@ location /v1/ {
proxy_request_buffering off; proxy_request_buffering off;
proxy_pass http://registry_app_server; proxy_pass http://registry_app_server;
proxy_temp_path /var/log/nginx/proxy_temp 1 2; proxy_temp_path /tmp 1 2;
client_max_body_size 20G; client_max_body_size 20G;
} }
@ -58,7 +57,7 @@ location /c1/ {
proxy_request_buffering off; proxy_request_buffering off;
proxy_pass http://verbs_app_server; proxy_pass http://verbs_app_server;
proxy_temp_path /var/log/nginx/proxy_temp 1 2; proxy_temp_path /tmp 1 2;
limit_req zone=verbs burst=10; limit_req zone=verbs burst=10;
} }

View file

@ -45,8 +45,6 @@ class DefaultConfig(object):
PREFERRED_URL_SCHEME = 'http' PREFERRED_URL_SCHEME = 'http'
SERVER_HOSTNAME = 'localhost:5000' SERVER_HOSTNAME = 'localhost:5000'
AVATAR_KIND = 'local'
REGISTRY_TITLE = 'CoreOS Enterprise Registry' REGISTRY_TITLE = 'CoreOS Enterprise Registry'
REGISTRY_TITLE_SHORT = 'Enterprise Registry' REGISTRY_TITLE_SHORT = 'Enterprise Registry'
@ -165,6 +163,10 @@ class DefaultConfig(object):
# Feature Flag: Whether users can be renamed # Feature Flag: Whether users can be renamed
FEATURE_USER_RENAME = False FEATURE_USER_RENAME = False
# Feature Flag: Whether non-encrypted passwords (as opposed to encrypted tokens) can be used for
# basic auth.
FEATURE_REQUIRE_ENCRYPTED_BASIC_AUTH = False
BUILD_MANAGER = ('enterprise', {}) BUILD_MANAGER = ('enterprise', {})
DISTRIBUTED_STORAGE_CONFIG = { DISTRIBUTED_STORAGE_CONFIG = {
@ -201,3 +203,11 @@ class DefaultConfig(object):
# Signed registry grant token expiration in seconds # Signed registry grant token expiration in seconds
SIGNED_GRANT_EXPIRATION_SEC = 60 * 60 * 24 # One day to complete a push/pull SIGNED_GRANT_EXPIRATION_SEC = 60 * 60 * 24 # One day to complete a push/pull
# The various avatar background colors.
AVATAR_KIND = 'local'
AVATAR_COLORS = ['#969696', '#aec7e8', '#ff7f0e', '#ffbb78', '#2ca02c', '#98df8a', '#d62728',
'#ff9896', '#9467bd', '#c5b0d5', '#8c564b', '#c49c94', '#e377c2', '#f7b6d2',
'#7f7f7f', '#c7c7c7', '#bcbd22', '#1f77b4', '#17becf', '#9edae5', '#393b79',
'#5254a3', '#6b6ecf', '#9c9ede', '#9ecae1', '#31a354', '#b5cf6b', '#a1d99b',
'#8c6d31', '#ad494a', '#e7ba52', '#a55194']

View file

@ -139,7 +139,7 @@ def uuid_generator():
return str(uuid.uuid4()) return str(uuid.uuid4())
_get_epoch_timestamp = lambda: int(time.time()) get_epoch_timestamp = lambda: int(time.time())
def close_db_filter(_): def close_db_filter(_):
@ -167,6 +167,17 @@ class BaseModel(ReadSlaveModel):
database = db database = db
read_slaves = (read_slave,) read_slaves = (read_slave,)
def __getattribute__(self, name):
""" Adds _id accessors so that foreign key field IDs can be looked up without making
a database roundtrip.
"""
if name.endswith('_id'):
field_name = name[0:len(name) - 3]
if field_name in self._meta.fields:
return self._data.get(field_name)
return super(BaseModel, self).__getattribute__(name)
class User(BaseModel): class User(BaseModel):
uuid = CharField(default=uuid_generator, max_length=36, null=True) uuid = CharField(default=uuid_generator, max_length=36, null=True)
@ -484,7 +495,7 @@ class RepositoryTag(BaseModel):
name = CharField() name = CharField()
image = ForeignKeyField(Image) image = ForeignKeyField(Image)
repository = ForeignKeyField(Repository) repository = ForeignKeyField(Repository)
lifetime_start_ts = IntegerField(default=_get_epoch_timestamp) lifetime_start_ts = IntegerField(default=get_epoch_timestamp)
lifetime_end_ts = IntegerField(null=True, index=True) lifetime_end_ts = IntegerField(null=True, index=True)
hidden = BooleanField(default=False) hidden = BooleanField(default=False)
@ -493,6 +504,9 @@ class RepositoryTag(BaseModel):
read_slaves = (read_slave,) read_slaves = (read_slave,)
indexes = ( indexes = (
(('repository', 'name'), False), (('repository', 'name'), False),
# This unique index prevents deadlocks when concurrently moving and deleting tags
(('repository', 'name', 'lifetime_end_ts'), True),
) )

View file

@ -19,7 +19,7 @@ up_mysql() {
down_mysql() { down_mysql() {
docker kill mysql docker kill mysql
docker rm mysql docker rm -v mysql
} }
up_mariadb() { up_mariadb() {
@ -36,24 +36,24 @@ up_mariadb() {
down_mariadb() { down_mariadb() {
docker kill mariadb docker kill mariadb
docker rm mariadb docker rm -v mariadb
} }
up_percona() { up_percona() {
# Run a SQL database on port 3306 inside of Docker. # Run a SQL database on port 3306 inside of Docker.
docker run --name percona -p 3306:3306 -d dockerfile/percona docker run --name percona -p 3306:3306 -d percona
# Sleep for 10s # Sleep for 10s
echo 'Sleeping for 10...' echo 'Sleeping for 10...'
sleep 10 sleep 10
# Add the daabase to mysql. # Add the daabase to mysql.
docker run --rm --link percona:percona dockerfile/percona sh -c 'echo "create database genschema" | mysql -h $PERCONA_PORT_3306_TCP_ADDR' docker run --rm --link percona:percona percona sh -c 'echo "create database genschema" | mysql -h $PERCONA_PORT_3306_TCP_ADDR'
} }
down_percona() { down_percona() {
docker kill percona docker kill percona
docker rm percona docker rm -v percona
} }
up_postgres() { up_postgres() {
@ -70,7 +70,7 @@ up_postgres() {
down_postgres() { down_postgres() {
docker kill postgres docker kill postgres
docker rm postgres docker rm -v postgres
} }
gen_migrate() { gen_migrate() {

View file

@ -0,0 +1,26 @@
"""Add a unique index to prevent deadlocks with tags.
Revision ID: 2b4dc0818a5e
Revises: 2b2529fd23ff
Create Date: 2015-03-20 23:37:10.558179
"""
# revision identifiers, used by Alembic.
revision = '2b4dc0818a5e'
down_revision = '2b2529fd23ff'
from alembic import op
import sqlalchemy as sa
def upgrade(tables):
### commands auto generated by Alembic - please adjust! ###
op.create_index('repositorytag_repository_id_name_lifetime_end_ts', 'repositorytag', ['repository_id', 'name', 'lifetime_end_ts'], unique=True)
### end Alembic commands ###
def downgrade(tables):
### commands auto generated by Alembic - please adjust! ###
op.drop_index('repositorytag_repository_id_name_lifetime_end_ts', table_name='repositorytag')
### end Alembic commands ###

View file

@ -18,7 +18,7 @@ from data.database import (User, Repository, Image, AccessToken, Role, Repositor
DerivedImageStorage, ImageStorageTransformation, random_string_generator, DerivedImageStorage, ImageStorageTransformation, random_string_generator,
db, BUILD_PHASE, QuayUserField, ImageStorageSignature, QueueItem, db, BUILD_PHASE, QuayUserField, ImageStorageSignature, QueueItem,
ImageStorageSignatureKind, validate_database_url, db_for_update, ImageStorageSignatureKind, validate_database_url, db_for_update,
AccessTokenKind, Star) AccessTokenKind, Star, get_epoch_timestamp)
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)
@ -310,11 +310,54 @@ def _list_entity_robots(entity_name):
.where(User.robot == True, User.username ** (entity_name + '+%'))) .where(User.robot == True, User.username ** (entity_name + '+%')))
def list_entity_robot_tuples(entity_name): class _TupleWrapper(object):
return (_list_entity_robots(entity_name) def __init__(self, data, fields):
.select(User.username, FederatedLogin.service_ident) self._data = data
.tuples()) self._fields = fields
def get(self, field):
return self._data[self._fields.index(field.name + ':' + field.model_class.__name__)]
class TupleSelector(object):
""" Helper class for selecting tuples from a peewee query and easily accessing
them as if they were objects.
"""
def __init__(self, query, fields):
self._query = query.select(*fields).tuples()
self._fields = [field.name + ':' + field.model_class.__name__ for field in fields]
def __iter__(self):
return self._build_iterator()
def _build_iterator(self):
for tuple_data in self._query:
yield _TupleWrapper(tuple_data, self._fields)
def list_entity_robot_permission_teams(entity_name):
query = (_list_entity_robots(entity_name)
.join(RepositoryPermission, JOIN_LEFT_OUTER,
on=(RepositoryPermission.user == FederatedLogin.user))
.join(Repository, JOIN_LEFT_OUTER)
.switch(User)
.join(TeamMember, JOIN_LEFT_OUTER)
.join(Team, JOIN_LEFT_OUTER))
fields = [User.username, FederatedLogin.service_ident, Repository.name, Team.name]
return TupleSelector(query, fields)
def list_robot_permissions(robot_name):
return (RepositoryPermission.select(RepositoryPermission, User, Repository)
.join(Repository)
.join(Visibility)
.switch(RepositoryPermission)
.join(Role)
.switch(RepositoryPermission)
.join(User)
.where(User.username == robot_name, User.robot == True))
def convert_user_to_organization(user, admin_user): def convert_user_to_organization(user, admin_user):
# Change the user to an organization. # Change the user to an organization.
@ -636,6 +679,73 @@ def get_user_or_org_by_customer_id(customer_id):
except User.DoesNotExist: except User.DoesNotExist:
return None return None
def get_matching_user_namespaces(namespace_prefix, username, limit=10):
query = (Repository
.select()
.join(Namespace, on=(Repository.namespace_user == Namespace.id))
.switch(Repository)
.join(Visibility)
.switch(Repository)
.join(RepositoryPermission, JOIN_LEFT_OUTER)
.where(Namespace.username ** (namespace_prefix + '%'))
.group_by(Repository.namespace_user, Repository))
count = 0
namespaces = {}
for repo in _filter_to_repos_for_user(query, username):
if not repo.namespace_user.username in namespaces:
namespaces[repo.namespace_user.username] = repo.namespace_user
count = count + 1
if count >= limit:
break
return namespaces.values()
def get_matching_user_teams(team_prefix, user, limit=10):
query = (Team.select()
.join(User)
.switch(Team)
.join(TeamMember)
.where(TeamMember.user == user, Team.name ** (team_prefix + '%'))
.distinct(Team.id)
.limit(limit))
return query
def get_matching_robots(name_prefix, username, limit=10):
admined_orgs = (get_user_organizations(username)
.switch(Team)
.join(TeamRole)
.where(TeamRole.name == 'admin'))
prefix_checks = False
for org in admined_orgs:
prefix_checks = prefix_checks | (User.username ** (org.username + '+' + name_prefix + '%'))
prefix_checks = prefix_checks | (User.username ** (username + '+' + name_prefix + '%'))
return User.select().where(prefix_checks).limit(limit)
def get_matching_admined_teams(team_prefix, user, limit=10):
admined_orgs = (get_user_organizations(user.username)
.switch(Team)
.join(TeamRole)
.where(TeamRole.name == 'admin'))
query = (Team.select()
.join(User)
.switch(Team)
.join(TeamMember)
.where(Team.name ** (team_prefix + '%'), Team.organization << (admined_orgs))
.distinct(Team.id)
.limit(limit))
return query
def get_matching_teams(team_prefix, organization): def get_matching_teams(team_prefix, organization):
query = Team.select().where(Team.name ** (team_prefix + '%'), query = Team.select().where(Team.name ** (team_prefix + '%'),
Team.organization == organization) Team.organization == organization)
@ -654,13 +764,13 @@ def get_matching_users(username_prefix, robot_namespace=None,
(User.robot == True))) (User.robot == True)))
query = (User query = (User
.select(User.username, User.robot) .select(User.username, User.email, User.robot)
.group_by(User.username, User.robot) .group_by(User.username, User.email, User.robot)
.where(direct_user_query)) .where(direct_user_query))
if organization: if organization:
query = (query query = (query
.select(User.username, User.robot, fn.Sum(Team.id)) .select(User.username, User.email, User.robot, fn.Sum(Team.id))
.join(TeamMember, JOIN_LEFT_OUTER) .join(TeamMember, JOIN_LEFT_OUTER)
.join(Team, JOIN_LEFT_OUTER, on=((Team.id == TeamMember.team) & .join(Team, JOIN_LEFT_OUTER, on=((Team.id == TeamMember.team) &
(Team.organization == organization)))) (Team.organization == organization))))
@ -669,9 +779,11 @@ def get_matching_users(username_prefix, robot_namespace=None,
class MatchingUserResult(object): class MatchingUserResult(object):
def __init__(self, *args): def __init__(self, *args):
self.username = args[0] self.username = args[0]
self.is_robot = args[1] self.email = args[1]
self.robot = args[2]
if organization: if organization:
self.is_org_member = (args[2] != None) self.is_org_member = (args[3] != None)
else: else:
self.is_org_member = None self.is_org_member = None
@ -787,7 +899,7 @@ def get_visible_repository_count(username=None, include_public=True,
def get_visible_repositories(username=None, include_public=True, page=None, def get_visible_repositories(username=None, include_public=True, page=None,
limit=None, sort=False, namespace=None): limit=None, sort=False, namespace=None, namespace_only=False):
query = _visible_repository_query(username=username, include_public=include_public, page=page, query = _visible_repository_query(username=username, include_public=include_public, page=page,
limit=limit, namespace=namespace, limit=limit, namespace=namespace,
select_models=[Repository, Namespace, Visibility]) select_models=[Repository, Namespace, Visibility])
@ -798,6 +910,9 @@ def get_visible_repositories(username=None, include_public=True, page=None,
if limit: if limit:
query = query.limit(limit) query = query.limit(limit)
if namespace and namespace_only:
query = query.where(Namespace.username == namespace)
return query return query
@ -876,11 +991,73 @@ def _get_public_repo_visibility():
return _public_repo_visibility_cache return _public_repo_visibility_cache
def get_matching_repositories(repo_term, username=None): def get_sorted_matching_repositories(prefix, only_public, checker, limit=10):
""" Returns repositories matching the given prefix string and passing the given checker
function.
"""
last_week = datetime.now() - timedelta(weeks=1)
results = []
existing_ids = []
def get_search_results(search_clause, with_count):
if len(results) >= limit:
return
selected = [Repository, Namespace]
if with_count:
selected.append(fn.Count(LogEntry.id).alias('count'))
query = (Repository.select(*selected)
.join(Namespace, JOIN_LEFT_OUTER, on=(Namespace.id == Repository.namespace_user))
.switch(Repository)
.where(search_clause)
.group_by(Repository, Namespace))
if only_public:
query = query.where(Repository.visibility == _get_public_repo_visibility())
if existing_ids:
query = query.where(~(Repository.id << existing_ids))
if with_count:
query = (query.join(LogEntry, JOIN_LEFT_OUTER)
.where(LogEntry.datetime >= last_week)
.order_by(fn.Count(LogEntry.id).desc()))
for result in query:
if len(results) >= limit:
return results
# Note: We compare IDs here, instead of objects, because calling .visibility on the
# Repository will kick off a new SQL query to retrieve that visibility enum value. We don't
# join the visibility table in SQL, as well, because it is ungodly slow in MySQL :-/
result.is_public = result.visibility_id == _get_public_repo_visibility().id
result.count = result.count if with_count else 0
if not checker(result):
continue
results.append(result)
existing_ids.append(result.id)
# For performance reasons, we conduct the repo name and repo namespace searches on their
# own, and with and without counts on their own. This also affords us the ability to give
# higher precedence to repository names matching over namespaces, which is semantically correct.
get_search_results((Repository.name ** (prefix + '%')), with_count=True)
get_search_results((Repository.name ** (prefix + '%')), with_count=False)
get_search_results((Namespace.username ** (prefix + '%')), with_count=True)
get_search_results((Namespace.username ** (prefix + '%')), with_count=False)
return results
def get_matching_repositories(repo_term, username=None, limit=10, include_public=True):
namespace_term = repo_term namespace_term = repo_term
name_term = repo_term name_term = repo_term
visible = get_visible_repositories(username) visible = get_visible_repositories(username, include_public=include_public)
search_clauses = (Repository.name ** ('%' + name_term + '%') | search_clauses = (Repository.name ** ('%' + name_term + '%') |
Namespace.username ** ('%' + namespace_term + '%')) Namespace.username ** ('%' + namespace_term + '%'))
@ -894,8 +1071,7 @@ def get_matching_repositories(repo_term, username=None):
search_clauses = (Repository.name ** ('%' + name_term + '%') & search_clauses = (Repository.name ** ('%' + name_term + '%') &
Namespace.username ** ('%' + namespace_term + '%')) Namespace.username ** ('%' + namespace_term + '%'))
final = visible.where(search_clauses).limit(10) return visible.where(search_clauses).limit(limit)
return list(final)
def change_password(user, new_password): def change_password(user, new_password):
@ -905,6 +1081,7 @@ def change_password(user, new_password):
pw_hash = hash_password(new_password) pw_hash = hash_password(new_password)
user.invalid_login_attempts = 0 user.invalid_login_attempts = 0
user.password_hash = pw_hash user.password_hash = pw_hash
user.uuid = str(uuid4())
user.save() user.save()
# Remove any password required notifications for the user. # Remove any password required notifications for the user.
@ -1038,7 +1215,8 @@ def get_all_repo_teams(namespace_name, repository_name):
def get_all_repo_users(namespace_name, repository_name): def get_all_repo_users(namespace_name, repository_name):
return (RepositoryPermission.select(User.username, User.robot, Role.name, RepositoryPermission) return (RepositoryPermission.select(User.username, User.email, User.robot, Role.name,
RepositoryPermission)
.join(User) .join(User)
.switch(RepositoryPermission) .switch(RepositoryPermission)
.join(Role) .join(Role)
@ -1577,9 +1755,21 @@ def get_repository_images(namespace_name, repository_name):
return _get_repository_images_base(namespace_name, repository_name, lambda q: q) return _get_repository_images_base(namespace_name, repository_name, lambda q: q)
def _tag_alive(query): def _tag_alive(query, now_ts=None):
if now_ts is None:
now_ts = get_epoch_timestamp()
return query.where((RepositoryTag.lifetime_end_ts >> None) | return query.where((RepositoryTag.lifetime_end_ts >> None) |
(RepositoryTag.lifetime_end_ts > int(time.time()))) (RepositoryTag.lifetime_end_ts > now_ts))
def list_repository_tag_history(repository, limit=100):
query = (RepositoryTag
.select(RepositoryTag, Image)
.join(Image)
.where(RepositoryTag.repository == repository)
.order_by(RepositoryTag.lifetime_start_ts.desc())
.limit(limit))
return query
def list_repository_tags(namespace_name, repository_name, include_hidden=False, def list_repository_tags(namespace_name, repository_name, include_hidden=False,
@ -1610,14 +1800,19 @@ def list_repository_tags(namespace_name, repository_name, include_hidden=False,
def _garbage_collect_tags(namespace_name, repository_name): def _garbage_collect_tags(namespace_name, repository_name):
# We do this without using a join to prevent holding read locks on the repository table # We do this without using a join to prevent holding read locks on the repository table
repo = _get_repository(namespace_name, repository_name) repo = _get_repository(namespace_name, repository_name)
now = int(time.time()) expired_time = get_epoch_timestamp() - repo.namespace_user.removed_tag_expiration_s
(RepositoryTag tags_to_delete = list(RepositoryTag
.delete() .select(RepositoryTag.id)
.where(RepositoryTag.repository == repo, .where(RepositoryTag.repository == repo,
~(RepositoryTag.lifetime_end_ts >> None), ~(RepositoryTag.lifetime_end_ts >> None),
(RepositoryTag.lifetime_end_ts + repo.namespace_user.removed_tag_expiration_s) <= now) (RepositoryTag.lifetime_end_ts <= expired_time))
.execute()) .order_by(RepositoryTag.id))
if len(tags_to_delete) > 0:
(RepositoryTag
.delete()
.where(RepositoryTag.id << tags_to_delete)
.execute())
def garbage_collect_repository(namespace_name, repository_name): def garbage_collect_repository(namespace_name, repository_name):
@ -1713,46 +1908,39 @@ def _garbage_collect_storage(storage_id_whitelist):
logger.debug('Garbage collecting storages from candidates: %s', storage_id_whitelist) logger.debug('Garbage collecting storages from candidates: %s', storage_id_whitelist)
with config.app_config['DB_TRANSACTION_FACTORY'](db): with config.app_config['DB_TRANSACTION_FACTORY'](db):
# Track all of the data that should be removed from blob storage # Track all of the data that should be removed from blob storage
placements_to_remove = orphaned_storage_query(ImageStoragePlacement placements_to_remove = list(orphaned_storage_query(ImageStoragePlacement
.select(ImageStoragePlacement, .select(ImageStoragePlacement,
ImageStorage, ImageStorage,
ImageStorageLocation) ImageStorageLocation)
.join(ImageStorageLocation) .join(ImageStorageLocation)
.switch(ImageStoragePlacement) .switch(ImageStoragePlacement)
.join(ImageStorage), .join(ImageStorage),
storage_id_whitelist, storage_id_whitelist,
(ImageStorage, ImageStoragePlacement, (ImageStorage, ImageStoragePlacement,
ImageStorageLocation)) ImageStorageLocation)))
paths_to_remove = placements_query_to_paths_set(placements_to_remove.clone()) paths_to_remove = placements_query_to_paths_set(placements_to_remove)
# Remove the placements for orphaned storages # Remove the placements for orphaned storages
placements_subquery = (placements_to_remove if len(placements_to_remove) > 0:
.clone() placement_ids_to_remove = [placement.id for placement in placements_to_remove]
.select(ImageStoragePlacement.id) placements_removed = (ImageStoragePlacement
.alias('ps')) .delete()
inner = (ImageStoragePlacement .where(ImageStoragePlacement.id << placement_ids_to_remove)
.select(placements_subquery.c.id) .execute())
.from_(placements_subquery)) logger.debug('Removed %s image storage placements', placements_removed)
placements_removed = (ImageStoragePlacement
.delete()
.where(ImageStoragePlacement.id << inner)
.execute())
logger.debug('Removed %s image storage placements', placements_removed)
# Remove all orphaned storages # Remove all orphaned storages
# The comma after ImageStorage.id is VERY important, it makes it a tuple, which is a sequence # The comma after ImageStorage.id is VERY important, it makes it a tuple, which is a sequence
orphaned_storages = orphaned_storage_query(ImageStorage.select(ImageStorage.id), orphaned_storages = list(orphaned_storage_query(ImageStorage.select(ImageStorage.id),
storage_id_whitelist, storage_id_whitelist,
(ImageStorage.id,)).alias('osq') (ImageStorage.id,)).alias('osq'))
orphaned_storage_inner = (ImageStorage if len(orphaned_storages) > 0:
.select(orphaned_storages.c.id) storages_removed = (ImageStorage
.from_(orphaned_storages)) .delete()
storages_removed = (ImageStorage .where(ImageStorage.id << orphaned_storages)
.delete() .execute())
.where(ImageStorage.id << orphaned_storage_inner) logger.debug('Removed %s image storage records', storages_removed)
.execute())
logger.debug('Removed %s image storage records', storages_removed)
# We are going to make the conscious decision to not delete image storage blobs inside # We are going to make the conscious decision to not delete image storage blobs inside
# transactions. # transactions.
@ -1803,40 +1991,34 @@ def get_parent_images(namespace_name, repository_name, image_obj):
def create_or_update_tag(namespace_name, repository_name, tag_name, def create_or_update_tag(namespace_name, repository_name, tag_name,
tag_docker_image_id): tag_docker_image_id):
try:
repo = _get_repository(namespace_name, repository_name)
except Repository.DoesNotExist:
raise DataModelException('Invalid repository %s/%s' % (namespace_name, repository_name))
now_ts = get_epoch_timestamp()
with config.app_config['DB_TRANSACTION_FACTORY'](db): with config.app_config['DB_TRANSACTION_FACTORY'](db):
try: try:
repo = _get_repository(namespace_name, repository_name) tag = db_for_update(_tag_alive(RepositoryTag
except Repository.DoesNotExist: .select()
raise DataModelException('Invalid repository %s/%s' % (namespace_name, repository_name)) .where(RepositoryTag.repository == repo,
RepositoryTag.name == tag_name), now_ts)).get()
tag.lifetime_end_ts = now_ts
tag.save()
except RepositoryTag.DoesNotExist:
pass
try: try:
image = Image.get(Image.docker_image_id == tag_docker_image_id, Image.repository == repo) image = Image.get(Image.docker_image_id == tag_docker_image_id, Image.repository == repo)
except Image.DoesNotExist: except Image.DoesNotExist:
raise DataModelException('Invalid image with id: %s' % tag_docker_image_id) raise DataModelException('Invalid image with id: %s' % tag_docker_image_id)
now_ts = int(time.time()) return RepositoryTag.create(repository=repo, image=image, name=tag_name,
lifetime_start_ts=now_ts)
created = RepositoryTag.create(repository=repo, image=image, name=tag_name,
lifetime_start_ts=now_ts)
try:
# When we move a tag, we really end the timeline of the old one and create a new one
query = _tag_alive(RepositoryTag
.select()
.where(RepositoryTag.repository == repo, RepositoryTag.name == tag_name,
RepositoryTag.id != created.id))
tag = query.get()
tag.lifetime_end_ts = now_ts
tag.save()
except RepositoryTag.DoesNotExist:
# No tag that needs to be ended
pass
return created
def delete_tag(namespace_name, repository_name, tag_name): def delete_tag(namespace_name, repository_name, tag_name):
now_ts = get_epoch_timestamp()
with config.app_config['DB_TRANSACTION_FACTORY'](db): with config.app_config['DB_TRANSACTION_FACTORY'](db):
try: try:
query = _tag_alive(RepositoryTag query = _tag_alive(RepositoryTag
@ -1845,21 +2027,21 @@ def delete_tag(namespace_name, repository_name, tag_name):
.join(Namespace, on=(Repository.namespace_user == Namespace.id)) .join(Namespace, on=(Repository.namespace_user == Namespace.id))
.where(Repository.name == repository_name, .where(Repository.name == repository_name,
Namespace.username == namespace_name, Namespace.username == namespace_name,
RepositoryTag.name == tag_name)) RepositoryTag.name == tag_name), now_ts)
found = db_for_update(query).get() found = db_for_update(query).get()
except RepositoryTag.DoesNotExist: except RepositoryTag.DoesNotExist:
msg = ('Invalid repository tag \'%s\' on repository \'%s/%s\'' % msg = ('Invalid repository tag \'%s\' on repository \'%s/%s\'' %
(tag_name, namespace_name, repository_name)) (tag_name, namespace_name, repository_name))
raise DataModelException(msg) raise DataModelException(msg)
found.lifetime_end_ts = int(time.time()) found.lifetime_end_ts = now_ts
found.save() found.save()
def create_temporary_hidden_tag(repo, image, expiration_s): def create_temporary_hidden_tag(repo, image, expiration_s):
""" Create a tag with a defined timeline, that will not appear in the UI or CLI. Returns the name """ Create a tag with a defined timeline, that will not appear in the UI or CLI. Returns the name
of the temporary tag. """ of the temporary tag. """
now_ts = int(time.time()) now_ts = get_epoch_timestamp()
expire_ts = now_ts + expiration_s expire_ts = now_ts + expiration_s
tag_name = str(uuid4()) tag_name = str(uuid4())
RepositoryTag.create(repository=repo, image=image, name=tag_name, lifetime_start_ts=now_ts, RepositoryTag.create(repository=repo, image=image, name=tag_name, lifetime_start_ts=now_ts,

View file

@ -1,6 +1,9 @@
import redis import redis
import json import json
import threading import threading
import logging
logger = logging.getLogger(__name__)
class UserEventBuilder(object): class UserEventBuilder(object):
""" """
@ -68,8 +71,9 @@ class UserEvent(object):
def conduct(): def conduct():
try: try:
self.publish_event_data_sync(event_id, data_obj) self.publish_event_data_sync(event_id, data_obj)
except Exception as e: logger.debug('Published user event %s: %s', event_id, data_obj)
print e except Exception:
logger.exception('Could not publish user event')
thread = threading.Thread(target=conduct) thread = threading.Thread(target=conduct)
thread.start() thread.start()

View file

@ -1,6 +1,11 @@
import ldap import ldap
import logging import logging
import json
import itertools
import uuid
import struct
from util.aes import AESCipher
from util.validation import generate_valid_usernames from util.validation import generate_valid_usernames
from data import model from data import model
@ -106,6 +111,7 @@ class LDAPUsers(object):
return found_user is not None return found_user is not None
class UserAuthentication(object): class UserAuthentication(object):
def __init__(self, app=None): def __init__(self, app=None):
self.app = app self.app = app
@ -138,5 +144,81 @@ class UserAuthentication(object):
app.extensions['authentication'] = users app.extensions['authentication'] = users
return users return users
def _get_secret_key(self):
""" Returns the secret key to use for encrypting and decrypting. """
from app import app
app_secret_key = app.config['SECRET_KEY']
secret_key = None
# First try parsing the key as an int.
try:
big_int = int(app_secret_key)
secret_key = str(bytearray.fromhex('{:02x}'.format(big_int)))
except ValueError:
pass
# Next try parsing it as an UUID.
if secret_key is None:
try:
secret_key = uuid.UUID(app_secret_key).bytes
except ValueError:
pass
if secret_key is None:
secret_key = str(bytearray(map(ord, app_secret_key)))
# Otherwise, use the bytes directly.
return ''.join(itertools.islice(itertools.cycle(secret_key), 32))
def encrypt_user_password(self, password):
""" Returns an encrypted version of the user's password. """
data = {
'password': password
}
message = json.dumps(data)
cipher = AESCipher(self._get_secret_key())
return cipher.encrypt(message)
def _decrypt_user_password(self, encrypted):
""" Attempts to decrypt the given password and returns it. """
cipher = AESCipher(self._get_secret_key())
try:
message = cipher.decrypt(encrypted)
except ValueError:
return None
except TypeError:
return None
try:
data = json.loads(message)
except ValueError:
return None
return data.get('password', encrypted)
def verify_user(self, username_or_email, password, basic_auth=False):
# First try to decode the password as a signed token.
if basic_auth:
import features
decrypted = self._decrypt_user_password(password)
if decrypted is None:
# This is a normal password.
if features.REQUIRE_ENCRYPTED_BASIC_AUTH:
msg = ('Client login with unecrypted passwords is disabled. Please generate an ' +
'encrypted password in the user admin panel for use here.')
return (None, msg)
else:
password = decrypted
result = self.state.verify_user(username_or_email, password)
if result:
return (result, '')
else:
return (result, 'Invalid password.')
def __getattr__(self, name): def __getattr__(self, name):
return getattr(self.state, name, None) return getattr(self.state, name, None)

View file

@ -4,7 +4,7 @@
<h3>Invitation to join team: {{ teamname }}</h3> <h3>Invitation to join team: {{ teamname }}</h3>
{{ inviter | user_reference }} has invited you to join the team <b>{{ teamname }}</b> under organization {{ organization | user_reference }}. {{ inviter | user_reference }} has invited you to join the team {{ teamname | team_reference }} under organization {{ organization | user_reference }}.
<br><br> <br><br>

View file

@ -9,7 +9,7 @@ from data import model
from util.cache import cache_control_flask_restful from util.cache import cache_control_flask_restful
def image_view(image, image_map): def image_view(image, image_map, include_locations=True, include_ancestors=True):
extended_props = image extended_props = image
if image.storage and image.storage.id: if image.storage and image.storage.id:
extended_props = image.storage extended_props = image.storage
@ -20,24 +20,35 @@ def image_view(image, image_map):
if not aid or not aid in image_map: if not aid or not aid in image_map:
return '' return ''
return image_map[aid] return image_map[aid].docker_image_id
# Calculate the ancestors string, with the DBID's replaced with the docker IDs. image_data = {
ancestors = [docker_id(a) for a in image.ancestors.split('/')]
ancestors_string = '/'.join(ancestors)
return {
'id': image.docker_image_id, 'id': image.docker_image_id,
'created': format_date(extended_props.created), 'created': format_date(extended_props.created),
'comment': extended_props.comment, 'comment': extended_props.comment,
'command': json.loads(command) if command else None, 'command': json.loads(command) if command else None,
'size': extended_props.image_size, 'size': extended_props.image_size,
'locations': list(image.storage.locations),
'uploading': image.storage.uploading, 'uploading': image.storage.uploading,
'ancestors': ancestors_string, 'sort_index': len(image.ancestors),
'sort_index': len(image.ancestors)
} }
if include_locations:
image_data['locations'] = list(image.storage.locations)
if include_ancestors:
# Calculate the ancestors string, with the DBID's replaced with the docker IDs.
ancestors = [docker_id(a) for a in image.ancestors.split('/')]
image_data['ancestors'] = '/'.join(ancestors)
return image_data
def historical_image_view(image, image_map):
ancestors = [image_map[a] for a in image.ancestors.split('/')[1:-1]]
normal_view = image_view(image, image_map)
normal_view['history'] = [image_view(parent, image_map, False, False) for parent in ancestors]
return normal_view
@resource('/v1/repository/<repopath:repository>/image/') @resource('/v1/repository/<repopath:repository>/image/')
@path_param('repository', 'The full path of the repository. e.g. namespace/name') @path_param('repository', 'The full path of the repository. e.g. namespace/name')
@ -62,7 +73,7 @@ class RepositoryImageList(RepositoryParamResource):
filtered_images = [] filtered_images = []
for image in all_images: for image in all_images:
if str(image.id) in found_image_ids: if str(image.id) in found_image_ids:
image_map[str(image.id)] = image.docker_image_id image_map[str(image.id)] = image
filtered_images.append(image) filtered_images.append(image)
def add_tags(image_json): def add_tags(image_json):
@ -90,9 +101,9 @@ class RepositoryImage(RepositoryParamResource):
# Lookup all the ancestor images for the image. # Lookup all the ancestor images for the image.
image_map = {} image_map = {}
for current_image in model.get_parent_images(namespace, repository, image): for current_image in model.get_parent_images(namespace, repository, image):
image_map[str(current_image.id)] = image.docker_image_id image_map[str(current_image.id)] = current_image
return image_view(image, image_map) return historical_image_view(image, image_map)
@resource('/v1/repository/<repopath:repository>/image/<image_id>/changes') @resource('/v1/repository/<repopath:repository>/image/<image_id>/changes')

View file

@ -24,16 +24,22 @@ logger = logging.getLogger(__name__)
def org_view(o, teams): def org_view(o, teams):
admin_org = AdministerOrganizationPermission(o.username) is_admin = AdministerOrganizationPermission(o.username).can()
is_admin = admin_org.can() is_member = OrganizationMemberPermission(o.username).can()
view = { view = {
'name': o.username, 'name': o.username,
'email': o.email if is_admin else '', 'email': o.email if is_admin else '',
'avatar': avatar.compute_hash(o.email, name=o.username), 'avatar': avatar.get_data_for_user(o),
'teams': {t.name : team_view(o.username, t) for t in teams}, 'is_admin': is_admin,
'is_admin': is_admin 'is_member': is_member
} }
if teams is not None:
teams = sorted(teams, key=lambda team:team.id)
view['teams'] = {t.name : team_view(o.username, t) for t in teams}
view['ordered_teams'] = [team.name for team in teams]
if is_admin: if is_admin:
view['invoice_email'] = o.invoice_email view['invoice_email'] = o.invoice_email
@ -129,17 +135,17 @@ class Organization(ApiResource):
@nickname('getOrganization') @nickname('getOrganization')
def get(self, orgname): def get(self, orgname):
""" Get the details for the specified organization """ """ Get the details for the specified organization """
permission = OrganizationMemberPermission(orgname) try:
if permission.can(): org = model.get_organization(orgname)
try: except model.InvalidOrganizationException:
org = model.get_organization(orgname) raise NotFound()
except model.InvalidOrganizationException:
raise NotFound()
teams = None
if OrganizationMemberPermission(orgname).can():
teams = model.get_teams_within_org(org) teams = model.get_teams_within_org(org)
return org_view(org, teams)
raise Unauthorized() return org_view(org, teams)
@require_scope(scopes.ORG_ADMIN) @require_scope(scopes.ORG_ADMIN)
@nickname('changeOrganizationDetails') @nickname('changeOrganizationDetails')
@ -297,16 +303,14 @@ class ApplicationInformation(ApiResource):
if not application: if not application:
raise NotFound() raise NotFound()
org_hash = avatar.compute_hash(application.organization.email, app_email = application.avatar_email or application.organization.email
name=application.organization.username) app_data = avatar.get_data(application.name, app_email, 'app')
app_hash = (avatar.compute_hash(application.avatar_email, name=application.name) if
application.avatar_email else org_hash)
return { return {
'name': application.name, 'name': application.name,
'description': application.description, 'description': application.description,
'uri': application.application_uri, 'uri': application.application_uri,
'avatar': app_hash, 'avatar': app_data,
'organization': org_view(application.organization, []) 'organization': org_view(application.organization, [])
} }

View file

@ -2,6 +2,7 @@ import logging
from flask import request from flask import request
from app import avatar
from endpoints.api import (resource, nickname, require_repo_admin, RepositoryParamResource, from endpoints.api import (resource, nickname, require_repo_admin, RepositoryParamResource,
log_action, request_error, validate_json_request, path_param) log_action, request_error, validate_json_request, path_param)
from data import model from data import model
@ -16,7 +17,10 @@ def role_view(repo_perm_obj):
} }
def wrap_role_view_user(role_json, user): def wrap_role_view_user(role_json, user):
role_json['name'] = user.username
role_json['is_robot'] = user.robot role_json['is_robot'] = user.robot
if not user.robot:
role_json['avatar'] = avatar.get_data_for_user(user)
return role_json return role_json
@ -25,6 +29,12 @@ def wrap_role_view_org(role_json, user, org_members):
return role_json return role_json
def wrap_role_view_team(role_json, team):
role_json['name'] = team.name
role_json['avatar'] = avatar.get_data_for_team(team)
return role_json
@resource('/v1/repository/<repopath:repository>/permissions/team/') @resource('/v1/repository/<repopath:repository>/permissions/team/')
@path_param('repository', 'The full path of the repository. e.g. namespace/name') @path_param('repository', 'The full path of the repository. e.g. namespace/name')
class RepositoryTeamPermissionList(RepositoryParamResource): class RepositoryTeamPermissionList(RepositoryParamResource):
@ -35,8 +45,11 @@ class RepositoryTeamPermissionList(RepositoryParamResource):
""" List all team permission. """ """ List all team permission. """
repo_perms = model.get_all_repo_teams(namespace, repository) repo_perms = model.get_all_repo_teams(namespace, repository)
def wrapped_role_view(repo_perm):
return wrap_role_view_team(role_view(repo_perm), repo_perm.team)
return { return {
'permissions': {repo_perm.team.name: role_view(repo_perm) 'permissions': {repo_perm.team.name: wrapped_role_view(repo_perm)
for repo_perm in repo_perms} for repo_perm in repo_perms}
} }
@ -232,7 +245,7 @@ class RepositoryTeamPermission(RepositoryParamResource):
'role': new_permission['role']}, 'role': new_permission['role']},
repo=model.get_repository(namespace, repository)) repo=model.get_repository(namespace, repository))
return role_view(perm), 200 return wrap_role_view_team(role_view(perm), perm.team), 200
@require_repo_admin @require_repo_admin
@nickname('deleteTeamPermissions') @nickname('deleteTeamPermissions')

View file

@ -7,6 +7,7 @@ from auth.permissions import AdministerOrganizationPermission
from auth.auth_context import get_authenticated_user from auth.auth_context import get_authenticated_user
from auth import scopes from auth import scopes
from data import model from data import model
from app import avatar
def prototype_view(proto, org_members): def prototype_view(proto, org_members):
@ -16,6 +17,7 @@ def prototype_view(proto, org_members):
'is_robot': user.robot, 'is_robot': user.robot,
'kind': 'user', 'kind': 'user',
'is_org_member': user.robot or user.username in org_members, 'is_org_member': user.robot or user.username in org_members,
'avatar': avatar.get_data_for_user(user)
} }
if proto.delegate_user: if proto.delegate_user:
@ -24,6 +26,7 @@ def prototype_view(proto, org_members):
delegate_view = { delegate_view = {
'name': proto.delegate_team.name, 'name': proto.delegate_team.name,
'kind': 'team', 'kind': 'team',
'avatar': avatar.get_data_for_team(proto.delegate_team)
} }
return { return {

View file

@ -109,6 +109,8 @@ class RepositoryList(ApiResource):
@query_param('sort', 'Whether to sort the results.', type=truthy_bool, default=False) @query_param('sort', 'Whether to sort the results.', type=truthy_bool, default=False)
@query_param('count', 'Whether to include a count of the total number of results available.', @query_param('count', 'Whether to include a count of the total number of results available.',
type=truthy_bool, default=False) type=truthy_bool, default=False)
@query_param('namespace_only', 'Whether to limit only to the given namespace.',
type=truthy_bool, default=False)
def get(self, args): def get(self, args):
"""Fetch the list of repositories under a variety of situations.""" """Fetch the list of repositories under a variety of situations."""
username = None username = None
@ -129,7 +131,8 @@ class RepositoryList(ApiResource):
repo_query = model.get_visible_repositories(username, limit=args['limit'], page=args['page'], repo_query = model.get_visible_repositories(username, limit=args['limit'], page=args['page'],
include_public=args['public'], sort=args['sort'], include_public=args['public'], sort=args['sort'],
namespace=args['namespace']) namespace=args['namespace'],
namespace_only=args['namespace_only'])
def repo_view(repo_obj): def repo_view(repo_obj):
repo = { repo = {
'namespace': repo_obj.namespace_user.username, 'namespace': repo_obj.namespace_user.username,

View file

@ -5,16 +5,63 @@ from auth.permissions import AdministerOrganizationPermission, OrganizationMembe
from auth.auth_context import get_authenticated_user from auth.auth_context import get_authenticated_user
from auth import scopes from auth import scopes
from data import model from data import model
from data.database import User, Team, Repository, FederatedLogin
from util.names import format_robot_username from util.names import format_robot_username
from flask import abort
from app import avatar
def robot_view(name, token): def robot_view(name, token):
return { return {
'name': name, 'name': name,
'token': token, 'token': token
} }
def permission_view(permission):
return {
'repository': {
'name': permission.repository.name,
'is_public': permission.repository.visibility.name == 'public'
},
'role': permission.role.name
}
def robots_list(prefix):
tuples = model.list_entity_robot_permission_teams(prefix)
robots = {}
robot_teams = set()
for robot_tuple in tuples:
robot_name = robot_tuple.get(User.username)
if not robot_name in robots:
robots[robot_name] = {
'name': robot_name,
'token': robot_tuple.get(FederatedLogin.service_ident),
'teams': [],
'repositories': []
}
team_name = robot_tuple.get(Team.name)
repository_name = robot_tuple.get(Repository.name)
if team_name is not None:
check_key = robot_name + ':' + team_name
if not check_key in robot_teams:
robot_teams.add(check_key)
robots[robot_name]['teams'].append({
'name': team_name,
'avatar': avatar.get_data(team_name, team_name, 'team')
})
if repository_name is not None:
if not repository_name in robots[robot_name]['repositories']:
robots[robot_name]['repositories'].append(repository_name)
return {'robots': robots.values()}
@resource('/v1/user/robots') @resource('/v1/user/robots')
@internal_only @internal_only
class UserRobotList(ApiResource): class UserRobotList(ApiResource):
@ -24,10 +71,7 @@ class UserRobotList(ApiResource):
def get(self): def get(self):
""" List the available robots for the user. """ """ List the available robots for the user. """
user = get_authenticated_user() user = get_authenticated_user()
robots = model.list_entity_robot_tuples(user.username) return robots_list(user.username)
return {
'robots': [robot_view(name, password) for name, password in robots]
}
@resource('/v1/user/robots/<robot_shortname>') @resource('/v1/user/robots/<robot_shortname>')
@ -73,10 +117,7 @@ class OrgRobotList(ApiResource):
""" List the organization's robots. """ """ List the organization's robots. """
permission = OrganizationMemberPermission(orgname) permission = OrganizationMemberPermission(orgname)
if permission.can(): if permission.can():
robots = model.list_entity_robot_tuples(orgname) return robots_list(orgname)
return {
'robots': [robot_view(name, password) for name, password in robots]
}
raise Unauthorized() raise Unauthorized()
@ -125,6 +166,47 @@ class OrgRobot(ApiResource):
raise Unauthorized() raise Unauthorized()
@resource('/v1/user/robots/<robot_shortname>/permissions')
@path_param('robot_shortname', 'The short name for the robot, without any user or organization prefix')
@internal_only
class UserRobotPermissions(ApiResource):
""" Resource for listing the permissions a user's robot has in the system. """
@require_user_admin
@nickname('getUserRobotPermissions')
def get(self, robot_shortname):
""" Returns the list of repository permissions for the user's robot. """
parent = get_authenticated_user()
robot, password = model.get_robot(robot_shortname, parent)
permissions = model.list_robot_permissions(robot.username)
return {
'permissions': [permission_view(permission) for permission in permissions]
}
@resource('/v1/organization/<orgname>/robots/<robot_shortname>/permissions')
@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(UserRobotPermissions)
class OrgRobotPermissions(ApiResource):
""" Resource for listing the permissions an org's robot has in the system. """
@require_user_admin
@nickname('getOrgRobotPermissions')
def get(self, orgname, robot_shortname):
""" Returns the list of repository permissions for the org's robot. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
parent = model.get_organization(orgname)
robot, password = model.get_robot(robot_shortname, parent)
permissions = model.list_robot_permissions(robot.username)
return {
'permissions': [permission_view(permission) for permission in permissions]
}
abort(403)
@resource('/v1/user/robots/<robot_shortname>/regenerate') @resource('/v1/user/robots/<robot_shortname>/regenerate')
@path_param('robot_shortname', 'The short name for the robot, without any user or organization prefix') @path_param('robot_shortname', 'The short name for the robot, without any user or organization prefix')
@internal_only @internal_only

View file

@ -3,11 +3,15 @@ from endpoints.api import (ApiResource, parse_args, query_param, truthy_bool, ni
from data import model from data import model
from auth.permissions import (OrganizationMemberPermission, ViewTeamPermission, from auth.permissions import (OrganizationMemberPermission, ViewTeamPermission,
ReadRepositoryPermission, UserAdminPermission, ReadRepositoryPermission, UserAdminPermission,
AdministerOrganizationPermission) AdministerOrganizationPermission, ReadRepositoryPermission)
from auth.auth_context import get_authenticated_user from auth.auth_context import get_authenticated_user
from auth import scopes from auth import scopes
from app import avatar from app import avatar, get_app_url
from operator import itemgetter
from stringscore import liquidmetal
from util.names import parse_robot_username
import math
@resource('/v1/entities/<prefix>') @resource('/v1/entities/<prefix>')
class EntitySearch(ApiResource): class EntitySearch(ApiResource):
@ -45,7 +49,7 @@ class EntitySearch(ApiResource):
'name': namespace_name, 'name': namespace_name,
'kind': 'org', 'kind': 'org',
'is_org_member': True, 'is_org_member': True,
'avatar': avatar.compute_hash(organization.email, name=organization.username), 'avatar': avatar.get_data_for_org(organization),
}] }]
except model.InvalidOrganizationException: except model.InvalidOrganizationException:
@ -63,7 +67,8 @@ class EntitySearch(ApiResource):
result = { result = {
'name': team.name, 'name': team.name,
'kind': 'team', 'kind': 'team',
'is_org_member': True 'is_org_member': True,
'avatar': avatar.get_data_for_team(team)
} }
return result return result
@ -71,11 +76,12 @@ class EntitySearch(ApiResource):
user_json = { user_json = {
'name': user.username, 'name': user.username,
'kind': 'user', 'kind': 'user',
'is_robot': user.is_robot, 'is_robot': user.robot,
'avatar': avatar.get_data_for_user(user)
} }
if organization is not None: if organization is not None:
user_json['is_org_member'] = user.is_robot or user.is_org_member user_json['is_org_member'] = user.robot or user.is_org_member
return user_json return user_json
@ -128,3 +134,154 @@ class FindRepositories(ApiResource):
if (repo.visibility.name == 'public' or if (repo.visibility.name == 'public' or
ReadRepositoryPermission(repo.namespace_user.username, repo.name).can())] ReadRepositoryPermission(repo.namespace_user.username, repo.name).can())]
} }
def search_entity_view(username, entity):
kind = 'user'
avatar_data = avatar.get_data_for_user(entity)
href = '/user/' + entity.username
if entity.organization:
kind = 'organization'
avatar_data = avatar.get_data_for_org(entity)
href = '/organization/' + entity.username
elif entity.robot:
parts = parse_robot_username(entity.username)
if parts[0] == username:
href = '/user/' + username + '?tab=robots&showRobot=' + entity.username
else:
href = '/organization/' + parts[0] + '?tab=robots&showRobot=' + entity.username
kind = 'robot'
avatar_data = None
return {
'kind': kind,
'avatar': avatar_data,
'name': entity.username,
'score': 1,
'href': href
}
def conduct_team_search(username, query, encountered_teams, results):
""" Finds the matching teams where the user is a member. """
matching_teams = model.get_matching_user_teams(query, get_authenticated_user(), limit=5)
for team in matching_teams:
if team.id in encountered_teams:
continue
encountered_teams.add(team.id)
results.append({
'kind': 'team',
'name': team.name,
'organization': search_entity_view(username, team.organization),
'avatar': avatar.get_data_for_team(team),
'score': 2,
'href': '/organization/' + team.organization.username + '/teams/' + team.name
})
def conduct_admined_team_search(username, query, encountered_teams, results):
""" Finds matching teams in orgs admined by the user. """
matching_teams = model.get_matching_admined_teams(query, get_authenticated_user(), limit=5)
for team in matching_teams:
if team.id in encountered_teams:
continue
encountered_teams.add(team.id)
results.append({
'kind': 'team',
'name': team.name,
'organization': search_entity_view(username, team.organization),
'avatar': avatar.get_data_for_team(team),
'score': 2,
'href': '/organization/' + team.organization.username + '/teams/' + team.name
})
def conduct_repo_search(username, query, results):
""" Finds matching repositories. """
def can_read(repository):
if repository.is_public:
return True
return ReadRepositoryPermission(repository.namespace_user.username, repository.name).can()
only_public = username is None
matching_repos = model.get_sorted_matching_repositories(query, only_public, can_read, limit=5)
for repo in matching_repos:
repo_score = math.log(repo.count or 1, 10) or 1
# If the repository is under the user's namespace, give it 20% more weight.
namespace = repo.namespace_user.username
if OrganizationMemberPermission(namespace).can() or namespace == username:
repo_score = repo_score * 1.2
results.append({
'kind': 'repository',
'namespace': search_entity_view(username, repo.namespace_user),
'name': repo.name,
'description': repo.description,
'is_public': repo.is_public,
'score': repo_score,
'href': '/repository/' + repo.namespace_user.username + '/' + repo.name
})
def conduct_namespace_search(username, query, results):
""" Finds matching users and organizations. """
matching_entities = model.get_matching_user_namespaces(query, username, limit=5)
for entity in matching_entities:
results.append(search_entity_view(username, entity))
def conduct_robot_search(username, query, results):
""" Finds matching robot accounts. """
matching_robots = model.get_matching_robots(query, username, limit=5)
for robot in matching_robots:
results.append(search_entity_view(username, robot))
@resource('/v1/find/all')
class ConductSearch(ApiResource):
""" Resource for finding users, repositories, teams, etc. """
@parse_args
@query_param('query', 'The search query.', type=str, default='')
@require_scope(scopes.READ_REPO)
@nickname('conductSearch')
def get(self, args):
""" Get a list of entities and resources that match the specified query. """
query = args['query']
if not query:
return {'results': []}
username = None
results = []
if get_authenticated_user():
username = get_authenticated_user().username
# Search for teams.
encountered_teams = set()
conduct_team_search(username, query, encountered_teams, results)
conduct_admined_team_search(username, query, encountered_teams, results)
# Search for robot accounts.
conduct_robot_search(username, query, results)
# Search for repos.
conduct_repo_search(username, query, results)
# Search for users and orgs.
conduct_namespace_search(username, query, results)
# Modify the results' scores via how close the query term is to each result's name.
for result in results:
result['score'] = result['score'] * liquidmetal.score(result['name'], query)
return {'results': sorted(results, key=itemgetter('score'), reverse=True)}

View file

@ -15,6 +15,7 @@ from auth.permissions import SuperUserPermission
from auth.auth_context import get_authenticated_user from auth.auth_context import get_authenticated_user
from data.database import User from data.database import User
from util.config.configutil import add_enterprise_config_defaults from util.config.configutil import add_enterprise_config_defaults
from util.config.provider import CannotWriteConfigException
from util.config.validator import validate_service_for_config, SSL_FILENAMES from util.config.validator import validate_service_for_config, SSL_FILENAMES
from data.runmigration import run_alembic_migration from data.runmigration import run_alembic_migration

View file

@ -108,7 +108,7 @@ def user_view(user):
'username': user.username, 'username': user.username,
'email': user.email, 'email': user.email,
'verified': user.verified, 'verified': user.verified,
'avatar': avatar.compute_hash(user.email, name=user.username), 'avatar': avatar.get_data_for_user(user),
'super_user': superusers.is_superuser(user.username) 'super_user': superusers.is_superuser(user.username)
} }

View file

@ -1,12 +1,45 @@
from flask import request from flask import request, abort
from endpoints.api import (resource, nickname, require_repo_read, require_repo_write, from endpoints.api import (resource, nickname, require_repo_read, require_repo_write,
RepositoryParamResource, log_action, NotFound, validate_json_request, RepositoryParamResource, log_action, NotFound, validate_json_request,
path_param) path_param, format_date)
from endpoints.api.image import image_view from endpoints.api.image import image_view
from data import model from data import model
from auth.auth_context import get_authenticated_user from auth.auth_context import get_authenticated_user
from datetime import datetime
@resource('/v1/repository/<repopath:repository>/tag/')
@path_param('repository', 'The full path of the repository. e.g. namespace/name')
class ListRepositoryTags(RepositoryParamResource):
""" Resource for listing repository tags. """
@require_repo_write
@nickname('listRepoTags')
def get(self, namespace, repository):
repo = model.get_repository(namespace, repository)
if not repo:
abort(404)
def tag_view(tag):
tag_info = {
'name': tag.name,
'docker_image_id': tag.image.docker_image_id,
}
if tag.lifetime_start_ts > 0:
tag_info['start_ts'] = tag.lifetime_start_ts
if tag.lifetime_end_ts > 0:
tag_info['end_ts'] = tag.lifetime_end_ts
return tag_info
tags = model.list_repository_tag_history(repo, limit=100)
return {'tags': [tag_view(tag) for tag in tags]}
@resource('/v1/repository/<repopath:repository>/tag/<tag>') @resource('/v1/repository/<repopath:repository>/tag/<tag>')
@path_param('repository', 'The full path of the repository. e.g. namespace/name') @path_param('repository', 'The full path of the repository. e.g. namespace/name')
@ -92,7 +125,7 @@ class RepositoryTagImages(RepositoryParamResource):
parent_images = model.get_parent_images(namespace, repository, tag_image) parent_images = model.get_parent_images(namespace, repository, tag_image)
image_map = {} image_map = {}
for image in parent_images: for image in parent_images:
image_map[str(image.id)] = image.docker_image_id image_map[str(image.id)] = image
parents = list(parent_images) parents = list(parent_images)
parents.reverse() parents.reverse()

View file

@ -52,11 +52,11 @@ def team_view(orgname, team):
view_permission = ViewTeamPermission(orgname, team.name) view_permission = ViewTeamPermission(orgname, team.name)
role = model.get_team_org_role(team).name role = model.get_team_org_role(team).name
return { return {
'id': team.id,
'name': team.name, 'name': team.name,
'description': team.description, 'description': team.description,
'can_view': view_permission.can(), 'can_view': view_permission.can(),
'role': role 'role': role,
'avatar': avatar.get_data_for_team(team)
} }
def member_view(member, invited=False): def member_view(member, invited=False):
@ -64,7 +64,7 @@ def member_view(member, invited=False):
'name': member.username, 'name': member.username,
'kind': 'user', 'kind': 'user',
'is_robot': member.robot, 'is_robot': member.robot,
'avatar': avatar.compute_hash(member.email, name=member.username) if not member.robot else None, 'avatar': avatar.get_data_for_user(member),
'invited': invited, 'invited': invited,
} }
@ -76,7 +76,7 @@ def invite_view(invite):
return { return {
'email': invite.email, 'email': invite.email,
'kind': 'invite', 'kind': 'invite',
'avatar': avatar.compute_hash(invite.email), 'avatar': avatar.get_data(invite.email, invite.email, 'user'),
'invited': True 'invited': True
} }

View file

@ -1,7 +1,8 @@
import logging import logging
import json import json
from flask import request from random import SystemRandom
from flask import request, abort
from flask.ext.login import logout_user from flask.ext.login import logout_user
from flask.ext.principal import identity_changed, AnonymousIdentity from flask.ext.principal import identity_changed, AnonymousIdentity
from peewee import IntegrityError from peewee import IntegrityError
@ -35,7 +36,7 @@ def user_view(user):
admin_org = AdministerOrganizationPermission(o.username) admin_org = AdministerOrganizationPermission(o.username)
return { return {
'name': o.username, 'name': o.username,
'avatar': avatar.compute_hash(o.email, name=o.username), 'avatar': avatar.get_data_for_org(o),
'is_org_admin': admin_org.can(), 'is_org_admin': admin_org.can(),
'can_create_repo': admin_org.can() or CreateRepositoryPermission(o.username).can(), 'can_create_repo': admin_org.can() or CreateRepositoryPermission(o.username).can(),
'preferred_namespace': not (o.stripe_id is None) 'preferred_namespace': not (o.stripe_id is None)
@ -58,15 +59,16 @@ def user_view(user):
logins = model.list_federated_logins(user) logins = model.list_federated_logins(user)
user_response = { user_response = {
'verified': user.verified,
'anonymous': False, 'anonymous': False,
'username': user.username, 'username': user.username,
'avatar': avatar.compute_hash(user.email, name=user.username), 'avatar': avatar.get_data_for_user(user)
} }
user_admin = UserAdminPermission(user.username) user_admin = UserAdminPermission(user.username)
if user_admin.can(): if user_admin.can():
user_response.update({ user_response.update({
'is_me': True,
'verified': user.verified,
'email': user.email, 'email': user.email,
'organizations': [org_view(o) for o in organizations], 'organizations': [org_view(o) for o in organizations],
'logins': [login_view(login) for login in logins], 'logins': [login_view(login) for login in logins],
@ -76,7 +78,7 @@ def user_view(user):
'tag_expiration': user.removed_tag_expiration_s, 'tag_expiration': user.removed_tag_expiration_s,
}) })
if features.SUPER_USERS: if features.SUPER_USERS and SuperUserPermission().can():
user_response.update({ user_response.update({
'super_user': user and user == get_authenticated_user() and SuperUserPermission().can() 'super_user': user and user == get_authenticated_user() and SuperUserPermission().can()
}) })
@ -175,8 +177,8 @@ class User(ApiResource):
'description': 'The user\'s email address', 'description': 'The user\'s email address',
}, },
'avatar': { 'avatar': {
'type': 'string', 'type': 'object',
'description': 'Avatar hash representing the user\'s icon' 'description': 'Avatar data representing the user\'s icon'
}, },
'organizations': { 'organizations': {
'type': 'array', 'type': 'array',
@ -224,8 +226,13 @@ class User(ApiResource):
if 'password' in user_data: if 'password' in user_data:
logger.debug('Changing password for user: %s', user.username) logger.debug('Changing password for user: %s', user.username)
log_action('account_change_password', user.username) log_action('account_change_password', user.username)
# Change the user's password.
model.change_password(user, user_data['password']) model.change_password(user, user_data['password'])
# Login again to reset their session cookie.
common_login(user)
if features.MAILING: if features.MAILING:
send_password_changed(user.username, user.email) send_password_changed(user.username, user.email)
@ -335,13 +342,51 @@ class PrivateRepositories(ApiResource):
} }
@resource('/v1/user/clientkey')
@internal_only
class ClientKey(ApiResource):
""" Operations for returning an encrypted key which can be used in place of a password
for the Docker client. """
schemas = {
'GenerateClientKey': {
'id': 'GenerateClientKey',
'type': 'object',
'required': [
'password',
],
'properties': {
'password': {
'type': 'string',
'description': 'The user\'s password',
},
}
}
}
@require_user_admin
@nickname('generateUserClientKey')
@validate_json_request('GenerateClientKey')
def post(self):
""" Return's the user's private client key. """
username = get_authenticated_user().username
password = request.get_json()['password']
(result, error_message) = authentication.verify_user(username, password)
if not result:
raise request_error(message=error_message)
return {
'key': authentication.encrypt_user_password(password)
}
def conduct_signin(username_or_email, password): def conduct_signin(username_or_email, password):
needs_email_verification = False needs_email_verification = False
invalid_credentials = False invalid_credentials = False
verified = None verified = None
try: try:
verified = authentication.verify_user(username_or_email, password) (verified, error_message) = authentication.verify_user(username_or_email, password)
except model.TooManyUsersException as ex: except model.TooManyUsersException as ex:
raise license_error(exception=ex) raise license_error(exception=ex)
@ -407,7 +452,7 @@ class ConvertToOrganization(ApiResource):
# Ensure that the sign in credentials work. # Ensure that the sign in credentials work.
admin_password = convert_data['adminPassword'] admin_password = convert_data['adminPassword']
admin_user = authentication.verify_user(admin_username, admin_password) (admin_user, error_message) = authentication.verify_user(admin_username, admin_password)
if not admin_user: if not admin_user:
raise request_error(reason='invaliduser', raise request_error(reason='invaliduser',
message='The admin user credentials are not valid') message='The admin user credentials are not valid')
@ -621,17 +666,16 @@ class UserNotification(ApiResource):
def authorization_view(access_token): def authorization_view(access_token):
oauth_app = access_token.application oauth_app = access_token.application
app_email = oauth_app.avatar_email or oauth_app.organization.email
return { return {
'application': { 'application': {
'name': oauth_app.name, 'name': oauth_app.name,
'description': oauth_app.description, 'description': oauth_app.description,
'url': oauth_app.application_uri, 'url': oauth_app.application_uri,
'avatar': avatar.compute_hash(oauth_app.avatar_email or oauth_app.organization.email, 'avatar': avatar.get_data(oauth_app.name, app_email, 'app'),
name=oauth_app.name),
'organization': { 'organization': {
'name': oauth_app.organization.username, 'name': oauth_app.organization.username,
'avatar': avatar.compute_hash(oauth_app.organization.email, 'avatar': avatar.get_data_for_org(oauth_app.organization)
name=oauth_app.organization.username)
} }
}, },
'scopes': scopes.get_scope_information(access_token.scope), 'scopes': scopes.get_scope_information(access_token.scope),
@ -745,6 +789,7 @@ class StarredRepositoryList(ApiResource):
'repository': repository, 'repository': repository,
}, 201 }, 201
@resource('/v1/user/starred/<repopath:repository>') @resource('/v1/user/starred/<repopath:repository>')
@path_param('repository', 'The full path of the repository. e.g. namespace/name') @path_param('repository', 'The full path of the repository. e.g. namespace/name')
class StarredRepository(RepositoryParamResource): class StarredRepository(RepositoryParamResource):
@ -759,3 +804,17 @@ class StarredRepository(RepositoryParamResource):
if repo: if repo:
model.unstar_repository(user, repo) model.unstar_repository(user, repo)
return 'Deleted', 204 return 'Deleted', 204
@resource('/v1/users/<username>')
class Users(ApiResource):
""" Operations related to retrieving information about other users. """
@nickname('getUserInformation')
def get(self, username):
""" Get user information for the specified user. """
user = model.get_user(username)
if user is None or user.organization or user.robot:
abort(404)
return user_view(user)

View file

@ -157,7 +157,10 @@ def github_oauth_callback():
if error: if error:
return render_ologin_error('GitHub', error) return render_ologin_error('GitHub', error)
# Exchange the OAuth code.
token = exchange_code_for_token(request.args.get('code'), github_login) token = exchange_code_for_token(request.args.get('code'), github_login)
# Retrieve the user's information.
user_data = get_user(github_login, token) user_data = get_user(github_login, token)
if not user_data or not 'login' in user_data: if not user_data or not 'login' in user_data:
return render_ologin_error('GitHub') return render_ologin_error('GitHub')
@ -172,16 +175,35 @@ def github_oauth_callback():
token_param = { token_param = {
'access_token': token, 'access_token': token,
} }
# Retrieve the user's orgnizations (if organization filtering is turned on)
if github_login.allowed_organizations() is not None:
get_orgs = client.get(github_login.orgs_endpoint(), params=token_param,
headers={'Accept': 'application/vnd.github.moondragon+json'})
organizations = set([org.get('login').lower() for org in get_orgs.json()])
if not (organizations & set(github_login.allowed_organizations())):
err = """You are not a member of an allowed GitHub organization.
Please contact your system administrator if you believe this is in error."""
return render_ologin_error('GitHub', err)
# Find the e-mail address for the user: we will accept any email, but we prefer the primary
get_email = client.get(github_login.email_endpoint(), params=token_param, get_email = client.get(github_login.email_endpoint(), params=token_param,
headers=v3_media_type) headers=v3_media_type)
# We will accept any email, but we prefer the primary
found_email = None found_email = None
for user_email in get_email.json(): for user_email in get_email.json():
if not github_login.is_enterprise() and not user_email['verified']:
continue
found_email = user_email['email'] found_email = user_email['email']
if user_email['primary']: if user_email['primary']:
break break
if found_email is None:
err = 'There is no verified e-mail address attached to the GitHub account.'
return render_ologin_error('GitHub', err)
metadata = { metadata = {
'service_username': username 'service_username': username
} }

View file

@ -4,6 +4,7 @@ import json
from flask import make_response from flask import make_response
from app import app from app import app
from util.useremails import CannotSendEmailException from util.useremails import CannotSendEmailException
from util.config.provider import CannotWriteConfigException
from data import model from data import model
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -17,3 +18,11 @@ def handle_dme(ex):
def handle_emailexception(ex): def handle_emailexception(ex):
message = 'Could not send email. Please contact an administrator and report this problem.' message = 'Could not send email. Please contact an administrator and report this problem.'
return make_response(json.dumps({'message': message}), 400) return make_response(json.dumps({'message': message}), 400)
@app.errorhandler(CannotWriteConfigException)
def handle_configexception(ex):
message = ('Configuration could not be written to the mounted volume. \n' +
'Please make sure the mounted volume is not read-only and restart ' +
'the setup process. \n\nIssue: %s' % ex)
return make_response(json.dumps({'message': message}), 400)

View file

@ -109,19 +109,17 @@ def create_user():
issue='robot-login-failure') issue='robot-login-failure')
if authentication.user_exists(username): if authentication.user_exists(username):
verified = authentication.verify_user(username, password) (verified, error_message) = authentication.verify_user(username, password, basic_auth=True)
if verified: if verified:
# Mark that the user was logged in. # Mark that the user was logged in.
event = userevents.get_event(username) event = userevents.get_event(username)
event.publish_event_data('docker-cli', {'action': 'login'}) event.publish_event_data('docker-cli', {'action': 'login'})
return success return success
else: else:
# Mark that the login failed. # Mark that the login failed.
event = userevents.get_event(username) event = userevents.get_event(username)
event.publish_event_data('docker-cli', {'action': 'loginfailure'}) event.publish_event_data('docker-cli', {'action': 'loginfailure'})
abort(400, error_message, issue='login-failure')
abort(400, 'Invalid password.', issue='login-failure')
elif not features.USER_CREATION: elif not features.USER_CREATION:
abort(400, 'User creation is disabled. Please speak to your administrator.') abort(400, 'User creation is disabled. Please speak to your administrator.')
@ -231,6 +229,16 @@ def create_repository(namespace, repository):
repo = model.create_repository(namespace, repository, repo = model.create_repository(namespace, repository,
get_authenticated_user()) get_authenticated_user())
if get_authenticated_user():
user_event_data = {
'action': 'push_start',
'repository': repository,
'namespace': namespace
}
event = userevents.get_event(get_authenticated_user().username)
event.publish_event_data('docker-cli', user_event_data)
return make_response('Created', 201) return make_response('Created', 201)
@ -248,20 +256,6 @@ def update_images(namespace, repository):
# Make sure the repo actually exists. # Make sure the repo actually exists.
abort(404, message='Unknown repository', issue='unknown-repo') abort(404, message='Unknown repository', issue='unknown-repo')
if get_authenticated_user():
logger.debug('Publishing push event')
username = get_authenticated_user().username
# Mark that the user has pushed the repo.
user_data = {
'action': 'pushed_repo',
'repository': repository,
'namespace': namespace
}
event = userevents.get_event(username)
event.publish_event_data('docker-cli', user_data)
logger.debug('GCing repository') logger.debug('GCing repository')
model.garbage_collect_repository(namespace, repository) model.garbage_collect_repository(namespace, repository)
@ -272,6 +266,7 @@ def update_images(namespace, repository):
event_data = { event_data = {
'updated_tags': updated_tags, 'updated_tags': updated_tags,
} }
track_and_log('push_repo', repo) track_and_log('push_repo', repo)
spawn_notification(repo, 'repo_push', event_data) spawn_notification(repo, 'repo_push', event_data)
return make_response('Updated', 204) return make_response('Updated', 204)

View file

@ -3,7 +3,6 @@ import logging
from flask import (abort, redirect, request, url_for, make_response, Response, from flask import (abort, redirect, request, url_for, make_response, Response,
Blueprint, send_from_directory, jsonify, send_file) Blueprint, send_from_directory, jsonify, send_file)
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 get_healthchecker from health.healthcheck import get_healthchecker
@ -39,7 +38,6 @@ STATUS_TAGS = app.config['STATUS_TAGS']
@web.route('/', methods=['GET'], defaults={'path': ''}) @web.route('/', methods=['GET'], defaults={'path': ''})
@web.route('/organization/<path:path>', methods=['GET'])
@no_cache @no_cache
def index(path, **kwargs): def index(path, **kwargs):
return render_page_template('index.html', **kwargs) return render_page_template('index.html', **kwargs)
@ -50,6 +48,18 @@ def internal_error_display():
return render_page_template('500.html') return render_page_template('500.html')
@web.route('/organization/<path:path>', methods=['GET'])
@no_cache
def org_view(path):
return index('')
@web.route('/user/<path:path>', methods=['GET'])
@no_cache
def user_view(path):
return index('')
@web.route('/snapshot', methods=['GET']) @web.route('/snapshot', methods=['GET'])
@web.route('/snapshot/', methods=['GET']) @web.route('/snapshot/', methods=['GET'])
@web.route('/snapshot/<path:path>', methods=['GET']) @web.route('/snapshot/<path:path>', methods=['GET'])
@ -210,20 +220,6 @@ def endtoend_health():
return response return response
@app.route("/avatar/<avatar_hash>")
@set_cache_headers
def render_avatar(avatar_hash, headers):
try:
size = int(request.args.get('size', 16))
except ValueError:
size = 16
generated = Avatar.generate(size, avatar_hash, "PNG")
resp = make_response(generated, 200, {'Content-Type': 'image/png'})
resp.headers.extend(headers)
return resp
@web.route('/tos', methods=['GET']) @web.route('/tos', methods=['GET'])
@no_cache @no_cache
def tos(): def tos():
@ -248,21 +244,6 @@ def robots():
return send_from_directory('static', 'robots.txt') return send_from_directory('static', 'robots.txt')
@web.route('/<path:repository>')
@no_cache
@process_oauth
@parse_repository_name_and_tag
def redirect_to_repository(namespace, reponame, tag):
permission = ReadRepositoryPermission(namespace, reponame)
is_public = model.repository_is_public(namespace, reponame)
if permission.can() or is_public:
repository_name = '/'.join([namespace, reponame])
return redirect(url_for('web.repository', path=repository_name, tag=tag))
abort(404)
@web.route('/receipt', methods=['GET']) @web.route('/receipt', methods=['GET'])
@route_show_if(features.BILLING) @route_show_if(features.BILLING)
@require_session_login @require_session_login
@ -449,15 +430,16 @@ def request_authorization_code():
# Load the application information. # Load the application information.
oauth_app = provider.get_application_for_client_id(client_id) oauth_app = provider.get_application_for_client_id(client_id)
app_email = oauth_app.email or organization.email
oauth_app_view = { oauth_app_view = {
'name': oauth_app.name, 'name': oauth_app.name,
'description': oauth_app.description, 'description': oauth_app.description,
'url': oauth_app.application_uri, 'url': oauth_app.application_uri,
'avatar': avatar.compute_hash(oauth_app.avatar_email, name=oauth_app.name), 'avatar': avatar.get_data(oauth_app.name, app_email, 'app'),
'organization': { 'organization': {
'name': oauth_app.organization.username, 'name': oauth_app.organization.username,
'avatar': avatar.compute_hash(oauth_app.organization.email, 'avatar': avatar.get_data_for_org(oauth_app.organization)
name=oauth_app.organization.username)
} }
} }
@ -533,3 +515,31 @@ def attach_custom_build_trigger(namespace, repository_name):
return redirect(full_url) return redirect(full_url)
abort(403) abort(403)
@web.route('/<path:repository>')
@no_cache
@process_oauth
@parse_repository_name_and_tag
def redirect_to_repository(namespace, reponame, tag):
permission = ReadRepositoryPermission(namespace, reponame)
is_public = model.repository_is_public(namespace, reponame)
if permission.can() or is_public:
repository_name = '/'.join([namespace, reponame])
return redirect(url_for('web.repository', path=repository_name, tag=tag))
abort(404)
@web.route('/<namespace>')
@no_cache
@process_oauth
def redirect_to_namespace(namespace):
user_or_org = model.get_user_or_org(namespace)
if not user_or_org:
abort(404)
if user_or_org.organization:
return redirect(url_for('web.org_view', path=namespace))
else:
return redirect(url_for('web.user_view', path=namespace))

View file

@ -21,12 +21,17 @@ EXTERNAL_CSS = [
'netdna.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.css', 'netdna.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.css',
'netdna.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css', 'netdna.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css',
'fonts.googleapis.com/css?family=Source+Sans+Pro:400,700', 'fonts.googleapis.com/css?family=Source+Sans+Pro:400,700',
's3.amazonaws.com/cdn.core-os.net/icons/core-icons.css'
] ]
EXTERNAL_FONTS = [ EXTERNAL_FONTS = [
'netdna.bootstrapcdn.com/font-awesome/4.2.0/fonts/fontawesome-webfont.woff?v=4.2.0', 'netdna.bootstrapcdn.com/font-awesome/4.2.0/fonts/fontawesome-webfont.woff?v=4.2.0',
'netdna.bootstrapcdn.com/font-awesome/4.2.0/fonts/fontawesome-webfont.ttf?v=4.2.0', 'netdna.bootstrapcdn.com/font-awesome/4.2.0/fonts/fontawesome-webfont.ttf?v=4.2.0',
'netdna.bootstrapcdn.com/font-awesome/4.2.0/fonts/fontawesome-webfont.svg?v=4.2.0', 'netdna.bootstrapcdn.com/font-awesome/4.2.0/fonts/fontawesome-webfont.svg?v=4.2.0',
'cdn.core-os.net/icons/core-icons.woff',
'cdn.core-os.net/icons/core-icons.ttf',
'cdn.core-os.net/icons/core-icons.svg',
] ]

View file

@ -57,7 +57,7 @@ def __gen_image_uuid(repo, image_num):
global_image_num = [0] global_image_num = [0]
def __create_subtree(repo, structure, creator_username, parent): def __create_subtree(repo, structure, creator_username, parent, tag_map):
num_nodes, subtrees, last_node_tags = structure num_nodes, subtrees, last_node_tags = structure
# create the nodes # create the nodes
@ -102,12 +102,18 @@ def __create_subtree(repo, structure, creator_username, parent):
tag = model.create_or_update_tag(repo.namespace_user.username, repo.name, tag_name, tag = model.create_or_update_tag(repo.namespace_user.username, repo.name, tag_name,
new_image.docker_image_id) new_image.docker_image_id)
tag_map[tag_name] = tag
for tag_name in last_node_tags:
if tag_name[0] == '#': if tag_name[0] == '#':
tag.lifetime_end_ts = int(time.time()) - 1 tag = tag_map[tag_name]
tag.name = tag_name[1:]
tag.lifetime_end_ts = tag_map[tag_name[1:]].lifetime_start_ts
tag.lifetime_start_ts = tag.lifetime_end_ts - 10
tag.save() tag.save()
for subtree in subtrees: for subtree in subtrees:
__create_subtree(repo, subtree, creator_username, new_image) __create_subtree(repo, subtree, creator_username, new_image, tag_map)
def __generate_repository(user, name, description, is_public, permissions, def __generate_repository(user, name, description, is_public, permissions,
@ -127,9 +133,9 @@ def __generate_repository(user, name, description, is_public, permissions,
if isinstance(structure, list): if isinstance(structure, list):
for s in structure: for s in structure:
__create_subtree(repo, s, user.username, None) __create_subtree(repo, s, user.username, None, {})
else: else:
__create_subtree(repo, structure, user.username, None) __create_subtree(repo, structure, user.username, None, {})
return repo return repo

View file

@ -3,6 +3,9 @@ import logging.config
from app import app as application from app import app as application
# Note: We need to import this module to make sure the decorators are registered.
import endpoints.decorated
from endpoints.index import index 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

View file

@ -48,3 +48,4 @@ pygpgme
cachetools cachetools
mock mock
psutil psutil
stringscore

View file

@ -54,6 +54,7 @@ redis==2.10.3
reportlab==2.7 reportlab==2.7
requests==2.5.1 requests==2.5.1
six==1.9.0 six==1.9.0
stringscore==0.1.0
stripe==1.20.1 stripe==1.20.1
trollius==1.0.4 trollius==1.0.4
tzlocal==1.1.2 tzlocal==1.1.2

View file

@ -278,6 +278,15 @@
display: block; display: block;
} }
.config-list-field-element input {
vertical-align: middle;
}
.config-list-field-element .item-delete {
display: inline-block;
margin-left: 20px;
}
.config-list-field-element input { .config-list-field-element input {
width: 350px; width: 350px;
} }
@ -764,10 +773,17 @@
padding: 10px; padding: 10px;
} }
.co-table.no-lines td {
border-bottom: 0px;
padding: 6px;
}
.co-table thead td { .co-table thead td {
color: #999;
font-size: 90%;
text-transform: uppercase; text-transform: uppercase;
font-size: 16px; font-weight: 300;
color: #666; padding-top: 0px !important;
} }
.co-table thead td a { .co-table thead td a {
@ -804,11 +820,45 @@
width: 30px; width: 30px;
} }
.co-table td.caret-col {
width: 10px;
padding-left: 6px;
padding-right: 0px;
color: #aaa;
}
.co-table td.caret-col i.fa {
cursor: pointer;
}
.co-table .add-row-spacer td {
padding: 5px;
}
.co-table .add-row td { .co-table .add-row td {
padding-top: 10px;
border-top: 2px solid #eee; border-top: 2px solid #eee;
border-bottom: none; border-bottom: none;
} }
.co-table tr.co-table-header-row td {
font-size: 12px;
text-transform: uppercase;
color: #ccc;
border-bottom: none;
padding-left: 10px;
padding-top: 10px;
padding-bottom: 4px;
}
.co-table tr.co-table-header-row td i.fa {
margin-right: 4px;
}
.co-table tr.indented-row td:first-child {
padding-left: 28px;
}
.cor-checkable-menu { .cor-checkable-menu {
display: inline-block; display: inline-block;
} }
@ -910,3 +960,78 @@
margin-bottom: 10px; margin-bottom: 10px;
} }
.co-alert {
padding: 16px;
padding-left: 46px;
position: relative;
margin-bottom: 20px;
position: relative;
border: 1px solid #eee;
}
.co-alert.co-alert-success {
background: #F0FFF4;
}
.co-alert.co-alert-success:before {
font-family: FontAwesome;
content: "\f058";
position: absolute;
top: 11px;
left: 12px;
font-size: 22px;
color: #83D29C;
}
.co-alert.co-alert-info {
background: #F0FAFF;
}
.co-alert.co-alert-info:before {
font-family: FontAwesome;
content: "\f05a";
position: absolute;
top: 11px;
left: 12px;
font-size: 22px;
color: #83B7D2;
}
.co-alert.co-alert-warning {
background: #FFFBF0;
}
.co-alert.co-alert-warning:before {
font-family: FontAwesome;
content: "\f071";
position: absolute;
top: 11px;
left: 12px;
font-size: 22px;
color: #E4C212;
}
.co-alert.co-alert-danger {
background: #FFF0F0;
}
.co-alert.co-alert-danger:before {
font-family: core-icons;
content: "\f107";
position: absolute;
top: 11px;
left: 12px;
font-size: 22px;
color: red;
}
.co-alert.co-alert-danger:after {
font-family: FontAwesome;
content: "\f12a";
position: absolute;
top: 16px;
left: 20px;
font-size: 16px;
color: white;
z-index: 2;
}

View file

@ -1,3 +1,14 @@
.repo-panel-info-element .right-controls {
margin-bottom: 20px;
float: right;
}
.repo-panel-info-element .right-controls .copy-box {
width: 400px;
display: inline-block;
margin-left: 10px;
}
.repo-panel-info-element .stat-col { .repo-panel-info-element .stat-col {
border-right: 2px solid #eee; border-right: 2px solid #eee;
} }

View file

@ -64,3 +64,135 @@
.repo-panel-tags-element .options-col { .repo-panel-tags-element .options-col {
padding-left: 20px; padding-left: 20px;
} }
.repo-panel-tags-element .options-col .fa-download {
color: #999;
cursor: pointer;
}
.repo-panel-tags-element .history-list {
margin: 10px;
border-left: 2px solid #eee;
}
.repo-panel-tags-element .history-entry {
position:relative;
margin-top: 20px;
padding-left: 26px;
transition: all 350ms ease-in-out;
}
.repo-panel-tags-element .history-entry .history-text {
transition: transform 350ms ease-in-out, opacity 350ms ease-in-out;
overflow: hidden;
height: 40px;
}
.repo-panel-tags-element .history-entry.filtered-mismatch {
margin-top: 10px;
}
.repo-panel-tags-element .history-entry.filtered-mismatch .history-text {
height: 18px;
opacity: 0;
}
.repo-panel-tags-element .history-entry.filtered-mismatch .history-icon {
opacity: 0.5;
transform: scale(0.5, 0.5);
}
.repo-panel-tags-element .history-entry .history-date-break {
font-size: 16px;
}
.repo-panel-tags-element .history-entry .history-date-break:before {
content: "";
position: absolute;
border-radius: 50%;
width: 12px;
height: 12px;
background: #ccc;
top: 4px;
left: -7px;
}
.repo-panel-tags-element .history-entry .history-icon {
border-radius: 50%;
width: 32px;
height: 32px;
line-height: 33px;
text-align: center;
font-size: 20px;
color: white;
background: #ccc;
position: absolute;
left: -17px;
top: -4px;
display: inline-block;
transition: all 350ms ease-in-out;
}
.repo-panel-tags-element .history-entry.move .history-icon:before {
content: "\f061";
font-family: FontAwesome;
}
.repo-panel-tags-element .history-entry.create .history-icon:before {
content: "\f02b";
font-family: FontAwesome;
}
.repo-panel-tags-element .history-entry.delete .history-icon:before {
content: "\f014";
font-family: FontAwesome;
}
.repo-panel-tags-element .history-entry.move .history-icon {
background-color: #1f77b4;
}
.repo-panel-tags-element .history-entry.create .history-icon {
background-color: #98df8a;
}
.repo-panel-tags-element .history-entry.delete .history-icon {
background-color: #ff9896;
}
.repo-panel-tags-element .history-entry .history-icon .fa-tag {
margin-right: 0px;
}
.repo-panel-tags-element .history-entry .tag-span {
display: inline-block;
border-radius: 4px;
padding: 2px;
background: #eee;
padding-right: 6px;
color: black;
cursor: pointer;
}
.repo-panel-tags-element .history-entry .tag-span.checked {
background: #F6FCFF;
}
.repo-panel-tags-element .history-entry .tag-span:before {
content: "\f02b";
font-family: FontAwesome;
margin-left: 4px;
margin-right: 4px;
}
.repo-panel-tags-element .history-entry .history-description {
color: #777;
}
.repo-panel-tags-element .history-entry .history-datetime {
font-size: 12px;
color: #ccc;
}

View file

@ -0,0 +1,7 @@
.application-manager-element .co-table {
margin-top: 20px;
}
.application-manager-element i.fa {
margin-right: 4px;
}

View file

@ -0,0 +1,3 @@
.authorized-apps-manager .avatar {
margin-right: 4px;
}

View file

@ -0,0 +1,31 @@
.avatar-element {
display: inline-block;
vertical-align: middle;
color: white !important;
text-align: center;
position: relative;
background: white;
overflow: hidden;
}
.avatar-element.team {
border-radius: 50%;
}
.avatar-element img {
position: absolute;
top: 0px;
left: 0px;
background: white;
}
.avatar-element .letter {
cursor: default !important;
font-style: normal !important;
font-weight: normal !important;
font-variant: normal !important;
}
a .avatar-element .letter {
cursor: pointer !important;
}

View file

@ -0,0 +1,25 @@
.billing-invoices-element .invoice-title {
padding: 6px;
cursor: pointer;
}
.billing-invoices-element .invoice-status .success {
color: green;
}
.billing-invoices-element .invoice-status .pending {
color: steelblue;
}
.billing-invoices-element .invoice-status .danger {
color: red;
}
.billing-invoices-element .invoice-amount:before {
content: '$';
}
.billing-invoices-element .fa-download {
color: #aaa;
}

View file

@ -10,6 +10,11 @@
text-decoration: none !important; text-decoration: none !important;
} }
.build-mini-status a {
text-decoration: none !important;
color: black;
}
.build-mini-status .timing { .build-mini-status .timing {
display: inline-block; display: inline-block;
margin-left: 30px; margin-left: 30px;
@ -29,5 +34,15 @@
bottom: 4px; bottom: 4px;
line-height: 33px; line-height: 33px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; }
.build-mini-status .build-description .tbd-content {
position: absolute;
top: 0px;
bottom: 0px;
left: 0px;
right: 0px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }

View file

@ -0,0 +1,35 @@
.convert-user-to-org .convert-form h3 {
margin-bottom: 20px;
}
.convert-user-to-org #convertForm {
max-width: 700px;
}
.convert-user-to-org #convertForm .form-group {
margin-bottom: 20px;
}
.convert-user-to-org #convertForm input {
margin-bottom: 10px;
margin-left: 20px;
}
.convert-user-to-org #convertForm .existing-data {
font-size: 16px;
font-weight: bold;
}
.convert-user-to-org #convertForm .description {
margin-top: 10px;
display: block;
color: #888;
font-size: 12px;
margin-left: 20px;
}
.convert-user-to-org #convertForm .existing-data {
display: block;
padding-left: 20px;
margin-top: 10px;
}

View file

@ -0,0 +1,7 @@
.entity-reference .new-entity-reference .entity-name {
margin-left: 6px;
}
.entity-reference .new-entity-reference .fa-wrench {
width: 16px;
}

View file

@ -0,0 +1,52 @@
.entity-search-element {
position: relative;
display: block;
}
.entity-search-element .entity-reference {
position: absolute !important;
top: 7px;
left: 8px;
right: 36px;
z-index: 0;
pointer-events: none;
}
.entity-search-element .entity-reference .entity-reference-element {
pointer-events: none;
}
.entity-search-element .entity-reference-element i.fa-exclamation-triangle {
pointer-events: all;
}
.entity-search-element .entity-reference .entity-name {
display: none;
}
.entity-search-element input {
vertical-align: middle;
width: 100%;
}
.entity-search-element.persistent input {
padding-left: 28px;
padding-right: 28px;
}
.entity-search-element .twitter-typeahead {
vertical-align: middle;
display: block !important;
margin-right: 36px;
}
.entity-search-element .dropdown {
vertical-align: middle;
position: absolute;
top: 0px;
right: 0px;
}
.entity-search-element .menuitem .avatar {
margin-right: 4px;
}

View file

@ -0,0 +1,3 @@
.external-login-button i.fa {
margin-right: 4px;
}

View file

@ -0,0 +1,11 @@
.external-logins-manager .empty {
color: #ccc;
}
.external-logins-manager .external-auth-provider td:first-child {
font-size: 18px;
}
.external-logins-manager .external-auth-provider td:first-child i.fa {
margin-right: 6px;
}

View file

@ -0,0 +1,19 @@
.fetch-tag-dialog .modal-table {
width: 100%;
}
.fetch-tag-dialog .modal-table .first-col {
width: 140px;
}
.fetch-tag-dialog .co-dialog .modal-body {
padding: 20px;
}
.fetch-tag-dialog .entity-search {
margin: 10px;
}
.fetch-tag-dialog pre.command {
margin-top: 10px;
}

View file

@ -0,0 +1,223 @@
nav.navbar {
border: 0px;
border-radius: 0px;
}
nav.navbar-default .navbar-nav>li>a {
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 {
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;
}
.header-bar-element .header-bar-content.search-visible {
box-shadow: 0px 1px 4px #ccc;
}
.header-bar-element .header-bar-content {
z-index: 5;
position: absolute;
top: 0px;
left: 0px;
right: 0px;
background: white;
}
.header-bar-element .search-box {
position: absolute;
left: 0px;
right: 0px;
top: -50px;
z-index: 4;
height: 83px;
transition: top 0.3s cubic-bezier(.23,.88,.72,.98);
background: white;
box-shadow: 0px 1px 16px #444;
padding: 10px;
}
.header-bar-element .search-box.search-visible {
top: 50px;
}
.header-bar-element .search-box.results-visible {
box-shadow: 0px 1px 4px #ccc;
}
.header-bar-element .search-box .search-label {
display: inline-block;
text-transform: uppercase;
font-size: 12px;
font-weight: bold;
color: #ccc;
margin-right: 10px;
position: absolute;
top: 34px;
left: 14px;
}
.header-bar-element .search-box .search-box-wrapper {
position: absolute;
top: 0px;
left: 100px;
right: 10px;
padding: 10px;
}
.header-bar-element .search-box .search-box-wrapper input {
font-size: 28px;
width: 100%;
padding: 10px;
border: 0px;
}
.header-bar-element .search-results {
position: absolute;
left: 0px;
right: 0px;
top: -130px;
z-index: 3;
transition: top 0.4s cubic-bezier(.23,.88,.72,.98), height 0.25s ease-in-out;
background: white;
box-shadow: 0px 1px 16px #444;
padding-top: 20px;
}
.header-bar-element .search-results.loading, .header-bar-element .search-results.results {
top: 130px;
}
.header-bar-element .search-results.loading {
height: 50px;
}
.header-bar-element .search-results.no-results {
height: 150px;
}
.header-bar-element .search-results ul {
padding: 0px;
margin: 0px;
}
.header-bar-element .search-results li {
list-style: none;
padding: 6px;
margin-bottom: 4px;
padding-left: 20px;
position: relative;
}
.header-bar-element .search-results li .kind {
text-transform: uppercase;
font-size: 12px;
display: inline-block;
margin-right: 10px;
color: #aaa;
width: 80px;
text-align: right;
}
.header-bar-element .search-results .avatar {
margin-left: 6px;
margin-right: 2px;
}
.header-bar-element .search-results li.current {
background: rgb(223, 242, 255);
cursor: pointer;
}
.header-bar-element .search-results li i.fa {
margin-left: 6px;
margin-right: 4px;
}
.header-bar-element .search-results li .description {
overflow: hidden;
text-overflow: ellipsis;
max-height: 24px;
padding-left: 10px;
display: inline-block;
color: #aaa;
vertical-align: middle;
}
.header-bar-element .search-results li .score:before {
content: "Score: ";
}
.header-bar-element .search-results li .score {
float: right;
color: #ccc;
}
.header-bar-element .search-results li .result-name {
vertical-align: middle;
}
.header-bar-element .search-results li .clarification {
font-size: 12px;
margin-left: 6px;
display: inline-block;
}
.header-bar-element .avatar {
margin-right: 6px;
}
.user-tools {
position: relative;
display: inline-block;
}
.user-tools .user-tool {
font-size: 24px;
margin-top: 14px;
color: #428bca;
margin-right: 20px;
}
.user-tools.with-menu {
margin-right: 6px;
}
.user-tools .caret {
position: absolute;
top: 3px;
left: 23px
}
.user-tools .notifications-bubble {
position: absolute;
top: 2px;
left: 13px;
}
.user-tools i.user-tool:hover {
cursor: pointer;
color: #333;
}
.user-tools .new-menu {
background: transparent !important;
}
.header-bar-element .context-dropdown i.fa {
width: 16px;
text-align: center;
display: inline-block;
}

View file

@ -0,0 +1,4 @@
.image-link a {
font-family: Consolas, "Lucida Console", Monaco, monospace;
font-size: 12px;
}

View file

@ -0,0 +1,79 @@
.image-view-layer-element {
position: relative;
padding: 10px;
padding-left: 170px;
}
.image-view-layer-element .image-id {
font-family: monospace;
position: absolute;
top: 10px;
left: 10px;
width: 110px;
text-align: right;
}
.image-view-layer-element .image-id a {
color: #aaa;
}
.image-view-layer-element.first .image-id {
font-weight: bold;
font-size: 110%;
}
.image-view-layer-element.first .image-id a {
color: black;
}
.image-view-layer-element .image-comment {
margin-bottom: 10px;
}
.image-view-layer-element .nondocker-command {
font-family: monospace;
padding: 2px;
}
.image-view-layer-element .nondocker-command:before {
content: "\f120";
font-family: "FontAwesome";
font-size: 16px;
margin-right: 6px;
color: #999;
}
.image-view-layer-element .image-layer-line {
position: absolute;
top: 0px;
bottom: 0px;
left: 140px;
border-left: 2px solid #428bca;
width: 0px;
}
.image-view-layer-element.first .image-layer-line {
top: 20px;
}
.image-view-layer-element.last .image-layer-line {
height: 16px;
}
.image-view-layer-element .image-layer-dot {
position: absolute;
top: 14px;
left: 135px;
border: 2px solid #428bca;
border-radius: 50%;
width: 12px;
height: 12px;
background: white;
z-index: 2;
}
.image-view-layer-element.first .image-layer-dot {
background: #428bca;
}

View file

@ -0,0 +1,8 @@
.prototype-manager-element i.fa {
margin-right: 4px;
}
.prototype-manager-element td {
padding: 10px !important;
vertical-align: middle !important;
}

View file

@ -1,3 +1,15 @@
.repository-permissions-table #add-entity-permission { .repository-permissions-table #add-entity-permission {
padding-left: 0px; padding-left: 0px;
} }
.repository-permissions-table .user-permission-entity {
position: relative;
}
.repository-permissions-table .outside-org {
position: absolute;
top: 15px;
left: -2px;
font-size: 16px;
color: #E8BB03;
}

View file

@ -0,0 +1,86 @@
.robots-manager-element .robot a {
font-size: 16px;
cursor: pointer;
}
.robots-manager-element .robot .prefix {
color: #aaa;
}
.robots-manager-element .robot i {
margin-right: 10px;
}
.robots-manager-element .popup-input-button i.fa {
margin-right: 4px;
}
.robots-manager-element .empty {
color: #ccc;
}
.robots-manager-element tr.open td {
border-bottom: 1px solid transparent;
}
.robots-manager-element .permissions-table-wrapper {
margin-left: 0px;
border-left: 2px solid #ccc;
padding-left: 20px;
}
.robots-manager-element .permissions-table tbody tr:last-child td {
border-bottom: 0px;
}
.robots-manager-element .permissions-display-row {
position: relative;
padding-bottom: 20px;
}
.robots-manager-element .permissions-display-row td:first-child {
min-width: 300px;
}
.robots-manager-element .repo-circle {
color: #999;
display: inline-block;
position: relative;
background: #eee;
padding: 4px;
border-radius: 50%;
display: inline-block;
width: 46px;
height: 46px;
margin-right: 6px;
}
.robots-manager-element .repo-circle .fa-hdd-o {
font-size: 1.7em;
}
.robots-manager-element .repo-circle.no-background .fa-hdd-o {
font-size: 1.7em;
}
.robots-manager-element .repo-circle .fa-lock {
width: 16px;
height: 16px;
line-height: 16px;
font-size: 12px !important;
}
.robots-manager-element .repo-circle.no-background .fa-lock {
bottom: 5px;
right: 2px;
}
.robots-manager-element .member-perm-summary {
margin-right: 14px;
}
.robots-manager-element .co-filter-box {
float: right;
min-width: 175px;
margin-bottom: 10px;
}

View file

@ -0,0 +1,59 @@
.teams-manager .popup-input-button {
float: right;
}
.teams-manager .manager-header {
border-bottom: 1px solid #eee;
margin-bottom: 10px;
}
.teams-manager .cor-options-menu {
display: inline-block;
margin-left: 10px;
}
.teams-manager .header-col .info-icon {
font-size: 16px;
}
.teams-manager .header-col .header-text {
text-transform: uppercase;
font-size: 14px;
color: #aaa !important;
display: inline-block;
padding-top: 4px;
}
.teams-manager .control-col {
padding-top: 6px;
}
.teams-manager .team-listing {
margin-bottom: 10px;
}
.teams-manager .team-listing .avatar {
margin-right: 6px;
}
.teams-manager .team-member-list .fa {
color: #ccc;
}
.teams-manager .team-member-list {
position: relative;
min-height: 20px;
padding: 4px;
padding-left: 40px;
}
.teams-manager .team-member-list .team-member a {
text-decoration: none !important;
}
.teams-manager .team-member-list .team-member-more {
vertical-align: middle;
padding-left: 6px;
color: #aaa;
font-size: 14px;
}

View file

@ -0,0 +1,25 @@
.image-view .image-view-header {
padding: 10px;
background: #e8f1f6;
margin: -10px;
margin-bottom: 20px;
}
.image-view .image-view-header .section-icon {
margin-right: 6px;
}
.image-view .image-view-header .section {
padding: 4px;
display: inline-block;
margin-right: 20px;
}
.image-view .co-tab-content {
padding: 20px;
padding-top: 10px;
}
.image-view .co-tab-content h3 {
margin-bottom: 20px;
}

View file

@ -0,0 +1,4 @@
.new-organization .co-main-content-panel {
padding: 30px;
position: relative;
}

View file

@ -0,0 +1,90 @@
.new-repo .co-main-content-panel {
padding: 30px;
}
.new-repo .namespace-selector-header .slash {
color: #444;
padding-left: 6px;
padding-right: 6px;
}
.new-repo .required-plan {
margin: 10px;
margin-top: 20px;
margin-left: 50px;
}
.new-repo .required-plan .alert {
color: #444 !important;
}
.new-repo .new-header .popover {
font-size: 14px;
}
.new-repo .new-header .repo-circle {
margin-right: 14px;
}
.new-repo .new-header .name-container {
display: inline-block;
width: 300px;
vertical-align: middle;
}
.new-repo .description {
margin-left: 10px;
margin-top: 10px;
}
.new-repo .section {
padding-bottom: 20px;
border-bottom: 1px solid #eee;
margin-bottom: 16px;
}
.new-repo .section-title {
float: right;
color: #aaa;
}
.new-repo .repo-option {
margin: 6px;
margin-top: 16px;
}
.new-repo .repo-option label {
font-weight: normal;
}
.new-repo .repo-option i {
font-size: 18px;
padding-left: 10px;
padding-right: 10px;
width: 42px;
display: inline-block;
text-align: center;
}
.new-repo .option-description {
display: inline-block;
vertical-align: top;
}
.new-repo .option-description label {
display: block;
}
.new-repo .cbox {
margin: 10px;
}
.new-repo .initialize-repo {
margin: 10px;
margin-top: 16px;
margin-left: 20px;
}
.new-repo .initialize-repo .file-drop {
margin: 10px;
}

View file

@ -0,0 +1,26 @@
.org-view .organization-name {
vertical-align: middle;
margin-left: 6px;
}
.org-view h3 {
margin-bottom: 20px;
margin-top: 0px;
}
.org-view .section-description-header {
padding-left: 40px;
position: relative;
margin-bottom: 20px;
min-height: 50px;
}
.org-view .section-description-header:before {
font-family: FontAwesome;
content: "\f05a";
position: absolute;
top: -4px;
left: 6px;
font-size: 27px;
color: #888;
}

View file

@ -24,3 +24,15 @@
.repo-list .namespaces-list li .avatar { .repo-list .namespaces-list li .avatar {
margin-right: 10px; margin-right: 10px;
} }
.repo-list .new-org {
margin-top: 20px !important;
padding-top: 14px;
border-top: 1px solid #eee;
}
.repo-list .new-org i.fa {
width: 30px;
margin-right: 10px;
text-align: center;
}

View file

@ -44,3 +44,7 @@
.repository-view .heading-controls .btn .fa { .repository-view .heading-controls .btn .fa {
margin-right: 6px; margin-right: 6px;
} }
.repository-view .tag-span {
white-space: nowrap;
}

View file

@ -0,0 +1,55 @@
.team-view .co-main-content-panel {
padding: 20px;
}
.team-view .team-name {
vertical-align: middle;
margin-left: 6px;
}
.team-view .team-view-header {
border-bottom: 1px solid #eee;
margin-bottom: 10px;
padding-bottom: 10px;
}
.team-view .team-view-header button i.fa {
margin-right: 4px;
}
.team-view .team-view-header > h3 {
margin-top: 10px;
}
.team-view .team-view-header .popover {
max-width: none !important;
}
.team-view .team-view-header .popover.bottom-right .arrow:after {
border-bottom-color: #f7f7f7;
top: 2px;
}
.team-view .team-view-header .popover-content {
font-size: 14px;
padding-top: 6px;
min-width: 500px;
}
.team-view .team-view-header .popover-content input {
background: white;
}
.team-view .team-view-add-element .help-text {
font-size: 13px;
color: #ccc;
margin-top: 10px;
}
.team-view .co-table-header-row td {
padding-top: 20px !important;
}
.team-view .co-table-header-row:first-child td {
padding-top: 10px !important;
}

View file

@ -0,0 +1,3 @@
.tutorial-view .co-main-content-panel {
padding: 30px;
}

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