From 608ffd9663b84f24056e943b8883f742b98ca79f Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 18 Jul 2016 18:20:00 -0400 Subject: [PATCH] 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. --- app.py | 2 + config.py | 12 ++ data/database.py | 38 ++++- .../c9b91bee7554_add_labels_to_the_schema.py | 89 ++++++++++ data/model/__init__.py | 33 +++- data/model/label.py | 121 +++++++++++++ data/model/repository.py | 18 +- data/model/tag.py | 30 +++- endpoints/api/__init__.py | 1 + endpoints/api/manifest.py | 159 ++++++++++++++++++ endpoints/v2/manifest.py | 13 +- initdb.py | 33 +++- static/js/directives/ui/logs-view.js | 5 + static/js/services/string-builder-service.js | 3 +- test/data/test.db | Bin 1175552 -> 1220608 bytes test/helpers.py | 7 +- test/registry_tests.py | 49 ++++++ test/test_api_security.py | 50 ++++++ test/test_api_usage.py | 155 +++++++++++++++++ test/test_validation.py | 44 +++++ util/config/superusermanager.py | 4 +- util/label_validator.py | 20 +++ util/migrate/__init__.py | 20 ++- util/validation.py | 37 +++- 24 files changed, 907 insertions(+), 36 deletions(-) create mode 100644 data/migrations/versions/c9b91bee7554_add_labels_to_the_schema.py create mode 100644 data/model/label.py create mode 100644 endpoints/api/manifest.py create mode 100644 test/test_validation.py create mode 100644 util/label_validator.py diff --git a/app.py b/app.py index 9060b38a4..dc9671001 100644 --- a/app.py +++ b/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) diff --git a/config.py b/config.py index 7bb083710..e0ae3f8fb 100644 --- a/config.py +++ b/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 = [] + diff --git a/data/database.py b/data/database.py index 94d469f07..367dac336 100644 --- a/data/database.py +++ b/data/database.py @@ -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)] diff --git a/data/migrations/versions/c9b91bee7554_add_labels_to_the_schema.py b/data/migrations/versions/c9b91bee7554_add_labels_to_the_schema.py new file mode 100644 index 000000000..21f1113ff --- /dev/null +++ b/data/migrations/versions/c9b91bee7554_add_labels_to_the_schema.py @@ -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 ### diff --git a/data/model/__init__.py b/data/model/__init__.py index b138bcf35..1aa512120 100644 --- a/data/model/__init__.py +++ b/data/model/__init__.py @@ -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, +) diff --git a/data/model/label.py b/data/model/label.py new file mode 100644 index 000000000..ad5eadc7d --- /dev/null +++ b/data/model/label.py @@ -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 + diff --git a/data/model/repository.py b/data/model/repository.py index ca4ff15a9..ebba1029f 100644 --- a/data/model/repository.py +++ b/data/model/repository.py @@ -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. diff --git a/data/model/tag.py b/data/model/tag.py index 4b048e748..96aa34e76 100644 --- a/data/model/tag.py +++ b/data/model/tag.py @@ -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)) diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py index ccb83e554..3f0241b7d 100644 --- a/endpoints/api/__init__.py +++ b/endpoints/api/__init__.py @@ -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 diff --git a/endpoints/api/manifest.py b/endpoints/api/manifest.py new file mode 100644 index 000000000..2a0daddd3 --- /dev/null +++ b/endpoints/api/manifest.py @@ -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//manifest/' +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/') +@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 + diff --git a/endpoints/v2/manifest.py b/endpoints/v2/manifest.py index c30ae63f5..f68baa984 100644 --- a/endpoints/v2/manifest.py +++ b/endpoints/v2/manifest.py @@ -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. diff --git a/initdb.py b/initdb.py index 0e6fab37a..69ccb38e5 100644 --- a/initdb.py +++ b/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', {}, {}) diff --git a/static/js/directives/ui/logs-view.js b/static/js/directives/ui/logs-view.js index 4c6321c63..a7439ca7b 100644 --- a/static/js/directives/ui/logs-view.js +++ b/static/js/directives/ui/logs-view.js @@ -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', diff --git a/static/js/services/string-builder-service.js b/static/js/services/string-builder-service.js index 87fe2cd66..cb5ba19f1 100644 --- a/static/js/services/string-builder-service.js +++ b/static/js/services/string-builder-service.js @@ -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 = { diff --git a/test/data/test.db b/test/data/test.db index 6ceefdff8dd40df39f592b120e4bedc5629fbcff..183613dcf84a6efebb8a9467f670c72bc2f14de0 100644 GIT binary patch delta 74546 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 vZ$Y?o2P6 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 Y)&)=bK!aYHVTA8aK-~J&?D4bz4Y#~=<^TWy diff --git a/test/helpers.py b/test/helpers.py index 0fb535427..ee35152aa 100644 --- a/test/helpers.py +++ b/test/helpers.py @@ -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 diff --git a/test/registry_tests.py b/test/registry_tests.py index 19a0c9544..538475333 100644 --- a/test/registry_tests.py +++ b/test/registry_tests.py @@ -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' diff --git a/test/test_api_security.py b/test/test_api_security.py index 258efde7d..da7f23a81 100644 --- a/test/test_api_security.py +++ b/test/test_api_security.py @@ -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() diff --git a/test/test_api_usage.py b/test/test_api_usage.py index f7a8dcb16..8761281f5 100644 --- a/test/test_api_usage.py +++ b/test/test_api_usage.py @@ -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) diff --git a/test/test_validation.py b/test/test_validation.py new file mode 100644 index 000000000..980e76b07 --- /dev/null +++ b/test/test_validation.py @@ -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() \ No newline at end of file diff --git a/util/config/superusermanager.py b/util/config/superusermanager.py index adf26332d..2f587166e 100644 --- a/util/config/superusermanager.py +++ b/util/config/superusermanager.py @@ -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 diff --git a/util/label_validator.py b/util/label_validator.py new file mode 100644 index 000000000..dc7f67ae1 --- /dev/null +++ b/util/label_validator.py @@ -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 diff --git a/util/migrate/__init__.py b/util/migrate/__init__.py index 809bcaef8..9f72f7872 100644 --- a/util/migrate/__init__.py +++ b/util/migrate/__init__.py @@ -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)) diff --git a/util/validation.py b/util/validation.py index 2dc49cff0..1f6c5666c 100644 --- a/util/validation.py +++ b/util/validation.py @@ -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 +