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 cd grunt && grunt
ADD conf/init/svlogd_config /svlogd_config
ADD conf/init/doupdatelimits.sh /etc/my_init.d/
ADD conf/init/preplogsdir.sh /etc/my_init.d/
ADD conf/init/copy_syslog_config.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/gunicorn_registry /etc/service/gunicorn_registry
ADD conf/init/gunicorn_verbs /etc/service/gunicorn_verbs
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
ADD conf/init/service/ /etc/service/
RUN rm -rf /etc/service/syslog-forwarder
# Download any external libs.
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_KEY = 'QUAY_OVERRIDE_CONFIG'
LICENSE_FILENAME = 'conf/stack/license.enc'
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])
else:
authenticated = authentication.verify_user(credentials[0], credentials[1])
(authenticated, error_message) = authentication.verify_user(credentials[0], credentials[1],
basic_auth=True)
if authenticated:
logger.debug('Successfully validated user: %s' % authenticated.username)

View file

@ -1,4 +1,5 @@
import hashlib
import math
class Avatar(object):
def __init__(self, app=None):
@ -7,8 +8,7 @@ class Avatar(object):
def _init_app(self, app):
return AVATAR_CLASSES[app.config.get('AVATAR_KIND', 'Gravatar')](
app.config['SERVER_HOSTNAME'],
app.config['PREFERRED_URL_SCHEME'])
app.config['PREFERRED_URL_SCHEME'], app.config['AVATAR_COLORS'], app.config['HTTPCLIENT'])
def __getattr__(self, name):
return getattr(self.state, name, None)
@ -16,48 +16,83 @@ class Avatar(object):
class BaseAvatar(object):
""" Base class for all avatar implementations. """
def __init__(self, server_hostname, preferred_url_scheme):
self.server_hostname = server_hostname
def __init__(self, preferred_url_scheme, colors, http_client):
self.preferred_url_scheme = preferred_url_scheme
self.colors = colors
self.http_client = http_client
def get_url(self, email, size=16, name=None):
""" Returns the full URL for viewing the avatar of the given email address, with
an optional size.
def get_mail_html(self, name, email_or_id, size=16, kind='user'):
""" Returns the full HTML and CSS for viewing the avatar of the given name and email address,
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):
""" Computes the avatar hash for the given email address. If the name is given and a default
avatar is being computed, the name can be used in place of the email address. """
raise NotImplementedError
if url is not None:
# Try to load the gravatar. If we get a non-404 response, then we use it in place of
# the CSS avatar.
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):
""" Avatar system that uses gravatar for generating avatars. """
def compute_hash(self, email, name=None):
email = email or ""
return hashlib.md5(email.strip().lower()).hexdigest()
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)
def _get_url(self, hash_value, size=16):
return '%s://www.gravatar.com/avatar/%s?d=404&size=%s' % (self.preferred_url_scheme,
hash_value, size)
class LocalAvatar(BaseAvatar):
""" Avatar system that uses the local system for generating avatars. """
def compute_hash(self, email, name=None):
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)
pass
AVATAR_CLASSES = {
'gravatar': GravatarAvatar,

View file

@ -157,8 +157,12 @@ class EphemeralBuilderManager(BaseManager):
etcd_host = self._manager_config.get('ETCD_HOST', '127.0.0.1')
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_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'
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,
coreos_channel=coreos_channel,
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 }}
TOKEN={{ token }}
SERVER=wss://{{ manager_hostname }}
{% if logentries_token -%}
LOGENTRIES_TOKEN={{ logentries_token }}
{%- endif %}
coreos:
update:
@ -19,6 +22,17 @@ coreos:
group: {{ coreos_channel }}
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',
'quay.io/coreos/registry-build-worker',
quay_username,
@ -29,3 +43,10 @@ coreos:
flattened=True,
restart_policy='no'
) | 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;
default_type application/octet-stream;
access_log /var/log/nginx/nginx.access.log;
access_log /dev/stdout;
sendfile 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] '
'"$request" $status $body_bytes_sent '
'"$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
pid /tmp/nginx.pid;
error_log /var/log/nginx/nginx.error.log;
error_log /dev/stdout;
worker_processes 2;
worker_priority -10;

