Basic labels support
Adds basic labels support to the registry code (V2), and the API. Note that this does not yet add any UI related support.
This commit is contained in:
parent
427070b453
commit
608ffd9663
24 changed files with 907 additions and 36 deletions
2
app.py
2
app.py
|
@ -40,6 +40,7 @@ from util.config.superusermanager import SuperUserManager
|
|||
from util.secscan.api import SecurityScannerAPI
|
||||
from util.metrics.metricqueue import MetricQueue
|
||||
from util.metrics.prometheus import PrometheusPlugin
|
||||
from util.label_validator import LabelValidator
|
||||
|
||||
OVERRIDE_CONFIG_DIRECTORY = 'conf/stack/'
|
||||
OVERRIDE_CONFIG_YAML_FILENAME = 'conf/stack/config.yaml'
|
||||
|
@ -183,6 +184,7 @@ userevents = UserEventsBuilderModule(app)
|
|||
superusers = SuperUserManager(app)
|
||||
signer = Signer(app, config_provider)
|
||||
instance_keys = InstanceKeys(app)
|
||||
label_validator = LabelValidator(app)
|
||||
|
||||
start_cloudwatch_sender(metric_queue, app)
|
||||
|
||||
|
|
12
config.py
12
config.py
|
@ -357,3 +357,15 @@ class DefaultConfig(object):
|
|||
|
||||
# URL that specifies the location of the prometheus stats aggregator.
|
||||
PROMETHEUS_AGGREGATOR_URL = 'http://localhost:9092'
|
||||
|
||||
# Reverse DNS prefixes that are reserved for internal use on labels and should not be allowable
|
||||
# to be set via the API.
|
||||
DEFAULT_LABEL_KEY_RESERVED_PREFIXES = ['com.docker.', 'io.docker.', 'org.dockerproject.',
|
||||
'org.opencontainers.', 'io.cncf.',
|
||||
'io.kubernetes.', 'io.k8s.',
|
||||
'io.quay', 'com.coreos', 'com.tectonic',
|
||||
'internal', 'quay']
|
||||
|
||||
# Overridable list of reverse DNS prefixes that are reserved for internal use on labels.
|
||||
LABEL_KEY_RESERVED_PREFIXES = []
|
||||
|
||||
|
|
|
@ -423,8 +423,7 @@ class Repository(BaseModel):
|
|||
# These models don't need to use transitive deletes, because the referenced objects
|
||||
# are cleaned up directly
|
||||
skip_transitive_deletes = {RepositoryTag, RepositoryBuild, RepositoryBuildTrigger, BlobUpload,
|
||||
Image, TagManifest, DerivedStorageForImage}
|
||||
|
||||
Image, TagManifest, TagManifestLabel, Label, DerivedStorageForImage}
|
||||
delete_instance_filtered(self, Repository, delete_nullable, skip_transitive_deletes)
|
||||
|
||||
|
||||
|
@ -920,5 +919,40 @@ class ServiceKey(BaseModel):
|
|||
approval = ForeignKeyField(ServiceKeyApproval, null=True)
|
||||
|
||||
|
||||
class MediaType(BaseModel):
|
||||
""" MediaType is an enumeration of the possible formats of various objects in the data model. """
|
||||
name = CharField(index=True, unique=True)
|
||||
|
||||
|
||||
class LabelSourceType(BaseModel):
|
||||
""" LabelSourceType is an enumeration of the possible sources for a label. """
|
||||
name = CharField(index=True, unique=True)
|
||||
mutable = BooleanField(default=False)
|
||||
|
||||
|
||||
class Label(BaseModel):
|
||||
""" Label represents user-facing metadata associated with another entry in the
|
||||
database (e.g. a Manifest). """
|
||||
uuid = CharField(default=uuid_generator, index=True, unique=True)
|
||||
key = CharField(index=True)
|
||||
value = TextField()
|
||||
media_type = ForeignKeyField(MediaType)
|
||||
source_type = ForeignKeyField(LabelSourceType)
|
||||
|
||||
|
||||
class TagManifestLabel(BaseModel):
|
||||
""" Mapping from a tag manifest to a label. """
|
||||
repository = ForeignKeyField(Repository, index=True)
|
||||
annotated = ForeignKeyField(TagManifest, index=True)
|
||||
label = ForeignKeyField(Label)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
read_slaves = (read_slave,)
|
||||
indexes = (
|
||||
(('annotated', 'label'), True),
|
||||
)
|
||||
|
||||
|
||||
is_model = lambda x: inspect.isclass(x) and issubclass(x, BaseModel) and x is not BaseModel
|
||||
all_models = [model[1] for model in inspect.getmembers(sys.modules[__name__], is_model)]
|
||||
|
|
|
@ -0,0 +1,89 @@
|
|||
"""Add labels to the schema
|
||||
|
||||
Revision ID: c9b91bee7554
|
||||
Revises: 983247d75af3
|
||||
Create Date: 2016-08-22 15:40:25.226541
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'c9b91bee7554'
|
||||
down_revision = '983247d75af3'
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from util.migrate import UTF8LongText, UTF8CharField
|
||||
|
||||
def upgrade(tables):
|
||||
### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('labelsourcetype',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('mutable', sa.Boolean(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_labelsourcetype'))
|
||||
)
|
||||
op.create_index('labelsourcetype_name', 'labelsourcetype', ['name'], unique=True)
|
||||
op.create_table('mediatype',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_mediatype'))
|
||||
)
|
||||
op.create_index('mediatype_name', 'mediatype', ['name'], unique=True)
|
||||
op.create_table('label',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('uuid', sa.String(length=255), nullable=False),
|
||||
sa.Column('key', UTF8CharField(length=255), nullable=False),
|
||||
sa.Column('value', UTF8LongText(), nullable=False),
|
||||
sa.Column('media_type_id', sa.Integer(), nullable=False),
|
||||
sa.Column('source_type_id', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['media_type_id'], ['mediatype.id'], name=op.f('fk_label_media_type_id_mediatype')),
|
||||
sa.ForeignKeyConstraint(['source_type_id'], ['labelsourcetype.id'], name=op.f('fk_label_source_type_id_labelsourcetype')),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_label'))
|
||||
)
|
||||
op.create_index('label_key', 'label', ['key'], unique=False)
|
||||
op.create_index('label_media_type_id', 'label', ['media_type_id'], unique=False)
|
||||
op.create_index('label_source_type_id', 'label', ['source_type_id'], unique=False)
|
||||
op.create_index('label_uuid', 'label', ['uuid'], unique=True)
|
||||
op.create_table('tagmanifestlabel',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('repository_id', sa.Integer(), nullable=False),
|
||||
sa.Column('annotated_id', sa.Integer(), nullable=False),
|
||||
sa.Column('label_id', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['annotated_id'], ['tagmanifest.id'], name=op.f('fk_tagmanifestlabel_annotated_id_tagmanifest')),
|
||||
sa.ForeignKeyConstraint(['label_id'], ['label.id'], name=op.f('fk_tagmanifestlabel_label_id_label')),
|
||||
sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], name=op.f('fk_tagmanifestlabel_repository_id_repository')),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_tagmanifestlabel'))
|
||||
)
|
||||
op.create_index('tagmanifestlabel_annotated_id', 'tagmanifestlabel', ['annotated_id'], unique=False)
|
||||
op.create_index('tagmanifestlabel_annotated_id_label_id', 'tagmanifestlabel', ['annotated_id', 'label_id'], unique=True)
|
||||
op.create_index('tagmanifestlabel_label_id', 'tagmanifestlabel', ['label_id'], unique=False)
|
||||
op.create_index('tagmanifestlabel_repository_id', 'tagmanifestlabel', ['repository_id'], unique=False)
|
||||
### end Alembic commands ###
|
||||
|
||||
op.bulk_insert(tables.logentrykind, [
|
||||
{'name':'manifest_label_add'},
|
||||
{'name':'manifest_label_delete'},
|
||||
])
|
||||
|
||||
op.bulk_insert(tables.mediatype, [
|
||||
{'name':'text/plain'},
|
||||
{'name':'application/json'},
|
||||
])
|
||||
|
||||
op.bulk_insert(tables.labelsourcetype, [
|
||||
{'name':'manifest', 'mutable': False},
|
||||
{'name':'api', 'mutable': True},
|
||||
{'name':'internal', 'mutable': False},
|
||||
])
|
||||
|
||||
|
||||
def downgrade(tables):
|
||||
op.execute(tables.logentrykind.delete().where(tables.logentrykind.c.name == op.inline_literal('manifest_label_add')))
|
||||
op.execute(tables.logentrykind.delete().where(tables.logentrykind.c.name == op.inline_literal('manifest_label_delete')))
|
||||
|
||||
### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('tagmanifestlabel')
|
||||
op.drop_table('label')
|
||||
op.drop_table('mediatype')
|
||||
op.drop_table('labelsourcetype')
|
||||
### end Alembic commands ###
|
|
@ -5,9 +5,18 @@ class DataModelException(Exception):
|
|||
pass
|
||||
|
||||
|
||||
class InvalidLabelKeyException(DataModelException):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidMediaTypeException(DataModelException):
|
||||
pass
|
||||
|
||||
|
||||
class BlobDoesNotExist(DataModelException):
|
||||
pass
|
||||
|
||||
|
||||
class TorrentInfoDoesNotExist(DataModelException):
|
||||
pass
|
||||
|
||||
|
@ -105,7 +114,23 @@ config = Config()
|
|||
|
||||
# There MUST NOT be any circular dependencies between these subsections. If there are fix it by
|
||||
# moving the minimal number of things to _basequery
|
||||
# TODO document the methods and modules for each one of the submodules below.
|
||||
from data.model import (blob, build, image, log, notification, oauth, organization, permission,
|
||||
repository, service_keys, storage, tag, team, token, user, release,
|
||||
modelutil)
|
||||
from data.model import (
|
||||
blob,
|
||||
build,
|
||||
image,
|
||||
label,
|
||||
log,
|
||||
modelutil,
|
||||
notification,
|
||||
oauth,
|
||||
organization,
|
||||
permission,
|
||||
release,
|
||||
repository,
|
||||
service_keys,
|
||||
storage,
|
||||
tag,
|
||||
team,
|
||||
token,
|
||||
user,
|
||||
)
|
||||
|
|
121
data/model/label.py
Normal file
121
data/model/label.py
Normal file
|
@ -0,0 +1,121 @@
|
|||
import logging
|
||||
|
||||
from data.database import Label, TagManifestLabel, MediaType, LabelSourceType, db_transaction
|
||||
from data.model import InvalidLabelKeyException, InvalidMediaTypeException, DataModelException
|
||||
from data.model._basequery import prefix_search
|
||||
from util.validation import validate_label_key
|
||||
from util.validation import is_json
|
||||
from cachetools import lru_cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_label_source_types():
|
||||
source_type_map = {}
|
||||
for kind in LabelSourceType.select():
|
||||
source_type_map[kind.id] = kind.name
|
||||
source_type_map[kind.name] = kind.id
|
||||
|
||||
return source_type_map
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_media_types():
|
||||
media_type_map = {}
|
||||
for kind in MediaType.select():
|
||||
media_type_map[kind.id] = kind.name
|
||||
media_type_map[kind.name] = kind.id
|
||||
|
||||
return media_type_map
|
||||
|
||||
|
||||
def _get_label_source_type_id(name):
|
||||
kinds = get_label_source_types()
|
||||
return kinds[name]
|
||||
|
||||
|
||||
def _get_media_type_id(name):
|
||||
kinds = get_media_types()
|
||||
return kinds[name]
|
||||
|
||||
|
||||
def create_manifest_label(tag_manifest, key, value, source_type_name, media_type_name=None):
|
||||
""" Creates a new manifest label on a specific tag manifest. """
|
||||
if not key:
|
||||
raise InvalidLabelKeyException()
|
||||
|
||||
# Note that we don't prevent invalid label names coming from the manifest to be stored, as Docker
|
||||
# does not currently prevent them from being put into said manifests.
|
||||
if not validate_label_key(key) and source_type_name != 'manifest':
|
||||
raise InvalidLabelKeyException()
|
||||
|
||||
# Find the matching media type. If none specified, we infer.
|
||||
if media_type_name is None:
|
||||
media_type_name = 'text/plain'
|
||||
if is_json(value):
|
||||
media_type_name = 'application/json'
|
||||
|
||||
media_type_id = _get_media_type_id(media_type_name)
|
||||
if media_type_id is None:
|
||||
raise InvalidMediaTypeException
|
||||
|
||||
source_type_id = _get_label_source_type_id(source_type_name)
|
||||
|
||||
with db_transaction():
|
||||
label = Label.create(key=key, value=value, source_type=source_type_id, media_type=media_type_id)
|
||||
TagManifestLabel.create(annotated=tag_manifest, label=label,
|
||||
repository=tag_manifest.tag.repository)
|
||||
|
||||
return label
|
||||
|
||||
|
||||
def list_manifest_labels(tag_manifest, prefix_filter=None):
|
||||
""" Lists all labels found on the given tag manifest. """
|
||||
query = (Label.select(Label, MediaType)
|
||||
.join(MediaType)
|
||||
.switch(Label)
|
||||
.join(LabelSourceType)
|
||||
.switch(Label)
|
||||
.join(TagManifestLabel)
|
||||
.where(TagManifestLabel.annotated == tag_manifest))
|
||||
|
||||
if prefix_filter is not None:
|
||||
query = query.where(prefix_search(Label.key, prefix_filter))
|
||||
|
||||
return query
|
||||
|
||||
|
||||
def get_manifest_label(label_uuid, tag_manifest):
|
||||
""" Retrieves the manifest label on the tag manifest with the given ID. """
|
||||
try:
|
||||
return (Label.select(Label, LabelSourceType)
|
||||
.join(LabelSourceType)
|
||||
.where(Label.uuid == label_uuid)
|
||||
.switch(Label)
|
||||
.join(TagManifestLabel)
|
||||
.where(TagManifestLabel.annotated == tag_manifest)
|
||||
.get())
|
||||
except Label.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
def delete_manifest_label(label_uuid, tag_manifest):
|
||||
""" Deletes the manifest label on the tag manifest with the given ID. """
|
||||
|
||||
# Find the label itself.
|
||||
label = get_manifest_label(label_uuid, tag_manifest)
|
||||
if label is None:
|
||||
return None
|
||||
|
||||
if not label.source_type.mutable:
|
||||
raise DataModelException('Cannot delete immutable label')
|
||||
|
||||
# Delete the mapping record and label.
|
||||
deleted_count = TagManifestLabel.delete().where(TagManifestLabel.label == label).execute()
|
||||
if deleted_count != 1:
|
||||
logger.warning('More than a single label deleted for matching label %s', label_uuid)
|
||||
|
||||
label.delete_instance(recursive=False)
|
||||
return label
|
||||
|
|
@ -10,7 +10,8 @@ from data.model import (DataModelException, tag, db_transaction, storage, permis
|
|||
from data.database import (Repository, Namespace, RepositoryTag, Star, Image, User,
|
||||
Visibility, RepositoryPermission, RepositoryActionCount,
|
||||
Role, RepositoryAuthorizedEmail, TagManifest, DerivedStorageForImage,
|
||||
get_epoch_timestamp, db_random_func)
|
||||
Label, TagManifestLabel, db_for_update, get_epoch_timestamp,
|
||||
db_random_func)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -50,11 +51,24 @@ def _purge_all_repository_tags(namespace_name, repository_name):
|
|||
raise DataModelException('Invalid repository \'%s/%s\'' %
|
||||
(namespace_name, repository_name))
|
||||
|
||||
# Delete all manifests.
|
||||
# Finds all the tags to delete.
|
||||
repo_tags = list(RepositoryTag.select().where(RepositoryTag.repository == repo.id))
|
||||
if not repo_tags:
|
||||
return
|
||||
|
||||
# Find all labels to delete.
|
||||
labels = list(TagManifestLabel
|
||||
.select(TagManifestLabel.label)
|
||||
.where(TagManifestLabel.repository == repo))
|
||||
|
||||
# Delete all the mapping entries.
|
||||
TagManifestLabel.delete().where(TagManifestLabel.repository == repo).execute()
|
||||
|
||||
# Delete all the matching labels.
|
||||
if labels:
|
||||
Label.delete().where(Label.id << [label.id for label in labels]).execute()
|
||||
|
||||
# Delete all the manifests.
|
||||
TagManifest.delete().where(TagManifest.tag << repo_tags).execute()
|
||||
|
||||
# Delete all tags.
|
||||
|
|
|
@ -5,7 +5,8 @@ from uuid import uuid4
|
|||
from data.model import (image, db_transaction, DataModelException, _basequery,
|
||||
InvalidManifestException)
|
||||
from data.database import (RepositoryTag, Repository, Image, ImageStorage, Namespace, TagManifest,
|
||||
RepositoryNotification, get_epoch_timestamp, db_for_update)
|
||||
RepositoryNotification, Label, TagManifestLabel, get_epoch_timestamp,
|
||||
db_for_update)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -150,6 +151,22 @@ def garbage_collect_tags(repo):
|
|||
|
||||
num_deleted_manifests = 0
|
||||
if len(manifests_to_delete) > 0:
|
||||
# Find the set of IDs for all the labels to delete.
|
||||
labels = list(TagManifestLabel
|
||||
.select(TagManifestLabel.id)
|
||||
.where(TagManifestLabel.annotated << manifests_to_delete))
|
||||
|
||||
if len(labels) > 0:
|
||||
# Delete the mapping entries.
|
||||
(TagManifestLabel
|
||||
.delete()
|
||||
.where(TagManifestLabel.annotated << manifests_to_delete)
|
||||
.execute())
|
||||
|
||||
# Delete the labels themselves.
|
||||
Label.delete().where(Label.id << [label.id for label in labels]).execute()
|
||||
|
||||
# Delete the tag manifests themselves.
|
||||
num_deleted_manifests = (TagManifest
|
||||
.delete()
|
||||
.where(TagManifest.id << manifests_to_delete)
|
||||
|
@ -234,15 +251,22 @@ def revert_tag(repo_obj, tag_name, docker_image_id):
|
|||
|
||||
def store_tag_manifest(namespace, repo_name, tag_name, docker_image_id, manifest_digest,
|
||||
manifest_data):
|
||||
""" Stores a tag manifest for a specific tag name in the database. Returns the TagManifest
|
||||
object, as well as a boolean indicating whether the TagManifest was created or updated.
|
||||
"""
|
||||
with db_transaction():
|
||||
tag = create_or_update_tag(namespace, repo_name, tag_name, docker_image_id)
|
||||
|
||||
try:
|
||||
manifest = TagManifest.get(digest=manifest_digest)
|
||||
if manifest.tag == tag:
|
||||
return manifest, False
|
||||
|
||||
manifest.tag = tag
|
||||
manifest.save()
|
||||
return manifest, True
|
||||
except TagManifest.DoesNotExist:
|
||||
return TagManifest.create(tag=tag, digest=manifest_digest, json_data=manifest_data)
|
||||
return TagManifest.create(tag=tag, digest=manifest_digest, json_data=manifest_data), True
|
||||
|
||||
|
||||
def get_active_tag(namespace, repo_name, tag_name):
|
||||
|
@ -282,7 +306,7 @@ def load_manifest_by_digest(namespace, repo_name, digest):
|
|||
|
||||
def _load_repo_manifests(namespace, repo_name):
|
||||
return _tag_alive(TagManifest
|
||||
.select(TagManifest, RepositoryTag)
|
||||
.select(TagManifest, RepositoryTag, Repository)
|
||||
.join(RepositoryTag)
|
||||
.join(Repository)
|
||||
.join(Namespace, on=(Namespace.id == Repository.namespace_user))
|
||||
|
|
|
@ -364,6 +364,7 @@ import endpoints.api.discovery
|
|||
import endpoints.api.error
|
||||
import endpoints.api.image
|
||||
import endpoints.api.logs
|
||||
import endpoints.api.manifest
|
||||
import endpoints.api.organization
|
||||
import endpoints.api.permission
|
||||
import endpoints.api.prototype
|
||||
|
|
159
endpoints/api/manifest.py
Normal file
159
endpoints/api/manifest.py
Normal file
|
@ -0,0 +1,159 @@
|
|||
""" Manage the manifests of a repository. """
|
||||
|
||||
from app import label_validator
|
||||
from flask import request
|
||||
from endpoints.api import (resource, nickname, require_repo_read, require_repo_write,
|
||||
RepositoryParamResource, log_action, validate_json_request,
|
||||
path_param, parse_args, query_param, truthy_bool, abort, api)
|
||||
from endpoints.exception import NotFound
|
||||
from data import model
|
||||
|
||||
from digest import digest_tools
|
||||
|
||||
BASE_MANIFEST_ROUTE = '/v1/repository/<apirepopath:repository>/manifest/<regex("{0}"):manifestref>'
|
||||
MANIFEST_DIGEST_ROUTE = BASE_MANIFEST_ROUTE.format(digest_tools.DIGEST_PATTERN)
|
||||
ALLOWED_LABEL_MEDIA_TYPES = ['text/plain', 'application/json']
|
||||
|
||||
def label_view(label):
|
||||
view = {
|
||||
'id': label.uuid,
|
||||
'key': label.key,
|
||||
'value': label.value,
|
||||
'source_type': label.source_type.name,
|
||||
'media_type': label.media_type.name,
|
||||
}
|
||||
|
||||
return view
|
||||
|
||||
@resource(MANIFEST_DIGEST_ROUTE + '/labels')
|
||||
@path_param('repository', 'The full path of the repository. e.g. namespace/name')
|
||||
@path_param('manifestref', 'The digest of the manifest')
|
||||
class RepositoryManifestLabels(RepositoryParamResource):
|
||||
""" Resource for listing the labels on a specific repository manifest. """
|
||||
schemas = {
|
||||
'AddLabel': {
|
||||
'type': 'object',
|
||||
'description': 'Adds a label to a manifest',
|
||||
'required': [
|
||||
'key',
|
||||
'value',
|
||||
'media_type',
|
||||
],
|
||||
'properties': {
|
||||
'key': {
|
||||
'type': 'string',
|
||||
'description': 'The key for the label',
|
||||
},
|
||||
'value': {
|
||||
'type': 'string',
|
||||
'description': 'The value for the label',
|
||||
},
|
||||
'media_type': {
|
||||
'type': ['string'],
|
||||
'description': 'The media type for this label',
|
||||
'enum': ALLOWED_LABEL_MEDIA_TYPES,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@require_repo_read
|
||||
@nickname('listManifestLabels')
|
||||
@parse_args()
|
||||
@query_param('filter', 'If specified, only labels matching the given prefix will be returned',
|
||||
type=str, default=None)
|
||||
def get(self, namespace, repository, manifestref, parsed_args):
|
||||
try:
|
||||
tag_manifest = model.tag.load_manifest_by_digest(namespace, repository, manifestref)
|
||||
except model.DataModelException:
|
||||
raise NotFound()
|
||||
|
||||
labels = model.label.list_manifest_labels(tag_manifest, prefix_filter=parsed_args['filter'])
|
||||
return {
|
||||
'labels': [label_view(label) for label in labels]
|
||||
}
|
||||
|
||||
@require_repo_write
|
||||
@nickname('addManifestLabel')
|
||||
@validate_json_request('AddLabel')
|
||||
def post(self, namespace, repository, manifestref):
|
||||
""" Adds a new label into the tag manifest. """
|
||||
try:
|
||||
tag_manifest = model.tag.load_manifest_by_digest(namespace, repository, manifestref)
|
||||
except model.DataModelException:
|
||||
raise NotFound()
|
||||
|
||||
label_data = request.get_json()
|
||||
|
||||
# Check for any reserved prefixes.
|
||||
if label_validator.has_reserved_prefix(label_data['key']):
|
||||
abort(400, message='Label has a reserved prefix')
|
||||
|
||||
label = model.label.create_manifest_label(tag_manifest, label_data['key'],
|
||||
label_data['value'], 'api',
|
||||
media_type_name=label_data['media_type'])
|
||||
metadata = {
|
||||
'id': label.uuid,
|
||||
'key': label_data['key'],
|
||||
'value': label_data['value'],
|
||||
'manifest_digest': manifestref,
|
||||
'media_type': label_data['media_type'],
|
||||
}
|
||||
|
||||
log_action('manifest_label_add', namespace, metadata, repo=tag_manifest.tag.repository)
|
||||
|
||||
resp = {'label': label_view(label)}
|
||||
repo_string = '%s/%s' % (namespace, repository)
|
||||
headers = {
|
||||
'Location': api.url_for(ManageRepositoryManifestLabel, repository=repo_string,
|
||||
manifestref=manifestref, labelid=label.uuid),
|
||||
}
|
||||
return resp, 201, headers
|
||||
|
||||
|
||||
@resource(MANIFEST_DIGEST_ROUTE + '/labels/<labelid>')
|
||||
@path_param('repository', 'The full path of the repository. e.g. namespace/name')
|
||||
@path_param('manifestref', 'The digest of the manifest')
|
||||
@path_param('labelid', 'The ID of the label')
|
||||
class ManageRepositoryManifestLabel(RepositoryParamResource):
|
||||
""" Resource for managing the labels on a specific repository manifest. """
|
||||
@require_repo_read
|
||||
@nickname('getManifestLabel')
|
||||
@parse_args()
|
||||
def get(self, namespace, repository, manifestref, labelid):
|
||||
""" Retrieves the label with the specific ID under the manifest. """
|
||||
try:
|
||||
tag_manifest = model.tag.load_manifest_by_digest(namespace, repository, manifestref)
|
||||
except model.DataModelException:
|
||||
raise NotFound()
|
||||
|
||||
label = model.label.get_manifest_label(labelid, tag_manifest)
|
||||
if label is None:
|
||||
raise NotFound()
|
||||
|
||||
return label_view(label)
|
||||
|
||||
|
||||
@require_repo_write
|
||||
@nickname('deleteManifestLabel')
|
||||
def delete(self, namespace, repository, manifestref, labelid):
|
||||
""" Deletes an existing label from a manifest. """
|
||||
try:
|
||||
tag_manifest = model.tag.load_manifest_by_digest(namespace, repository, manifestref)
|
||||
except model.DataModelException:
|
||||
raise NotFound()
|
||||
|
||||
deleted = model.label.delete_manifest_label(labelid, tag_manifest)
|
||||
if deleted is None:
|
||||
raise NotFound()
|
||||
|
||||
metadata = {
|
||||
'id': labelid,
|
||||
'key': deleted.key,
|
||||
'value': deleted.value,
|
||||
'manifest_digest': manifestref
|
||||
}
|
||||
|
||||
log_action('manifest_label_delete', namespace, metadata, repo=tag_manifest.tag.repository)
|
||||
return 'Deleted', 204
|
||||
|
|
@ -47,7 +47,7 @@ JWS_ALGORITHM = 'RS256'
|
|||
|
||||
ImageMetadata = namedtuple('ImageMetadata', ['digest', 'v1_metadata', 'v1_metadata_str'])
|
||||
ExtractedV1Metadata = namedtuple('ExtractedV1Metadata', ['docker_id', 'parent', 'created',
|
||||
'comment', 'command'])
|
||||
'comment', 'command', 'labels'])
|
||||
|
||||
|
||||
_SIGNATURES_KEY = 'signatures'
|
||||
|
@ -144,9 +144,10 @@ class SignedManifest(object):
|
|||
if not 'id' in v1_metadata:
|
||||
raise ManifestInvalid(detail={'message': 'invalid manifest v1 history'})
|
||||
|
||||
labels = v1_metadata.get('config', {}).get('Labels', {}) or {}
|
||||
extracted = ExtractedV1Metadata(v1_metadata['id'], v1_metadata.get('parent'),
|
||||
v1_metadata.get('created'), v1_metadata.get('comment'),
|
||||
command)
|
||||
command, labels)
|
||||
yield ImageMetadata(image_digest, extracted, metadata_string)
|
||||
|
||||
@property
|
||||
|
@ -450,8 +451,12 @@ def _write_manifest_itself(namespace_name, repo_name, manifest):
|
|||
# Store the manifest pointing to the tag.
|
||||
manifest_digest = manifest.digest
|
||||
leaf_layer_id = images_map[layers[-1].v1_metadata.docker_id].docker_image_id
|
||||
model.tag.store_tag_manifest(namespace_name, repo_name, tag_name, leaf_layer_id, manifest_digest,
|
||||
manifest.bytes)
|
||||
tag_manifest, manifest_created = model.tag.store_tag_manifest(namespace_name, repo_name, tag_name,
|
||||
leaf_layer_id, manifest_digest,
|
||||
manifest.bytes)
|
||||
if manifest_created:
|
||||
for key, value in layers[-1].v1_metadata.labels.iteritems():
|
||||
model.label.create_manifest_label(tag_manifest, key, value, 'manifest')
|
||||
|
||||
# Queue all blob manifests for replication.
|
||||
# TODO(jschorr): Find a way to optimize this insertion.
|
||||
|
|
33
initdb.py
33
initdb.py
|
@ -12,9 +12,6 @@ from peewee import (SqliteDatabase, create_model_tables, drop_model_tables, save
|
|||
from itertools import count
|
||||
from uuid import UUID, uuid4
|
||||
from threading import Event
|
||||
from hashlib import sha256
|
||||
from Crypto.PublicKey import RSA
|
||||
from jwkest.jwk import RSAKey
|
||||
|
||||
from email.utils import formatdate
|
||||
from data.database import (db, all_models, Role, TeamRole, Visibility, LoginService,
|
||||
|
@ -22,7 +19,7 @@ from data.database import (db, all_models, Role, TeamRole, Visibility, LoginServ
|
|||
ImageStorageTransformation, ImageStorageSignatureKind,
|
||||
ExternalNotificationEvent, ExternalNotificationMethod, NotificationKind,
|
||||
QuayRegion, QuayService, UserRegion, OAuthAuthorizationCode,
|
||||
ServiceKeyApprovalType)
|
||||
ServiceKeyApprovalType, MediaType, LabelSourceType)
|
||||
from data import model
|
||||
from data.queue import WorkQueue
|
||||
from app import app, storage as store, tf
|
||||
|
@ -345,6 +342,9 @@ def initialize_database():
|
|||
|
||||
LogEntryKind.create(name='take_ownership')
|
||||
|
||||
LogEntryKind.create(name='manifest_label_add')
|
||||
LogEntryKind.create(name='manifest_label_delete')
|
||||
|
||||
ImageStorageLocation.create(name='local_eu')
|
||||
ImageStorageLocation.create(name='local_us')
|
||||
|
||||
|
@ -389,6 +389,13 @@ def initialize_database():
|
|||
QuayRegion.create(name='us')
|
||||
QuayService.create(name='quay')
|
||||
|
||||
MediaType.create(name='text/plain')
|
||||
MediaType.create(name='application/json')
|
||||
|
||||
LabelSourceType.create(name='manifest')
|
||||
LabelSourceType.create(name='api', mutable=True)
|
||||
LabelSourceType.create(name='internal')
|
||||
|
||||
|
||||
def wipe_database():
|
||||
logger.debug('Wiping all data from the DB.')
|
||||
|
@ -474,6 +481,24 @@ def populate_database(minimal=False, with_storage=False):
|
|||
|
||||
simple_repo = __generate_repository(with_storage, new_user_1, 'simple', 'Simple repository.', False,
|
||||
[], (4, [], ['latest', 'prod']))
|
||||
|
||||
# Add some labels to the latest tag's manifest.
|
||||
tag_manifest = model.tag.load_tag_manifest(new_user_1.username, 'simple', 'latest')
|
||||
first_label = model.label.create_manifest_label(tag_manifest, 'foo', 'bar', 'manifest')
|
||||
model.label.create_manifest_label(tag_manifest, 'foo', 'baz', 'api')
|
||||
model.label.create_manifest_label(tag_manifest, 'anotherlabel', '1234', 'internal')
|
||||
|
||||
label_metadata = {
|
||||
'key': 'foo',
|
||||
'value': 'bar',
|
||||
'id': first_label.id,
|
||||
'manifest_digest': tag_manifest.digest
|
||||
}
|
||||
|
||||
model.log.log_action('manifest_label_add', new_user_1.username, performer=new_user_1,
|
||||
timestamp=datetime.now(), metadata=label_metadata,
|
||||
repository=tag_manifest.tag.repository)
|
||||
|
||||
model.blob.initiate_upload(new_user_1.username, simple_repo.name, str(uuid4()), 'local_us', {})
|
||||
model.notification.create_repo_notification(simple_repo, 'repo_push', 'quay_notification', {}, {})
|
||||
|
||||
|
|
|
@ -230,6 +230,9 @@ angular.module('quay').directive('logsView', function () {
|
|||
}
|
||||
},
|
||||
|
||||
'manifest_label_add': 'Label {key} added to manifest {manifest_digest}',
|
||||
'manifest_label_delete': 'Label {key} deleted from manifest {manifest_digest}',
|
||||
|
||||
// Note: These are deprecated.
|
||||
'add_repo_webhook': 'Add webhook in repository {repo}',
|
||||
'delete_repo_webhook': 'Delete webhook in repository {repo}'
|
||||
|
@ -287,6 +290,8 @@ angular.module('quay').directive('logsView', function () {
|
|||
'service_key_extend': 'Extend Service Key Expiration',
|
||||
'service_key_rotate': 'Automatic rotation of Service Key',
|
||||
'take_ownership': 'Take Namespace Ownership',
|
||||
'manifest_label_add': 'Add Manifest Label',
|
||||
'manifest_label_delete': 'Delete Manifest Label',
|
||||
|
||||
// Note: these are deprecated.
|
||||
'add_repo_webhook': 'Add webhook',
|
||||
|
|
|
@ -22,7 +22,8 @@ angular.module('quay').factory('StringBuilderService', ['$sce', 'UtilService', f
|
|||
'application_name': 'cloud',
|
||||
'image': 'archive',
|
||||
'original_image': 'archive',
|
||||
'client_id': 'chain'
|
||||
'client_id': 'chain',
|
||||
'manifest_digest': 'link'
|
||||
};
|
||||
|
||||
var filters = {
|
||||
|
|
Binary file not shown.
|
@ -16,6 +16,7 @@ class assert_action_logged(object):
|
|||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
updated_count = self._get_log_count()
|
||||
error_msg = 'Missing new log entry of kind %s' % self.log_kind
|
||||
assert self.existing_count == (updated_count - 1), error_msg
|
||||
if exc_val is None:
|
||||
updated_count = self._get_log_count()
|
||||
error_msg = 'Missing new log entry of kind %s' % self.log_kind
|
||||
assert self.existing_count == (updated_count - 1), error_msg
|
||||
|
|
|
@ -1129,6 +1129,55 @@ class V1RegistryTests(V1RegistryPullMixin, V1RegistryPushMixin, RegistryTestsMix
|
|||
class V2RegistryTests(V2RegistryPullMixin, V2RegistryPushMixin, RegistryTestsMixin,
|
||||
RegistryTestCaseMixin, LiveServerTestCase):
|
||||
""" Tests for V2 registry. """
|
||||
def test_label_invalid_manifest(self):
|
||||
images = [{
|
||||
'id': 'someid',
|
||||
'config': {'Labels': None},
|
||||
'contents': 'somecontent'
|
||||
}]
|
||||
|
||||
self.do_push('devtable', 'newrepo', 'devtable', 'password', images=images)
|
||||
self.do_pull('devtable', 'newrepo', 'devtable', 'password')
|
||||
|
||||
def test_labels(self):
|
||||
# Push a new repo with the latest tag.
|
||||
images = [{
|
||||
'id': 'someid',
|
||||
'config': {'Labels': {'foo': 'bar', 'baz': 'meh', 'theoretically-invalid--label': 'foo'}},
|
||||
'contents': 'somecontent'
|
||||
}]
|
||||
|
||||
(_, digest) = self.do_push('devtable', 'newrepo', 'devtable', 'password', images=images)
|
||||
|
||||
self.conduct_api_login('devtable', 'password')
|
||||
labels = self.conduct('GET', '/api/v1/repository/devtable/newrepo/manifest/' + digest + '/labels').json()
|
||||
self.assertEquals(3, len(labels['labels']))
|
||||
|
||||
self.assertEquals('manifest', labels['labels'][0]['source_type'])
|
||||
self.assertEquals('manifest', labels['labels'][1]['source_type'])
|
||||
self.assertEquals('manifest', labels['labels'][2]['source_type'])
|
||||
|
||||
self.assertEquals('text/plain', labels['labels'][0]['media_type'])
|
||||
self.assertEquals('text/plain', labels['labels'][1]['media_type'])
|
||||
self.assertEquals('text/plain', labels['labels'][2]['media_type'])
|
||||
|
||||
def test_json_labels(self):
|
||||
# Push a new repo with the latest tag.
|
||||
images = [{
|
||||
'id': 'someid',
|
||||
'config': {'Labels': {'foo': 'bar', 'baz': '{"some": "json"}'}},
|
||||
'contents': 'somecontent'
|
||||
}]
|
||||
|
||||
(_, digest) = self.do_push('devtable', 'newrepo', 'devtable', 'password', images=images)
|
||||
|
||||
self.conduct_api_login('devtable', 'password')
|
||||
labels = self.conduct('GET', '/api/v1/repository/devtable/newrepo/manifest/' + digest + '/labels').json()
|
||||
self.assertEquals(2, len(labels['labels']))
|
||||
|
||||
self.assertEquals('text/plain', labels['labels'][0]['media_type'])
|
||||
self.assertEquals('application/json', labels['labels'][1]['media_type'])
|
||||
|
||||
def test_invalid_manifest_type(self):
|
||||
namespace = 'devtable'
|
||||
repository = 'somerepo'
|
||||
|
|
|
@ -52,6 +52,7 @@ from endpoints.api.superuser import (SuperUserLogs, SuperUserList, SuperUserMana
|
|||
SuperUserServiceKey, SuperUserServiceKeyApproval,
|
||||
SuperUserTakeOwnership)
|
||||
from endpoints.api.secscan import RepositoryImageSecurity
|
||||
from endpoints.api.manifest import RepositoryManifestLabels, ManageRepositoryManifestLabel
|
||||
|
||||
|
||||
try:
|
||||
|
@ -4298,5 +4299,54 @@ class TestRepositoryImageSecurity(ApiTestCase):
|
|||
self._run_test('GET', 404, 'devtable', None)
|
||||
|
||||
|
||||
class TestRepositoryManifestLabels(ApiTestCase):
|
||||
def setUp(self):
|
||||
ApiTestCase.setUp(self)
|
||||
self._set_url(RepositoryManifestLabels, repository='devtable/simple', manifestref='sha256:abcd')
|
||||
|
||||
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)
|
||||
|
||||
def test_post_anonymous(self):
|
||||
self._run_test('POST', 401, None, dict(key='foo', value='bar', media_type='text/plain'))
|
||||
|
||||
def test_post_freshuser(self):
|
||||
self._run_test('POST', 403, 'freshuser', dict(key='foo', value='bar', media_type='text/plain'))
|
||||
|
||||
def test_post_reader(self):
|
||||
self._run_test('POST', 403, 'reader', dict(key='foo', value='bar', media_type='text/plain'))
|
||||
|
||||
def test_post_devtable(self):
|
||||
self._run_test('POST', 404, 'devtable', dict(key='foo', value='bar', media_type='text/plain'))
|
||||
|
||||
|
||||
class TestManageRepositoryManifestLabel(ApiTestCase):
|
||||
def setUp(self):
|
||||
ApiTestCase.setUp(self)
|
||||
self._set_url(ManageRepositoryManifestLabel, repository='devtable/simple',
|
||||
manifestref='sha256:abcd', labelid='someid')
|
||||
|
||||
def test_delete_anonymous(self):
|
||||
self._run_test('DELETE', 401, None, None)
|
||||
|
||||
def test_delete_freshuser(self):
|
||||
self._run_test('DELETE', 403, 'freshuser', None)
|
||||
|
||||
def test_delete_reader(self):
|
||||
self._run_test('DELETE', 403, 'reader', None)
|
||||
|
||||
def test_delete_devtable(self):
|
||||
self._run_test('DELETE', 404, 'devtable', None)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
|
@ -65,6 +65,8 @@ from endpoints.api.superuser import (SuperUserLogs, SuperUserList, SuperUserMana
|
|||
from endpoints.api.secscan import RepositoryImageSecurity
|
||||
from endpoints.api.suconfig import (SuperUserRegistryStatus, SuperUserConfig, SuperUserConfigFile,
|
||||
SuperUserCreateInitialSuperUser)
|
||||
from endpoints.api.manifest import RepositoryManifestLabels, ManageRepositoryManifestLabel
|
||||
|
||||
|
||||
try:
|
||||
app.register_blueprint(api_bp, url_prefix='/api')
|
||||
|
@ -1765,6 +1767,15 @@ class TestDeleteRepository(ApiTestCase):
|
|||
RepositoryActionCount.create(repository=repository,
|
||||
date=datetime.datetime.now() - datetime.timedelta(days=5), count=6)
|
||||
|
||||
# Create some labels.
|
||||
tag_manifest = model.tag.load_tag_manifest(ADMIN_ACCESS_USER, 'complex', 'prod')
|
||||
model.label.create_manifest_label(tag_manifest, 'foo', 'bar', 'manifest')
|
||||
model.label.create_manifest_label(tag_manifest, 'foo', 'baz', 'manifest')
|
||||
model.label.create_manifest_label(tag_manifest, 'something', '{}', 'api',
|
||||
media_type_name='application/json')
|
||||
|
||||
model.label.create_manifest_label(tag_manifest, 'something', '{"some": "json"}', 'manifest')
|
||||
|
||||
# Delete the repository.
|
||||
with check_transitive_deletes():
|
||||
self.deleteResponse(Repository, params=dict(repository=self.COMPLEX_REPO))
|
||||
|
@ -3941,6 +3952,150 @@ class TestSuperUserKeyManagement(ApiTestCase):
|
|||
self.assertEquals('whazzup!?', json['approval']['notes'])
|
||||
|
||||
|
||||
class TestRepositoryManifestLabels(ApiTestCase):
|
||||
def test_basic_labels(self):
|
||||
self.login(ADMIN_ACCESS_USER)
|
||||
|
||||
# Find the manifest digest for the prod tag in the complex repo.
|
||||
tag_manifest = model.tag.load_tag_manifest(ADMIN_ACCESS_USER, 'complex', 'prod')
|
||||
repository = ADMIN_ACCESS_USER + '/complex'
|
||||
|
||||
# Check the existing labels on the complex repo, which should be empty
|
||||
json = self.getJsonResponse(RepositoryManifestLabels,
|
||||
params=dict(repository=repository, manifestref=tag_manifest.digest))
|
||||
|
||||
self.assertEquals(0, len(json['labels']))
|
||||
|
||||
# Add some labels to the manifest.
|
||||
with assert_action_logged('manifest_label_add'):
|
||||
label1 = self.postJsonResponse(RepositoryManifestLabels,
|
||||
params=dict(repository=repository,
|
||||
manifestref=tag_manifest.digest),
|
||||
data=dict(key='hello', value='world',
|
||||
media_type='text/plain'),
|
||||
expected_code=201)
|
||||
|
||||
|
||||
with assert_action_logged('manifest_label_add'):
|
||||
label2 = self.postJsonResponse(RepositoryManifestLabels,
|
||||
params=dict(repository=repository,
|
||||
manifestref=tag_manifest.digest),
|
||||
data=dict(key='hi', value='there',
|
||||
media_type='text/plain'),
|
||||
expected_code=201)
|
||||
|
||||
with assert_action_logged('manifest_label_add'):
|
||||
label3 = self.postJsonResponse(RepositoryManifestLabels,
|
||||
params=dict(repository=repository,
|
||||
manifestref=tag_manifest.digest),
|
||||
data=dict(key='hello', value='someone',
|
||||
media_type='application/json'),
|
||||
expected_code=201)
|
||||
|
||||
|
||||
# Ensure we have *3* labels
|
||||
json = self.getJsonResponse(RepositoryManifestLabels,
|
||||
params=dict(repository=repository,
|
||||
manifestref=tag_manifest.digest))
|
||||
|
||||
self.assertEquals(3, len(json['labels']))
|
||||
|
||||
self.assertNotEquals(label2['label']['id'], label1['label']['id'])
|
||||
self.assertNotEquals(label3['label']['id'], label1['label']['id'])
|
||||
self.assertNotEquals(label2['label']['id'], label3['label']['id'])
|
||||
|
||||
self.assertEquals('text/plain', label1['label']['media_type'])
|
||||
self.assertEquals('text/plain', label2['label']['media_type'])
|
||||
self.assertEquals('application/json', label3['label']['media_type'])
|
||||
|
||||
# Delete a label.
|
||||
with assert_action_logged('manifest_label_delete'):
|
||||
self.deleteResponse(ManageRepositoryManifestLabel,
|
||||
params=dict(repository=repository,
|
||||
manifestref=tag_manifest.digest,
|
||||
labelid=label1['label']['id']))
|
||||
|
||||
# Ensure the label is gone.
|
||||
json = self.getJsonResponse(RepositoryManifestLabels,
|
||||
params=dict(repository=repository,
|
||||
manifestref=tag_manifest.digest))
|
||||
|
||||
self.assertEquals(2, len(json['labels']))
|
||||
|
||||
# Check filtering.
|
||||
json = self.getJsonResponse(RepositoryManifestLabels,
|
||||
params=dict(repository=repository,
|
||||
manifestref=tag_manifest.digest,
|
||||
filter='hello'))
|
||||
|
||||
self.assertEquals(1, len(json['labels']))
|
||||
|
||||
|
||||
def test_prefixed_labels(self):
|
||||
self.login(ADMIN_ACCESS_USER)
|
||||
|
||||
# Find the manifest digest for the prod tag in the complex repo.
|
||||
tag_manifest = model.tag.load_tag_manifest(ADMIN_ACCESS_USER, 'complex', 'prod')
|
||||
repository = ADMIN_ACCESS_USER + '/complex'
|
||||
|
||||
self.postJsonResponse(RepositoryManifestLabels,
|
||||
params=dict(repository=repository,
|
||||
manifestref=tag_manifest.digest),
|
||||
data=dict(key='com.dockers.whatever', value='pants',
|
||||
media_type='text/plain'),
|
||||
expected_code=201)
|
||||
|
||||
|
||||
self.postJsonResponse(RepositoryManifestLabels,
|
||||
params=dict(repository=repository,
|
||||
manifestref=tag_manifest.digest),
|
||||
data=dict(key='my.cool.prefix.for.my.label', value='value',
|
||||
media_type='text/plain'),
|
||||
expected_code=201)
|
||||
|
||||
|
||||
|
||||
def test_add_invalid_media_type(self):
|
||||
self.login(ADMIN_ACCESS_USER)
|
||||
|
||||
tag_manifest = model.tag.load_tag_manifest(ADMIN_ACCESS_USER, 'complex', 'prod')
|
||||
repository = ADMIN_ACCESS_USER + '/complex'
|
||||
|
||||
self.postResponse(RepositoryManifestLabels,
|
||||
params=dict(repository=repository,
|
||||
manifestref=tag_manifest.digest),
|
||||
data=dict(key='hello', value='world', media_type='some/invalid'),
|
||||
expected_code=400)
|
||||
|
||||
|
||||
def test_add_invalid_key(self):
|
||||
self.login(ADMIN_ACCESS_USER)
|
||||
|
||||
tag_manifest = model.tag.load_tag_manifest(ADMIN_ACCESS_USER, 'complex', 'prod')
|
||||
repository = ADMIN_ACCESS_USER + '/complex'
|
||||
|
||||
# Try to add an empty label key.
|
||||
self.postResponse(RepositoryManifestLabels,
|
||||
params=dict(repository=repository,
|
||||
manifestref=tag_manifest.digest),
|
||||
data=dict(key='', value='world'),
|
||||
expected_code=400)
|
||||
|
||||
# Try to add an invalid label key.
|
||||
self.postResponse(RepositoryManifestLabels,
|
||||
params=dict(repository=repository,
|
||||
manifestref=tag_manifest.digest),
|
||||
data=dict(key='invalid___key', value='world'),
|
||||
expected_code=400)
|
||||
|
||||
# Try to add a label key in a reserved namespace.
|
||||
self.postResponse(RepositoryManifestLabels,
|
||||
params=dict(repository=repository,
|
||||
manifestref=tag_manifest.digest),
|
||||
data=dict(key='io.docker.whatever', value='world'),
|
||||
expected_code=400)
|
||||
|
||||
|
||||
class TestSuperUserManagement(ApiTestCase):
|
||||
def test_get_user(self):
|
||||
self.login(ADMIN_ACCESS_USER)
|
||||
|
|
44
test/test_validation.py
Normal file
44
test/test_validation.py
Normal file
|
@ -0,0 +1,44 @@
|
|||
import unittest
|
||||
from util.validation import validate_label_key
|
||||
|
||||
class TestLabelKeyValidation(unittest.TestCase):
|
||||
def assertValidKey(self, key):
|
||||
self.assertTrue(validate_label_key(key))
|
||||
|
||||
def assertInvalidKey(self, key):
|
||||
self.assertFalse(validate_label_key(key))
|
||||
|
||||
def test_basic_keys(self):
|
||||
self.assertValidKey('foo')
|
||||
self.assertValidKey('bar')
|
||||
|
||||
self.assertValidKey('foo1')
|
||||
self.assertValidKey('bar2')
|
||||
|
||||
self.assertValidKey('1')
|
||||
self.assertValidKey('12')
|
||||
self.assertValidKey('123')
|
||||
self.assertValidKey('1234')
|
||||
|
||||
self.assertValidKey('git-sha')
|
||||
|
||||
self.assertValidKey('com.coreos.something')
|
||||
self.assertValidKey('io.quay.git-sha')
|
||||
|
||||
def test_invalid_keys(self):
|
||||
self.assertInvalidKey('')
|
||||
self.assertInvalidKey('git_sha')
|
||||
|
||||
def test_must_start_with_alphanumeric(self):
|
||||
self.assertInvalidKey('-125')
|
||||
self.assertInvalidKey('-foo')
|
||||
self.assertInvalidKey('foo-')
|
||||
self.assertInvalidKey('123-')
|
||||
|
||||
def test_no_double_dashesdots(self):
|
||||
self.assertInvalidKey('foo--bar')
|
||||
self.assertInvalidKey('foo..bar')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
|
@ -1,5 +1,5 @@
|
|||
from multiprocessing.sharedctypes import Array
|
||||
from util.validation import MAX_LENGTH
|
||||
from util.validation import MAX_USERNAME_LENGTH
|
||||
|
||||
class SuperUserManager(object):
|
||||
""" In-memory helper class for quickly accessing (and updating) the valid
|
||||
|
@ -11,7 +11,7 @@ class SuperUserManager(object):
|
|||
usernames = app.config.get('SUPER_USERS', [])
|
||||
usernames_str = ','.join(usernames)
|
||||
|
||||
self._max_length = len(usernames_str) + MAX_LENGTH + 1
|
||||
self._max_length = len(usernames_str) + MAX_USERNAME_LENGTH + 1
|
||||
self._array = Array('c', self._max_length, lock=True)
|
||||
self._array.value = usernames_str
|
||||
|
||||
|
|
20
util/label_validator.py
Normal file
20
util/label_validator.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
class LabelValidator(object):
|
||||
""" Helper class for validating that labels meet prefix requirements. """
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
overridden_prefixes = app.config.get('LABEL_KEY_RESERVED_PREFIXES', [])
|
||||
for prefix in overridden_prefixes:
|
||||
if not prefix.endswith('.'):
|
||||
raise Exception('Prefix "%s" in LABEL_KEY_RESERVED_PREFIXES must end in a dot', prefix)
|
||||
|
||||
default_prefixes = app.config.get('DEFAULT_LABEL_KEY_RESERVED_PREFIXES', [])
|
||||
self.reserved_prefixed_set = set(default_prefixes + overridden_prefixes)
|
||||
|
||||
def has_reserved_prefix(self, label_key):
|
||||
""" Validates that the provided label key does not match any reserved prefixes. """
|
||||
for prefix in self.reserved_prefixed_set:
|
||||
if label_key.startswith(prefix):
|
||||
return True
|
||||
|
||||
return False
|
|
@ -1,7 +1,7 @@
|
|||
import logging
|
||||
|
||||
from sqlalchemy.types import TypeDecorator, Text
|
||||
from sqlalchemy.dialects.mysql import TEXT as MySQLText, LONGTEXT
|
||||
from sqlalchemy.types import TypeDecorator, Text, String
|
||||
from sqlalchemy.dialects.mysql import TEXT as MySQLText, LONGTEXT, VARCHAR as MySQLString
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -20,3 +20,19 @@ class UTF8LongText(TypeDecorator):
|
|||
return dialect.type_descriptor(LONGTEXT(charset='utf8mb4', collation='utf8mb4_unicode_ci'))
|
||||
else:
|
||||
return dialect.type_descriptor(Text())
|
||||
|
||||
|
||||
class UTF8CharField(TypeDecorator):
|
||||
""" Platform-independent UTF-8 Char type.
|
||||
|
||||
Uses MySQL's VARCHAR with charset utf8mb4, otherwise uses String, because
|
||||
other engines default to UTF-8.
|
||||
"""
|
||||
impl = String
|
||||
|
||||
def load_dialect_impl(self, dialect):
|
||||
if dialect.name == 'mysql':
|
||||
return dialect.type_descriptor(MySQLString(charset='utf8mb4', collation='utf8mb4_unicode_ci',
|
||||
length=self.impl.length))
|
||||
else:
|
||||
return dialect.type_descriptor(String(length=self.impl.length))
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import string
|
||||
import re
|
||||
import json
|
||||
|
||||
import anunidecode # Don't listen to pylint's lies. This import is required.
|
||||
|
||||
|
@ -9,14 +10,21 @@ INVALID_PASSWORD_MESSAGE = 'Invalid password, password must be at least ' + \
|
|||
INVALID_USERNAME_CHARACTERS = r'[^a-z0-9_]'
|
||||
VALID_CHARACTERS = string.digits + string.lowercase
|
||||
|
||||
MIN_LENGTH = 4
|
||||
MAX_LENGTH = 30
|
||||
MIN_USERNAME_LENGTH = 4
|
||||
MAX_USERNAME_LENGTH = 30
|
||||
|
||||
VALID_LABEL_KEY_REGEX = r'^[a-z0-9](([a-z0-9]|[-.](?![.-]))*[a-z0-9])?$'
|
||||
|
||||
|
||||
def validate_label_key(label_key):
|
||||
if len(label_key) > 255:
|
||||
return False
|
||||
|
||||
return bool(re.match(VALID_LABEL_KEY_REGEX, label_key))
|
||||
|
||||
|
||||
def validate_email(email_address):
|
||||
if re.match(r'[^@]+@[^@]+\.[^@]+', email_address):
|
||||
return True
|
||||
return False
|
||||
return bool(re.match(r'[^@]+@[^@]+\.[^@]+', email_address))
|
||||
|
||||
|
||||
def validate_username(username):
|
||||
|
@ -25,10 +33,10 @@ def validate_username(username):
|
|||
if not regex_match:
|
||||
return (False, 'Username must match expression [a-z0-9_]+')
|
||||
|
||||
length_match = (len(username) >= MIN_LENGTH and len(username) <= MAX_LENGTH)
|
||||
length_match = (len(username) >= MIN_USERNAME_LENGTH and len(username) <= MAX_USERNAME_LENGTH)
|
||||
if not length_match:
|
||||
return (False, 'Username must be between %s and %s characters in length' %
|
||||
(MIN_LENGTH, MAX_LENGTH))
|
||||
(MIN_USERNAME_LENGTH, MAX_USERNAME_LENGTH))
|
||||
|
||||
return (True, '')
|
||||
|
||||
|
@ -58,9 +66,20 @@ def generate_valid_usernames(input_username):
|
|||
if prefix.endswith('_'):
|
||||
prefix = prefix[0:len(prefix) - 1]
|
||||
|
||||
num_filler_chars = max(0, MIN_LENGTH - len(prefix))
|
||||
num_filler_chars = max(0, MIN_USERNAME_LENGTH - len(prefix))
|
||||
|
||||
while num_filler_chars + len(prefix) <= MAX_LENGTH:
|
||||
while num_filler_chars + len(prefix) <= MAX_USERNAME_LENGTH:
|
||||
for suffix in _gen_filler_chars(num_filler_chars):
|
||||
yield prefix + suffix
|
||||
num_filler_chars += 1
|
||||
|
||||
|
||||
def is_json(value):
|
||||
if ((value.startswith('{') and value.endswith('}')) or
|
||||
(value.startswith('[') and value.endswith(']'))):
|
||||
try:
|
||||
json.loads(value)
|
||||
return True
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
|
|
Reference in a new issue