610 lines
24 KiB
Python
610 lines
24 KiB
Python
# Copyright 2016 OpenStack Foundation
|
|
# All Rights Reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
# not use this file except in compliance with the License. You may obtain
|
|
# a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
# License for the specific language governing permissions and limitations
|
|
# under the License.
|
|
|
|
from oslo_config import cfg
|
|
from oslo_log import log as logging
|
|
from oslo_versionedobjects import base
|
|
from oslo_versionedobjects import fields
|
|
|
|
from glare.common import exception
|
|
from glare.common import utils
|
|
from glare.db import artifact_api
|
|
from glare.i18n import _
|
|
from glare.objects.meta import fields as glare_fields
|
|
from glare.objects.meta import validators
|
|
from glare.objects.meta import wrappers
|
|
|
|
global_artifact_opts = [
|
|
cfg.BoolOpt('delayed_delete', default=False,
|
|
help=_("If False defines that artifacts must be deleted "
|
|
"immediately after the user call. Otherwise they just "
|
|
"will be marked as deleted so they can be scrubbed "
|
|
"by some other tool in the background.")),
|
|
]
|
|
|
|
CONF = cfg.CONF
|
|
CONF.register_opts(global_artifact_opts)
|
|
|
|
LOG = logging.getLogger(__name__)
|
|
|
|
|
|
class BaseArtifact(base.VersionedObject):
|
|
"""BaseArtifact is a central place in Glare. It execute Glare business
|
|
logic operations and checks in like:
|
|
1) Check if artifact satisfies all requirements and can be activated
|
|
2) Check that artifact is not deactivated and download blobs
|
|
...
|
|
BaseArtifact interacts with database and saves/request artifact info
|
|
from specified database API. Base Artifact is an abstract class so
|
|
all concrete classes must be inherited from that class. Concrete classes
|
|
must define custom fields in addition to BaseArtifact fields and db_api
|
|
that must be used for interaction with database.
|
|
"""
|
|
|
|
OBJ_PROJECT_NAMESPACE = 'glare'
|
|
|
|
DEFAULT_ARTIFACT_VERSION = '0.0.0'
|
|
|
|
STATUS = ('drafted', 'active', 'deactivated', 'deleted')
|
|
|
|
Field = wrappers.Field.init
|
|
DictField = wrappers.DictField.init
|
|
ListField = wrappers.ListField.init
|
|
Blob = wrappers.BlobField.init
|
|
|
|
fields = {
|
|
'id': Field(fields.StringField, system=True,
|
|
validators=[validators.UUID()], nullable=False,
|
|
sortable=True, description="Artifact UUID."),
|
|
'name': Field(fields.StringField, required_on_activate=False,
|
|
nullable=False, sortable=True,
|
|
validators=[validators.MinStrLen(1)],
|
|
description="Artifact Name."),
|
|
'owner': Field(fields.StringField, system=True,
|
|
required_on_activate=False, nullable=False,
|
|
sortable=True, description="ID of user/tenant who "
|
|
"uploaded artifact."),
|
|
'status': Field(fields.StringField, default='drafted',
|
|
nullable=False, sortable=True, mutable=True,
|
|
validators=[validators.AllowedValues(STATUS)],
|
|
description="Artifact status."),
|
|
'created_at': Field(fields.DateTimeField, system=True,
|
|
nullable=False, sortable=True,
|
|
description="Datetime when artifact has "
|
|
"been created."),
|
|
'updated_at': Field(fields.DateTimeField, system=True,
|
|
nullable=False, sortable=True, mutable=True,
|
|
description="Datetime when artifact has "
|
|
"been updated last time."),
|
|
'activated_at': Field(fields.DateTimeField, system=True,
|
|
required_on_activate=False, sortable=True,
|
|
description="Datetime when artifact has became "
|
|
"active."),
|
|
'description': Field(fields.StringField, mutable=True,
|
|
required_on_activate=False, default="",
|
|
validators=[validators.MaxStrLen(4096)],
|
|
filter_ops=[],
|
|
description="Artifact description."),
|
|
'tags': ListField(fields.String, mutable=True,
|
|
required_on_activate=False,
|
|
# tags are filtered without any operators
|
|
filter_ops=[],
|
|
validators=[validators.Unique(convert_to_set=True)],
|
|
element_validators=[
|
|
validators.ForbiddenChars([',', '/']),
|
|
validators.MinStrLen(1)
|
|
],
|
|
description="List of tags added to Artifact."),
|
|
'metadata': DictField(fields.String, required_on_activate=False,
|
|
element_validators=[validators.MinStrLen(1)],
|
|
description="Key-value dict with useful "
|
|
"information about an artifact."),
|
|
'visibility': Field(fields.StringField, default='private',
|
|
nullable=False, sortable=True, mutable=True,
|
|
validators=[validators.AllowedValues(
|
|
['private', 'public'])],
|
|
description="Artifact visibility that defines "
|
|
"if artifact can be available to "
|
|
"other users."),
|
|
'version': Field(glare_fields.VersionField, required_on_activate=False,
|
|
default=DEFAULT_ARTIFACT_VERSION, nullable=False,
|
|
sortable=True, validators=[validators.Version()],
|
|
description="Artifact version(semver).")
|
|
}
|
|
|
|
artifact_type_opts = [
|
|
cfg.BoolOpt('delayed_delete',
|
|
help=_(
|
|
"If False defines that artifacts must be deleted "
|
|
"immediately after the user call. Otherwise they just "
|
|
"will be marked as deleted so they can be scrubbed "
|
|
"by some other tool in the background. "
|
|
"Redefines global parameter of the same name "
|
|
"from [DEFAULT] section.")),
|
|
]
|
|
|
|
def __new__(cls, *args, **kwargs):
|
|
CONF.register_opts(cls.artifact_type_opts, group=cls.get_type_name())
|
|
return base.VersionedObject.__new__(cls)
|
|
|
|
@classmethod
|
|
def list_artifact_type_opts(cls):
|
|
return cls.artifact_type_opts
|
|
|
|
db_api = artifact_api.ArtifactAPI()
|
|
|
|
@classmethod
|
|
def is_blob(cls, field_name):
|
|
"""Helper to check that a field is a blob.
|
|
|
|
:param field_name: name of the field
|
|
:return: True if the field is a blob, False otherwise
|
|
"""
|
|
return isinstance(cls.fields.get(field_name), glare_fields.BlobField)
|
|
|
|
@classmethod
|
|
def is_blob_dict(cls, field_name):
|
|
"""Helper to check that field is a blob dict.
|
|
|
|
:param field_name: name of the field
|
|
:return: True if the field is a blob dict, False otherwise
|
|
"""
|
|
return (isinstance(cls.fields.get(field_name), glare_fields.Dict) and
|
|
cls.fields[field_name].element_type ==
|
|
glare_fields.BlobFieldType)
|
|
|
|
@classmethod
|
|
def init_artifact(cls, context, values):
|
|
"""Initialize an empty versioned object with values.
|
|
|
|
Initialize vo object with default values and values specified by user.
|
|
Also reset all changes of initialized object so user can track own
|
|
changes.
|
|
|
|
:param context: user context
|
|
:param values: values needs to be set
|
|
:return: artifact with initialized values
|
|
"""
|
|
af = cls(context)
|
|
# setup default values for all non specified fields
|
|
default_fields = []
|
|
for field in af.fields:
|
|
if field not in values:
|
|
default_fields.append(field)
|
|
if default_fields:
|
|
af.obj_set_defaults(*default_fields)
|
|
|
|
# apply values specified by user
|
|
for name, value in values.items():
|
|
setattr(af, name, value)
|
|
return af
|
|
|
|
@classmethod
|
|
def get_type_name(cls):
|
|
"""Return type name that allows to find artifact type in Glare
|
|
|
|
Type name allows to find artifact type definition in Glare registry.
|
|
|
|
:return: string that identifies current artifact type
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
def create(self, context):
|
|
"""Create new artifact in Glare repo.
|
|
|
|
:param context: user context
|
|
:return: created artifact object
|
|
"""
|
|
values = self.obj_changes_to_primitive()
|
|
values['type_name'] = self.get_type_name()
|
|
af_vals = self.db_api.save(context, None, values)
|
|
return self.init_artifact(context, af_vals)
|
|
|
|
def save(self, context):
|
|
"""Save artifact in Glare repo.
|
|
|
|
:param context: user context
|
|
:return: updated artifact object
|
|
"""
|
|
updated_af = self.db_api.save(context, self.id,
|
|
self.obj_changes_to_primitive())
|
|
return self.init_artifact(context, updated_af)
|
|
|
|
@classmethod
|
|
def show(cls, context, artifact_id):
|
|
"""Return Artifact from Glare repo
|
|
|
|
:param context: user context
|
|
:param artifact_id: id of requested artifact
|
|
:return: requested artifact object
|
|
"""
|
|
af = cls.db_api.get(context, artifact_id)
|
|
return cls.init_artifact(context, af)
|
|
|
|
@classmethod
|
|
def _get_field_type(cls, obj):
|
|
"""Get string representation of field type for filters."""
|
|
if isinstance(obj, fields.IntegerField) or obj is fields.Integer:
|
|
return 'int'
|
|
elif isinstance(obj, fields.FloatField) or obj is fields.Float:
|
|
return 'numeric'
|
|
elif isinstance(obj, fields.FlexibleBooleanField) or \
|
|
obj is fields.FlexibleBoolean:
|
|
return 'bool'
|
|
return 'string'
|
|
|
|
@classmethod
|
|
def _parse_sort_values(cls, sort):
|
|
"""Prepare sorting parameters for database."""
|
|
new_sort = []
|
|
for key, direction in sort:
|
|
if key not in cls.fields:
|
|
msg = _("The field %s doesn't exist.") % key
|
|
raise exception.BadRequest(msg)
|
|
# check if field can be sorted
|
|
if not cls.fields[key].sortable:
|
|
msg = _("The field %s is not sortable.") % key
|
|
raise exception.BadRequest(msg)
|
|
new_sort.append((key, direction, cls._get_field_type(
|
|
cls.fields.get(key))))
|
|
return new_sort
|
|
|
|
@classmethod
|
|
def _validate_filter_ops(cls, filter_name, op):
|
|
field = cls.fields.get(filter_name)
|
|
if op not in field.filter_ops:
|
|
msg = (_("Unsupported filter type '%(key)s'."
|
|
"The following filters are supported "
|
|
"%(filters)s") % {
|
|
'key': op, 'filters': str(field.filter_ops)})
|
|
raise exception.BadRequest(message=msg)
|
|
|
|
@classmethod
|
|
def _parse_filter_values(cls, filters):
|
|
# input format for filters is list of tuples:
|
|
# (filter_name, filter_value)
|
|
# output format for filters is list of tuples:
|
|
# (field_name, key_name, op, field_type, value)
|
|
new_filters = []
|
|
|
|
for filter_name, filter_value in filters:
|
|
if filter_name in ('tags-any', 'tags'):
|
|
if ':' in filter_value:
|
|
msg = _("Tags are filtered without operator")
|
|
raise exception.BadRequest(msg)
|
|
new_filters.append(
|
|
(filter_name, None, None, None, filter_value))
|
|
continue
|
|
|
|
key_name = None
|
|
if '.' in filter_name:
|
|
filter_name, key_name = filter_name.rsplit('.', 1)
|
|
if not isinstance(cls.fields.get(filter_name),
|
|
glare_fields.Dict):
|
|
msg = _("Field %s is not Dict") % filter_name
|
|
raise exception.BadRequest(msg)
|
|
|
|
if cls.fields.get(filter_name) is None:
|
|
msg = _("Unable filter '%s'") % filter_name
|
|
raise exception.BadRequest(msg)
|
|
|
|
field_type = cls.fields.get(filter_name)
|
|
if isinstance(field_type, glare_fields.List) or isinstance(
|
|
field_type, glare_fields.Dict) and key_name is not None:
|
|
field_type = field_type.element_type
|
|
|
|
try:
|
|
op, val = utils.split_filter_op(filter_value)
|
|
if isinstance(field_type, glare_fields.Dict):
|
|
if op not in ['eq', 'in']:
|
|
msg = (_("Unsupported filter type '%s'. The following "
|
|
"filters are supported: eq, in") % op)
|
|
raise exception.BadRequest(message=msg)
|
|
if op == 'in':
|
|
new_filters.append((
|
|
filter_name, utils.split_filter_value_for_quotes(
|
|
val), op, None, None))
|
|
else:
|
|
new_filters.append((
|
|
filter_name, val, op, None, None))
|
|
else:
|
|
cls._validate_filter_ops(filter_name, op)
|
|
if op == 'in':
|
|
value = [field_type.coerce(cls(), filter_name, value)
|
|
for value in
|
|
utils.split_filter_value_for_quotes(val)]
|
|
else:
|
|
value = field_type.coerce(cls(), filter_name, val)
|
|
new_filters.append(
|
|
(filter_name, key_name, op,
|
|
cls._get_field_type(field_type), value))
|
|
except ValueError:
|
|
msg = _("Invalid filter value: %s") % str(val)
|
|
raise exception.BadRequest(msg)
|
|
|
|
return new_filters
|
|
|
|
@classmethod
|
|
def list(cls, context, filters=None, marker=None, limit=None,
|
|
sort=None, latest=False):
|
|
"""Return list of artifacts requested by user.
|
|
|
|
:param context: user context
|
|
:param filters: filters that need to be applied to artifact
|
|
:param marker: the artifact that considered as begin of the list
|
|
so all artifacts before marker (including marker itself) will not be
|
|
added to artifact list
|
|
:param limit: maximum number of items in the list
|
|
:param sort: sorting options
|
|
:param latest: flag that indicates, that only artifacts with highest
|
|
versions should be returned in output
|
|
:return: list of artifact objects
|
|
"""
|
|
|
|
default_sort_parameters = (
|
|
('created_at', 'desc', None), ('id', 'asc', None))
|
|
# Parse sort parameters and update them with defaults
|
|
sort = [] if sort is None else cls._parse_sort_values(sort)
|
|
for default_sort in default_sort_parameters:
|
|
for s in sort:
|
|
# If the default sort parameter already in the list - skip it
|
|
if s[0] == default_sort[0]:
|
|
break
|
|
else:
|
|
sort.append(default_sort)
|
|
|
|
default_filter_parameters = [
|
|
('status', None, 'neq', None, 'deleted')]
|
|
if cls.get_type_name() != 'all':
|
|
default_filter_parameters.append(
|
|
('type_name', None, 'eq', None, cls.get_type_name()))
|
|
# Parse filter parameters and update them with defaults
|
|
filters = [] if filters is None else cls._parse_filter_values(filters)
|
|
for default_filter in default_filter_parameters:
|
|
if default_filter not in filters:
|
|
filters.append(default_filter)
|
|
|
|
return [cls.init_artifact(context, af)
|
|
for af in cls.db_api.list(
|
|
context, filters, marker, limit, sort, latest)]
|
|
|
|
@classmethod
|
|
def delete(cls, context, af):
|
|
"""Delete artifact and all its blobs from Glare.
|
|
|
|
:param context: user context
|
|
:param af: artifact object targeted for deletion
|
|
"""
|
|
# marking artifact as deleted
|
|
cls.db_api.save(context, af.id, {'status': 'deleted'})
|
|
|
|
# collect all uploaded blobs
|
|
blobs = {}
|
|
for name in af.fields:
|
|
if cls.is_blob(name) or cls.is_blob_dict(name):
|
|
field = getattr(af, name)
|
|
if field:
|
|
blobs[name] = field
|
|
|
|
LOG.debug("Marked artifact %(artifact)s as deleted.",
|
|
{'artifact': af.id})
|
|
|
|
return blobs
|
|
|
|
@classmethod
|
|
def get_max_blob_size(cls, field_name):
|
|
"""Get the maximum allowed blob size in bytes.
|
|
|
|
:param field_name: blob or blob dict field name
|
|
:return: maximum blob size in bytes
|
|
"""
|
|
return getattr(cls.fields[field_name], 'max_blob_size')
|
|
|
|
@classmethod
|
|
def get_max_folder_size(cls, field_name):
|
|
"""Get the maximum allowed folder size in bytes.
|
|
|
|
:param field_name: folder (blob dict) field name
|
|
:return: maximum folder size in bytes
|
|
"""
|
|
return getattr(cls.fields[field_name], 'max_folder_size')
|
|
|
|
@classmethod
|
|
def update_blob(cls, context, af_id, field_name, values):
|
|
"""Update blob info in database.
|
|
|
|
:param context: user context
|
|
:param af_id: id of modified artifact
|
|
:param field_name: blob or blob dict field name
|
|
:param values: updated blob values
|
|
:return: updated artifact definition in Glare
|
|
"""
|
|
af_upd = cls.db_api.update_blob(context, af_id, {field_name: values})
|
|
return cls.init_artifact(context, af_upd)
|
|
|
|
@classmethod
|
|
def validate_activate(cls, context, af):
|
|
"""Validation hook for activation."""
|
|
pass
|
|
|
|
@classmethod
|
|
def validate_upload(cls, context, af, field_name, fd):
|
|
"""Validation hook for uploading."""
|
|
return fd, None
|
|
|
|
@classmethod
|
|
def validate_download(cls, context, af, field_name, fd):
|
|
"""Validation hook for downloading."""
|
|
return fd, None
|
|
|
|
@classmethod
|
|
def validate_publish(cls, context, af):
|
|
"""Validation hook for publishing."""
|
|
pass
|
|
|
|
@classmethod
|
|
def validate_delete(cls, context, af):
|
|
"""Validation hook for deletion."""
|
|
pass
|
|
|
|
@classmethod
|
|
def get_default_store(cls, context=None, af=None,
|
|
field_name=None, blob_key=None):
|
|
"""Return a default store type for artifact type."""
|
|
for t in CONF.enabled_artifact_types:
|
|
type_name, __, store_name = t.partition(':')
|
|
if type_name == cls.get_type_name():
|
|
return store_name
|
|
|
|
def to_notification(self):
|
|
"""Return notification body that can be send to listeners.
|
|
|
|
:return: dict with notification information
|
|
"""
|
|
return {
|
|
'type': self.get_type_name(),
|
|
'id': self.id,
|
|
'description': self.description,
|
|
'name': self.name,
|
|
'version': self.version,
|
|
'visibility': self.visibility,
|
|
'status': self.status,
|
|
'created_at': self.created_at,
|
|
'updated_at': self.updated_at,
|
|
'activated_at': self.activated_at,
|
|
'owner': self.owner
|
|
}
|
|
|
|
def to_dict(self):
|
|
"""Convert oslo versioned object to dictionary.
|
|
|
|
:return: dict with field names and field values
|
|
"""
|
|
return self.obj_to_primitive()['versioned_object.data']
|
|
|
|
def obj_changes_to_primitive(self):
|
|
changes = self.obj_get_changes()
|
|
res = {}
|
|
for key, val in changes.items():
|
|
if val is not None and hasattr(val, 'to_primitive'):
|
|
res[key] = val.to_primitive()
|
|
else:
|
|
res[key] = val
|
|
return res
|
|
|
|
@classmethod
|
|
def _schema_field(cls, field, field_name=''):
|
|
field_type = utils.get_schema_type(field)
|
|
schema = {}
|
|
|
|
# generate schema for validators
|
|
for val in getattr(field, 'validators', []):
|
|
schema.update(val.to_jsonschema())
|
|
|
|
schema['type'] = (field_type
|
|
if not field.nullable else [field_type, 'null'])
|
|
schema['glareType'] = utils.get_glare_type(field)
|
|
output_blob_schema = {
|
|
'type': ['object', 'null'],
|
|
'properties': {
|
|
'size': {'type': ['number', 'null']},
|
|
'md5': {'type': ['string', 'null']},
|
|
'sha1': {'type': ['string', 'null']},
|
|
'sha256': {'type': ['string', 'null']},
|
|
'external': {'type': 'boolean'},
|
|
'status': {'type': 'string',
|
|
'enum': list(
|
|
glare_fields.BlobFieldType.BLOB_STATUS)},
|
|
'content_type': {'type': 'string'},
|
|
},
|
|
'required': ['size', 'md5', 'sha1', 'sha256', 'external', 'status',
|
|
'content_type'],
|
|
'additionalProperties': False
|
|
}
|
|
|
|
if field.system:
|
|
schema['readOnly'] = True
|
|
|
|
if isinstance(field, glare_fields.Dict):
|
|
element_type = utils.get_schema_type(field.element_type)
|
|
property_validators = schema.pop('propertyValidators', [])
|
|
if field.element_type is glare_fields.BlobFieldType:
|
|
schema['additionalProperties'] = output_blob_schema
|
|
else:
|
|
if schema.get('properties'):
|
|
properties = {}
|
|
required = schema.pop('required', [])
|
|
for key in schema.pop('properties'):
|
|
properties[key] = {
|
|
'type': (element_type
|
|
if key in required
|
|
else [element_type, 'null'])}
|
|
for val in property_validators:
|
|
properties[key].update(val)
|
|
schema['properties'] = properties
|
|
schema['additionalProperties'] = False
|
|
else:
|
|
schema['additionalProperties'] = {'type': element_type}
|
|
for val in property_validators:
|
|
schema['additionalProperties'].update(val)
|
|
|
|
if isinstance(field, glare_fields.List):
|
|
items_validators = schema.pop('itemValidators', [])
|
|
schema['items'] = {
|
|
'type': utils.get_schema_type(field.element_type)}
|
|
for val in items_validators:
|
|
schema['items'].update(val)
|
|
|
|
if isinstance(field, glare_fields.BlobField):
|
|
schema.update(output_blob_schema)
|
|
|
|
if isinstance(field, fields.DateTimeField):
|
|
schema['format'] = 'date-time'
|
|
|
|
if field_name == 'status':
|
|
schema['enum'] = cls.STATUS
|
|
|
|
if field.description:
|
|
schema['description'] = field.description
|
|
if field.mutable:
|
|
schema['mutable'] = True
|
|
if field.sortable:
|
|
schema['sortable'] = True
|
|
if not field.required_on_activate:
|
|
schema['required_on_activate'] = False
|
|
if field._default is not None:
|
|
schema['default'] = field._default
|
|
|
|
schema['filter_ops'] = field.filter_ops
|
|
|
|
return schema
|
|
|
|
@classmethod
|
|
def gen_schemas(cls):
|
|
"""Return json schema representation of the artifact type."""
|
|
schemas_prop = {}
|
|
for field_name, field in cls.fields.items():
|
|
schemas_prop[field_name] = cls._schema_field(
|
|
field, field_name=field_name)
|
|
schemas = {'properties': schemas_prop,
|
|
'name': cls.get_type_name(),
|
|
'version': cls.VERSION,
|
|
'title': 'Artifact type %s of version %s' %
|
|
(cls.get_type_name(), cls.VERSION),
|
|
'type': 'object',
|
|
'required': ['name']}
|
|
|
|
return schemas
|