View file

@ -1,6 +1,5 @@
# vim: ft=nginx
client_body_temp_path /var/log/nginx/client_body 1 2;
server_name _;
keepalive_timeout 5;
@ -36,7 +35,7 @@ location /v1/repositories/ {
proxy_pass http://registry_app_server;
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;
}
@ -47,7 +46,7 @@ location /v1/ {
proxy_request_buffering off;
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;
}
@ -58,7 +57,7 @@ location /c1/ {
proxy_request_buffering off;
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;
}

View file

@ -45,8 +45,6 @@ class DefaultConfig(object):
PREFERRED_URL_SCHEME = 'http'
SERVER_HOSTNAME = 'localhost:5000'
AVATAR_KIND = 'local'
REGISTRY_TITLE = 'CoreOS Enterprise Registry'
REGISTRY_TITLE_SHORT = 'Enterprise Registry'
@ -165,6 +163,10 @@ class DefaultConfig(object):
# Feature Flag: Whether users can be renamed
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', {})
DISTRIBUTED_STORAGE_CONFIG = {
@ -201,3 +203,11 @@ class DefaultConfig(object):
# Signed registry grant token expiration in seconds
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())
_get_epoch_timestamp = lambda: int(time.time())
get_epoch_timestamp = lambda: int(time.time())
def close_db_filter(_):
@ -167,6 +167,17 @@ class BaseModel(ReadSlaveModel):
database = db
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):
uuid = CharField(default=uuid_generator, max_length=36, null=True)
@ -484,7 +495,7 @@ class RepositoryTag(BaseModel):
name = CharField()
image = ForeignKeyField(Image)
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)
hidden = BooleanField(default=False)
@ -493,6 +504,9 @@ class RepositoryTag(BaseModel):
read_slaves = (read_slave,)
indexes = (
(('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() {
docker kill mysql
docker rm mysql
docker rm -v mysql
}
up_mariadb() {
@ -36,24 +36,24 @@ up_mariadb() {
down_mariadb() {
docker kill mariadb
docker rm mariadb
docker rm -v mariadb
}
up_percona() {
# 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
echo 'Sleeping for 10...'
sleep 10
# 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() {
docker kill percona
docker rm percona
docker rm -v percona
}
up_postgres() {
@ -70,7 +70,7 @@ up_postgres() {
down_postgres() {
docker kill postgres
docker rm postgres
docker rm -v postgres
}
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,
db, BUILD_PHASE, QuayUserField, ImageStorageSignature, QueueItem,
ImageStorageSignatureKind, validate_database_url, db_for_update,
AccessTokenKind, Star)
AccessTokenKind, Star, get_epoch_timestamp)
from peewee import JOIN_LEFT_OUTER, fn
from util.validation import (validate_username, validate_email, validate_password,
INVALID_PASSWORD_MESSAGE)
@ -310,11 +310,54 @@ def _list_entity_robots(entity_name):
.where(User.robot == True, User.username ** (entity_name + '+%')))
def list_entity_robot_tuples(entity_name):
return (_list_entity_robots(entity_name)
.select(User.username, FederatedLogin.service_ident)
.tuples())
class _TupleWrapper(object):
def __init__(self, data, fields):
self._data = data
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):
# Change the user to an organization.
@ -636,6 +679,73 @@ def get_user_or_org_by_customer_id(customer_id):
except User.DoesNotExist:
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):
query = Team.select().where(Team.name ** (team_prefix + '%'),
Team.organization == organization)
@ -654,13 +764,13 @@ def get_matching_users(username_prefix, robot_namespace=None,
(User.robot == True)))
query = (User
.select(User.username, User.robot)
.group_by(User.username, User.robot)
.select(User.username, User.email, User.robot)
.group_by(User.username, User.email, User.robot)
.where(direct_user_query))
if organization:
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(Team, JOIN_LEFT_OUTER, on=((Team.id == TeamMember.team) &
(Team.organization == organization))))
@ -669,9 +779,11 @@ def get_matching_users(username_prefix, robot_namespace=None,
class MatchingUserResult(object):
def __init__(self, *args):
self.username = args[0]
self.is_robot = args[1]
self.email = args[1]
self.robot = args[2]
if organization:
self.is_org_member = (args[2] != None)
self.is_org_member = (args[3] != None)
else:
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,
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,
limit=limit, namespace=namespace,
select_models=[Repository, Namespace, Visibility])
@ -798,6 +910,9 @@ def get_visible_repositories(username=None, include_public=True, page=None,
if limit:
query = query.limit(limit)
if namespace and namespace_only:
query = query.where(Namespace.username == namespace)
return query
@ -876,11 +991,73 @@ def _get_public_repo_visibility():
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
name_term = repo_term
visible = get_visible_repositories(username)
visible = get_visible_repositories(username, include_public=include_public)
search_clauses = (Repository.name ** ('%' + name_term + '%') |
Namespace.username ** ('%' + namespace_term + '%'))
@ -894,8 +1071,7 @@ def get_matching_repositories(repo_term, username=None):
search_clauses = (Repository.name ** ('%' + name_term + '%') &
Namespace.username ** ('%' + namespace_term + '%'))
final = visible.where(search_clauses).limit(10)
return list(final)
return visible.where(search_clauses).limit(limit)
def change_password(user, new_password):
@ -905,6 +1081,7 @@ def change_password(user, new_password):
pw_hash = hash_password(new_password)
user.invalid_login_attempts = 0
user.password_hash = pw_hash
user.uuid = str(uuid4())
user.save()
# 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):
return (RepositoryPermission.select(User.username, User.robot, Role.name, RepositoryPermission)
return (RepositoryPermission.select(User.username, User.email, User.robot, Role.name,
RepositoryPermission)
.join(User)
.switch(RepositoryPermission)
.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)
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) |
(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,
@ -1610,14 +1800,19 @@ def list_repository_tags(namespace_name, repository_name, include_hidden=False,
def _garbage_collect_tags(namespace_name, repository_name):
# We do this without using a join to prevent holding read locks on the repository table
repo = _get_repository(namespace_name, repository_name)
now = int(time.time())
expired_time = get_epoch_timestamp() - repo.namespace_user.removed_tag_expiration_s
(RepositoryTag
.delete()
.where(RepositoryTag.repository == repo,
~(RepositoryTag.lifetime_end_ts >> None),
(RepositoryTag.lifetime_end_ts + repo.namespace_user.removed_tag_expiration_s) <= now)
.execute())
tags_to_delete = list(RepositoryTag
.select(RepositoryTag.id)
.where(RepositoryTag.repository == repo,
~(RepositoryTag.lifetime_end_ts >> None),
(RepositoryTag.lifetime_end_ts <= expired_time))
.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):
@ -1713,46 +1908,39 @@ def _garbage_collect_storage(storage_id_whitelist):
logger.debug('Garbage collecting storages from candidates: %s', storage_id_whitelist)
with config.app_config['DB_TRANSACTION_FACTORY'](db):
# Track all of the data that should be removed from blob storage
placements_to_remove = orphaned_storage_query(ImageStoragePlacement
.select(ImageStoragePlacement,
ImageStorage,
ImageStorageLocation)
.join(ImageStorageLocation)
.switch(ImageStoragePlacement)
.join(ImageStorage),
storage_id_whitelist,
(ImageStorage, ImageStoragePlacement,
ImageStorageLocation))
placements_to_remove = list(orphaned_storage_query(ImageStoragePlacement
.select(ImageStoragePlacement,
ImageStorage,
ImageStorageLocation)
.join(ImageStorageLocation)
.switch(ImageStoragePlacement)
.join(ImageStorage),
storage_id_whitelist,
(ImageStorage, ImageStoragePlacement,
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
placements_subquery = (placements_to_remove
.clone()
.select(ImageStoragePlacement.id)
.alias('ps'))
inner = (ImageStoragePlacement
.select(placements_subquery.c.id)
.from_(placements_subquery))
placements_removed = (ImageStoragePlacement
.delete()
.where(ImageStoragePlacement.id << inner)
.execute())
logger.debug('Removed %s image storage placements', placements_removed)
if len(placements_to_remove) > 0:
placement_ids_to_remove = [placement.id for placement in placements_to_remove]
placements_removed = (ImageStoragePlacement
.delete()
.where(ImageStoragePlacement.id << placement_ids_to_remove)
.execute())
logger.debug('Removed %s image storage placements', placements_removed)
# Remove all orphaned storages
# 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),
storage_id_whitelist,
(ImageStorage.id,)).alias('osq')
orphaned_storage_inner = (ImageStorage
.select(orphaned_storages.c.id)
.from_(orphaned_storages))
storages_removed = (ImageStorage
.delete()
.where(ImageStorage.id << orphaned_storage_inner)
.execute())
logger.debug('Removed %s image storage records', storages_removed)
orphaned_storages = list(orphaned_storage_query(ImageStorage.select(ImageStorage.id),
storage_id_whitelist,
(ImageStorage.id,)).alias('osq'))
if len(orphaned_storages) > 0:
storages_removed = (ImageStorage
.delete()
.where(ImageStorage.id << orphaned_storages)
.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
# 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,
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):
try:
repo = _get_repository(namespace_name, repository_name)
except Repository.DoesNotExist:
raise DataModelException('Invalid repository %s/%s' % (namespace_name, repository_name))
tag = db_for_update(_tag_alive(RepositoryTag
.select()
.where(RepositoryTag.repository == repo,
RepositoryTag.name == tag_name), now_ts)).get()
tag.lifetime_end_ts = now_ts
tag.save()
except RepositoryTag.DoesNotExist:
pass
try:
image = Image.get(Image.docker_image_id == tag_docker_image_id, Image.repository == repo)
except Image.DoesNotExist:
raise DataModelException('Invalid image with id: %s' % tag_docker_image_id)
now_ts = int(time.time())
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
return RepositoryTag.create(repository=repo, image=image, name=tag_name,
lifetime_start_ts=now_ts)
def delete_tag(namespace_name, repository_name, tag_name):
now_ts = get_epoch_timestamp()
with config.app_config['DB_TRANSACTION_FACTORY'](db):
try:
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))
.where(Repository.name == repository_name,
Namespace.username == namespace_name,
RepositoryTag.name == tag_name))
RepositoryTag.name == tag_name), now_ts)
found = db_for_update(query).get()
except RepositoryTag.DoesNotExist:
msg = ('Invalid repository tag \'%s\' on repository \'%s/%s\'' %
(tag_name, namespace_name, repository_name))
raise DataModelException(msg)
found.lifetime_end_ts = int(time.time())
found.lifetime_end_ts = now_ts
found.save()
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
of the temporary tag. """
now_ts = int(time.time())
now_ts = get_epoch_timestamp()
expire_ts = now_ts + expiration_s
tag_name = str(uuid4())
RepositoryTag.create(repository=repo, image=image, name=tag_name, lifetime_start_ts=now_ts,

View file

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

View file

@ -1,6 +1,11 @@
import ldap
import logging
import json
import itertools
import uuid
import struct
from util.aes import AESCipher
from util.validation import generate_valid_usernames
from data import model
@ -106,6 +111,7 @@ class LDAPUsers(object):
return found_user is not None
class UserAuthentication(object):
def __init__(self, app=None):
self.app = app
@ -138,5 +144,81 @@ class UserAuthentication(object):
app.extensions['authentication'] = 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):
return getattr(self.state, name, None)

View file

@ -4,7 +4,7 @@
<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>

View file

@ -9,7 +9,7 @@ from data import model
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
if image.storage and image.storage.id:
extended_props = image.storage
@ -20,24 +20,35 @@ def image_view(image, image_map):
if not aid or not aid in image_map:
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.
ancestors = [docker_id(a) for a in image.ancestors.split('/')]
ancestors_string = '/'.join(ancestors)
return {
image_data = {
'id': image.docker_image_id,
'created': format_date(extended_props.created),
'comment': extended_props.comment,
'command': json.loads(command) if command else None,
'size': extended_props.image_size,
'locations': list(image.storage.locations),
'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/')
@path_param('repository', 'The full path of the repository. e.g. namespace/name')
@ -62,7 +73,7 @@ class RepositoryImageList(RepositoryParamResource):
filtered_images = []
for image in all_images:
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)
def add_tags(image_json):
@ -90,9 +101,9 @@ class RepositoryImage(RepositoryParamResource):
# Lookup all the ancestor images for the image.
image_map = {}
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')

View file

@ -24,16 +24,22 @@ logger = logging.getLogger(__name__)
def org_view(o, teams):
admin_org = AdministerOrganizationPermission(o.username)
is_admin = admin_org.can()
is_admin = AdministerOrganizationPermission(o.username).can()
is_member = OrganizationMemberPermission(o.username).can()
view = {
'name': o.username,
'email': o.email if is_admin else '',
'avatar': avatar.compute_hash(o.email, name=o.username),
'teams': {t.name : team_view(o.username, t) for t in teams},
'is_admin': is_admin
'avatar': avatar.get_data_for_user(o),
'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:
view['invoice_email'] = o.invoice_email
@ -129,17 +135,17 @@ class Organization(ApiResource):
@nickname('getOrganization')
def get(self, orgname):
""" Get the details for the specified organization """
permission = OrganizationMemberPermission(orgname)
if permission.can():
try:
org = model.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
try:
org = model.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
teams = None
if OrganizationMemberPermission(orgname).can():
teams = model.get_teams_within_org(org)
return org_view(org, teams)
raise Unauthorized()
return org_view(org, teams)
@require_scope(scopes.ORG_ADMIN)
@nickname('changeOrganizationDetails')
@ -218,7 +224,7 @@ class OrgPrivateRepositories(ApiResource):
@path_param('orgname', 'The name of the organization')
class OrgnaizationMemberList(ApiResource):
""" Resource for listing the members of an organization. """
@require_scope(scopes.ORG_ADMIN)
@nickname('getOrganizationMembers')
def get(self, orgname):
@ -297,16 +303,14 @@ class ApplicationInformation(ApiResource):
if not application:
raise NotFound()
org_hash = avatar.compute_hash(application.organization.email,
name=application.organization.username)
app_hash = (avatar.compute_hash(application.avatar_email, name=application.name) if
application.avatar_email else org_hash)
app_email = application.avatar_email or application.organization.email
app_data = avatar.get_data(application.name, app_email, 'app')
return {
'name': application.name,
'description': application.description,
'uri': application.application_uri,
'avatar': app_hash,
'avatar': app_data,
'organization': org_view(application.organization, [])
}

View file

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

View file

@ -7,6 +7,7 @@ from auth.permissions import AdministerOrganizationPermission
from auth.auth_context import get_authenticated_user
from auth import scopes
from data import model
from app import avatar
def prototype_view(proto, org_members):
@ -16,6 +17,7 @@ def prototype_view(proto, org_members):
'is_robot': user.robot,
'kind': 'user',
'is_org_member': user.robot or user.username in org_members,
'avatar': avatar.get_data_for_user(user)
}
if proto.delegate_user:
@ -24,6 +26,7 @@ def prototype_view(proto, org_members):
delegate_view = {
'name': proto.delegate_team.name,
'kind': 'team',
'avatar': avatar.get_data_for_team(proto.delegate_team)
}
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('count', 'Whether to include a count of the total number of results available.',
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):
"""Fetch the list of repositories under a variety of situations."""
username = None
@ -129,7 +131,8 @@ class RepositoryList(ApiResource):
repo_query = model.get_visible_repositories(username, limit=args['limit'], page=args['page'],
include_public=args['public'], sort=args['sort'],
namespace=args['namespace'])
namespace=args['namespace'],
namespace_only=args['namespace_only'])
def repo_view(repo_obj):
repo = {
'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 import scopes
from data import model
from data.database import User, Team, Repository, FederatedLogin
from util.names import format_robot_username
from flask import abort
from app import avatar
def robot_view(name, token):
return {
'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')
@internal_only
class UserRobotList(ApiResource):
@ -24,10 +71,7 @@ class UserRobotList(ApiResource):
def get(self):
""" List the available robots for the user. """
user = get_authenticated_user()
robots = model.list_entity_robot_tuples(user.username)
return {
'robots': [robot_view(name, password) for name, password in robots]
}
return robots_list(user.username)
@resource('/v1/user/robots/<robot_shortname>')
@ -73,10 +117,7 @@ class OrgRobotList(ApiResource):
""" List the organization's robots. """
permission = OrganizationMemberPermission(orgname)
if permission.can():
robots = model.list_entity_robot_tuples(orgname)
return {
'robots': [robot_view(name, password) for name, password in robots]
}
return robots_list(orgname)
raise Unauthorized()
@ -125,6 +166,47 @@ class OrgRobot(ApiResource):
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')
@path_param('robot_shortname', 'The short name for the robot, without any user or organization prefix')
@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 auth.permissions import (OrganizationMemberPermission, ViewTeamPermission,
ReadRepositoryPermission, UserAdminPermission,
AdministerOrganizationPermission)
AdministerOrganizationPermission, ReadRepositoryPermission)
from auth.auth_context import get_authenticated_user
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>')
class EntitySearch(ApiResource):
@ -45,7 +49,7 @@ class EntitySearch(ApiResource):
'name': namespace_name,
'kind': 'org',
'is_org_member': True,
'avatar': avatar.compute_hash(organization.email, name=organization.username),
'avatar': avatar.get_data_for_org(organization),
}]
except model.InvalidOrganizationException:
@ -63,7 +67,8 @@ class EntitySearch(ApiResource):
result = {
'name': team.name,
'kind': 'team',
'is_org_member': True
'is_org_member': True,
'avatar': avatar.get_data_for_team(team)
}
return result
@ -71,11 +76,12 @@ class EntitySearch(ApiResource):
user_json = {
'name': user.username,
'kind': 'user',
'is_robot': user.is_robot,
'is_robot': user.robot,
'avatar': avatar.get_data_for_user(user)
}
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
@ -128,3 +134,154 @@ class FindRepositories(ApiResource):
if (repo.visibility.name == 'public' or
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 data.database import User
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 data.runmigration import run_alembic_migration

View file

@ -108,7 +108,7 @@ def user_view(user):
'username': user.username,
'email': user.email,
'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)
}

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,
RepositoryParamResource, log_action, NotFound, validate_json_request,
path_param)
path_param, format_date)
from endpoints.api.image import image_view
from data import model
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>')
@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)
image_map = {}
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.reverse()

