1 # -*- coding: utf-8 -*-
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
7 # http://www.apache.org/licenses/LICENSE-2.0
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
13 # See the License for the specific language governing permissions and
14 # limitations under the License.
17 from uuid
import uuid4
18 from http
import HTTPStatus
20 from osm_common
.dbbase
import deep_update_rfc7396
21 from osm_nbi
.validation
import validate_input
, ValidationError
, is_valid_uuid
23 __author__
= "Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
26 class EngineException(Exception):
28 def __init__(self
, message
, http_code
=HTTPStatus
.BAD_REQUEST
):
29 self
.http_code
= http_code
30 super(Exception, self
).__init
__(message
)
33 def deep_get(target_dict
, key_list
):
35 Get a value from target_dict entering in the nested keys. If keys does not exist, it returns None
36 Example target_dict={a: {b: 5}}; key_list=[a,b] returns 5; both key_list=[a,b,c] and key_list=[f,h] return None
37 :param target_dict: dictionary to be read
38 :param key_list: list of keys to read from target_dict
39 :return: The wanted value if exist, None otherwise
42 if not isinstance(target_dict
, dict) or key
not in target_dict
:
44 target_dict
= target_dict
[key
]
48 def get_iterable(input_var
):
50 Returns an iterable, in case input_var is None it just returns an empty tuple
51 :param input_var: can be a list, tuple or None
52 :return: input_var or () if it is None
60 """utility for compare dot separate versions. Fills with zeros to proper number comparison"""
62 for point
in v
.split("."):
63 filled
.append(point
.zfill(8))
68 # static variables for all instance classes
69 topic
= None # to_override
70 topic_msg
= None # to_override
71 schema_new
= None # to_override
72 schema_edit
= None # to_override
73 multiproject
= True # True if this Topic can be shared by several projects. Then it contains _admin.projects_read
77 # Alternative ID Fields for some Topics
84 def __init__(self
, db
, fs
, msg
, auth
):
88 self
.logger
= logging
.getLogger("nbi.engine")
92 def id_field(topic
, value
):
93 """Returns ID Field for given topic and field value"""
94 if topic
in BaseTopic
.alt_id_field
.keys() and not is_valid_uuid(value
):
95 return BaseTopic
.alt_id_field
[topic
]
100 def _remove_envelop(indata
=None):
105 def check_quota(self
, session
):
107 Check whether topic quota is exceeded by the given project
108 Used by relevant topics' 'new' function to decide whether or not creation of the new item should be allowed
109 :param projects: projects (tuple) for which quota should be checked
110 :param override: boolean. If true, don't raise ValidationError even though quota be exceeded
113 DbException if project not found
114 ValidationError if quota exceeded and not overridden
116 if session
["force"] or session
["admin"]:
118 projects
= session
["project_id"]
119 for project
in projects
:
120 proj
= self
.auth
.get_project(project
)
122 quota
= proj
.get("quotas", {}).get(self
.topic
, self
.default_quota
)
123 count
= self
.db
.count(self
.topic
, {"_admin.projects_read": pid
})
126 raise ValidationError("{} quota ({}) exceeded for project {} ({})".format(self
.topic
, quota
, name
, pid
))
128 def _validate_input_new(self
, input, force
=False):
130 Validates input user content for a new entry. It uses jsonschema. Some overrides will use pyangbind
131 :param input: user input content for the new topic
132 :param force: may be used for being more tolerant
133 :return: The same input content, or a changed version of it.
136 validate_input(input, self
.schema_new
)
139 def _validate_input_edit(self
, input, force
=False):
141 Validates input user content for an edition. It uses jsonschema. Some overrides will use pyangbind
142 :param input: user input content for the new topic
143 :param force: may be used for being more tolerant
144 :return: The same input content, or a changed version of it.
147 validate_input(input, self
.schema_edit
)
151 def _get_project_filter(session
):
153 Generates a filter dictionary for querying database, so that only allowed items for this project can be
154 addressed. Only propietary or public can be used. Allowed projects are at _admin.project_read/write. If it is
155 not present or contains ANY mean public.
156 :param session: contains:
157 project_id: project list this session has rights to access. Can be empty, one or several
158 set_project: items created will contain this project list
160 public: True, False or None
161 method: "list", "show", "write", "delete"
163 :return: dictionary with project filter
166 project_filter_n
= []
167 project_filter
= list(session
["project_id"])
169 if session
["method"] not in ("list", "delete"):
171 project_filter
.append("ANY")
172 elif session
["public"] is not None:
173 if session
["public"]:
174 project_filter
.append("ANY")
176 project_filter_n
.append("ANY")
178 if session
.get("PROJECT.ne"):
179 project_filter_n
.append(session
["PROJECT.ne"])
182 if session
["method"] in ("list", "show", "delete") or session
.get("set_project"):
183 p_filter
["_admin.projects_read.cont"] = project_filter
185 p_filter
["_admin.projects_write.cont"] = project_filter
187 if session
["method"] in ("list", "show", "delete") or session
.get("set_project"):
188 p_filter
["_admin.projects_read.ncont"] = project_filter_n
190 p_filter
["_admin.projects_write.ncont"] = project_filter_n
194 def check_conflict_on_new(self
, session
, indata
):
196 Check that the data to be inserted is valid
197 :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
198 :param indata: data to be inserted
199 :return: None or raises EngineException
203 def check_conflict_on_edit(self
, session
, final_content
, edit_content
, _id
):
205 Check that the data to be edited/uploaded is valid
206 :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
207 :param final_content: data once modified. This method may change it.
208 :param edit_content: incremental data that contains the modifications to apply
209 :param _id: internal _id
210 :return: None or raises EngineException
212 if not self
.multiproject
:
214 # Change public status
215 if session
["public"] is not None:
216 if session
["public"] and "ANY" not in final_content
["_admin"]["projects_read"]:
217 final_content
["_admin"]["projects_read"].append("ANY")
218 final_content
["_admin"]["projects_write"].clear()
219 if not session
["public"] and "ANY" in final_content
["_admin"]["projects_read"]:
220 final_content
["_admin"]["projects_read"].remove("ANY")
222 # Change project status
223 if session
.get("set_project"):
224 for p
in session
["set_project"]:
225 if p
not in final_content
["_admin"]["projects_read"]:
226 final_content
["_admin"]["projects_read"].append(p
)
228 def check_unique_name(self
, session
, name
, _id
=None):
230 Check that the name is unique for this project
231 :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
232 :param name: name to be checked
233 :param _id: If not None, ignore this entry that are going to change
234 :return: None or raises EngineException
236 if not self
.multiproject
:
239 _filter
= self
._get
_project
_filter
(session
)
240 _filter
["name"] = name
242 _filter
["_id.neq"] = _id
243 if self
.db
.get_one(self
.topic
, _filter
, fail_on_empty
=False, fail_on_more
=False):
244 raise EngineException("name '{}' already exists for {}".format(name
, self
.topic
), HTTPStatus
.CONFLICT
)
247 def format_on_new(content
, project_id
=None, make_public
=False):
249 Modifies content descriptor to include _admin
250 :param content: descriptor to be modified
251 :param project_id: if included, it add project read/write permissions. Can be None or a list
252 :param make_public: if included it is generated as public for reading.
253 :return: op_id: operation id on asynchronous operation, None otherwise. In addition content is modified
256 if "_admin" not in content
:
257 content
["_admin"] = {}
258 if not content
["_admin"].get("created"):
259 content
["_admin"]["created"] = now
260 content
["_admin"]["modified"] = now
261 if not content
.get("_id"):
262 content
["_id"] = str(uuid4())
263 if project_id
is not None:
264 if not content
["_admin"].get("projects_read"):
265 content
["_admin"]["projects_read"] = list(project_id
)
267 content
["_admin"]["projects_read"].append("ANY")
268 if not content
["_admin"].get("projects_write"):
269 content
["_admin"]["projects_write"] = list(project_id
)
273 def format_on_edit(final_content
, edit_content
):
275 Modifies final_content to admin information upon edition
276 :param final_content: final content to be stored at database
277 :param edit_content: user requested update content
278 :return: operation id, if this edit implies an asynchronous operation; None otherwise
280 if final_content
.get("_admin"):
282 final_content
["_admin"]["modified"] = now
285 def _send_msg(self
, action
, content
, not_send_msg
=None):
286 if self
.topic_msg
and not_send_msg
is not False:
287 content
.pop("_admin", None)
288 if isinstance(not_send_msg
, list):
289 not_send_msg
.append((self
.topic_msg
, action
, content
))
291 self
.msg
.write(self
.topic_msg
, action
, content
)
293 def check_conflict_on_del(self
, session
, _id
, db_content
):
295 Check if deletion can be done because of dependencies if it is not force. To override
296 :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
297 :param _id: internal _id
298 :param db_content: The database content of this item _id
299 :return: None if ok or raises EngineException with the conflict
304 def _update_input_with_kwargs(desc
, kwargs
):
306 Update descriptor with the kwargs. It contains dot separated keys
307 :param desc: dictionary to be updated
308 :param kwargs: plain dictionary to be used for updating.
309 :return: None, 'desc' is modified. It raises EngineException.
314 for k
, v
in kwargs
.items():
315 update_content
= desc
319 if kitem_old
is not None:
320 update_content
= update_content
[kitem_old
]
321 if isinstance(update_content
, dict):
323 elif isinstance(update_content
, list):
324 kitem_old
= int(kitem
)
326 raise EngineException(
327 "Invalid query string '{}'. Descriptor is not a list nor dict at '{}'".format(k
, kitem
))
328 update_content
[kitem_old
] = v
330 raise EngineException(
331 "Invalid query string '{}'. Descriptor does not contain '{}'".format(k
, kitem_old
))
333 raise EngineException("Invalid query string '{}'. Expected integer index list instead of '{}'".format(
336 raise EngineException(
337 "Invalid query string '{}'. Index '{}' out of range".format(k
, kitem_old
))
339 def show(self
, session
, _id
):
341 Get complete information on an topic
342 :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
343 :param _id: server internal id
344 :return: dictionary, raise exception if not found.
346 if not self
.multiproject
:
349 filter_db
= self
._get
_project
_filter
(session
)
350 # To allow project&user addressing by name AS WELL AS _id
351 filter_db
[BaseTopic
.id_field(self
.topic
, _id
)] = _id
352 return self
.db
.get_one(self
.topic
, filter_db
)
353 # TODO transform data for SOL005 URL requests
354 # TODO remove _admin if not admin
356 def get_file(self
, session
, _id
, path
=None, accept_header
=None):
358 Only implemented for descriptor topics. Return the file content of a descriptor
359 :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
360 :param _id: Identity of the item to get content
361 :param path: artifact path or "$DESCRIPTOR" or None
362 :param accept_header: Content of Accept header. Must contain applition/zip or/and text/plain
363 :return: opened file or raises an exception
365 raise EngineException("Method get_file not valid for this topic", HTTPStatus
.INTERNAL_SERVER_ERROR
)
367 def list(self
, session
, filter_q
=None):
369 Get a list of the topic that matches a filter
370 :param session: contains the used login username and working project
371 :param filter_q: filter of data to be applied
372 :return: The list, it can be empty if no one match the filter.
376 if self
.multiproject
:
377 filter_q
.update(self
._get
_project
_filter
(session
))
379 # TODO transform data for SOL005 URL requests. Transform filtering
380 # TODO implement "field-type" query string SOL005
381 return self
.db
.get_list(self
.topic
, filter_q
)
383 def new(self
, rollback
, session
, indata
=None, kwargs
=None, headers
=None):
385 Creates a new entry into database.
386 :param rollback: list to append created items at database in case a rollback may to be done
387 :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
388 :param indata: data to be inserted
389 :param kwargs: used to override the indata descriptor
390 :param headers: http request headers
392 _id: identity of the inserted data.
393 op_id: operation id if this is asynchronous, None otherwise
396 if self
.multiproject
:
397 self
.check_quota(session
)
399 content
= self
._remove
_envelop
(indata
)
401 # Override descriptor with query string kwargs
402 self
._update
_input
_with
_kwargs
(content
, kwargs
)
403 content
= self
._validate
_input
_new
(content
, force
=session
["force"])
404 self
.check_conflict_on_new(session
, content
)
405 op_id
= self
.format_on_new(content
, project_id
=session
["project_id"], make_public
=session
["public"])
406 _id
= self
.db
.create(self
.topic
, content
)
407 rollback
.append({"topic": self
.topic
, "_id": _id
})
409 content
["op_id"] = op_id
410 self
._send
_msg
("created", content
)
412 except ValidationError
as e
:
413 raise EngineException(e
, HTTPStatus
.UNPROCESSABLE_ENTITY
)
415 def upload_content(self
, session
, _id
, indata
, kwargs
, headers
):
417 Only implemented for descriptor topics. Used for receiving content by chunks (with a transaction_id header
418 and/or gzip file. It will store and extract)
419 :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
420 :param _id : the database id of entry to be updated
421 :param indata: http body request
422 :param kwargs: user query string to override parameters. NOT USED
423 :param headers: http request headers
424 :return: True package has is completely uploaded or False if partial content has been uplodaed.
425 Raise exception on error
427 raise EngineException("Method upload_content not valid for this topic", HTTPStatus
.INTERNAL_SERVER_ERROR
)
429 def delete_list(self
, session
, filter_q
=None):
431 Delete a several entries of a topic. This is for internal usage and test only, not exposed to NBI API
432 :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
433 :param filter_q: filter of data to be applied
434 :return: The deleted list, it can be empty if no one match the filter.
436 # TODO add admin to filter, validate rights
439 if self
.multiproject
:
440 filter_q
.update(self
._get
_project
_filter
(session
))
441 return self
.db
.del_list(self
.topic
, filter_q
)
443 def delete_extra(self
, session
, _id
, db_content
, not_send_msg
=None):
445 Delete other things apart from database entry of a item _id.
446 e.g.: other associated elements at database and other file system storage
447 :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
448 :param _id: server internal id
449 :param db_content: The database content of the _id. It is already deleted when reached this method, but the
450 content is needed in same cases
451 :param not_send_msg: To not send message (False) or store content (list) instead
452 :return: None if ok or raises EngineException with the problem
456 def delete(self
, session
, _id
, dry_run
=False, not_send_msg
=None):
458 Delete item by its internal _id
459 :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
460 :param _id: server internal id
461 :param dry_run: make checking but do not delete
462 :param not_send_msg: To not send message (False) or store content (list) instead
463 :return: operation id (None if there is not operation), raise exception if error or not found, conflict, ...
466 # To allow addressing projects and users by name AS WELL AS by _id
467 filter_q
= {BaseTopic
.id_field(self
.topic
, _id
): _id
}
468 item_content
= self
.db
.get_one(self
.topic
, filter_q
)
470 # TODO add admin to filter, validate rights
471 # data = self.get_item(topic, _id)
472 self
.check_conflict_on_del(session
, _id
, item_content
)
476 if self
.multiproject
:
477 filter_q
.update(self
._get
_project
_filter
(session
))
478 if self
.multiproject
and session
["project_id"]:
479 # remove reference from project_read. If not last delete
480 # if this topic is not part of session["project_id"] no midification at database is done and an exception
482 self
.db
.set_one(self
.topic
, filter_q
, update_dict
=None,
483 pull
={"_admin.projects_read": {"$in": session
["project_id"]}})
484 # try to delete if there is not any more reference from projects. Ignore if it is not deleted
485 filter_q
= {'_id': _id
, '_admin.projects_read': [[], ["ANY"]]}
486 v
= self
.db
.del_one(self
.topic
, filter_q
, fail_on_empty
=False)
487 if not v
or not v
["deleted"]:
490 self
.db
.del_one(self
.topic
, filter_q
)
491 self
.delete_extra(session
, _id
, item_content
, not_send_msg
=not_send_msg
)
492 self
._send
_msg
("deleted", {"_id": _id
}, not_send_msg
=not_send_msg
)
495 def edit(self
, session
, _id
, indata
=None, kwargs
=None, content
=None):
497 Change the content of an item
498 :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
499 :param _id: server internal id
500 :param indata: contains the changes to apply
501 :param kwargs: modifies indata
502 :param content: original content of the item
503 :return: op_id: operation id if this is processed asynchronously, None otherwise
505 indata
= self
._remove
_envelop
(indata
)
507 # Override descriptor with query string kwargs
509 self
._update
_input
_with
_kwargs
(indata
, kwargs
)
511 if indata
and session
.get("set_project"):
512 raise EngineException("Cannot edit content and set to project (query string SET_PROJECT) at same time",
513 HTTPStatus
.UNPROCESSABLE_ENTITY
)
514 indata
= self
._validate
_input
_edit
(indata
, force
=session
["force"])
516 # TODO self._check_edition(session, indata, _id, force)
518 content
= self
.show(session
, _id
)
519 deep_update_rfc7396(content
, indata
)
521 # To allow project addressing by name AS WELL AS _id. Get the _id, just in case the provided one is a name
522 _id
= content
.get("_id") or _id
524 self
.check_conflict_on_edit(session
, content
, indata
, _id
=_id
)
525 op_id
= self
.format_on_edit(content
, indata
)
527 self
.db
.replace(self
.topic
, _id
, content
)
529 indata
.pop("_admin", None)
531 indata
["op_id"] = op_id
533 self
._send
_msg
("edited", indata
)
535 except ValidationError
as e
:
536 raise EngineException(e
, HTTPStatus
.UNPROCESSABLE_ENTITY
)