Merge branch 'phase1-11-07-2015' of github.com:coreos-inc/quay into phase1-11-07-2015
This commit is contained in:
commit
8019994462
57 changed files with 1450 additions and 466 deletions
75
ROADMAP.md
75
ROADMAP.md
|
@ -1,38 +1,61 @@
|
||||||
# Quay Roadmap
|
# Quay Roadmap
|
||||||
|
|
||||||
**work in progress**
|
### Sprint 11/4 - 11/18 DockerCon
|
||||||
|
- Launch Registry v2.0 API
|
||||||
|
- Launch Quay-Sec
|
||||||
|
|
||||||
### Short Term
|
### Sprint 11/18 - 12/1 Builds (Planned 8 Days)
|
||||||
- Framework for microservice decomposition
|
- Move build traffic to Packet
|
||||||
- Improve documentation
|
- Preliminary tests reduce build start latency from 2 minutes to 20 seconds
|
||||||
- Ability to answer 80% of tickets with a link to the docs
|
- Multi-step builds
|
||||||
- Eliminate old UI screenshots/references
|
- build artifact
|
||||||
- Auth provider as a service
|
- bundle artifact
|
||||||
- Registry v2 compatible
|
- test bundle
|
||||||
|
- Docker Notary
|
||||||
|
- Support signed images with a known key
|
||||||
|
- Give thanks
|
||||||
|
|
||||||
### Medium Term
|
### Sprint 12/2 - 12/15 eBay Labels (Planned)
|
||||||
- Registry v2 support
|
- Labels
|
||||||
- Forward and backward compatible with registry v1
|
- Support for Midas Package Manager-like distribution
|
||||||
- Support ACI push spec
|
- Integrated with Docker labels
|
||||||
- Translate between ACI and docker images transparently
|
- Mutable and immutable
|
||||||
- Integrate docs with the search bar
|
- Searchable and fleshed out API
|
||||||
- Full text search?
|
- Integrate with tectonic.com sales pipeline
|
||||||
- Running on top of Tectonic
|
- Mirror Quay customers in tectonic (SVOC)?
|
||||||
- BitTorrent distribution support
|
- Callbacks to inform tectonic about quay events
|
||||||
- Fully launch our API
|
- Accept and apply QE licenses to the stack
|
||||||
|
|
||||||
|
### Sprint 12/16 - 12/29 Distribution (Planned 8 Days)
|
||||||
|
- Tectonic care and feeding
|
||||||
|
- Build tools to give us a concrete/declarative cluster deploy story
|
||||||
|
- Build a tool to migrate an app between tectonic clusters
|
||||||
|
- Assess the feasibility of upgrading a running cluster
|
||||||
|
- Geo distribution through tectonic
|
||||||
|
- Spin up a tectonic cluster in another region
|
||||||
|
- Modify registry to run standalone on a tectonic cluster
|
||||||
|
- Read available Quay.io
|
||||||
|
- Ability to choose uptime of data-plane auditability
|
||||||
|
|
||||||
|
### Sprint 12/30 - 1/12 (Planned 8 Days)
|
||||||
|
- Launch our API GA
|
||||||
- Versioned and backward compatible
|
- Versioned and backward compatible
|
||||||
- Adequate documentation
|
- Adequate documentation
|
||||||
|
|
||||||
### Long Term
|
### Unallocated
|
||||||
- Become the Tectonic app store
|
|
||||||
- Pods/apps as top level concept
|
|
||||||
- Builds as top level concept
|
- Builds as top level concept
|
||||||
- Multiple Quay.io repos from a single git push
|
- Multiple Quay.io repos from a single git push
|
||||||
- Multi-step builds
|
- Become the Tectonic app store
|
||||||
- build artifact
|
- Pods/apps as top level concept
|
||||||
- bundle artifact
|
- Distribution tool
|
||||||
- test bundle
|
- Help people to get their apps from quay to Tectonic
|
||||||
|
- Requires App manifest or adequate flexibility
|
||||||
|
- AppC support
|
||||||
|
- rkt push
|
||||||
|
- discovery
|
||||||
- Immediately consistent multi-region data availability
|
- Immediately consistent multi-region data availability
|
||||||
- Cockroach?
|
- Cockroach?
|
||||||
- 2 factor auth
|
- 2 factor auth
|
||||||
- How to integrate with Docker CLI?
|
- How to integrate with Docker CLI?
|
||||||
|
- Mirroring from another registry
|
||||||
|
- Mirroring to a dependent registry
|
||||||
|
|
2
app.py
2
app.py
|
@ -35,6 +35,7 @@ from util.saas.metricqueue import MetricQueue
|
||||||
from util.config.provider import get_config_provider
|
from util.config.provider import get_config_provider
|
||||||
from util.config.configutil import generate_secret_key
|
from util.config.configutil import generate_secret_key
|
||||||
from util.config.superusermanager import SuperUserManager
|
from util.config.superusermanager import SuperUserManager
|
||||||
|
from util.secscan.secscanendpoint import SecurityScanEndpoint
|
||||||
|
|
||||||
OVERRIDE_CONFIG_DIRECTORY = 'conf/stack/'
|
OVERRIDE_CONFIG_DIRECTORY = 'conf/stack/'
|
||||||
OVERRIDE_CONFIG_YAML_FILENAME = 'conf/stack/config.yaml'
|
OVERRIDE_CONFIG_YAML_FILENAME = 'conf/stack/config.yaml'
|
||||||
|
@ -147,6 +148,7 @@ image_replication_queue = WorkQueue(app.config['REPLICATION_QUEUE_NAME'], tf)
|
||||||
dockerfile_build_queue = WorkQueue(app.config['DOCKERFILE_BUILD_QUEUE_NAME'], tf,
|
dockerfile_build_queue = WorkQueue(app.config['DOCKERFILE_BUILD_QUEUE_NAME'], tf,
|
||||||
reporter=MetricQueueReporter(metric_queue))
|
reporter=MetricQueueReporter(metric_queue))
|
||||||
notification_queue = WorkQueue(app.config['NOTIFICATION_QUEUE_NAME'], tf)
|
notification_queue = WorkQueue(app.config['NOTIFICATION_QUEUE_NAME'], tf)
|
||||||
|
secscan_endpoint = SecurityScanEndpoint(app, config_provider)
|
||||||
|
|
||||||
database.configure(app.config)
|
database.configure(app.config)
|
||||||
model.config.app_config = app.config
|
model.config.app_config = app.config
|
||||||
|
|
23
build.sh
23
build.sh
|
@ -1,4 +1,23 @@
|
||||||
TAG=$(git rev-parse --short HEAD)$(test -n "$(git status --porcelain)" && echo -dirty)
|
#!/usr/bin/env bash
|
||||||
REPO=quay.io/quay/quay:$TAG
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
|
echo 'dirty build not supported' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# get named head (ex: branch, tag, etc..)
|
||||||
|
NAME="$( git rev-parse --abbrev-ref HEAD )"
|
||||||
|
|
||||||
|
# get 7-character sha
|
||||||
|
SHA=$( git rev-parse --short HEAD )
|
||||||
|
|
||||||
|
# checkout commit so .git/HEAD points to full sha (used in Dockerfile)
|
||||||
|
git checkout $SHA
|
||||||
|
|
||||||
|
REPO=quay.io/quay/quay:$SHA
|
||||||
docker build -t $REPO .
|
docker build -t $REPO .
|
||||||
echo $REPO
|
echo $REPO
|
||||||
|
|
||||||
|
git checkout "$NAME"
|
||||||
|
|
2
conf/init/service/securityworker/log/run
Normal file
2
conf/init/service/securityworker/log/run
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
#!/bin/sh
|
||||||
|
exec logger -i -t securityworker
|
8
conf/init/service/securityworker/run
Normal file
8
conf/init/service/securityworker/run
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
#! /bin/bash
|
||||||
|
|
||||||
|
echo 'Starting security scanner worker'
|
||||||
|
|
||||||
|
cd /
|
||||||
|
venv/bin/python -m workers.securityworker 2>&1
|
||||||
|
|
||||||
|
echo 'Security scanner worker exited'
|
|
@ -63,6 +63,27 @@ location /v1/ {
|
||||||
client_max_body_size 20G;
|
client_max_body_size 20G;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /v1/_ping {
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
add_header X-Docker-Registry-Version 0.6.0;
|
||||||
|
add_header X-Docker-Registry-Standalone 0;
|
||||||
|
return 200 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
location /v2/ {
|
||||||
|
proxy_buffering off;
|
||||||
|
|
||||||
|
proxy_request_buffering off;
|
||||||
|
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
|
||||||
|
proxy_pass http://registry_app_server;
|
||||||
|
proxy_temp_path /tmp 1 2;
|
||||||
|
|
||||||
|
client_max_body_size 20G;
|
||||||
|
}
|
||||||
|
|
||||||
location /c1/ {
|
location /c1/ {
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
|
|
||||||
|
@ -80,13 +101,6 @@ location /static/ {
|
||||||
error_page 404 /404;
|
error_page 404 /404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /v1/_ping {
|
|
||||||
add_header Content-Type text/plain;
|
|
||||||
add_header X-Docker-Registry-Version 0.6.0;
|
|
||||||
add_header X-Docker-Registry-Standalone 0;
|
|
||||||
return 200 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ ^/b1/controller(/?)(.*) {
|
location ~ ^/b1/controller(/?)(.*) {
|
||||||
proxy_pass http://build_manager_controller_server/$2;
|
proxy_pass http://build_manager_controller_server/$2;
|
||||||
}
|
}
|
||||||
|
|
|
@ -250,3 +250,12 @@ class DefaultConfig(object):
|
||||||
|
|
||||||
# Experiment: Async garbage collection
|
# Experiment: Async garbage collection
|
||||||
EXP_ASYNC_GARBAGE_COLLECTION = []
|
EXP_ASYNC_GARBAGE_COLLECTION = []
|
||||||
|
|
||||||
|
# Security scanner
|
||||||
|
FEATURE_SECURITY_SCANNER = False
|
||||||
|
SECURITY_SCANNER = {
|
||||||
|
'ENDPOINT': 'http://192.168.99.100:6060',
|
||||||
|
'ENGINE_VERSION_TARGET': 1,
|
||||||
|
'API_VERSION': 'v1',
|
||||||
|
'API_TIMEOUT_SECONDS': 10,
|
||||||
|
}
|
||||||
|
|
|
@ -484,11 +484,12 @@ class EmailConfirmation(BaseModel):
|
||||||
|
|
||||||
class ImageStorage(BaseModel):
|
class ImageStorage(BaseModel):
|
||||||
uuid = CharField(default=uuid_generator, index=True, unique=True)
|
uuid = CharField(default=uuid_generator, index=True, unique=True)
|
||||||
checksum = CharField(null=True)
|
checksum = CharField(null=True) # TODO remove when all checksums have been moved back to Image
|
||||||
image_size = BigIntegerField(null=True)
|
image_size = BigIntegerField(null=True)
|
||||||
uncompressed_size = BigIntegerField(null=True)
|
uncompressed_size = BigIntegerField(null=True)
|
||||||
uploading = BooleanField(default=True, null=True)
|
uploading = BooleanField(default=True, null=True)
|
||||||
cas_path = BooleanField(default=True)
|
cas_path = BooleanField(default=True)
|
||||||
|
content_checksum = CharField(null=True, index=True)
|
||||||
|
|
||||||
|
|
||||||
class ImageStorageTransformation(BaseModel):
|
class ImageStorageTransformation(BaseModel):
|
||||||
|
@ -570,6 +571,11 @@ class Image(BaseModel):
|
||||||
command = TextField(null=True)
|
command = TextField(null=True)
|
||||||
aggregate_size = BigIntegerField(null=True)
|
aggregate_size = BigIntegerField(null=True)
|
||||||
v1_json_metadata = TextField(null=True)
|
v1_json_metadata = TextField(null=True)
|
||||||
|
v1_checksum = CharField(null=True)
|
||||||
|
|
||||||
|
security_indexed = BooleanField(default=False)
|
||||||
|
security_indexed_engine = IntegerField(default=-1)
|
||||||
|
parent = ForeignKeyField('self', index=True, null=True, related_name='children')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
database = db
|
database = db
|
||||||
|
@ -577,6 +583,8 @@ class Image(BaseModel):
|
||||||
indexes = (
|
indexes = (
|
||||||
# we don't really want duplicates
|
# we don't really want duplicates
|
||||||
(('repository', 'docker_image_id'), True),
|
(('repository', 'docker_image_id'), True),
|
||||||
|
|
||||||
|
(('security_indexed_engine', 'security_indexed'), False),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -743,6 +751,7 @@ class RepositoryNotification(BaseModel):
|
||||||
method = ForeignKeyField(ExternalNotificationMethod)
|
method = ForeignKeyField(ExternalNotificationMethod)
|
||||||
title = CharField(null=True)
|
title = CharField(null=True)
|
||||||
config_json = TextField()
|
config_json = TextField()
|
||||||
|
event_config_json = TextField(default='{}')
|
||||||
|
|
||||||
|
|
||||||
class RepositoryAuthorizedEmail(BaseModel):
|
class RepositoryAuthorizedEmail(BaseModel):
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
"""Separate v1 and v2 checksums.
|
||||||
|
|
||||||
|
Revision ID: 2827d36939e4
|
||||||
|
Revises: 73669db7e12
|
||||||
|
Create Date: 2015-11-04 16:29:48.905775
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '2827d36939e4'
|
||||||
|
down_revision = '5cdc2d819c5'
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade(tables):
|
||||||
|
### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('image', sa.Column('v1_checksum', sa.String(length=255), nullable=True))
|
||||||
|
op.add_column('imagestorage', sa.Column('content_checksum', sa.String(length=255), nullable=True))
|
||||||
|
op.create_index('imagestorage_content_checksum', 'imagestorage', ['content_checksum'], unique=False)
|
||||||
|
### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade(tables):
|
||||||
|
### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index('imagestorage_content_checksum', table_name='imagestorage')
|
||||||
|
op.drop_column('imagestorage', 'content_checksum')
|
||||||
|
op.drop_column('image', 'v1_checksum')
|
||||||
|
### end Alembic commands ###
|
|
@ -0,0 +1,27 @@
|
||||||
|
"""Add event-specific config
|
||||||
|
|
||||||
|
Revision ID: 50925110da8c
|
||||||
|
Revises: 2fb9492c20cc
|
||||||
|
Create Date: 2015-10-13 18:03:14.859839
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '50925110da8c'
|
||||||
|
down_revision = '57dad559ff2d'
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from util.migrate import UTF8LongText
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade(tables):
|
||||||
|
### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('repositorynotification', sa.Column('event_config_json', UTF8LongText, nullable=False))
|
||||||
|
### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade(tables):
|
||||||
|
### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('repositorynotification', 'event_config_json')
|
||||||
|
### end Alembic commands ###
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""add support for quay's security indexer
|
||||||
|
Revision ID: 57dad559ff2d
|
||||||
|
Revises: 154f2befdfbe
|
||||||
|
Create Date: 2015-07-13 16:51:41.669249
|
||||||
|
"""
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '57dad559ff2d'
|
||||||
|
down_revision = '73669db7e12'
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
def upgrade(tables):
|
||||||
|
### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('image', sa.Column('parent_id', sa.Integer(), nullable=True))
|
||||||
|
op.add_column('image', sa.Column('security_indexed', sa.Boolean(), nullable=False, default=False, server_default=sa.sql.expression.false()))
|
||||||
|
op.add_column('image', sa.Column('security_indexed_engine', sa.Integer(), nullable=False, default=-1, server_default="-1"))
|
||||||
|
op.create_index('image_parent_id', 'image', ['parent_id'], unique=False)
|
||||||
|
op.create_foreign_key(op.f('fk_image_parent_id_image'), 'image', 'image', ['parent_id'], ['id'])
|
||||||
|
### end Alembic commands ###
|
||||||
|
op.create_index('image_security_indexed_engine_security_indexed', 'image', ['security_indexed_engine', 'security_indexed'])
|
||||||
|
|
||||||
|
def downgrade(tables):
|
||||||
|
### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index('image_security_indexed_engine_security_indexed', 'image')
|
||||||
|
op.drop_constraint(op.f('fk_image_parent_id_image'), 'image', type_='foreignkey')
|
||||||
|
op.drop_index('image_parent_id', table_name='image')
|
||||||
|
op.drop_column('image', 'security_indexed')
|
||||||
|
op.drop_column('image', 'security_indexed_engine')
|
||||||
|
op.drop_column('image', 'parent_id')
|
||||||
|
### end Alembic commands ###
|
|
@ -0,0 +1,41 @@
|
||||||
|
"""Add vulnerability_found event
|
||||||
|
|
||||||
|
Revision ID: 5cdc2d819c5
|
||||||
|
Revises: 50925110da8c
|
||||||
|
Create Date: 2015-10-13 18:05:32.157858
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '5cdc2d819c5'
|
||||||
|
down_revision = '50925110da8c'
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade(tables):
|
||||||
|
op.bulk_insert(tables.externalnotificationevent,
|
||||||
|
[
|
||||||
|
{'id':6, 'name':'vulnerability_found'},
|
||||||
|
])
|
||||||
|
|
||||||
|
op.bulk_insert(tables.notificationkind,
|
||||||
|
[
|
||||||
|
{'id':11, 'name':'vulnerability_found'},
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade(tables):
|
||||||
|
op.execute(
|
||||||
|
(tables.externalnotificationevent.delete()
|
||||||
|
.where(tables.externalnotificationevent.c.name == op.inline_literal('vulnerability_found')))
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
op.execute(
|
||||||
|
(tables.notificationkind.delete()
|
||||||
|
.where(tables.notificationkind.c.name == op.inline_literal('vulnerability_found')))
|
||||||
|
|
||||||
|
)
|
|
@ -17,7 +17,7 @@ def get_repo_blob_by_digest(namespace, repo_name, blob_digest):
|
||||||
.join(Repository)
|
.join(Repository)
|
||||||
.join(Namespace)
|
.join(Namespace)
|
||||||
.where(Repository.name == repo_name, Namespace.username == namespace,
|
.where(Repository.name == repo_name, Namespace.username == namespace,
|
||||||
ImageStorage.checksum == blob_digest))
|
ImageStorage.content_checksum == blob_digest))
|
||||||
if not placements:
|
if not placements:
|
||||||
raise BlobDoesNotExist('Blob does not exist with digest: {0}'.format(blob_digest))
|
raise BlobDoesNotExist('Blob does not exist with digest: {0}'.format(blob_digest))
|
||||||
|
|
||||||
|
@ -35,11 +35,11 @@ def store_blob_record_and_temp_link(namespace, repo_name, blob_digest, location_
|
||||||
repo = _basequery.get_existing_repository(namespace, repo_name)
|
repo = _basequery.get_existing_repository(namespace, repo_name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
storage = ImageStorage.get(checksum=blob_digest)
|
storage = ImageStorage.get(content_checksum=blob_digest)
|
||||||
location = ImageStorageLocation.get(name=location_name)
|
location = ImageStorageLocation.get(name=location_name)
|
||||||
ImageStoragePlacement.get(storage=storage, location=location)
|
ImageStoragePlacement.get(storage=storage, location=location)
|
||||||
except ImageStorage.DoesNotExist:
|
except ImageStorage.DoesNotExist:
|
||||||
storage = ImageStorage.create(checksum=blob_digest)
|
storage = ImageStorage.create(content_checksum=blob_digest)
|
||||||
except ImageStoragePlacement.DoesNotExist:
|
except ImageStoragePlacement.DoesNotExist:
|
||||||
ImageStoragePlacement.create(storage=storage, location=location)
|
ImageStoragePlacement.create(storage=storage, location=location)
|
||||||
|
|
||||||
|
|
|
@ -284,10 +284,7 @@ def set_image_metadata(docker_image_id, namespace_name, repository_name, created
|
||||||
except Image.DoesNotExist:
|
except Image.DoesNotExist:
|
||||||
raise DataModelException('No image with specified id and repository')
|
raise DataModelException('No image with specified id and repository')
|
||||||
|
|
||||||
# We cleanup any old checksum in case it's a retry after a fail
|
|
||||||
fetched.storage.checksum = None
|
|
||||||
fetched.created = datetime.now()
|
fetched.created = datetime.now()
|
||||||
|
|
||||||
if created_date_str is not None:
|
if created_date_str is not None:
|
||||||
try:
|
try:
|
||||||
fetched.created = dateutil.parser.parse(created_date_str).replace(tzinfo=None)
|
fetched.created = dateutil.parser.parse(created_date_str).replace(tzinfo=None)
|
||||||
|
@ -295,12 +292,18 @@ def set_image_metadata(docker_image_id, namespace_name, repository_name, created
|
||||||
# parse raises different exceptions, so we cannot use a specific kind of handler here.
|
# parse raises different exceptions, so we cannot use a specific kind of handler here.
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# We cleanup any old checksum in case it's a retry after a fail
|
||||||
|
fetched.v1_checksum = None
|
||||||
|
fetched.storage.checksum = None # TODO remove when storage checksums are no longer read
|
||||||
|
fetched.storage.content_checksum = None
|
||||||
|
|
||||||
fetched.comment = comment
|
fetched.comment = comment
|
||||||
fetched.command = command
|
fetched.command = command
|
||||||
fetched.v1_json_metadata = v1_json_metadata
|
fetched.v1_json_metadata = v1_json_metadata
|
||||||
|
|
||||||
if parent:
|
if parent:
|
||||||
fetched.ancestors = '%s%s/' % (parent.ancestors, parent.id)
|
fetched.ancestors = '%s%s/' % (parent.ancestors, parent.id)
|
||||||
|
fetched.parent = parent
|
||||||
|
|
||||||
fetched.save()
|
fetched.save()
|
||||||
fetched.storage.save()
|
fetched.storage.save()
|
||||||
|
|
|
@ -113,12 +113,13 @@ def delete_matching_notifications(target, kind_name, **kwargs):
|
||||||
notification.delete_instance()
|
notification.delete_instance()
|
||||||
|
|
||||||
|
|
||||||
def create_repo_notification(repo, event_name, method_name, config, title=None):
|
def create_repo_notification(repo, event_name, method_name, method_config, event_config, title=None):
|
||||||
event = ExternalNotificationEvent.get(ExternalNotificationEvent.name == event_name)
|
event = ExternalNotificationEvent.get(ExternalNotificationEvent.name == event_name)
|
||||||
method = ExternalNotificationMethod.get(ExternalNotificationMethod.name == method_name)
|
method = ExternalNotificationMethod.get(ExternalNotificationMethod.name == method_name)
|
||||||
|
|
||||||
return RepositoryNotification.create(repository=repo, event=event, method=method,
|
return RepositoryNotification.create(repository=repo, event=event, method=method,
|
||||||
config_json=json.dumps(config), title=title)
|
config_json=json.dumps(method_config), title=title,
|
||||||
|
event_config_json=json.dumps(event_config))
|
||||||
|
|
||||||
|
|
||||||
def get_repo_notification(uuid):
|
def get_repo_notification(uuid):
|
||||||
|
|
|
@ -75,6 +75,14 @@ def simple_checksum_handler(json_data):
|
||||||
return h, fn
|
return h, fn
|
||||||
|
|
||||||
|
|
||||||
|
def content_checksum_handler():
|
||||||
|
h = hashlib.sha256()
|
||||||
|
|
||||||
|
def fn(buf):
|
||||||
|
h.update(buf)
|
||||||
|
return h, fn
|
||||||
|
|
||||||
|
|
||||||
def compute_simple(fp, json_data):
|
def compute_simple(fp, json_data):
|
||||||
data = json_data + '\n'
|
data = json_data + '\n'
|
||||||
return 'sha256:{0}'.format(sha256_file(fp, data))
|
return 'sha256:{0}'.format(sha256_file(fp, data))
|
||||||
|
|
|
@ -93,6 +93,11 @@ class NotFound(ApiException):
|
||||||
ApiException.__init__(self, None, 404, 'Not Found', payload)
|
ApiException.__init__(self, None, 404, 'Not Found', payload)
|
||||||
|
|
||||||
|
|
||||||
|
class DownstreamIssue(ApiException):
|
||||||
|
def __init__(self, payload=None):
|
||||||
|
ApiException.__init__(self, None, 520, 'Downstream Issue', payload)
|
||||||
|
|
||||||
|
|
||||||
@api_bp.app_errorhandler(ApiException)
|
@api_bp.app_errorhandler(ApiException)
|
||||||
@crossdomain(origin='*', headers=['Authorization', 'Content-Type'])
|
@crossdomain(origin='*', headers=['Authorization', 'Content-Type'])
|
||||||
def handle_api_error(error):
|
def handle_api_error(error):
|
||||||
|
@ -418,4 +423,5 @@ import endpoints.api.tag
|
||||||
import endpoints.api.team
|
import endpoints.api.team
|
||||||
import endpoints.api.trigger
|
import endpoints.api.trigger
|
||||||
import endpoints.api.user
|
import endpoints.api.user
|
||||||
|
import endpoints.api.secscan
|
||||||
|
|
||||||
|
|
|
@ -57,6 +57,10 @@ class RepositoryNotificationList(RepositoryParamResource):
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
'description': 'JSON config information for the specific method of notification'
|
'description': 'JSON config information for the specific method of notification'
|
||||||
},
|
},
|
||||||
|
'eventConfig': {
|
||||||
|
'type': 'object',
|
||||||
|
'description': 'JSON config information for the specific event of notification',
|
||||||
|
},
|
||||||
'title': {
|
'title': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'description': 'The human-readable title of the notification',
|
'description': 'The human-readable title of the notification',
|
||||||
|
@ -84,6 +88,7 @@ class RepositoryNotificationList(RepositoryParamResource):
|
||||||
|
|
||||||
new_notification = model.notification.create_repo_notification(repo, parsed['event'],
|
new_notification = model.notification.create_repo_notification(repo, parsed['event'],
|
||||||
parsed['method'], parsed['config'],
|
parsed['method'], parsed['config'],
|
||||||
|
parsed['eventConfig'],
|
||||||
parsed.get('title', None))
|
parsed.get('title', None))
|
||||||
|
|
||||||
resp = notification_view(new_notification)
|
resp = notification_view(new_notification)
|
||||||
|
|
103
endpoints/api/secscan.py
Normal file
103
endpoints/api/secscan.py
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
""" List and manage repository vulnerabilities and other sec information. """
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import features
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from app import secscan_endpoint
|
||||||
|
from data import model
|
||||||
|
from endpoints.api import (require_repo_read, NotFound, DownstreamIssue, path_param,
|
||||||
|
RepositoryParamResource, resource, nickname, show_if, parse_args,
|
||||||
|
query_param)
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _call_security_api(relative_url, *args, **kwargs):
|
||||||
|
""" Issues an HTTP call to the sec API at the given relative URL. """
|
||||||
|
try:
|
||||||
|
response = secscan_endpoint.call_api(relative_url, *args, **kwargs)
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
raise DownstreamIssue(payload=dict(message='API call timed out'))
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
raise DownstreamIssue(payload=dict(message='Could not connect to downstream service'))
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
raise NotFound()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response_data = json.loads(response.text)
|
||||||
|
except ValueError:
|
||||||
|
raise DownstreamIssue(payload=dict(message='Non-json response from downstream service'))
|
||||||
|
|
||||||
|
if response.status_code / 100 != 2:
|
||||||
|
logger.warning('Got %s status code to call: %s', response.status_code, response.text)
|
||||||
|
raise DownstreamIssue(payload=dict(message=response_data['Message']))
|
||||||
|
|
||||||
|
return response_data
|
||||||
|
|
||||||
|
|
||||||
|
@show_if(features.SECURITY_SCANNER)
|
||||||
|
@resource('/v1/repository/<repopath:repository>/tag/<tag>/vulnerabilities')
|
||||||
|
@path_param('repository', 'The full path of the repository. e.g. namespace/name')
|
||||||
|
@path_param('tag', 'The name of the tag')
|
||||||
|
class RepositoryTagVulnerabilities(RepositoryParamResource):
|
||||||
|
""" Operations for managing the vulnerabilities in a repository tag. """
|
||||||
|
|
||||||
|
@require_repo_read
|
||||||
|
@nickname('getRepoTagVulnerabilities')
|
||||||
|
@parse_args
|
||||||
|
@query_param('minimumPriority', 'Minimum vulnerability priority', type=str,
|
||||||
|
default='Low')
|
||||||
|
def get(self, args, namespace, repository, tag):
|
||||||
|
""" Fetches the vulnerabilities (if any) for a repository tag. """
|
||||||
|
try:
|
||||||
|
tag_image = model.tag.get_tag_image(namespace, repository, tag)
|
||||||
|
except model.DataModelException:
|
||||||
|
raise NotFound()
|
||||||
|
|
||||||
|
if not tag_image.security_indexed:
|
||||||
|
logger.debug('Image %s for tag %s under repository %s/%s not security indexed',
|
||||||
|
tag_image.docker_image_id, tag, namespace, repository)
|
||||||
|
return {
|
||||||
|
'security_indexed': False
|
||||||
|
}
|
||||||
|
|
||||||
|
data = _call_security_api('layers/%s/vulnerabilities', tag_image.docker_image_id,
|
||||||
|
minimumPriority=args.minimumPriority)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'security_indexed': True,
|
||||||
|
'data': data,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@show_if(features.SECURITY_SCANNER)
|
||||||
|
@resource('/v1/repository/<repopath:repository>/image/<imageid>/packages')
|
||||||
|
@path_param('repository', 'The full path of the repository. e.g. namespace/name')
|
||||||
|
@path_param('imageid', 'The image ID')
|
||||||
|
class RepositoryImagePackages(RepositoryParamResource):
|
||||||
|
""" Operations for listing the packages added/removed in an image. """
|
||||||
|
|
||||||
|
@require_repo_read
|
||||||
|
@nickname('getRepoImagePackages')
|
||||||
|
def get(self, namespace, repository, imageid):
|
||||||
|
""" Fetches the packages added/removed in the given repo image. """
|
||||||
|
repo_image = model.image.get_repo_image(namespace, repository, imageid)
|
||||||
|
if repo_image is None:
|
||||||
|
raise NotFound()
|
||||||
|
|
||||||
|
if not repo_image.security_indexed:
|
||||||
|
return {
|
||||||
|
'security_indexed': False
|
||||||
|
}
|
||||||
|
|
||||||
|
data = _call_security_api('layers/%s/packages/diff', repo_image.docker_image_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'security_indexed': True,
|
||||||
|
'data': data,
|
||||||
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ from flask import request, abort
|
||||||
|
|
||||||
from endpoints.api import (resource, nickname, require_repo_read, require_repo_write,
|
from endpoints.api import (resource, nickname, require_repo_read, require_repo_write,
|
||||||
RepositoryParamResource, log_action, NotFound, validate_json_request,
|
RepositoryParamResource, log_action, NotFound, validate_json_request,
|
||||||
path_param, parse_args, query_param)
|
path_param, parse_args, query_param, truthy_bool)
|
||||||
from endpoints.api.image import image_view
|
from endpoints.api.image import image_view
|
||||||
from data import model
|
from data import model
|
||||||
from auth.auth_context import get_authenticated_user
|
from auth.auth_context import get_authenticated_user
|
||||||
|
@ -135,7 +135,10 @@ class RepositoryTagImages(RepositoryParamResource):
|
||||||
""" Resource for listing the images in a specific repository tag. """
|
""" Resource for listing the images in a specific repository tag. """
|
||||||
@require_repo_read
|
@require_repo_read
|
||||||
@nickname('listTagImages')
|
@nickname('listTagImages')
|
||||||
def get(self, namespace, repository, tag):
|
@parse_args
|
||||||
|
@query_param('owned', 'If specified, only images wholely owned by this tag are returned.',
|
||||||
|
type=truthy_bool, default=False)
|
||||||
|
def get(self, args, namespace, repository, tag):
|
||||||
""" List the images for the specified repository tag. """
|
""" List the images for the specified repository tag. """
|
||||||
try:
|
try:
|
||||||
tag_image = model.tag.get_tag_image(namespace, repository, tag)
|
tag_image = model.tag.get_tag_image(namespace, repository, tag)
|
||||||
|
@ -144,15 +147,37 @@ class RepositoryTagImages(RepositoryParamResource):
|
||||||
|
|
||||||
parent_images = model.image.get_parent_images(namespace, repository, tag_image)
|
parent_images = model.image.get_parent_images(namespace, repository, tag_image)
|
||||||
image_map = {}
|
image_map = {}
|
||||||
|
|
||||||
|
image_map[str(tag_image.id)] = tag_image
|
||||||
|
|
||||||
for image in parent_images:
|
for image in parent_images:
|
||||||
image_map[str(image.id)] = image
|
image_map[str(image.id)] = image
|
||||||
|
|
||||||
|
image_map_all = dict(image_map)
|
||||||
|
|
||||||
parents = list(parent_images)
|
parents = list(parent_images)
|
||||||
parents.reverse()
|
parents.reverse()
|
||||||
all_images = [tag_image] + parents
|
all_images = [tag_image] + parents
|
||||||
|
|
||||||
|
# Filter the images returned to those not found in the ancestry of any of the other tags in
|
||||||
|
# the repository.
|
||||||
|
if args['owned']:
|
||||||
|
all_tags = model.tag.list_repository_tags(namespace, repository)
|
||||||
|
for current_tag in all_tags:
|
||||||
|
if current_tag.name == tag:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Remove the tag's image ID.
|
||||||
|
tag_image_id = str(current_tag.image_id)
|
||||||
|
image_map.pop(tag_image_id, None)
|
||||||
|
|
||||||
|
# Remove any ancestors:
|
||||||
|
for ancestor_id in current_tag.image.ancestors.split('/'):
|
||||||
|
image_map.pop(ancestor_id, None)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'images': [image_view(image, image_map) for image in all_images]
|
'images': [image_view(image, image_map_all) for image in all_images
|
||||||
|
if not args['owned'] or (str(image.id) in image_map)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -84,6 +84,40 @@ def _build_summary(event_data):
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
class VulnerabilityFoundEvent(NotificationEvent):
|
||||||
|
@classmethod
|
||||||
|
def event_name(cls):
|
||||||
|
return 'vulnerability_found'
|
||||||
|
|
||||||
|
def get_level(self, event_data, notification_data):
|
||||||
|
priority = event_data['vulnerability']['priority']
|
||||||
|
if priority == 'Defcon1' or priority == 'Critical':
|
||||||
|
return 'error'
|
||||||
|
|
||||||
|
if priority == 'Medium' or priority == 'High':
|
||||||
|
return 'warning'
|
||||||
|
|
||||||
|
return 'info'
|
||||||
|
|
||||||
|
def get_sample_data(self, repository):
|
||||||
|
return build_event_data(repository, {
|
||||||
|
'tags': ['latest', 'prod'],
|
||||||
|
'image': 'some-image-id',
|
||||||
|
'vulnerability': {
|
||||||
|
'id': 'CVE-FAKE-CVE',
|
||||||
|
'description': 'A futurist vulnerability',
|
||||||
|
'link': 'https://security-tracker.debian.org/tracker/CVE-FAKE-CVE',
|
||||||
|
'priority': 'Critical',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
def get_summary(self, event_data, notification_data):
|
||||||
|
msg = '%s vulnerability detected in repository %s in tags %s'
|
||||||
|
return msg % (event_data['vulnerability']['priority'],
|
||||||
|
event_data['repository'],
|
||||||
|
', '.join(event_data['tags']))
|
||||||
|
|
||||||
|
|
||||||
class BuildQueueEvent(NotificationEvent):
|
class BuildQueueEvent(NotificationEvent):
|
||||||
@classmethod
|
@classmethod
|
||||||
def event_name(cls):
|
def event_name(cls):
|
||||||
|
|
|
@ -249,6 +249,10 @@ def put_image_layer(namespace, repository, image_id):
|
||||||
h, sum_hndlr = checksums.simple_checksum_handler(json_data)
|
h, sum_hndlr = checksums.simple_checksum_handler(json_data)
|
||||||
sr.add_handler(sum_hndlr)
|
sr.add_handler(sum_hndlr)
|
||||||
|
|
||||||
|
# Add a handler which computes the content checksum only
|
||||||
|
ch, content_sum_hndlr = checksums.content_checksum_handler()
|
||||||
|
sr.add_handler(content_sum_hndlr)
|
||||||
|
|
||||||
# Stream write the data to storage.
|
# Stream write the data to storage.
|
||||||
with database.CloseForLongOperation(app.config):
|
with database.CloseForLongOperation(app.config):
|
||||||
try:
|
try:
|
||||||
|
@ -278,6 +282,7 @@ def put_image_layer(namespace, repository, image_id):
|
||||||
# We don't have a checksum stored yet, that's fine skipping the check.
|
# We don't have a checksum stored yet, that's fine skipping the check.
|
||||||
# Not removing the mark though, image is not downloadable yet.
|
# Not removing the mark though, image is not downloadable yet.
|
||||||
session['checksum'] = csums
|
session['checksum'] = csums
|
||||||
|
session['content_checksum'] = 'sha256:{0}'.format(ch.hexdigest())
|
||||||
return make_response('true', 200)
|
return make_response('true', 200)
|
||||||
|
|
||||||
checksum = repo_image.storage.checksum
|
checksum = repo_image.storage.checksum
|
||||||
|
@ -339,8 +344,9 @@ def put_image_checksum(namespace, repository, image_id):
|
||||||
abort(409, 'Cannot set checksum for image %(image_id)s',
|
abort(409, 'Cannot set checksum for image %(image_id)s',
|
||||||
issue='image-write-error', image_id=image_id)
|
issue='image-write-error', image_id=image_id)
|
||||||
|
|
||||||
logger.debug('Storing image checksum')
|
logger.debug('Storing image and content checksums')
|
||||||
err = store_checksum(repo_image.storage, checksum)
|
content_checksum = session.get('content_checksum', None)
|
||||||
|
err = store_checksum(repo_image, checksum, content_checksum)
|
||||||
if err:
|
if err:
|
||||||
abort(400, err)
|
abort(400, err)
|
||||||
|
|
||||||
|
@ -429,14 +435,18 @@ def generate_ancestry(image_id, uuid, locations, parent_id=None, parent_uuid=Non
|
||||||
store.put_content(locations, store.image_ancestry_path(uuid), json.dumps(data))
|
store.put_content(locations, store.image_ancestry_path(uuid), json.dumps(data))
|
||||||
|
|
||||||
|
|
||||||
def store_checksum(image_storage, checksum):
|
def store_checksum(image_with_storage, checksum, content_checksum):
|
||||||
checksum_parts = checksum.split(':')
|
checksum_parts = checksum.split(':')
|
||||||
if len(checksum_parts) != 2:
|
if len(checksum_parts) != 2:
|
||||||
return 'Invalid checksum format'
|
return 'Invalid checksum format'
|
||||||
|
|
||||||
# We store the checksum
|
# We store the checksum
|
||||||
image_storage.checksum = checksum
|
image_with_storage.storage.checksum = checksum # TODO remove when v1 checksums are on image only
|
||||||
image_storage.save()
|
image_with_storage.storage.content_checksum = content_checksum
|
||||||
|
image_with_storage.storage.save()
|
||||||
|
|
||||||
|
image_with_storage.v1_checksum = checksum
|
||||||
|
image_with_storage.save()
|
||||||
|
|
||||||
|
|
||||||
@v1_bp.route('/images/<image_id>/json', methods=['PUT'])
|
@v1_bp.route('/images/<image_id>/json', methods=['PUT'])
|
||||||
|
|
|
@ -58,11 +58,12 @@ def index(path, **kwargs):
|
||||||
def internal_error_display():
|
def internal_error_display():
|
||||||
return render_page_template('500.html')
|
return render_page_template('500.html')
|
||||||
|
|
||||||
#TODO: reenable once fixed
|
@web.errorhandler(404)
|
||||||
#@web.errorhandler(404)
|
@web.route('/404', methods=['GET'])
|
||||||
#@web.route('/404', methods=['GET'])
|
def not_found_error_display(e = None):
|
||||||
#def not_found_error_display(e = None):
|
resp = render_page_template('404.html')
|
||||||
# return render_page_template('404.html')
|
resp.status_code = 404
|
||||||
|
return resp
|
||||||
|
|
||||||
@web.route('/organization/<path:path>', methods=['GET'])
|
@web.route('/organization/<path:path>', methods=['GET'])
|
||||||
@no_cache
|
@no_cache
|
||||||
|
@ -398,7 +399,6 @@ def confirm_recovery():
|
||||||
|
|
||||||
@web.route('/repository/<path:repository>/status', methods=['GET'])
|
@web.route('/repository/<path:repository>/status', methods=['GET'])
|
||||||
@parse_repository_name
|
@parse_repository_name
|
||||||
@no_cache
|
|
||||||
@anon_protect
|
@anon_protect
|
||||||
def build_status_badge(namespace, repository):
|
def build_status_badge(namespace, repository):
|
||||||
token = request.args.get('token', None)
|
token = request.args.get('token', None)
|
||||||
|
@ -422,8 +422,13 @@ def build_status_badge(namespace, repository):
|
||||||
else:
|
else:
|
||||||
status_name = 'none'
|
status_name = 'none'
|
||||||
|
|
||||||
|
if request.headers.get('If-None-Match') == status_name:
|
||||||
|
return Response(status=304)
|
||||||
|
|
||||||
response = make_response(STATUS_TAGS[status_name])
|
response = make_response(STATUS_TAGS[status_name])
|
||||||
response.content_type = 'image/svg+xml'
|
response.content_type = 'image/svg+xml'
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['ETag'] = status_name
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
|
4
events/vulnerability_found.html
Normal file
4
events/vulnerability_found.html
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
A <a href="{{ event_data.vulnerability.link }}">{{ event_data.vulnerability.priority }} vulnerability</a> ({{ event_data.vulnerability.id }}) was detected in tags
|
||||||
|
{{ 'tags' | icon_image }}
|
||||||
|
{% for tag in event_data.tags %}{%if loop.index > 1 %}, {% endif %}{{ (event_data.repository, tag) | repository_tag_reference }}{% endfor %} in
|
||||||
|
repository {{ event_data.repository | repository_reference }}
|
|
@ -43,4 +43,6 @@ class TarImageFormatter(object):
|
||||||
""" Returns TAR file header data for a folder with the given name. """
|
""" Returns TAR file header data for a folder with the given name. """
|
||||||
info = tarfile.TarInfo(name=name)
|
info = tarfile.TarInfo(name=name)
|
||||||
info.type = tarfile.DIRTYPE
|
info.type = tarfile.DIRTYPE
|
||||||
|
# allow the directory to be readable by non-root users
|
||||||
|
info.mode = 0755
|
||||||
return info.tobuf()
|
return info.tobuf()
|
|
@ -5,6 +5,7 @@ import random
|
||||||
import calendar
|
import calendar
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from sys import maxsize
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from peewee import (SqliteDatabase, create_model_tables, drop_model_tables, savepoint_sqlite,
|
from peewee import (SqliteDatabase, create_model_tables, drop_model_tables, savepoint_sqlite,
|
||||||
savepoint)
|
savepoint)
|
||||||
|
@ -82,7 +83,7 @@ def __create_subtree(repo, structure, creator_username, parent, tag_map):
|
||||||
new_image_locations = new_image.storage.locations
|
new_image_locations = new_image.storage.locations
|
||||||
new_image.storage.uuid = __gen_image_uuid(repo, image_num)
|
new_image.storage.uuid = __gen_image_uuid(repo, image_num)
|
||||||
new_image.storage.uploading = False
|
new_image.storage.uploading = False
|
||||||
new_image.storage.checksum = checksum
|
new_image.storage.content_checksum = checksum
|
||||||
new_image.storage.save()
|
new_image.storage.save()
|
||||||
|
|
||||||
# Write some data for the storage.
|
# Write some data for the storage.
|
||||||
|
@ -95,6 +96,10 @@ def __create_subtree(repo, structure, creator_username, parent, tag_map):
|
||||||
path = path_builder(new_image.storage.uuid)
|
path = path_builder(new_image.storage.uuid)
|
||||||
store.put_content('local_us', path, checksum)
|
store.put_content('local_us', path, checksum)
|
||||||
|
|
||||||
|
new_image.security_indexed = False
|
||||||
|
new_image.security_indexed_engine = maxsize
|
||||||
|
new_image.save()
|
||||||
|
|
||||||
creation_time = REFERENCE_DATE + timedelta(weeks=image_num) + timedelta(days=model_num)
|
creation_time = REFERENCE_DATE + timedelta(weeks=image_num) + timedelta(days=model_num)
|
||||||
command_list = SAMPLE_CMDS[image_num % len(SAMPLE_CMDS)]
|
command_list = SAMPLE_CMDS[image_num % len(SAMPLE_CMDS)]
|
||||||
command = json.dumps(command_list) if command_list else None
|
command = json.dumps(command_list) if command_list else None
|
||||||
|
@ -309,6 +314,7 @@ def initialize_database():
|
||||||
ExternalNotificationEvent.create(name='build_start')
|
ExternalNotificationEvent.create(name='build_start')
|
||||||
ExternalNotificationEvent.create(name='build_success')
|
ExternalNotificationEvent.create(name='build_success')
|
||||||
ExternalNotificationEvent.create(name='build_failure')
|
ExternalNotificationEvent.create(name='build_failure')
|
||||||
|
ExternalNotificationEvent.create(name='vulnerability_found')
|
||||||
|
|
||||||
ExternalNotificationMethod.create(name='quay_notification')
|
ExternalNotificationMethod.create(name='quay_notification')
|
||||||
ExternalNotificationMethod.create(name='email')
|
ExternalNotificationMethod.create(name='email')
|
||||||
|
@ -323,6 +329,7 @@ def initialize_database():
|
||||||
NotificationKind.create(name='build_start')
|
NotificationKind.create(name='build_start')
|
||||||
NotificationKind.create(name='build_success')
|
NotificationKind.create(name='build_success')
|
||||||
NotificationKind.create(name='build_failure')
|
NotificationKind.create(name='build_failure')
|
||||||
|
NotificationKind.create(name='vulnerability_found')
|
||||||
|
|
||||||
NotificationKind.create(name='password_required')
|
NotificationKind.create(name='password_required')
|
||||||
NotificationKind.create(name='over_private_usage')
|
NotificationKind.create(name='over_private_usage')
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
#createNotificationModal .dropdown-select {
|
||||||
|
margin: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#createNotificationModal .options-table {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#createNotificationModal .options-table td {
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#createNotificationModal .options-table td.name {
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#createNotificationModal .options-table-wrapper {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
|
@ -571,6 +571,7 @@ i.toggle-icon:hover {
|
||||||
|
|
||||||
.notification-error {
|
.notification-error {
|
||||||
background: red;
|
background: red;
|
||||||
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-notification.notification-animated {
|
.user-notification.notification-animated {
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
<!-- Modal message dialog -->
|
<!-- Modal message dialog -->
|
||||||
<div class="modal fade" id="createNotificationModal">
|
<div class="co-dialog modal fade" id="createNotificationModal">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<form id="createForm" name="createForm" ng-submit="createNotification()">
|
<form id="createForm" name="createForm" ng-submit="createNotification()">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true" ng-disabled="creating">×</button>
|
<button type="button" class="close" data-dismiss="modal" aria-hidden="true" ng-disabled="creating">×</button>
|
||||||
<h4 class="modal-title">
|
<h4 class="modal-title">
|
||||||
Create Repository Notification
|
<i class="fa fa-bell"></i> Create Repository Notification
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
@ -29,109 +29,127 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create View -->
|
<!-- Create View -->
|
||||||
<table style="width: 100%" ng-show="status == ''">
|
<div class="options-table-wrapper">
|
||||||
<tr>
|
<table class="options-table" ng-show="status == ''">
|
||||||
<td style="width: 120px">Notification title:</td>
|
<tr>
|
||||||
<td style="padding-right: 21px;">
|
<td class="name">Notification title:</td>
|
||||||
<input class="form-control" type="text" placeholder="(Optional Title)" ng-model="currentTitle"
|
<td>
|
||||||
style="margin: 10px;">
|
<input class="form-control" type="text" placeholder="(Optional Title)" ng-model="currentTitle">
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
<tr>
|
<table class="options-table" ng-show="status == ''">
|
||||||
<td style="width: 120px">When this occurs:</td>
|
<tr>
|
||||||
<td>
|
<td class="name">When this occurs:</td>
|
||||||
<div class="dropdown-select" placeholder="'(Notification Event)'" selected-item="currentEvent.title"
|
<td>
|
||||||
handle-item-selected="handleEventSelected(datum)" clear-value="clearCounter">
|
<div class="dropdown-select" placeholder="'(Notification Event)'" selected-item="currentEvent.title"
|
||||||
<!-- Icons -->
|
handle-item-selected="handleEventSelected(datum)" clear-value="clearCounter">
|
||||||
<i class="dropdown-select-icon fa fa-lg" ng-class="currentEvent.icon"></i>
|
<!-- Icons -->
|
||||||
|
<i class="dropdown-select-icon fa fa-lg" ng-class="currentEvent.icon"></i>
|
||||||
|
|
||||||
<!-- Dropdown menu -->
|
<!-- Dropdown menu -->
|
||||||
<ul class="dropdown-select-menu pull-right" role="menu">
|
<ul class="dropdown-select-menu pull-right" role="menu">
|
||||||
<li ng-repeat="event in events">
|
<li ng-repeat="event in events">
|
||||||
<a href="javascript:void(0)" ng-click="setEvent(event)">
|
<a href="javascript:void(0)" ng-click="setEvent(event)">
|
||||||
<i class="fa fa-lg" ng-class="event.icon"></i> {{ event.title }}
|
<i class="fa fa-lg" ng-class="event.icon"></i> {{ event.title }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr ng-repeat="field in currentEvent.fields">
|
||||||
<td>Then issue a:</td>
|
<td class="name" valign="top">With {{ field.title }} of:</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="dropdown-select" placeholder="'(Notification Action)'" selected-item="currentMethod.title"
|
<div ng-switch on="field.type">
|
||||||
handle-item-selected="handleMethodSelected(datum)" clear-value="clearCounter">
|
<select class="form-control" ng-if="field.type == 'enum'"
|
||||||
<!-- Icons -->
|
ng-model="currentEventConfig[field.name]" required>
|
||||||
<i class="dropdown-select-icon fa fa-lg" ng-class="currentMethod.icon"></i>
|
<option ng-repeat="(key, info) in field.values | orderObjectBy: 'index'" value="{{key}}">{{ info.title }}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
<!-- Dropdown menu -->
|
<div class="co-alert co-alert-info"
|
||||||
<ul class="dropdown-select-menu pull-right" role="menu">
|
style="margin-top: 6px; margin-bottom: 10px;"
|
||||||
<li ng-repeat="method in methods">
|
ng-if="field.values[currentEventConfig[field.name]].description">
|
||||||
<a href="javascript:void(0)" ng-click="setMethod(method)">
|
{{ field.values[currentEventConfig[field.name]].description }}
|
||||||
<i class="fa fa-lg" ng-class="method.icon"></i> {{ method.title }}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<tr ng-if="currentMethod.fields.length"><td colspan="2"><hr></td></tr>
|
|
||||||
|
|
||||||
<tr ng-repeat="field in currentMethod.fields">
|
|
||||||
<td valign="top" style="padding-top: 10px">{{ field.title }}:</td>
|
|
||||||
<td>
|
|
||||||
<div ng-switch on="field.type">
|
|
||||||
<span ng-switch-when="email">
|
|
||||||
<input type="email" class="form-control" ng-model="currentConfig[field.name]" required>
|
|
||||||
</span>
|
|
||||||
<input type="url" class="form-control" ng-model="currentConfig[field.name]" ng-switch-when="url" required>
|
|
||||||
<input type="text" class="form-control" ng-model="currentConfig[field.name]" ng-switch-when="string" required>
|
|
||||||
<!-- TODO(jschorr): unify the ability to create an input box with all the usual features -->
|
|
||||||
<div ng-switch-when="regex">
|
|
||||||
<input type="text" class="form-control" ng-model="currentConfig[field.name]"
|
|
||||||
ng-pattern="getPattern(field)"
|
|
||||||
placeholder="{{ field.placeholder }}"
|
|
||||||
ng-name="field.name"
|
|
||||||
id="{{ field.name }}"
|
|
||||||
required>
|
|
||||||
|
|
||||||
<div class="alert alert-warning" style="margin-top: 10px; margin-bottom: 10px"
|
|
||||||
ng-if="field.regex_fail_message && hasRegexMismatch(createForm.$error, field.name)">
|
|
||||||
<span ng-bind-html="field.regex_fail_message"></span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="entity-search" namespace="repository.namespace"
|
</td>
|
||||||
placeholder="''"
|
</tr>
|
||||||
current-entity="currentConfig[field.name]"
|
</table>
|
||||||
ng-model="currentConfig[field.name]"
|
|
||||||
allowed-entities="['user', 'team', 'org']"
|
|
||||||
ng-switch-when="entity"></div>
|
|
||||||
|
|
||||||
<div ng-if="getHelpUrl(field, currentConfig)"
|
<table class="options-table" ng-show="status == ''">
|
||||||
style="margin-top: 10px; margin-bottom: 10px">
|
<tr>
|
||||||
See: <a href="{{ getHelpUrl(field, currentConfig) }}" target="_blank">{{ getHelpUrl(field, currentConfig) }}</a>
|
<td class="name">Then issue a:</td>
|
||||||
|
<td>
|
||||||
|
<div class="dropdown-select" placeholder="'(Notification Action)'" selected-item="currentMethod.title"
|
||||||
|
handle-item-selected="handleMethodSelected(datum)" clear-value="clearCounter">
|
||||||
|
<!-- Icons -->
|
||||||
|
<i class="dropdown-select-icon fa fa-lg" ng-class="currentMethod.icon"></i>
|
||||||
|
|
||||||
|
<!-- Dropdown menu -->
|
||||||
|
<ul class="dropdown-select-menu pull-right" role="menu">
|
||||||
|
<li ng-repeat="method in methods">
|
||||||
|
<a href="javascript:void(0)" ng-click="setMethod(method)">
|
||||||
|
<i class="fa fa-lg" ng-class="method.icon"></i> {{ method.title }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
|
||||||
|
|
||||||
<tr ng-if="currentMethod.id == 'webhook'">
|
<tr ng-repeat="field in currentMethod.fields">
|
||||||
<td colspan="2">
|
<td valign="top" class="name">{{ field.title }}:</td>
|
||||||
<div class="alert alert-info" style="margin-top: 20px; margin-bottom: 0px">
|
<td>
|
||||||
JSON metadata representing the event will be <b>POST</b>ed to the URL.
|
<div ng-switch on="field.type">
|
||||||
<br><br>
|
<span ng-switch-when="email">
|
||||||
The contents for each event can be found in the user guide:
|
<input type="email" class="form-control" ng-model="currentConfig[field.name]" required>
|
||||||
<a href="http://docs.quay.io/guides/notifications.html#webhook{{ currentEvent.id ? '_' + currentEvent.id : '' }}"
|
</span>
|
||||||
target="_blank">
|
<input type="url" class="form-control" ng-model="currentConfig[field.name]" ng-switch-when="url" required>
|
||||||
http://docs.quay.io/guides/notifications.html
|
<input type="text" class="form-control" ng-model="currentConfig[field.name]" ng-switch-when="string" required>
|
||||||
</a>
|
<!-- TODO(jschorr): unify the ability to create an input box with all the usual features -->
|
||||||
</div>
|
<div ng-switch-when="regex">
|
||||||
</td>
|
<input type="text" class="form-control" ng-model="currentConfig[field.name]"
|
||||||
</tr>
|
ng-pattern="getPattern(field)"
|
||||||
</table>
|
placeholder="{{ field.placeholder }}"
|
||||||
|
ng-name="field.name"
|
||||||
|
id="{{ field.name }}"
|
||||||
|
required>
|
||||||
|
|
||||||
|
<div class="alert alert-warning" style="margin-top: 10px; margin-bottom: 10px"
|
||||||
|
ng-if="field.regex_fail_message && hasRegexMismatch(createForm.$error, field.name)">
|
||||||
|
<span ng-bind-html="field.regex_fail_message"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="entity-search" namespace="repository.namespace"
|
||||||
|
placeholder="''"
|
||||||
|
current-entity="currentConfig[field.name]"
|
||||||
|
ng-model="currentConfig[field.name]"
|
||||||
|
allowed-entities="['user', 'team', 'org']"
|
||||||
|
ng-switch-when="entity"></div>
|
||||||
|
|
||||||
|
<div ng-if="getHelpUrl(field, currentConfig)"
|
||||||
|
style="margin-top: 10px; margin-bottom: 10px">
|
||||||
|
See: <a href="{{ getHelpUrl(field, currentConfig) }}" target="_blank">{{ getHelpUrl(field, currentConfig) }}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="co-alert co-alert-info" ng-if="currentMethod.id == 'webhook'"
|
||||||
|
style="margin-top: 6px; margin-bottom: 0px">
|
||||||
|
JSON metadata representing the event will be <b>POST</b>ed to the URL.
|
||||||
|
<br><br>
|
||||||
|
The contents for each event can be found in the user guide:
|
||||||
|
<a href="http://docs.quay.io/guides/notifications.html#webhook{{ currentEvent.id ? '_' + currentEvent.id : '' }}"
|
||||||
|
target="_blank">
|
||||||
|
http://docs.quay.io/guides/notifications.html
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Auth e-mail button bar -->
|
<!-- Auth e-mail button bar -->
|
||||||
|
|
|
@ -20,14 +20,14 @@
|
||||||
<div class="image-section">
|
<div class="image-section">
|
||||||
<i class="fa fa-tag section-icon" data-title="Current Tags" bs-tooltip></i>
|
<i class="fa fa-tag section-icon" data-title="Current Tags" bs-tooltip></i>
|
||||||
<span class="section-info section-info-with-dropdown">
|
<span class="section-info section-info-with-dropdown">
|
||||||
<a class="label tag label-default" ng-repeat="tag in imageData.tags"
|
<a class="label tag label-default" ng-repeat="tag in getTags(imageData)"
|
||||||
href="javascript:void(0)" ng-click="tagSelected({'tag': tag})">
|
href="javascript:void(0)" ng-click="tagSelected({'tag': tag})">
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
</a>
|
</a>
|
||||||
<span style="color: #ccc;" ng-if="!imageData.tags.length">(No Tags)</span>
|
<span style="color: #ccc;" ng-if="!getTags(imageData).length">(No Tags)</span>
|
||||||
|
|
||||||
<div class="dropdown" data-placement="top"
|
<div class="dropdown" data-placement="top"
|
||||||
ng-if="tracker.repository.can_write || imageData.tags">
|
ng-if="tracker.repository.can_write || getTags(imageData)">
|
||||||
<a href="javascript:void(0)" class="dropdown-button" data-toggle="dropdown"
|
<a href="javascript:void(0)" class="dropdown-button" data-toggle="dropdown"
|
||||||
bs-tooltip="tooltip.title" data-title="Manage Tags"
|
bs-tooltip="tooltip.title" data-title="Manage Tags"
|
||||||
data-container="body">
|
data-container="body">
|
||||||
|
@ -35,7 +35,7 @@
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<ul class="dropdown-menu pull-right">
|
<ul class="dropdown-menu pull-right">
|
||||||
<li ng-repeat="tag in imageData.tags">
|
<li ng-repeat="tag in getTags(imageData)">
|
||||||
<a href="javascript:void(0)" ng-click="tagSelected({'tag': tag})">
|
<a href="javascript:void(0)" ng-click="tagSelected({'tag': tag})">
|
||||||
<i class="fa fa-tag"></i>{{ tag }}
|
<i class="fa fa-tag"></i>{{ tag }}
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<div class="repo-panel-changes-element">
|
<div class="repo-panel-changes-element">
|
||||||
<div class="resource-view" resource="imagesResource"
|
<div class="cor-loader" ng-show="loading"></div>
|
||||||
error-message="'Could not load repository images'">
|
<div ng-show="!loading">
|
||||||
<h3 class="tab-header">
|
<h3 class="tab-header">
|
||||||
Visualize Tags:
|
Visualize Tags:
|
||||||
<span class="multiselect-dropdown" items="tagNames" selected-items="selectedTagsSlice"
|
<span class="multiselect-dropdown" items="tagNames" selected-items="selectedTagsSlice"
|
||||||
|
@ -19,49 +19,46 @@
|
||||||
|
|
||||||
<!-- Tags Selected -->
|
<!-- Tags Selected -->
|
||||||
<div ng-show="selectedTagsSlice.length > 0">
|
<div ng-show="selectedTagsSlice.length > 0">
|
||||||
<div id="image-history row" class="resource-view" resource="imagesResource"
|
<!-- Tree View container -->
|
||||||
error-message="'Cannot load repository images'">
|
<div class="col-md-8">
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<!-- Image history tree -->
|
||||||
|
<div id="image-history-container" onresize="tree.notifyResized()"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tree View container -->
|
<!-- Side Panel -->
|
||||||
<div class="col-md-8">
|
<div class="col-md-4">
|
||||||
<div class="panel panel-default">
|
<div class="side-panel-title" ng-if="currentTag">
|
||||||
<!-- Image history tree -->
|
<i class="fa fa-tag"></i>{{ currentTag }}
|
||||||
<div id="image-history-container" onresize="tree.notifyResized()"></div>
|
</div>
|
||||||
</div>
|
<div class="side-panel-title" ng-if="currentImage">
|
||||||
|
<i class="fa fa-archive"></i>{{ currentImage.substr(0, 12) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Side Panel -->
|
<div class="side-panel">
|
||||||
<div class="col-md-4">
|
<!-- Tag Info -->
|
||||||
<div class="side-panel-title" ng-if="currentTag">
|
<div class="tag-info-sidebar"
|
||||||
<i class="fa fa-tag"></i>{{ currentTag }}
|
tracker="tracker"
|
||||||
</div>
|
tag="currentTag"
|
||||||
<div class="side-panel-title" ng-if="currentImage">
|
image-selected="setImage(image)"
|
||||||
<i class="fa fa-archive"></i>{{ currentImage.substr(0, 12) }}
|
delete-tag-requested="tagActionHandler.askDeleteTag(tag)"
|
||||||
|
ng-if="currentTag">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="side-panel">
|
<!-- Image Info -->
|
||||||
<!-- Tag Info -->
|
<div class="image-info-sidebar"
|
||||||
<div class="tag-info-sidebar"
|
tracker="tracker"
|
||||||
tracker="tracker"
|
image="currentImage"
|
||||||
tag="currentTag"
|
image-loader="imageLoader"
|
||||||
image-selected="setImage(image)"
|
tag-selected="setTag(tag)"
|
||||||
delete-tag-requested="tagActionHandler.askDeleteTag(tag)"
|
add-tag-requested="tagActionHandler.askAddTag(image)"
|
||||||
ng-if="currentTag">
|
ng-if="currentImage">
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Image Info -->
|
|
||||||
<div class="image-info-sidebar"
|
|
||||||
tracker="tracker"
|
|
||||||
image="currentImage"
|
|
||||||
tag-selected="setTag(tag)"
|
|
||||||
add-tag-requested="tagActionHandler.askAddTag(image)"
|
|
||||||
ng-if="currentImage">
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tag-operations-dialog" repository="repository" images="images"
|
<div class="tag-operations-dialog" repository="repository" image-loader="imageLoader"
|
||||||
action-handler="tagActionHandler" tag-changed="handleTagChanged(data)"></div>
|
action-handler="tagActionHandler" tag-changed="handleTagChanged(data)"></div>
|
|
@ -172,7 +172,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tag-operations-dialog" repository="repository"
|
<div class="tag-operations-dialog" repository="repository"
|
||||||
get-images="getImages({'callback': callback})"
|
image-loader="imageLoader"
|
||||||
action-handler="tagActionHandler"></div>
|
action-handler="tagActionHandler"></div>
|
||||||
|
|
||||||
<div class="fetch-tag-dialog" repository="repository" action-handler="fetchTagActionHandler"></div>
|
<div class="fetch-tag-dialog" repository="repository" action-handler="fetchTagActionHandler"></div>
|
|
@ -49,11 +49,11 @@
|
||||||
|
|
||||||
<div class="tag-specific-images-view"
|
<div class="tag-specific-images-view"
|
||||||
tag="tagToCreate"
|
tag="tagToCreate"
|
||||||
repository="repo"
|
repository="repository"
|
||||||
images="imagesInternal"
|
|
||||||
image-cutoff="toTagImage"
|
image-cutoff="toTagImage"
|
||||||
style="margin: 10px; margin-top: 20px; margin-bottom: -10px;"
|
style="margin: 10px; margin-top: 20px; margin-bottom: -10px;"
|
||||||
ng-show="isAnotherImageTag(toTagImage, tagToCreate)">
|
ng-show="isAnotherImageTag(toTagImage, tagToCreate)"
|
||||||
|
image-loader="imageLoader">
|
||||||
This will also delete any unattached images and delete the following images:
|
This will also delete any unattached images and delete the following images:
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -100,7 +100,7 @@
|
||||||
<span class="label label-default tag">{{ deleteTagInfo.tag }}</span>?
|
<span class="label label-default tag">{{ deleteTagInfo.tag }}</span>?
|
||||||
|
|
||||||
<div class="tag-specific-images-view" tag="deleteTagInfo.tag" repository="repository"
|
<div class="tag-specific-images-view" tag="deleteTagInfo.tag" repository="repository"
|
||||||
images="imagesInternal" style="margin-top: 20px">
|
image-loader="imageLoader" style="margin-top: 20px">
|
||||||
The following images and any other images not referenced by a tag will be deleted:
|
The following images and any other images not referenced by a tag will be deleted:
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
<div class="tag-specific-images-view-element" ng-show="tagSpecificImages.length">
|
<div class="tag-specific-images-view-element" ng-show="loading">
|
||||||
|
<div class="cor-loader-inline"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tag-specific-images-view-element" ng-show="tagSpecificImages.length && !loading">
|
||||||
<div ng-transclude></div>
|
<div ng-transclude></div>
|
||||||
<div class="image-listings">
|
<div class="image-listings">
|
||||||
<div class="image-listing" ng-repeat="image in tagSpecificImages | limitTo:5"
|
<div class="image-listing" ng-repeat="image in tagSpecificImages | limitTo:5"
|
||||||
|
|
17
static/js/directives/object-order-by.js
Normal file
17
static/js/directives/object-order-by.js
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
// From: http://justinklemm.com/angularjs-filter-ordering-objects-ngrepeat/ under MIT License
|
||||||
|
quayApp.filter('orderObjectBy', function() {
|
||||||
|
return function(items, field, reverse) {
|
||||||
|
var filtered = [];
|
||||||
|
angular.forEach(items, function(item) {
|
||||||
|
filtered.push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
filtered.sort(function (a, b) {
|
||||||
|
return (a[field] > b[field] ? 1 : -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
if(reverse) filtered.reverse();
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
};
|
||||||
|
});
|
|
@ -2,13 +2,15 @@
|
||||||
* An element which displays the changes visualization panel for a repository view.
|
* An element which displays the changes visualization panel for a repository view.
|
||||||
*/
|
*/
|
||||||
angular.module('quay').directive('repoPanelChanges', function () {
|
angular.module('quay').directive('repoPanelChanges', function () {
|
||||||
var RepositoryImageTracker = function(repository, images) {
|
var RepositoryImageTracker = function(repository, imageLoader) {
|
||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
this.images = images;
|
this.imageLoader = imageLoader;
|
||||||
|
|
||||||
// Build a map of image ID -> image.
|
// Build a map of image ID -> image.
|
||||||
|
var images = imageLoader.images;
|
||||||
var imageIDMap = {};
|
var imageIDMap = {};
|
||||||
this.images.map(function(image) {
|
|
||||||
|
images.forEach(function(image) {
|
||||||
imageIDMap[image.id] = image;
|
imageIDMap[image.id] = image;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -91,12 +93,13 @@ angular.module('quay').directive('repoPanelChanges', function () {
|
||||||
'selectedTags': '=selectedTags',
|
'selectedTags': '=selectedTags',
|
||||||
|
|
||||||
'imagesResource': '=imagesResource',
|
'imagesResource': '=imagesResource',
|
||||||
'images': '=images',
|
'imageLoader': '=imageLoader',
|
||||||
|
|
||||||
'isEnabled': '=isEnabled'
|
'isEnabled': '=isEnabled'
|
||||||
},
|
},
|
||||||
controller: function($scope, $element, $timeout, ApiService, UtilService, ImageMetadataService) {
|
controller: function($scope, $element, $timeout, ApiService, UtilService, ImageMetadataService) {
|
||||||
$scope.tagNames = [];
|
$scope.tagNames = [];
|
||||||
|
$scope.loading = true;
|
||||||
|
|
||||||
$scope.$watch('selectedTags', function(selectedTags) {
|
$scope.$watch('selectedTags', function(selectedTags) {
|
||||||
if (!selectedTags) { return; }
|
if (!selectedTags) { return; }
|
||||||
|
@ -110,18 +113,17 @@ angular.module('quay').directive('repoPanelChanges', function () {
|
||||||
$scope.currentImage = null;
|
$scope.currentImage = null;
|
||||||
$scope.currentTag = null;
|
$scope.currentTag = null;
|
||||||
|
|
||||||
if ($scope.tracker) {
|
$scope.loading = true;
|
||||||
refreshTree();
|
$scope.imageLoader.loadImages($scope.selectedTagsSlice, function() {
|
||||||
} else {
|
$scope.loading = false;
|
||||||
updateImages();
|
updateImages();
|
||||||
}
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
var updateImages = function() {
|
var updateImages = function() {
|
||||||
if (!$scope.repository || !$scope.images || !$scope.isEnabled) { return; }
|
if (!$scope.repository || !$scope.imageLoader || !$scope.isEnabled) { return; }
|
||||||
|
|
||||||
$scope.tracker = new RepositoryImageTracker($scope.repository, $scope.images);
|
|
||||||
|
|
||||||
|
$scope.tracker = new RepositoryImageTracker($scope.repository, $scope.imageLoader);
|
||||||
if ($scope.selectedTagsSlice && $scope.selectedTagsSlice.length) {
|
if ($scope.selectedTagsSlice && $scope.selectedTagsSlice.length) {
|
||||||
refreshTree();
|
refreshTree();
|
||||||
}
|
}
|
||||||
|
@ -131,22 +133,25 @@ angular.module('quay').directive('repoPanelChanges', function () {
|
||||||
$scope.$watch('repository', update);
|
$scope.$watch('repository', update);
|
||||||
$scope.$watch('isEnabled', update);
|
$scope.$watch('isEnabled', update);
|
||||||
|
|
||||||
$scope.$watch('images', updateImages);
|
|
||||||
|
|
||||||
$scope.updateState = function() {
|
$scope.updateState = function() {
|
||||||
update();
|
update();
|
||||||
};
|
};
|
||||||
|
|
||||||
var refreshTree = function() {
|
var refreshTree = function() {
|
||||||
if (!$scope.repository || !$scope.images || !$scope.isEnabled) { return; }
|
if (!$scope.repository || !$scope.imageLoader || !$scope.isEnabled) { return; }
|
||||||
if ($scope.selectedTagsSlice.length < 1) { return; }
|
if ($scope.selectedTagsSlice.length < 1) { return; }
|
||||||
|
|
||||||
$('#image-history-container').empty();
|
$('#image-history-container').empty();
|
||||||
|
|
||||||
|
var getTagsForImage = function(image) {
|
||||||
|
return $scope.imageLoader.getTagsForImage(image);
|
||||||
|
};
|
||||||
|
|
||||||
var tree = new ImageHistoryTree(
|
var tree = new ImageHistoryTree(
|
||||||
$scope.repository.namespace,
|
$scope.repository.namespace,
|
||||||
$scope.repository.name,
|
$scope.repository.name,
|
||||||
$scope.images,
|
$scope.imageLoader.images,
|
||||||
|
getTagsForImage,
|
||||||
UtilService.getFirstMarkdownLineAsText,
|
UtilService.getFirstMarkdownLineAsText,
|
||||||
$scope.getTimeSince,
|
$scope.getTimeSince,
|
||||||
ImageMetadataService.getEscapedFormattedCommand,
|
ImageMetadataService.getEscapedFormattedCommand,
|
||||||
|
@ -194,8 +199,6 @@ angular.module('quay').directive('repoPanelChanges', function () {
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.handleTagChanged = function(data) {
|
$scope.handleTagChanged = function(data) {
|
||||||
$scope.tracker = new RepositoryImageTracker($scope.repository, $scope.images);
|
|
||||||
|
|
||||||
data.removed.map(function(tag) {
|
data.removed.map(function(tag) {
|
||||||
$scope.currentImage = null;
|
$scope.currentImage = null;
|
||||||
$scope.currentTag = null;
|
$scope.currentTag = null;
|
||||||
|
@ -206,7 +209,7 @@ angular.module('quay').directive('repoPanelChanges', function () {
|
||||||
$scope.currentTag = tag;
|
$scope.currentTag = tag;
|
||||||
});
|
});
|
||||||
|
|
||||||
refreshTree();
|
update();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -12,7 +12,7 @@ angular.module('quay').directive('repoPanelTags', function () {
|
||||||
'repository': '=repository',
|
'repository': '=repository',
|
||||||
'selectedTags': '=selectedTags',
|
'selectedTags': '=selectedTags',
|
||||||
'imagesResource': '=imagesResource',
|
'imagesResource': '=imagesResource',
|
||||||
'images': '=images',
|
'imageLoader': '=imageLoader',
|
||||||
|
|
||||||
'isEnabled': '=isEnabled',
|
'isEnabled': '=isEnabled',
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,7 @@ angular.module('quay').directive('createExternalNotificationDialog', function ()
|
||||||
$scope.currentMethod = null;
|
$scope.currentMethod = null;
|
||||||
$scope.status = '';
|
$scope.status = '';
|
||||||
$scope.currentConfig = {};
|
$scope.currentConfig = {};
|
||||||
|
$scope.currentEventConfig = {};
|
||||||
$scope.clearCounter = 0;
|
$scope.clearCounter = 0;
|
||||||
$scope.unauthorizedEmail = false;
|
$scope.unauthorizedEmail = false;
|
||||||
|
|
||||||
|
@ -30,6 +31,7 @@ angular.module('quay').directive('createExternalNotificationDialog', function ()
|
||||||
|
|
||||||
$scope.setEvent = function(event) {
|
$scope.setEvent = function(event) {
|
||||||
$scope.currentEvent = event;
|
$scope.currentEvent = event;
|
||||||
|
$scope.currentEventConfig = {};
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.setMethod = function(method) {
|
$scope.setMethod = function(method) {
|
||||||
|
@ -89,6 +91,7 @@ angular.module('quay').directive('createExternalNotificationDialog', function ()
|
||||||
'event': $scope.currentEvent.id,
|
'event': $scope.currentEvent.id,
|
||||||
'method': $scope.currentMethod.id,
|
'method': $scope.currentMethod.id,
|
||||||
'config': $scope.currentConfig,
|
'config': $scope.currentConfig,
|
||||||
|
'eventConfig': $scope.currentEventConfig,
|
||||||
'title': $scope.currentTitle
|
'title': $scope.currentTitle
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,7 @@ angular.module('quay').directive('imageInfoSidebar', function () {
|
||||||
scope: {
|
scope: {
|
||||||
'tracker': '=tracker',
|
'tracker': '=tracker',
|
||||||
'image': '=image',
|
'image': '=image',
|
||||||
|
'imageLoader': '=imageLoader',
|
||||||
|
|
||||||
'tagSelected': '&tagSelected',
|
'tagSelected': '&tagSelected',
|
||||||
'addTagRequested': '&addTagRequested'
|
'addTagRequested': '&addTagRequested'
|
||||||
|
@ -25,6 +26,10 @@ angular.module('quay').directive('imageInfoSidebar', function () {
|
||||||
return Date.parse(dateString);
|
return Date.parse(dateString);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$scope.getTags = function(imageData) {
|
||||||
|
return $scope.imageLoader.getTagsForImage(imageData);
|
||||||
|
};
|
||||||
|
|
||||||
$scope.getFormattedCommand = ImageMetadataService.getFormattedCommand;
|
$scope.getFormattedCommand = ImageMetadataService.getFormattedCommand;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -11,46 +11,31 @@ angular.module('quay').directive('tagOperationsDialog', function () {
|
||||||
restrict: 'C',
|
restrict: 'C',
|
||||||
scope: {
|
scope: {
|
||||||
'repository': '=repository',
|
'repository': '=repository',
|
||||||
'images': '=images',
|
|
||||||
'actionHandler': '=actionHandler',
|
'actionHandler': '=actionHandler',
|
||||||
|
'imageLoader': '=imageLoader',
|
||||||
'getImages': '&getImages',
|
|
||||||
'tagChanged': '&tagChanged'
|
'tagChanged': '&tagChanged'
|
||||||
},
|
},
|
||||||
controller: function($scope, $element, $timeout, ApiService) {
|
controller: function($scope, $element, $timeout, ApiService) {
|
||||||
$scope.addingTag = false;
|
$scope.addingTag = false;
|
||||||
$scope.imagesInternal = [];
|
|
||||||
|
|
||||||
$scope.$watch('images', function(images) {
|
|
||||||
if (!images) { return; }
|
|
||||||
$scope.imagesInternal = images;
|
|
||||||
});
|
|
||||||
|
|
||||||
var markChanged = function(added, removed) {
|
var markChanged = function(added, removed) {
|
||||||
// Reload the repository and the images.
|
// Reload the repository.
|
||||||
$scope.repository.get().then(function(resp) {
|
$scope.repository.get().then(function(resp) {
|
||||||
$scope.repository = resp;
|
$scope.repository = resp;
|
||||||
|
$scope.imageLoader.reset()
|
||||||
|
|
||||||
var params = {
|
// Note: We need the timeout here so that Angular can $digest the images change
|
||||||
'repository': resp.namespace + '/' + resp.name
|
// on the parent scope before the tagChanged callback occurs.
|
||||||
};
|
$timeout(function() {
|
||||||
|
$scope.tagChanged({
|
||||||
ApiService.listRepositoryImages(null, params).then(function(resp) {
|
'data': { 'added': added, 'removed': removed }
|
||||||
$scope.images = resp.images;
|
});
|
||||||
|
}, 1);
|
||||||
// Note: We need the timeout here so that Angular can $digest the images change
|
|
||||||
// on the parent scope before the tagChanged callback occurs.
|
|
||||||
$timeout(function() {
|
|
||||||
$scope.tagChanged({
|
|
||||||
'data': { 'added': added, 'removed': removed }
|
|
||||||
});
|
|
||||||
}, 1);
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.isAnotherImageTag = function(image, tag) {
|
$scope.isAnotherImageTag = function(image, tag) {
|
||||||
if (!$scope.repository || !$scope.imagesInternal) { return; }
|
if (!$scope.repository) { return; }
|
||||||
|
|
||||||
var found = $scope.repository.tags[tag];
|
var found = $scope.repository.tags[tag];
|
||||||
if (found == null) { return false; }
|
if (found == null) { return false; }
|
||||||
|
@ -58,7 +43,7 @@ angular.module('quay').directive('tagOperationsDialog', function () {
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.isOwnedTag = function(image, tag) {
|
$scope.isOwnedTag = function(image, tag) {
|
||||||
if (!$scope.repository || !$scope.imagesInternal) { return; }
|
if (!$scope.repository) { return; }
|
||||||
|
|
||||||
var found = $scope.repository.tags[tag];
|
var found = $scope.repository.tags[tag];
|
||||||
if (found == null) { return false; }
|
if (found == null) { return false; }
|
||||||
|
@ -149,70 +134,39 @@ angular.module('quay').directive('tagOperationsDialog', function () {
|
||||||
}, errorHandler);
|
}, errorHandler);
|
||||||
};
|
};
|
||||||
|
|
||||||
var lazyLoadImages = function(callback) {
|
|
||||||
if ($scope.imagesInternal.length) {
|
|
||||||
callback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var isLoading = true;
|
|
||||||
$timeout(function() {
|
|
||||||
if (isLoading) {
|
|
||||||
$('#loadingImagesModal').modal({});
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
var cb = function(images) {
|
|
||||||
isLoading = false;
|
|
||||||
$('#loadingImagesModal').modal('hide');
|
|
||||||
$scope.imagesInternal = images;
|
|
||||||
callback();
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.getImages({'callback': cb});
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.actionHandler = {
|
$scope.actionHandler = {
|
||||||
'askDeleteTag': function(tag) {
|
'askDeleteTag': function(tag) {
|
||||||
lazyLoadImages(function() {
|
$scope.deleteTagInfo = {
|
||||||
$scope.deleteTagInfo = {
|
'tag': tag
|
||||||
'tag': tag
|
};
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
'askDeleteMultipleTags': function(tags) {
|
'askDeleteMultipleTags': function(tags) {
|
||||||
lazyLoadImages(function() {
|
$scope.deleteMultipleTagsInfo = {
|
||||||
$scope.deleteMultipleTagsInfo = {
|
'tags': tags
|
||||||
'tags': tags
|
};
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
'askAddTag': function(image) {
|
'askAddTag': function(image) {
|
||||||
lazyLoadImages(function() {
|
$scope.tagToCreate = '';
|
||||||
$scope.tagToCreate = '';
|
$scope.toTagImage = image;
|
||||||
$scope.toTagImage = image;
|
$scope.addingTag = false;
|
||||||
$scope.addingTag = false;
|
$scope.addTagForm.$setPristine();
|
||||||
$scope.addTagForm.$setPristine();
|
$element.find('#createOrMoveTagModal').modal('show');
|
||||||
$element.find('#createOrMoveTagModal').modal('show');
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
'askRevertTag': function(tag, image_id) {
|
'askRevertTag': function(tag, image_id) {
|
||||||
lazyLoadImages(function() {
|
if (tag.image_id == image_id) {
|
||||||
if (tag.image_id == image_id) {
|
bootbox.alert('This is the current image for the tag');
|
||||||
bootbox.alert('This is the current image for the tag');
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$scope.revertTagInfo = {
|
$scope.revertTagInfo = {
|
||||||
'tag': tag,
|
'tag': tag,
|
||||||
'image_id': image_id
|
'image_id': image_id
|
||||||
};
|
};
|
||||||
|
|
||||||
$element.find('#revertTagModal').modal('show');
|
$element.find('#revertTagModal').modal('show');
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,13 +12,12 @@ angular.module('quay').directive('tagSpecificImagesView', function () {
|
||||||
scope: {
|
scope: {
|
||||||
'repository': '=repository',
|
'repository': '=repository',
|
||||||
'tag': '=tag',
|
'tag': '=tag',
|
||||||
'images': '=images',
|
'imageLoader': '=imageLoader',
|
||||||
'imageCutoff': '=imageCutoff'
|
'imageCutoff': '=imageCutoff'
|
||||||
},
|
},
|
||||||
controller: function($scope, $element, UtilService) {
|
controller: function($scope, $element, UtilService) {
|
||||||
$scope.getFirstTextLine = UtilService.getFirstMarkdownLineAsText;
|
$scope.getFirstTextLine = UtilService.getFirstMarkdownLineAsText;
|
||||||
|
$scope.loading = false;
|
||||||
$scope.hasImages = false;
|
|
||||||
$scope.tagSpecificImages = [];
|
$scope.tagSpecificImages = [];
|
||||||
|
|
||||||
$scope.getImageListingClasses = function(image) {
|
$scope.getImageListingClasses = function(image) {
|
||||||
|
@ -35,39 +34,8 @@ angular.module('quay').directive('tagSpecificImagesView', function () {
|
||||||
return classes;
|
return classes;
|
||||||
};
|
};
|
||||||
|
|
||||||
var forAllTagImages = function(tag, callback, opt_cutoff) {
|
|
||||||
if (!tag) { return; }
|
|
||||||
|
|
||||||
if (!$scope.imageByDockerId) {
|
|
||||||
$scope.imageByDockerId = [];
|
|
||||||
for (var i = 0; i < $scope.images.length; ++i) {
|
|
||||||
var currentImage = $scope.images[i];
|
|
||||||
$scope.imageByDockerId[currentImage.id] = currentImage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var tag_image = $scope.imageByDockerId[tag.image_id];
|
|
||||||
if (!tag_image) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
callback(tag_image);
|
|
||||||
|
|
||||||
var ancestors = tag_image.ancestors.split('/').reverse();
|
|
||||||
for (var i = 0; i < ancestors.length; ++i) {
|
|
||||||
var image = $scope.imageByDockerId[ancestors[i]];
|
|
||||||
if (image) {
|
|
||||||
if (image == opt_cutoff) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
callback(image);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var refresh = function() {
|
var refresh = function() {
|
||||||
if (!$scope.repository || !$scope.tag || !$scope.images) {
|
if (!$scope.repository || !$scope.tag || !$scope.imageLoader) {
|
||||||
$scope.tagSpecificImages = [];
|
$scope.tagSpecificImages = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -78,49 +46,15 @@ angular.module('quay').directive('tagSpecificImagesView', function () {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var getIdsForTag = function(currentTag) {
|
$scope.loading = true;
|
||||||
var ids = {};
|
$scope.imageLoader.getTagSpecificImages($scope.tag, function(images) {
|
||||||
forAllTagImages(currentTag, function(image) {
|
$scope.loading = false;
|
||||||
ids[image.id] = true;
|
$scope.tagSpecificImages = images;
|
||||||
}, $scope.imageCutoff);
|
|
||||||
return ids;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Remove any IDs that match other tags.
|
|
||||||
var toDelete = getIdsForTag(tag);
|
|
||||||
for (var currentTagName in $scope.repository.tags) {
|
|
||||||
var currentTag = $scope.repository.tags[currentTagName];
|
|
||||||
if (currentTag != tag) {
|
|
||||||
for (var id in getIdsForTag(currentTag)) {
|
|
||||||
delete toDelete[id];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the matching list of images.
|
|
||||||
var images = [];
|
|
||||||
for (var i = 0; i < $scope.images.length; ++i) {
|
|
||||||
var image = $scope.images[i];
|
|
||||||
if (toDelete[image.id]) {
|
|
||||||
images.push(image);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
images.sort(function(a, b) {
|
|
||||||
var result = new Date(b.created) - new Date(a.created);
|
|
||||||
if (result != 0) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
return b.sort_index - a.sort_index;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$scope.tagSpecificImages = images;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.$watch('repository', refresh);
|
$scope.$watch('repository', refresh);
|
||||||
$scope.$watch('tag', refresh);
|
$scope.$watch('tag', refresh);
|
||||||
$scope.$watch('images', refresh);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return directiveDefinitionObject;
|
return directiveDefinitionObject;
|
||||||
|
|
|
@ -31,8 +31,8 @@ var DEPTH_WIDTH = 140;
|
||||||
/**
|
/**
|
||||||
* Based off of http://mbostock.github.io/d3/talk/20111018/tree.html by Mike Bostock (@mbostock)
|
* Based off of http://mbostock.github.io/d3/talk/20111018/tree.html by Mike Bostock (@mbostock)
|
||||||
*/
|
*/
|
||||||
function ImageHistoryTree(namespace, name, images, formatComment, formatTime, formatCommand,
|
function ImageHistoryTree(namespace, name, images, getTagsForImage, formatComment, formatTime,
|
||||||
opt_tagFilter) {
|
formatCommand, opt_tagFilter) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The namespace of the repo.
|
* The namespace of the repo.
|
||||||
|
@ -49,6 +49,11 @@ function ImageHistoryTree(namespace, name, images, formatComment, formatTime, fo
|
||||||
*/
|
*/
|
||||||
this.images_ = images;
|
this.images_ = images;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method to retrieve the tags for an image.
|
||||||
|
*/
|
||||||
|
this.getTagsForImage_ = getTagsForImage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Method to invoke to format a comment for an image.
|
* Method to invoke to format a comment for an image.
|
||||||
*/
|
*/
|
||||||
|
@ -424,7 +429,7 @@ ImageHistoryTree.prototype.buildRoot_ = function() {
|
||||||
"name": image.id.substr(0, 12),
|
"name": image.id.substr(0, 12),
|
||||||
"children": [],
|
"children": [],
|
||||||
"image": image,
|
"image": image,
|
||||||
"tags": image.tags,
|
"tags": this.getTagsForImage_(image),
|
||||||
"level": null
|
"level": null
|
||||||
};
|
};
|
||||||
imageByDockerId[image.id] = imageNode;
|
imageByDockerId[image.id] = imageNode;
|
||||||
|
@ -663,8 +668,9 @@ ImageHistoryTree.prototype.setTag_ = function(tagName) {
|
||||||
this.currentImage_ = null;
|
this.currentImage_ = null;
|
||||||
|
|
||||||
// Update the path.
|
// Update the path.
|
||||||
|
var that = this;
|
||||||
var tagImage = this.findImage_(function(image) {
|
var tagImage = this.findImage_(function(image) {
|
||||||
return image.tags.indexOf(tagName || '(no tag specified)') >= 0;
|
return tagName && (that.getTagsForImage_(image).indexOf(tagName) >= 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (tagImage) {
|
if (tagImage) {
|
||||||
|
|
|
@ -10,11 +10,11 @@
|
||||||
})
|
})
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
function RepoViewCtrl($scope, $routeParams, $location, $timeout, ApiService, UserService, AngularPollChannel) {
|
function RepoViewCtrl($scope, $routeParams, $location, $timeout, ApiService, UserService, AngularPollChannel, ImageLoaderService) {
|
||||||
$scope.namespace = $routeParams.namespace;
|
$scope.namespace = $routeParams.namespace;
|
||||||
$scope.name = $routeParams.name;
|
$scope.name = $routeParams.name;
|
||||||
|
|
||||||
$scope.imagesRequired = false;
|
var imageLoader = ImageLoaderService.getLoader($scope.namespace, $scope.name);
|
||||||
|
|
||||||
// Tab-enabled counters.
|
// Tab-enabled counters.
|
||||||
$scope.tagsShown = 0;
|
$scope.tagsShown = 0;
|
||||||
|
@ -25,8 +25,7 @@
|
||||||
$scope.viewScope = {
|
$scope.viewScope = {
|
||||||
'selectedTags': [],
|
'selectedTags': [],
|
||||||
'repository': null,
|
'repository': null,
|
||||||
'images': null,
|
'imageLoader': imageLoader,
|
||||||
'imagesResource': null,
|
|
||||||
'builds': null,
|
'builds': null,
|
||||||
'changesVisible': false
|
'changesVisible': false
|
||||||
};
|
};
|
||||||
|
@ -72,17 +71,6 @@
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
var loadImages = function(opt_callback) {
|
|
||||||
var params = {
|
|
||||||
'repository': $scope.namespace + '/' + $scope.name
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.viewScope.imagesResource = ApiService.listRepositoryImagesAsResource(params).get(function(resp) {
|
|
||||||
$scope.viewScope.images = resp.images;
|
|
||||||
opt_callback && opt_callback(resp.images);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
var loadRepositoryBuilds = function(callback) {
|
var loadRepositoryBuilds = function(callback) {
|
||||||
var params = {
|
var params = {
|
||||||
'repository': $scope.namespace + '/' + $scope.name,
|
'repository': $scope.namespace + '/' + $scope.name,
|
||||||
|
@ -149,14 +137,6 @@
|
||||||
}, 10);
|
}, 10);
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.requireImages = function() {
|
|
||||||
// Lazily load the repo's images if this is the first call to a tab
|
|
||||||
// that needs the images.
|
|
||||||
if ($scope.viewScope.images == null) {
|
|
||||||
loadImages();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.handleChangesState = function(value) {
|
$scope.handleChangesState = function(value) {
|
||||||
$scope.viewScope.changesVisible = value;
|
$scope.viewScope.changesVisible = value;
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,9 +2,9 @@
|
||||||
* Service which defines the various kinds of external notification and provides methods for
|
* Service which defines the various kinds of external notification and provides methods for
|
||||||
* easily looking up information about those kinds.
|
* easily looking up information about those kinds.
|
||||||
*/
|
*/
|
||||||
angular.module('quay').factory('ExternalNotificationData', ['Config', 'Features',
|
angular.module('quay').factory('ExternalNotificationData', ['Config', 'Features','VulnerabilityService',
|
||||||
|
|
||||||
function(Config, Features) {
|
function(Config, Features, VulnerabilityService) {
|
||||||
var externalNotificationData = {};
|
var externalNotificationData = {};
|
||||||
|
|
||||||
var events = [
|
var events = [
|
||||||
|
@ -43,6 +43,22 @@ function(Config, Features) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Features.SECURITY_SCANNER) {
|
||||||
|
events.push({
|
||||||
|
'id': 'vulnerability_found',
|
||||||
|
'title': 'Package Vulnerability Found',
|
||||||
|
'icon': 'fa-flag',
|
||||||
|
'fields': [
|
||||||
|
{
|
||||||
|
'name': 'level',
|
||||||
|
'type': 'enum',
|
||||||
|
'title': 'Minimum Severity Level',
|
||||||
|
'values': VulnerabilityService.LEVELS,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
var methods = [
|
var methods = [
|
||||||
{
|
{
|
||||||
'id': 'quay_notification',
|
'id': 'quay_notification',
|
||||||
|
|
110
static/js/services/image-loader-service.js
Normal file
110
static/js/services/image-loader-service.js
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
/**
|
||||||
|
* Helper service for tracking images needed by tags and caching them.
|
||||||
|
*/
|
||||||
|
angular.module('quay').factory('ImageLoaderService', ['ApiService', function(ApiService) {
|
||||||
|
var imageLoader = function(namespace, name) {
|
||||||
|
this.namespace = namespace;
|
||||||
|
this.name = name;
|
||||||
|
this.tagCache = {};
|
||||||
|
this.images = [];
|
||||||
|
this.imageMap = {};
|
||||||
|
this.imageTagMap = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
imageLoader.prototype.getTagSpecificImages = function(tag, callback) {
|
||||||
|
var errorDisplay = ApiService.errorDisplay('Could not load tag specific images', function() {
|
||||||
|
callback([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
var params = {
|
||||||
|
'repository': this.namespace + '/' + this.name,
|
||||||
|
'tag': tag,
|
||||||
|
'owned': true
|
||||||
|
};
|
||||||
|
|
||||||
|
ApiService.listTagImages(null, params).then(function(resp) {
|
||||||
|
callback(resp['images']);
|
||||||
|
}, errorDisplay);
|
||||||
|
};
|
||||||
|
|
||||||
|
imageLoader.prototype.getTagsForImage = function(image) {
|
||||||
|
return this.imageTagMap[image.id] || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
imageLoader.prototype.registerTagImages_ = function(tag, images) {
|
||||||
|
this.tagCache[tag] = images;
|
||||||
|
|
||||||
|
if (!images.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var that = this;
|
||||||
|
images.forEach(function(image) {
|
||||||
|
if (!that.imageMap[image.id]) {
|
||||||
|
that.imageMap[image.id] = image;
|
||||||
|
that.images.push(image);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var rootImage = images[0];
|
||||||
|
if (!this.imageTagMap[rootImage.id]) {
|
||||||
|
this.imageTagMap[rootImage.id] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.imageTagMap[rootImage.id].push(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
imageLoader.prototype.loadImages = function(tags, callback) {
|
||||||
|
var toLoad = [];
|
||||||
|
var that = this;
|
||||||
|
tags.forEach(function(tag) {
|
||||||
|
if (that.tagCache[tag]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toLoad.push(tag);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!toLoad.length) {
|
||||||
|
callback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var loadImages = function(index) {
|
||||||
|
if (index >= toLoad.length) {
|
||||||
|
callback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tag = toLoad[index];
|
||||||
|
var params = {
|
||||||
|
'repository': that.namespace + '/' + that.name,
|
||||||
|
'tag': tag,
|
||||||
|
};
|
||||||
|
|
||||||
|
ApiService.listTagImages(null, params).then(function(resp) {
|
||||||
|
that.registerTagImages_(tag, resp['images']);
|
||||||
|
loadImages(index + 1);
|
||||||
|
}, function() {
|
||||||
|
loadImages(index + 1);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
loadImages(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
imageLoader.prototype.reset = function() {
|
||||||
|
this.tagCache = {};
|
||||||
|
this.images = [];
|
||||||
|
this.imageMap = {};
|
||||||
|
this.imageTagMap = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
var imageLoaderService = {};
|
||||||
|
|
||||||
|
imageLoaderService.getLoader = function(namespace, name) {
|
||||||
|
return new imageLoader(namespace, name);
|
||||||
|
};
|
||||||
|
|
||||||
|
return imageLoaderService
|
||||||
|
}]);
|
|
@ -3,9 +3,9 @@
|
||||||
* in the sidebar) and provides helper methods for working with them.
|
* in the sidebar) and provides helper methods for working with them.
|
||||||
*/
|
*/
|
||||||
angular.module('quay').factory('NotificationService',
|
angular.module('quay').factory('NotificationService',
|
||||||
['$rootScope', '$interval', 'UserService', 'ApiService', 'StringBuilderService', 'PlanService', 'UserService', 'Config', '$location',
|
['$rootScope', '$interval', 'UserService', 'ApiService', 'StringBuilderService', 'PlanService', 'UserService', 'Config', '$location', 'VulnerabilityService',
|
||||||
|
|
||||||
function($rootScope, $interval, UserService, ApiService, StringBuilderService, PlanService, UserService, Config, $location) {
|
function($rootScope, $interval, UserService, ApiService, StringBuilderService, PlanService, UserService, Config, $location, VulnerabilityService) {
|
||||||
var notificationService = {
|
var notificationService = {
|
||||||
'user': null,
|
'user': null,
|
||||||
'notifications': [],
|
'notifications': [],
|
||||||
|
@ -120,6 +120,16 @@ function($rootScope, $interval, UserService, ApiService, StringBuilderService, P
|
||||||
return '/repository/' + metadata.repository + '/build?current=' + metadata.build_id;
|
return '/repository/' + metadata.repository + '/build?current=' + metadata.build_id;
|
||||||
},
|
},
|
||||||
'dismissable': true
|
'dismissable': true
|
||||||
|
},
|
||||||
|
'vulnerability_found': {
|
||||||
|
'level': function(metadata) {
|
||||||
|
var priority = metadata['vulnerability']['priority'];
|
||||||
|
return VulnerabilityService.LEVELS[priority].level;
|
||||||
|
},
|
||||||
|
'message': 'A {vulnerability.priority} vulnerability was detected in repository {repository}',
|
||||||
|
'page': function(metadata) {
|
||||||
|
return '/repository/' + metadata.repository + '?tab=tags';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -182,7 +192,13 @@ function($rootScope, $interval, UserService, ApiService, StringBuilderService, P
|
||||||
if (!kindInfo) {
|
if (!kindInfo) {
|
||||||
return 'notification-info';
|
return 'notification-info';
|
||||||
}
|
}
|
||||||
return 'notification-' + kindInfo['level'];
|
|
||||||
|
var level = kindInfo['level'];
|
||||||
|
if (level != null && typeof level != 'string') {
|
||||||
|
level = level(notification['metadata']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'notification-' + level;
|
||||||
};
|
};
|
||||||
|
|
||||||
notificationService.getClasses = function(notifications) {
|
notificationService.getClasses = function(notifications) {
|
||||||
|
|
|
@ -4,6 +4,27 @@
|
||||||
angular.module('quay').factory('StringBuilderService', ['$sce', 'UtilService', function($sce, UtilService) {
|
angular.module('quay').factory('StringBuilderService', ['$sce', 'UtilService', function($sce, UtilService) {
|
||||||
var stringBuilderService = {};
|
var stringBuilderService = {};
|
||||||
|
|
||||||
|
var fieldIcons = {
|
||||||
|
'inviter': 'user',
|
||||||
|
'username': 'user',
|
||||||
|
'user': 'user',
|
||||||
|
'email': 'envelope',
|
||||||
|
'activating_username': 'user',
|
||||||
|
'delegate_user': 'user',
|
||||||
|
'delegate_team': 'group',
|
||||||
|
'team': 'group',
|
||||||
|
'token': 'key',
|
||||||
|
'repo': 'hdd-o',
|
||||||
|
'robot': 'ci-robot',
|
||||||
|
'tag': 'tag',
|
||||||
|
'role': 'th-large',
|
||||||
|
'original_role': 'th-large',
|
||||||
|
'application_name': 'cloud',
|
||||||
|
'image': 'archive',
|
||||||
|
'original_image': 'archive',
|
||||||
|
'client_id': 'chain'
|
||||||
|
};
|
||||||
|
|
||||||
stringBuilderService.buildUrl = function(value_or_func, metadata) {
|
stringBuilderService.buildUrl = function(value_or_func, metadata) {
|
||||||
var url = value_or_func;
|
var url = value_or_func;
|
||||||
if (typeof url != 'string') {
|
if (typeof url != 'string') {
|
||||||
|
@ -43,28 +64,47 @@ angular.module('quay').factory('StringBuilderService', ['$sce', 'UtilService', f
|
||||||
return $sce.trustAsHtml(stringBuilderService.buildString(value_or_func, metadata, opt_codetag));
|
return $sce.trustAsHtml(stringBuilderService.buildString(value_or_func, metadata, opt_codetag));
|
||||||
};
|
};
|
||||||
|
|
||||||
stringBuilderService.buildString = function(value_or_func, metadata, opt_codetag) {
|
stringBuilderService.replaceField = function(description, prefix, key, value, opt_codetag) {
|
||||||
var fieldIcons = {
|
if (Array.isArray(value)) {
|
||||||
'inviter': 'user',
|
value = value.join(', ');
|
||||||
'username': 'user',
|
} else if (typeof value == 'object') {
|
||||||
'user': 'user',
|
for (var subkey in value) {
|
||||||
'email': 'envelope',
|
if (value.hasOwnProperty(subkey)) {
|
||||||
'activating_username': 'user',
|
description = stringBuilderService.replaceField(description, prefix + key + '.',
|
||||||
'delegate_user': 'user',
|
subkey, value[subkey], opt_codetag)
|
||||||
'delegate_team': 'group',
|
}
|
||||||
'team': 'group',
|
}
|
||||||
'token': 'key',
|
|
||||||
'repo': 'hdd-o',
|
|
||||||
'robot': 'ci-robot',
|
|
||||||
'tag': 'tag',
|
|
||||||
'role': 'th-large',
|
|
||||||
'original_role': 'th-large',
|
|
||||||
'application_name': 'cloud',
|
|
||||||
'image': 'archive',
|
|
||||||
'original_image': 'archive',
|
|
||||||
'client_id': 'chain'
|
|
||||||
};
|
|
||||||
|
|
||||||
|
return description
|
||||||
|
}
|
||||||
|
|
||||||
|
value = value.toString();
|
||||||
|
|
||||||
|
if (key.indexOf('image') >= 0) {
|
||||||
|
value = value.substr(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
var safe = UtilService.escapeHtmlString(value);
|
||||||
|
var markedDown = UtilService.getMarkedDown(value);
|
||||||
|
markedDown = markedDown.substr('<p>'.length, markedDown.length - '<p></p>'.length);
|
||||||
|
|
||||||
|
var icon = fieldIcons[key];
|
||||||
|
if (icon) {
|
||||||
|
if (icon.indexOf('ci-') < 0) {
|
||||||
|
icon = 'fa-' + icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
markedDown = '<i class="fa ' + icon + '"></i>' + markedDown;
|
||||||
|
}
|
||||||
|
|
||||||
|
var codeTag = opt_codetag || 'code';
|
||||||
|
description = description.replace('{' + prefix + key + '}',
|
||||||
|
'<' + codeTag + ' title="' + safe + '">' + markedDown + '</' + codeTag + '>');
|
||||||
|
|
||||||
|
return description
|
||||||
|
}
|
||||||
|
|
||||||
|
stringBuilderService.buildString = function(value_or_func, metadata, opt_codetag) {
|
||||||
var filters = {
|
var filters = {
|
||||||
'obj': function(value) {
|
'obj': function(value) {
|
||||||
if (!value) { return []; }
|
if (!value) { return []; }
|
||||||
|
@ -89,32 +129,7 @@ angular.module('quay').factory('StringBuilderService', ['$sce', 'UtilService', f
|
||||||
value = filters[key](value);
|
value = filters[key](value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
description = stringBuilderService.replaceField(description, '', key, value, opt_codetag);
|
||||||
value = value.join(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
value = value.toString();
|
|
||||||
|
|
||||||
if (key.indexOf('image') >= 0) {
|
|
||||||
value = value.substr(0, 12);
|
|
||||||
}
|
|
||||||
|
|
||||||
var safe = UtilService.escapeHtmlString(value);
|
|
||||||
var markedDown = UtilService.getMarkedDown(value);
|
|
||||||
markedDown = markedDown.substr('<p>'.length, markedDown.length - '<p></p>'.length);
|
|
||||||
|
|
||||||
var icon = fieldIcons[key];
|
|
||||||
if (icon) {
|
|
||||||
if (icon.indexOf('ci-') < 0) {
|
|
||||||
icon = 'fa-' + icon;
|
|
||||||
}
|
|
||||||
|
|
||||||
markedDown = '<i class="fa ' + icon + '"></i>' + markedDown;
|
|
||||||
}
|
|
||||||
|
|
||||||
var codeTag = opt_codetag || 'code';
|
|
||||||
description = description.replace('{' + key + '}',
|
|
||||||
'<' + codeTag + ' title="' + safe + '">' + markedDown + '</' + codeTag + '>');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return description.replace('\n', '<br>');
|
return description.replace('\n', '<br>');
|
||||||
|
|
98
static/js/services/vulnerability-service.js
Normal file
98
static/js/services/vulnerability-service.js
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
/**
|
||||||
|
* Service which provides helper methods for working with the vulnerability system.
|
||||||
|
*/
|
||||||
|
angular.module('quay').factory('VulnerabilityService', ['Config', function(Config) {
|
||||||
|
var vulnService = {};
|
||||||
|
|
||||||
|
// NOTE: This objects are used directly in the external-notification-data service, so make sure
|
||||||
|
// to update that code if the format here is changed.
|
||||||
|
vulnService.LEVELS = {
|
||||||
|
'Unknown': {
|
||||||
|
'title': 'Unknown',
|
||||||
|
'index': '6',
|
||||||
|
'level': 'info',
|
||||||
|
|
||||||
|
'description': 'Unknown is either a security problem that has not been assigned ' +
|
||||||
|
'to a priority yet or a priority that our system did not recognize',
|
||||||
|
'banner_required': false
|
||||||
|
},
|
||||||
|
|
||||||
|
'Negligible': {
|
||||||
|
'title': 'Negligible',
|
||||||
|
'index': '5',
|
||||||
|
'level': 'info',
|
||||||
|
|
||||||
|
'description': 'Negligible is technically a security problem, but is only theoretical ' +
|
||||||
|
'in nature, requires a very special situation, has almost no install base, ' +
|
||||||
|
'or does no real damage.',
|
||||||
|
'banner_required': false
|
||||||
|
},
|
||||||
|
|
||||||
|
'Low': {
|
||||||
|
'title': 'Low',
|
||||||
|
'index': '4',
|
||||||
|
'level': 'warning',
|
||||||
|
|
||||||
|
'description': 'Low is a security problem, but is hard to exploit due to environment, ' +
|
||||||
|
'requires a user-assisted attack, a small install base, or does very ' +
|
||||||
|
'little damage.',
|
||||||
|
'banner_required': false
|
||||||
|
},
|
||||||
|
|
||||||
|
'Medium': {
|
||||||
|
'title': 'Medium',
|
||||||
|
'value': 'Medium',
|
||||||
|
'index': '3',
|
||||||
|
'level': 'warning',
|
||||||
|
|
||||||
|
'description': 'Medium is a real security problem, and is exploitable for many people. ' +
|
||||||
|
'Includes network daemon denial of service attacks, cross-site scripting, ' +
|
||||||
|
'and gaining user privileges.',
|
||||||
|
'banner_required': false
|
||||||
|
},
|
||||||
|
|
||||||
|
'High': {
|
||||||
|
'title': 'High',
|
||||||
|
'value': 'High',
|
||||||
|
'index': '2',
|
||||||
|
'level': 'warning',
|
||||||
|
|
||||||
|
'description': 'High is a real problem, exploitable for many people in a default installation. ' +
|
||||||
|
'Includes serious remote denial of services, local root privilege escalations, ' +
|
||||||
|
'or data loss.',
|
||||||
|
'banner_required': false
|
||||||
|
},
|
||||||
|
|
||||||
|
'Critical': {
|
||||||
|
'title': 'Critical',
|
||||||
|
'value': 'Critical',
|
||||||
|
'index': '1',
|
||||||
|
'level': 'error',
|
||||||
|
|
||||||
|
'description': 'Critical is a world-burning problem, exploitable for nearly all people in ' +
|
||||||
|
'a installation of the package. Includes remote root privilege escalations, ' +
|
||||||
|
'or massive data loss.',
|
||||||
|
'banner_required': true
|
||||||
|
},
|
||||||
|
|
||||||
|
'Defcon1': {
|
||||||
|
'title': 'Defcon 1',
|
||||||
|
'value': 'Defcon1',
|
||||||
|
'index': '0',
|
||||||
|
'level': 'error',
|
||||||
|
|
||||||
|
'description': 'Defcon1 is a Critical problem which has been manually highlighted ' +
|
||||||
|
'by the Quay team. It requires immediate attention.',
|
||||||
|
'banner_required': true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
vulnService.getLevels = function() {
|
||||||
|
return Object.keys(vulnService.LEVELS).map(function(key) {
|
||||||
|
return vulnService.LEVELS[key];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return vulnService;
|
||||||
|
}]);
|
||||||
|
|
|
@ -34,8 +34,7 @@
|
||||||
|
|
||||||
<span class="cor-tab" tab-title="Visualize" tab-target="#changes"
|
<span class="cor-tab" tab-title="Visualize" tab-target="#changes"
|
||||||
tab-shown="handleChangesState(true)"
|
tab-shown="handleChangesState(true)"
|
||||||
tab-hidden="handleChangesState(false)"
|
tab-hidden="handleChangesState(false)">
|
||||||
tab-init="requireImages()">
|
|
||||||
<i class="fa fa-code-fork"></i>
|
<i class="fa fa-code-fork"></i>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
@ -64,10 +63,8 @@
|
||||||
<div id="tags" class="tab-pane">
|
<div id="tags" class="tab-pane">
|
||||||
<div class="repo-panel-tags"
|
<div class="repo-panel-tags"
|
||||||
repository="viewScope.repository"
|
repository="viewScope.repository"
|
||||||
images="viewScope.images"
|
image-loader="viewScope.imageLoader"
|
||||||
images-resource="viewScope.imagesResource"
|
|
||||||
selected-tags="viewScope.selectedTags"
|
selected-tags="viewScope.selectedTags"
|
||||||
get-images="getImages(callback)"
|
|
||||||
is-enabled="tagsShown"></div>
|
is-enabled="tagsShown"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -83,8 +80,7 @@
|
||||||
<div id="changes" class="tab-pane">
|
<div id="changes" class="tab-pane">
|
||||||
<div class="repo-panel-changes"
|
<div class="repo-panel-changes"
|
||||||
repository="viewScope.repository"
|
repository="viewScope.repository"
|
||||||
images="viewScope.images"
|
image-loader="viewScope.imageLoader"
|
||||||
images-resource="viewScope.imagesResource"
|
|
||||||
selected-tags="viewScope.selectedTags"
|
selected-tags="viewScope.selectedTags"
|
||||||
is-enabled="viewScope.changesVisible"></div>
|
is-enabled="viewScope.changesVisible"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
Binary file not shown.
|
@ -50,6 +50,8 @@ from endpoints.api.superuser import (SuperUserLogs, SuperUserList, SuperUserMana
|
||||||
SuperUserOrganizationManagement, SuperUserOrganizationList,
|
SuperUserOrganizationManagement, SuperUserOrganizationList,
|
||||||
SuperUserAggregateLogs)
|
SuperUserAggregateLogs)
|
||||||
|
|
||||||
|
from endpoints.api.secscan import RepositoryImagePackages, RepositoryTagVulnerabilities
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
app.register_blueprint(api_bp, url_prefix='/api')
|
app.register_blueprint(api_bp, url_prefix='/api')
|
||||||
|
@ -4210,18 +4212,54 @@ class TestOrganizationInvoiceField(ApiTestCase):
|
||||||
ApiTestCase.setUp(self)
|
ApiTestCase.setUp(self)
|
||||||
self._set_url(OrganizationInvoiceField, orgname='buynlarge', field_uuid='1234')
|
self._set_url(OrganizationInvoiceField, orgname='buynlarge', field_uuid='1234')
|
||||||
|
|
||||||
def test_get_anonymous(self):
|
def test_delete_anonymous(self):
|
||||||
self._run_test('DELETE', 403, None, None)
|
self._run_test('DELETE', 403, None, None)
|
||||||
|
|
||||||
def test_get_freshuser(self):
|
def test_delete_freshuser(self):
|
||||||
self._run_test('DELETE', 403, 'freshuser', None)
|
self._run_test('DELETE', 403, 'freshuser', None)
|
||||||
|
|
||||||
def test_get_reader(self):
|
def test_delete_reader(self):
|
||||||
self._run_test('DELETE', 403, 'reader', None)
|
self._run_test('DELETE', 403, 'reader', None)
|
||||||
|
|
||||||
def test_get_devtable(self):
|
def test_delete_devtable(self):
|
||||||
self._run_test('DELETE', 201, 'devtable', None)
|
self._run_test('DELETE', 201, 'devtable', None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepositoryTagVulnerabilities(ApiTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
ApiTestCase.setUp(self)
|
||||||
|
self._set_url(RepositoryTagVulnerabilities, repository='devtable/simple', tag='latest')
|
||||||
|
|
||||||
|
def test_get_anonymous(self):
|
||||||
|
self._run_test('GET', 401, None, None)
|
||||||
|
|
||||||
|
def test_get_freshuser(self):
|
||||||
|
self._run_test('GET', 403, 'freshuser', None)
|
||||||
|
|
||||||
|
def test_get_reader(self):
|
||||||
|
self._run_test('GET', 403, 'reader', None)
|
||||||
|
|
||||||
|
def test_get_devtable(self):
|
||||||
|
self._run_test('GET', 200, 'devtable', None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepositoryImagePackages(ApiTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
ApiTestCase.setUp(self)
|
||||||
|
self._set_url(RepositoryImagePackages, repository='devtable/simple', imageid='fake')
|
||||||
|
|
||||||
|
def test_get_anonymous(self):
|
||||||
|
self._run_test('GET', 401, None, None)
|
||||||
|
|
||||||
|
def test_get_freshuser(self):
|
||||||
|
self._run_test('GET', 403, 'freshuser', None)
|
||||||
|
|
||||||
|
def test_get_reader(self):
|
||||||
|
self._run_test('GET', 403, 'reader', None)
|
||||||
|
|
||||||
|
def test_get_devtable(self):
|
||||||
|
self._run_test('GET', 404, 'devtable', None)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
@ -1549,8 +1549,8 @@ class TestDeleteRepository(ApiTestCase):
|
||||||
model.build.create_repository_build(repository, delegate_token, {}, 'someid2', 'foobar2')
|
model.build.create_repository_build(repository, delegate_token, {}, 'someid2', 'foobar2')
|
||||||
|
|
||||||
# Create some notifications.
|
# Create some notifications.
|
||||||
model.notification.create_repo_notification(repository, 'repo_push', 'hipchat', {})
|
model.notification.create_repo_notification(repository, 'repo_push', 'hipchat', {}, {})
|
||||||
model.notification.create_repo_notification(repository, 'build_queued', 'slack', {})
|
model.notification.create_repo_notification(repository, 'build_queued', 'slack', {}, {})
|
||||||
|
|
||||||
# Create some logs.
|
# Create some logs.
|
||||||
model.log.log_action('push_repo', ADMIN_ACCESS_USER, repository=repository)
|
model.log.log_action('push_repo', ADMIN_ACCESS_USER, repository=repository)
|
||||||
|
@ -1984,7 +1984,7 @@ class TestRepositoryNotifications(ApiTestCase):
|
||||||
json = self.postJsonResponse(RepositoryNotificationList,
|
json = self.postJsonResponse(RepositoryNotificationList,
|
||||||
params=dict(repository=ADMIN_ACCESS_USER + '/simple'),
|
params=dict(repository=ADMIN_ACCESS_USER + '/simple'),
|
||||||
data=dict(config={'url': 'http://example.com'}, event='repo_push',
|
data=dict(config={'url': 'http://example.com'}, event='repo_push',
|
||||||
method='webhook'),
|
method='webhook', eventConfig={}),
|
||||||
expected_code=201)
|
expected_code=201)
|
||||||
|
|
||||||
self.assertEquals('repo_push', json['event'])
|
self.assertEquals('repo_push', json['event'])
|
||||||
|
@ -2024,7 +2024,8 @@ class TestRepositoryNotifications(ApiTestCase):
|
||||||
json = self.postJsonResponse(RepositoryNotificationList,
|
json = self.postJsonResponse(RepositoryNotificationList,
|
||||||
params=dict(repository=ADMIN_ACCESS_USER + '/simple'),
|
params=dict(repository=ADMIN_ACCESS_USER + '/simple'),
|
||||||
data=dict(config={'url': 'http://example.com'}, event='repo_push',
|
data=dict(config={'url': 'http://example.com'}, event='repo_push',
|
||||||
method='webhook', title='Some Notification'),
|
method='webhook', title='Some Notification',
|
||||||
|
eventConfig={}),
|
||||||
expected_code=201)
|
expected_code=201)
|
||||||
|
|
||||||
self.assertEquals('repo_push', json['event'])
|
self.assertEquals('repo_push', json['event'])
|
||||||
|
|
|
@ -55,3 +55,10 @@ class TestConfig(DefaultConfig):
|
||||||
FEATURE_GITHUB_BUILD = True
|
FEATURE_GITHUB_BUILD = True
|
||||||
|
|
||||||
CLOUDWATCH_NAMESPACE = None
|
CLOUDWATCH_NAMESPACE = None
|
||||||
|
|
||||||
|
FEATURE_SECURITY_SCANNER = True
|
||||||
|
SECURITY_SCANNER = {
|
||||||
|
'ENDPOINT': 'http://localhost/some/invalid/path',
|
||||||
|
'ENGINE_VERSION_TARGET': 1,
|
||||||
|
'API_CALL_TIMEOUT': 1
|
||||||
|
}
|
49
util/migrate/backfill_parent_id.py
Normal file
49
util/migrate/backfill_parent_id.py
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import logging
|
||||||
|
from data.database import Image, ImageStorage, db
|
||||||
|
from app import app
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def backfill_parent_id():
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
logger.debug('backfill_parent_id: Starting')
|
||||||
|
logger.debug('backfill_parent_id: This can be a LONG RUNNING OPERATION. Please wait!')
|
||||||
|
|
||||||
|
# Check for any images without parent
|
||||||
|
has_images = bool(list(Image
|
||||||
|
.select(Image.id)
|
||||||
|
.join(ImageStorage)
|
||||||
|
.where(Image.parent >> None, Image.ancestors != '/', ImageStorage.uploading == False)
|
||||||
|
.limit(1)))
|
||||||
|
|
||||||
|
if not has_images:
|
||||||
|
logger.debug('backfill_parent_id: No migration needed')
|
||||||
|
return
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# Load the record from the DB.
|
||||||
|
batch_images_ids = list(Image
|
||||||
|
.select(Image.id)
|
||||||
|
.join(ImageStorage)
|
||||||
|
.where(Image.parent >> None, Image.ancestors != '/', ImageStorage.uploading == False)
|
||||||
|
.limit(100))
|
||||||
|
|
||||||
|
if len(batch_images_ids) == 0:
|
||||||
|
logger.debug('backfill_parent_id: Completed')
|
||||||
|
return
|
||||||
|
|
||||||
|
for image_id in batch_images_ids:
|
||||||
|
with app.config['DB_TRANSACTION_FACTORY'](db):
|
||||||
|
try:
|
||||||
|
image = Image.select(Image.id, Image.ancestors).where(Image.id == image_id).get()
|
||||||
|
image.parent = image.ancestors.split('/')[-2]
|
||||||
|
image.save()
|
||||||
|
except Image.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
logging.getLogger('peewee').setLevel(logging.CRITICAL)
|
||||||
|
|
||||||
|
backfill_parent_id()
|
0
util/secscan/__init__.py
Normal file
0
util/secscan/__init__.py
Normal file
50
util/secscan/secscanendpoint.py
Normal file
50
util/secscan/secscanendpoint.py
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import features
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
|
||||||
|
from urlparse import urljoin
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class SecurityScanEndpoint(object):
|
||||||
|
""" Helper class for talking to the Security Scan service (Clair). """
|
||||||
|
def __init__(self, app, config_provider):
|
||||||
|
self.app = app
|
||||||
|
self.config_provider = config_provider
|
||||||
|
|
||||||
|
if not features.SECURITY_SCANNER:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.security_config = app.config['SECURITY_SCANNER']
|
||||||
|
|
||||||
|
self.certificate = self._getfilepath('CA_CERTIFICATE_FILENAME') or False
|
||||||
|
self.public_key = self._getfilepath('PUBLIC_KEY_FILENAME')
|
||||||
|
self.private_key = self._getfilepath('PRIVATE_KEY_FILENAME')
|
||||||
|
|
||||||
|
if self.public_key and self.private_key:
|
||||||
|
self.keys = (self.public_key, self.private_key)
|
||||||
|
else:
|
||||||
|
self.keys = None
|
||||||
|
|
||||||
|
def _getfilepath(self, config_key):
|
||||||
|
security_config = self.security_config
|
||||||
|
|
||||||
|
if config_key in security_config:
|
||||||
|
with self.config_provider.get_volume_file(security_config[config_key]) as f:
|
||||||
|
return f.name
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def call_api(self, relative_url, *args, **kwargs):
|
||||||
|
""" Issues an HTTP call to the sec API at the given relative URL. """
|
||||||
|
security_config = self.security_config
|
||||||
|
api_url = urljoin(security_config['ENDPOINT'], '/' + security_config['API_VERSION']) + '/'
|
||||||
|
url = urljoin(api_url, relative_url % args)
|
||||||
|
|
||||||
|
client = self.app.config['HTTPCLIENT']
|
||||||
|
timeout = security_config.get('API_TIMEOUT_SECONDS', 1)
|
||||||
|
logger.debug('Looking up sec information: %s', url)
|
||||||
|
|
||||||
|
return client.get(url, params=kwargs, timeout=timeout, cert=self.keys,
|
||||||
|
verify=self.certificate)
|
217
workers/securityworker.py
Normal file
217
workers/securityworker.py
Normal file
|
@ -0,0 +1,217 @@
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
import features
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
|
||||||
|
from sys import exc_info
|
||||||
|
from peewee import JOIN_LEFT_OUTER
|
||||||
|
from app import app, storage, OVERRIDE_CONFIG_DIRECTORY
|
||||||
|
from workers.worker import Worker
|
||||||
|
from data.database import Image, ImageStorage, ImageStorageLocation, ImageStoragePlacement, db_random_func, UseThenDisconnect
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
BATCH_SIZE = 20
|
||||||
|
INDEXING_INTERVAL = 10
|
||||||
|
API_METHOD_INSERT = '/layers'
|
||||||
|
API_METHOD_VERSION = '/versions/engine'
|
||||||
|
|
||||||
|
def _get_image_to_export(version):
|
||||||
|
Parent = Image.alias()
|
||||||
|
ParentImageStorage = ImageStorage.alias()
|
||||||
|
rimages = []
|
||||||
|
|
||||||
|
# Without parent
|
||||||
|
candidates = (Image
|
||||||
|
.select(Image.docker_image_id, ImageStorage.uuid, ImageStorage.checksum)
|
||||||
|
.join(ImageStorage)
|
||||||
|
.where(Image.security_indexed_engine < version, Image.parent >> None, ImageStorage.uploading == False, ImageStorage.checksum != '')
|
||||||
|
.limit(BATCH_SIZE*10)
|
||||||
|
.alias('candidates'))
|
||||||
|
|
||||||
|
images = (Image
|
||||||
|
.select(candidates.c.docker_image_id, candidates.c.uuid, candidates.c.checksum)
|
||||||
|
.from_(candidates)
|
||||||
|
.order_by(db_random_func())
|
||||||
|
.tuples()
|
||||||
|
.limit(BATCH_SIZE))
|
||||||
|
|
||||||
|
for image in images:
|
||||||
|
rimages.append({'docker_image_id': image[0], 'storage_uuid': image[1], 'storage_checksum': image[2], 'parent_docker_image_id': None, 'parent_storage_uuid': None})
|
||||||
|
|
||||||
|
# With analyzed parent
|
||||||
|
candidates = (Image
|
||||||
|
.select(Image.docker_image_id, ImageStorage.uuid, ImageStorage.checksum, Parent.docker_image_id.alias('parent_docker_image_id'), ParentImageStorage.uuid.alias('parent_storage_uuid'))
|
||||||
|
.join(Parent, on=(Image.parent == Parent.id))
|
||||||
|
.join(ParentImageStorage, on=(ParentImageStorage.id == Parent.storage))
|
||||||
|
.switch(Image)
|
||||||
|
.join(ImageStorage)
|
||||||
|
.where(Image.security_indexed_engine < version, Parent.security_indexed == True, Parent.security_indexed_engine >= version, ImageStorage.uploading == False, ImageStorage.checksum != '')
|
||||||
|
.limit(BATCH_SIZE*10)
|
||||||
|
.alias('candidates'))
|
||||||
|
|
||||||
|
images = (Image
|
||||||
|
.select(candidates.c.docker_image_id, candidates.c.uuid, candidates.c.checksum, candidates.c.parent_docker_image_id, candidates.c.parent_storage_uuid)
|
||||||
|
.from_(candidates)
|
||||||
|
.order_by(db_random_func())
|
||||||
|
.tuples()
|
||||||
|
.limit(BATCH_SIZE))
|
||||||
|
|
||||||
|
for image in images:
|
||||||
|
rimages.append({'docker_image_id': image[0], 'storage_uuid': image[1], 'storage_checksum': image[2], 'parent_docker_image_id': image[3], 'parent_storage_uuid': image[4]})
|
||||||
|
|
||||||
|
# Re-shuffle, otherwise the images without parents will always be on the top
|
||||||
|
random.shuffle(rimages)
|
||||||
|
|
||||||
|
return rimages
|
||||||
|
|
||||||
|
def _get_storage_locations(uuid):
|
||||||
|
query = (ImageStoragePlacement
|
||||||
|
.select()
|
||||||
|
.join(ImageStorageLocation)
|
||||||
|
.switch(ImageStoragePlacement)
|
||||||
|
.join(ImageStorage, JOIN_LEFT_OUTER)
|
||||||
|
.where(ImageStorage.uuid == uuid))
|
||||||
|
|
||||||
|
locations = list()
|
||||||
|
for location in query:
|
||||||
|
locations.append(location.location.name)
|
||||||
|
|
||||||
|
return locations
|
||||||
|
|
||||||
|
def _update_image(image, indexed, version):
|
||||||
|
query = (Image
|
||||||
|
.select()
|
||||||
|
.join(ImageStorage)
|
||||||
|
.where(Image.docker_image_id == image['docker_image_id'], ImageStorage.uuid == image['storage_uuid']))
|
||||||
|
|
||||||
|
updated_images = list()
|
||||||
|
for image in query:
|
||||||
|
updated_images.append(image.id)
|
||||||
|
|
||||||
|
query = (Image
|
||||||
|
.update(security_indexed=indexed, security_indexed_engine=version)
|
||||||
|
.where(Image.id << updated_images))
|
||||||
|
query.execute()
|
||||||
|
|
||||||
|
class SecurityWorker(Worker):
|
||||||
|
def __init__(self):
|
||||||
|
super(SecurityWorker, self).__init__()
|
||||||
|
if self._load_configuration():
|
||||||
|
self.add_operation(self._index_images, INDEXING_INTERVAL)
|
||||||
|
|
||||||
|
def _load_configuration(self):
|
||||||
|
# Load configuration
|
||||||
|
config = app.config.get('SECURITY_SCANNER')
|
||||||
|
|
||||||
|
if not config or not 'ENDPOINT' in config or not 'ENGINE_VERSION_TARGET' in config or not 'DISTRIBUTED_STORAGE_PREFERENCE' in app.config:
|
||||||
|
logger.exception('No configuration found for the security worker')
|
||||||
|
return False
|
||||||
|
self._api = config['ENDPOINT']
|
||||||
|
self._target_version = config['ENGINE_VERSION_TARGET']
|
||||||
|
self._default_storage_locations = app.config['DISTRIBUTED_STORAGE_PREFERENCE']
|
||||||
|
|
||||||
|
self._ca_verification = False
|
||||||
|
self._cert = None
|
||||||
|
if 'CA_CERTIFICATE_FILENAME' in config:
|
||||||
|
self._ca_verification = os.path.join(OVERRIDE_CONFIG_DIRECTORY, config['CA_CERTIFICATE_FILENAME'])
|
||||||
|
if not os.path.isfile(self._ca_verification):
|
||||||
|
logger.exception('Could not find configured CA file')
|
||||||
|
return False
|
||||||
|
if 'PRIVATE_KEY_FILENAME' in config and 'PUBLIC_KEY_FILENAME' in config:
|
||||||
|
self._cert = (
|
||||||
|
os.path.join(OVERRIDE_CONFIG_DIRECTORY, config['PUBLIC_KEY_FILENAME']),
|
||||||
|
os.path.join(OVERRIDE_CONFIG_DIRECTORY, config['PRIVATE_KEY_FILENAME']),
|
||||||
|
)
|
||||||
|
if not os.path.isfile(self._cert[0]) or not os.path.isfile(self._cert[1]):
|
||||||
|
logger.exception('Could not find configured key pair files')
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _index_images(self):
|
||||||
|
with UseThenDisconnect(app.config):
|
||||||
|
while True:
|
||||||
|
# Get images to analyze
|
||||||
|
try:
|
||||||
|
images = _get_image_to_export(self._target_version)
|
||||||
|
except Image.DoesNotExist:
|
||||||
|
logger.debug('No more image to analyze')
|
||||||
|
return
|
||||||
|
|
||||||
|
for img in images:
|
||||||
|
# Get layer storage URL
|
||||||
|
path = storage.image_layer_path(img['storage_uuid'])
|
||||||
|
locations = self._default_storage_locations
|
||||||
|
if not storage.exists(locations, path):
|
||||||
|
locations = _get_storage_locations(img['storage_uuid'])
|
||||||
|
if not storage.exists(locations, path):
|
||||||
|
logger.warning('Could not find a valid location to download layer %s', img['docker_image_id']+'.'+img['storage_uuid'])
|
||||||
|
# Mark as analyzed because that error is most likely to occur during the pre-process, with the database copy
|
||||||
|
# when images are actually removed on the real database (and therefore in S3)
|
||||||
|
_update_image(img, False, self._target_version)
|
||||||
|
continue
|
||||||
|
uri = storage.get_direct_download_url(locations, path)
|
||||||
|
if uri == None:
|
||||||
|
# Local storage hack
|
||||||
|
uri = path
|
||||||
|
|
||||||
|
# Forge request
|
||||||
|
request = {
|
||||||
|
'ID': img['docker_image_id']+'.'+img['storage_uuid'],
|
||||||
|
'TarSum': img['storage_checksum'],
|
||||||
|
'Path': uri
|
||||||
|
}
|
||||||
|
|
||||||
|
if img['parent_docker_image_id'] is not None and img['parent_storage_uuid'] is not None:
|
||||||
|
request['ParentID'] = img['parent_docker_image_id']+'.'+img['parent_storage_uuid']
|
||||||
|
|
||||||
|
# Post request
|
||||||
|
try:
|
||||||
|
logger.info('Analyzing %s', request['ID'])
|
||||||
|
# Using invalid certificates doesn't return proper errors because of
|
||||||
|
# https://github.com/shazow/urllib3/issues/556
|
||||||
|
httpResponse = requests.post(self._api + API_METHOD_INSERT, json=request, cert=self._cert, verify=self._ca_verification)
|
||||||
|
except:
|
||||||
|
logger.exception('An exception occurred when analyzing layer ID %s : %s', request['ID'], exc_info()[0])
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
jsonResponse = httpResponse.json()
|
||||||
|
except:
|
||||||
|
logger.exception('An exception occurred when analyzing layer ID %s : the response is not valid JSON (%s)', request['ID'], httpResponse.text)
|
||||||
|
return
|
||||||
|
|
||||||
|
if httpResponse.status_code == 201:
|
||||||
|
# The layer has been successfully indexed
|
||||||
|
api_version = jsonResponse['Version']
|
||||||
|
if api_version < self._target_version:
|
||||||
|
logger.warning('An engine runs on version %d but the target version is %d')
|
||||||
|
_update_image(img, True, api_version)
|
||||||
|
logger.info('Layer ID %s : analyzed successfully', request['ID'])
|
||||||
|
else:
|
||||||
|
if 'Message' in jsonResponse:
|
||||||
|
if 'OS and/or package manager are not supported' in jsonResponse['Message']:
|
||||||
|
# The current engine could not index this layer
|
||||||
|
logger.warning('A warning event occurred when analyzing layer ID %s : %s', request['ID'], jsonResponse['Message'])
|
||||||
|
# Hopefully, there is no version lower than the target one running
|
||||||
|
_update_image(img, False, self._target_version)
|
||||||
|
else:
|
||||||
|
logger.exception('An exception occurred when analyzing layer ID %s : %d %s', request['ID'], httpResponse.status_code, jsonResponse['Message'])
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
logger.exception('An exception occurred when analyzing layer ID %s : %d', request['ID'], httpResponse.status_code)
|
||||||
|
return
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
logging.getLogger('requests').setLevel(logging.WARNING)
|
||||||
|
logging.getLogger('apscheduler').setLevel(logging.CRITICAL)
|
||||||
|
|
||||||
|
if not features.SECURITY_SCANNER:
|
||||||
|
logger.debug('Security scanner disabled; skipping')
|
||||||
|
while True:
|
||||||
|
time.sleep(100000)
|
||||||
|
|
||||||
|
worker = SecurityWorker()
|
||||||
|
worker.start()
|
Reference in a new issue