View file

@ -52,11 +52,11 @@ def team_view(orgname, team):
view_permission = ViewTeamPermission(orgname, team.name)
role = model.get_team_org_role(team).name
return {
'id': team.id,
'name': team.name,
'description': team.description,
'can_view': view_permission.can(),
'role': role
'role': role,
'avatar': avatar.get_data_for_team(team)
}
def member_view(member, invited=False):
@ -64,7 +64,7 @@ def member_view(member, invited=False):
'name': member.username,
'kind': 'user',
'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,
}
@ -76,7 +76,7 @@ def invite_view(invite):
return {
'email': invite.email,
'kind': 'invite',
'avatar': avatar.compute_hash(invite.email),
'avatar': avatar.get_data(invite.email, invite.email, 'user'),
'invited': True
}

View file

@ -1,7 +1,8 @@
import logging
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.principal import identity_changed, AnonymousIdentity
from peewee import IntegrityError
@ -35,7 +36,7 @@ def user_view(user):
admin_org = AdministerOrganizationPermission(o.username)
return {
'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(),
'can_create_repo': admin_org.can() or CreateRepositoryPermission(o.username).can(),
'preferred_namespace': not (o.stripe_id is None)
@ -58,15 +59,16 @@ def user_view(user):
logins = model.list_federated_logins(user)
user_response = {
'verified': user.verified,
'anonymous': False,
'username': user.username,
'avatar': avatar.compute_hash(user.email, name=user.username),
'avatar': avatar.get_data_for_user(user)
}
user_admin = UserAdminPermission(user.username)
if user_admin.can():
user_response.update({
'is_me': True,
'verified': user.verified,
'email': user.email,
'organizations': [org_view(o) for o in organizations],
'logins': [login_view(login) for login in logins],
@ -76,7 +78,7 @@ def user_view(user):
'tag_expiration': user.removed_tag_expiration_s,
})
if features.SUPER_USERS:
if features.SUPER_USERS and SuperUserPermission().can():
user_response.update({
'super_user': user and user == get_authenticated_user() and SuperUserPermission().can()
})
@ -175,8 +177,8 @@ class User(ApiResource):
'description': 'The user\'s email address',
},
'avatar': {
'type': 'string',
'description': 'Avatar hash representing the user\'s icon'
'type': 'object',
'description': 'Avatar data representing the user\'s icon'
},
'organizations': {
'type': 'array',
@ -224,8 +226,13 @@ class User(ApiResource):
if 'password' in user_data:
logger.debug('Changing password for user: %s', user.username)
log_action('account_change_password', user.username)
# Change the user's password.
model.change_password(user, user_data['password'])
# Login again to reset their session cookie.
common_login(user)
if features.MAILING:
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):
needs_email_verification = False
invalid_credentials = False
verified = None
try:
verified = authentication.verify_user(username_or_email, password)
(verified, error_message) = authentication.verify_user(username_or_email, password)
except model.TooManyUsersException as ex:
raise license_error(exception=ex)
@ -407,7 +452,7 @@ class ConvertToOrganization(ApiResource):
# Ensure that the sign in credentials work.
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:
raise request_error(reason='invaliduser',
message='The admin user credentials are not valid')
@ -621,17 +666,16 @@ class UserNotification(ApiResource):
def authorization_view(access_token):
oauth_app = access_token.application
app_email = oauth_app.avatar_email or oauth_app.organization.email
return {
'application': {
'name': oauth_app.name,
'description': oauth_app.description,
'url': oauth_app.application_uri,
'avatar': avatar.compute_hash(oauth_app.avatar_email or oauth_app.organization.email,
name=oauth_app.name),
'avatar': avatar.get_data(oauth_app.name, app_email, 'app'),
'organization': {
'name': oauth_app.organization.username,
'avatar': avatar.compute_hash(oauth_app.organization.email,
name=oauth_app.organization.username)
'avatar': avatar.get_data_for_org(oauth_app.organization)
}
},
'scopes': scopes.get_scope_information(access_token.scope),
@ -745,6 +789,7 @@ class StarredRepositoryList(ApiResource):
'repository': repository,
}, 201
@resource('/v1/user/starred/<repopath:repository>')
@path_param('repository', 'The full path of the repository. e.g. namespace/name')
class StarredRepository(RepositoryParamResource):
@ -759,3 +804,17 @@ class StarredRepository(RepositoryParamResource):
if repo:
model.unstar_repository(user, repo)
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:
return render_ologin_error('GitHub', error)
# Exchange the OAuth code.
token = exchange_code_for_token(request.args.get('code'), github_login)
# Retrieve the user's information.
user_data = get_user(github_login, token)
if not user_data or not 'login' in user_data:
return render_ologin_error('GitHub')
@ -172,16 +175,35 @@ def github_oauth_callback():
token_param = {
'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,
headers=v3_media_type)
# We will accept any email, but we prefer the primary
found_email = None
for user_email in get_email.json():
if not github_login.is_enterprise() and not user_email['verified']:
continue
found_email = user_email['email']
if user_email['primary']:
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 = {
'service_username': username
}

View file

@ -4,6 +4,7 @@ import json
from flask import make_response
from app import app
from util.useremails import CannotSendEmailException
from util.config.provider import CannotWriteConfigException
from data import model
logger = logging.getLogger(__name__)
@ -17,3 +18,11 @@ def handle_dme(ex):
def handle_emailexception(ex):
message = 'Could not send email. Please contact an administrator and report this problem.'
return make_response(json.dumps({'message': message}), 400)
@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')
if authentication.user_exists(username):
verified = authentication.verify_user(username, password)
(verified, error_message) = authentication.verify_user(username, password, basic_auth=True)
if verified:
# Mark that the user was logged in.
event = userevents.get_event(username)
event.publish_event_data('docker-cli', {'action': 'login'})
return success
else:
# Mark that the login failed.
event = userevents.get_event(username)
event.publish_event_data('docker-cli', {'action': 'loginfailure'})
abort(400, 'Invalid password.', issue='login-failure')
abort(400, error_message, issue='login-failure')
elif not features.USER_CREATION:
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,
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)
@ -248,20 +256,6 @@ def update_images(namespace, repository):
# Make sure the repo actually exists.
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')
model.garbage_collect_repository(namespace, repository)
@ -272,6 +266,7 @@ def update_images(namespace, repository):
event_data = {
'updated_tags': updated_tags,
}
track_and_log('push_repo', repo)
spawn_notification(repo, 'repo_push', event_data)
return make_response('Updated', 204)

View file

@ -3,7 +3,6 @@ import logging
from flask import (abort, redirect, request, url_for, make_response, Response,
Blueprint, send_from_directory, jsonify, send_file)
from avatar_generator import Avatar
from flask.ext.login import current_user
from urlparse import urlparse
from health.healthcheck import get_healthchecker
@ -39,7 +38,6 @@ STATUS_TAGS = app.config['STATUS_TAGS']
@web.route('/', methods=['GET'], defaults={'path': ''})
@web.route('/organization/<path:path>', methods=['GET'])
@no_cache
def index(path, **kwargs):
return render_page_template('index.html', **kwargs)
@ -50,6 +48,18 @@ def internal_error_display():
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/<path:path>', methods=['GET'])
@ -210,20 +220,6 @@ def endtoend_health():
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'])
@no_cache
def tos():
@ -248,21 +244,6 @@ def robots():
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'])
@route_show_if(features.BILLING)
@require_session_login
@ -449,15 +430,16 @@ def request_authorization_code():
# Load the application information.
oauth_app = provider.get_application_for_client_id(client_id)
app_email = oauth_app.email or organization.email
oauth_app_view = {
'name': oauth_app.name,
'description': oauth_app.description,
'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': {
'name': oauth_app.organization.username,
'avatar': avatar.compute_hash(oauth_app.organization.email,
name=oauth_app.organization.username)
'avatar': avatar.get_data_for_org(oauth_app.organization)
}
}
@ -533,3 +515,31 @@ def attach_custom_build_trigger(namespace, repository_name):
return redirect(full_url)
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/bootstrap/3.3.2/css/bootstrap.min.css',
'fonts.googleapis.com/css?family=Source+Sans+Pro:400,700',
's3.amazonaws.com/cdn.core-os.net/icons/core-icons.css'
]
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.ttf?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]
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
# 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,
new_image.docker_image_id)
tag_map[tag_name] = tag
for tag_name in last_node_tags:
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()
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,
@ -127,9 +133,9 @@ def __generate_repository(user, name, description, is_public, permissions,
if isinstance(structure, list):
for s in structure:
__create_subtree(repo, s, user.username, None)
__create_subtree(repo, s, user.username, None, {})
else:
__create_subtree(repo, structure, user.username, None)
__create_subtree(repo, structure, user.username, None, {})
return repo

View file

@ -3,6 +3,9 @@ import logging.config
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.tags import tags
from endpoints.registry import registry

View file

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

View file

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

View file

@ -278,6 +278,15 @@
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 {
width: 350px;
}
@ -764,10 +773,17 @@
padding: 10px;
}
.co-table.no-lines td {
border-bottom: 0px;
padding: 6px;
}
.co-table thead td {
color: #999;
font-size: 90%;
text-transform: uppercase;
font-size: 16px;
color: #666;
font-weight: 300;
padding-top: 0px !important;
}
.co-table thead td a {
@ -804,11 +820,45 @@
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 {
padding-top: 10px;
border-top: 2px solid #eee;
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 {
display: inline-block;
}
@ -910,3 +960,78 @@
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 {
border-right: 2px solid #eee;
}

View file

@ -63,4 +63,136 @@
.repo-panel-tags-element .options-col {
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;
}
.build-mini-status a {
text-decoration: none !important;
color: black;
}
.build-mini-status .timing {
display: inline-block;
margin-left: 30px;
@ -29,5 +34,15 @@
bottom: 4px;
line-height: 33px;
overflow: hidden;
}
.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 {
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 {
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 {
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