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
22 from yaml
import safe_load
, YAMLError
24 __author__
= "Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
27 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))
67 def increment_ip_mac(ip_mac
, vm_index
=1):
68 if not isinstance(ip_mac
, str):
71 # try with ipv4 look for last dot
75 return "{}{}".format(ip_mac
[:i
], int(ip_mac
[i
:]) + vm_index
)
76 # try with ipv6 or mac look for last colon. Operate in hex
80 # format in hex, len can be 2 for mac or 4 for ipv6
81 return ("{}{:0" + str(len(ip_mac
) - i
) + "x}").format(
82 ip_mac
[:i
], int(ip_mac
[i
:], 16) + vm_index
90 # static variables for all instance classes
91 topic
= None # to_override
92 topic_msg
= None # to_override
93 quota_name
= None # to_override. If not provided topic will be used for quota_name
94 schema_new
= None # to_override
95 schema_edit
= None # to_override
96 multiproject
= True # True if this Topic can be shared by several projects. Then it contains _admin.projects_read
100 # Alternative ID Fields for some Topics
101 alt_id_field
= {"projects": "name", "users": "username", "roles": "name"}
103 def __init__(self
, db
, fs
, msg
, auth
):
107 self
.logger
= logging
.getLogger("nbi.engine")
111 def id_field(topic
, value
):
112 """Returns ID Field for given topic and field value"""
113 if topic
in BaseTopic
.alt_id_field
.keys() and not is_valid_uuid(value
):
114 return BaseTopic
.alt_id_field
[topic
]
119 def _remove_envelop(indata
=None):
124 def check_quota(self
, session
):
126 Check whether topic quota is exceeded by the given project
127 Used by relevant topics' 'new' function to decide whether or not creation of the new item should be allowed
128 :param session[project_id]: projects (tuple) for which quota should be checked
129 :param session[force]: boolean. If true, skip quota checking
132 DbException if project not found
133 ValidationError if quota exceeded in one of the projects
137 projects
= session
["project_id"]
138 for project
in projects
:
139 proj
= self
.auth
.get_project(project
)
141 quota_name
= self
.quota_name
or self
.topic
142 quota
= proj
.get("quotas", {}).get(quota_name
, self
.default_quota
)
143 count
= self
.db
.count(self
.topic
, {"_admin.projects_read": pid
})
146 raise ValidationError(
147 "quota ({}={}) exceeded for project {} ({})".format(
148 quota_name
, quota
, name
, pid
150 http_code
=HTTPStatus
.UNPROCESSABLE_ENTITY
,
153 def _validate_input_new(self
, input, force
=False):
155 Validates input user content for a new entry. It uses jsonschema. Some overrides will use pyangbind
156 :param input: user input content for the new topic
157 :param force: may be used for being more tolerant
158 :return: The same input content, or a changed version of it.
161 validate_input(input, self
.schema_new
)
164 def _validate_input_edit(self
, input, content
, force
=False):
166 Validates input user content for an edition. It uses jsonschema. Some overrides will use pyangbind
167 :param input: user input content for the new topic
168 :param force: may be used for being more tolerant
169 :return: The same input content, or a changed version of it.
172 validate_input(input, self
.schema_edit
)
176 def _get_project_filter(session
):
178 Generates a filter dictionary for querying database, so that only allowed items for this project can be
179 addressed. Only proprietary or public can be used. Allowed projects are at _admin.project_read/write. If it is
180 not present or contains ANY mean public.
181 :param session: contains:
182 project_id: project list this session has rights to access. Can be empty, one or several
183 set_project: items created will contain this project list
185 public: True, False or None
186 method: "list", "show", "write", "delete"
188 :return: dictionary with project filter
191 project_filter_n
= []
192 project_filter
= list(session
["project_id"])
194 if session
["method"] not in ("list", "delete"):
196 project_filter
.append("ANY")
197 elif session
["public"] is not None:
198 if session
["public"]:
199 project_filter
.append("ANY")
201 project_filter_n
.append("ANY")
203 if session
.get("PROJECT.ne"):
204 project_filter_n
.append(session
["PROJECT.ne"])
207 if session
["method"] in ("list", "show", "delete") or session
.get(
210 p_filter
["_admin.projects_read.cont"] = project_filter
212 p_filter
["_admin.projects_write.cont"] = project_filter
214 if session
["method"] in ("list", "show", "delete") or session
.get(
217 p_filter
["_admin.projects_read.ncont"] = project_filter_n
219 p_filter
["_admin.projects_write.ncont"] = project_filter_n
223 def check_conflict_on_new(self
, session
, indata
):
225 Check that the data to be inserted is valid
226 :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
227 :param indata: data to be inserted
228 :return: None or raises EngineException
232 def check_conflict_on_edit(self
, session
, final_content
, edit_content
, _id
):
234 Check that the data to be edited/uploaded is valid
235 :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
236 :param final_content: data once modified. This method may change it.
237 :param edit_content: incremental data that contains the modifications to apply
238 :param _id: internal _id
239 :return: final_content or raises EngineException
241 if not self
.multiproject
:
243 # Change public status
244 if session
["public"] is not None:
247 and "ANY" not in final_content
["_admin"]["projects_read"]
249 final_content
["_admin"]["projects_read"].append("ANY")
250 final_content
["_admin"]["projects_write"].clear()
252 not session
["public"]
253 and "ANY" in final_content
["_admin"]["projects_read"]
255 final_content
["_admin"]["projects_read"].remove("ANY")
257 # Change project status
258 if session
.get("set_project"):
259 for p
in session
["set_project"]:
260 if p
not in final_content
["_admin"]["projects_read"]:
261 final_content
["_admin"]["projects_read"].append(p
)
265 def check_unique_name(self
, session
, name
, _id
=None):
267 Check that the name is unique for this project
268 :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
269 :param name: name to be checked
270 :param _id: If not None, ignore this entry that are going to change
271 :return: None or raises EngineException
273 if not self
.multiproject
:
276 _filter
= self
._get
_project
_filter
(session
)
277 _filter
["name"] = name
279 _filter
["_id.neq"] = _id
281 self
.topic
, _filter
, fail_on_empty
=False, fail_on_more
=False
283 raise EngineException(
284 "name '{}' already exists for {}".format(name
, self
.topic
),
289 def format_on_new(content
, project_id
=None, make_public
=False):
291 Modifies content descriptor to include _admin
292 :param content: descriptor to be modified
293 :param project_id: if included, it add project read/write permissions. Can be None or a list
294 :param make_public: if included it is generated as public for reading.
295 :return: op_id: operation id on asynchronous operation, None otherwise. In addition content is modified
298 if "_admin" not in content
:
299 content
["_admin"] = {}
300 if not content
["_admin"].get("created"):
301 content
["_admin"]["created"] = now
302 content
["_admin"]["modified"] = now
303 if not content
.get("_id"):
304 content
["_id"] = str(uuid4())
305 if project_id
is not None:
306 if not content
["_admin"].get("projects_read"):
307 content
["_admin"]["projects_read"] = list(project_id
)
309 content
["_admin"]["projects_read"].append("ANY")
310 if not content
["_admin"].get("projects_write"):
311 content
["_admin"]["projects_write"] = list(project_id
)
315 def format_on_edit(final_content
, edit_content
):
317 Modifies final_content to admin information upon edition
318 :param final_content: final content to be stored at database
319 :param edit_content: user requested update content
320 :return: operation id, if this edit implies an asynchronous operation; None otherwise
322 if final_content
.get("_admin"):
324 final_content
["_admin"]["modified"] = now
327 def _send_msg(self
, action
, content
, not_send_msg
=None):
328 if self
.topic_msg
and not_send_msg
is not False:
329 content
= content
.copy()
330 content
.pop("_admin", None)
331 if isinstance(not_send_msg
, list):
332 not_send_msg
.append((self
.topic_msg
, action
, content
))
334 self
.msg
.write(self
.topic_msg
, action
, content
)
336 def check_conflict_on_del(self
, session
, _id
, db_content
):
338 Check if deletion can be done because of dependencies if it is not force. To override
339 :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
340 :param _id: internal _id
341 :param db_content: The database content of this item _id
342 :return: None if ok or raises EngineException with the conflict
347 def _update_input_with_kwargs(desc
, kwargs
, yaml_format
=False):
349 Update descriptor with the kwargs. It contains dot separated keys
350 :param desc: dictionary to be updated
351 :param kwargs: plain dictionary to be used for updating.
352 :param yaml_format: get kwargs values as yaml format.
353 :return: None, 'desc' is modified. It raises EngineException.
358 for k
, v
in kwargs
.items():
359 update_content
= desc
363 if kitem_old
is not None:
364 update_content
= update_content
[kitem_old
]
365 if isinstance(update_content
, dict):
367 if not isinstance(update_content
.get(kitem_old
), (dict, list)):
368 update_content
[kitem_old
] = {}
369 elif isinstance(update_content
, list):
370 # key must be an index of the list, must be integer
371 kitem_old
= int(kitem
)
372 # if index greater than list, extend the list
373 if kitem_old
>= len(update_content
):
374 update_content
+= [None] * (
375 kitem_old
- len(update_content
) + 1
377 if not isinstance(update_content
[kitem_old
], (dict, list)):
378 update_content
[kitem_old
] = {}
380 raise EngineException(
381 "Invalid query string '{}'. Descriptor is not a list nor dict at '{}'".format(
386 del update_content
[kitem_old
]
388 update_content
[kitem_old
] = v
if not yaml_format
else safe_load(v
)
390 raise EngineException(
391 "Invalid query string '{}'. Descriptor does not contain '{}'".format(
396 raise EngineException(
397 "Invalid query string '{}'. Expected integer index list instead of '{}'".format(
402 raise EngineException(
403 "Invalid query string '{}'. Index '{}' out of range".format(
408 raise EngineException("Invalid query string '{}' yaml format".format(k
))
410 def sol005_projection(self
, data
):
411 # Projection was moved to child classes
414 def show(self
, session
, _id
, filter_q
=None, api_req
=False):
416 Get complete information on an topic
417 :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
418 :param _id: server internal id
419 :param filter_q: dict: query parameter
420 :param api_req: True if this call is serving an external API request. False if serving internal request.
421 :return: dictionary, raise exception if not found.
423 if not self
.multiproject
:
426 filter_db
= self
._get
_project
_filter
(session
)
427 # To allow project&user addressing by name AS WELL AS _id
428 filter_db
[BaseTopic
.id_field(self
.topic
, _id
)] = _id
429 data
= self
.db
.get_one(self
.topic
, filter_db
)
431 # Only perform SOL005 projection if we are serving an external request
433 self
.sol005_projection(data
)
437 # TODO transform data for SOL005 URL requests
438 # TODO remove _admin if not admin
440 def get_file(self
, session
, _id
, path
=None, accept_header
=None):
442 Only implemented for descriptor topics. Return the file content of a descriptor
443 :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
444 :param _id: Identity of the item to get content
445 :param path: artifact path or "$DESCRIPTOR" or None
446 :param accept_header: Content of Accept header. Must contain applition/zip or/and text/plain
447 :return: opened file or raises an exception
449 raise EngineException(
450 "Method get_file not valid for this topic", HTTPStatus
.INTERNAL_SERVER_ERROR
453 def list(self
, session
, filter_q
=None, api_req
=False):
455 Get a list of the topic that matches a filter
456 :param session: contains the used login username and working project
457 :param filter_q: filter of data to be applied
458 :param api_req: True if this call is serving an external API request. False if serving internal request.
459 :return: The list, it can be empty if no one match the filter.
463 if self
.multiproject
:
464 filter_q
.update(self
._get
_project
_filter
(session
))
466 # TODO transform data for SOL005 URL requests. Transform filtering
467 # TODO implement "field-type" query string SOL005
468 data
= self
.db
.get_list(self
.topic
, filter_q
)
470 # Only perform SOL005 projection if we are serving an external request
472 data
= [self
.sol005_projection(inst
) for inst
in data
]
476 def new(self
, rollback
, session
, indata
=None, kwargs
=None, headers
=None):
478 Creates a new entry into database.
479 :param rollback: list to append created items at database in case a rollback may to be done
480 :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
481 :param indata: data to be inserted
482 :param kwargs: used to override the indata descriptor
483 :param headers: http request headers
485 _id: identity of the inserted data.
486 op_id: operation id if this is asynchronous, None otherwise
489 if self
.multiproject
:
490 self
.check_quota(session
)
492 content
= self
._remove
_envelop
(indata
)
494 # Override descriptor with query string kwargs
495 self
._update
_input
_with
_kwargs
(content
, kwargs
)
496 content
= self
._validate
_input
_new
(content
, force
=session
["force"])
497 self
.check_conflict_on_new(session
, content
)
498 op_id
= self
.format_on_new(
499 content
, project_id
=session
["project_id"], make_public
=session
["public"]
501 _id
= self
.db
.create(self
.topic
, content
)
502 rollback
.append({"topic": self
.topic
, "_id": _id
})
504 content
["op_id"] = op_id
505 self
._send
_msg
("created", content
)
507 except ValidationError
as e
:
508 raise EngineException(e
, HTTPStatus
.UNPROCESSABLE_ENTITY
)
510 def upload_content(self
, session
, _id
, indata
, kwargs
, headers
):
512 Only implemented for descriptor topics. Used for receiving content by chunks (with a transaction_id header
513 and/or gzip file. It will store and extract)
514 :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
515 :param _id : the database id of entry to be updated
516 :param indata: http body request
517 :param kwargs: user query string to override parameters. NOT USED
518 :param headers: http request headers
519 :return: True package has is completely uploaded or False if partial content has been uplodaed.
520 Raise exception on error
522 raise EngineException(
523 "Method upload_content not valid for this topic",
524 HTTPStatus
.INTERNAL_SERVER_ERROR
,
527 def delete_list(self
, session
, filter_q
=None):
529 Delete a several entries of a topic. This is for internal usage and test only, not exposed to NBI API
530 :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
531 :param filter_q: filter of data to be applied
532 :return: The deleted list, it can be empty if no one match the filter.
534 # TODO add admin to filter, validate rights
537 if self
.multiproject
:
538 filter_q
.update(self
._get
_project
_filter
(session
))
539 return self
.db
.del_list(self
.topic
, filter_q
)
541 def delete_extra(self
, session
, _id
, db_content
, not_send_msg
=None):
543 Delete other things apart from database entry of a item _id.
544 e.g.: other associated elements at database and other file system storage
545 :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
546 :param _id: server internal id
547 :param db_content: The database content of the _id. It is already deleted when reached this method, but the
548 content is needed in same cases
549 :param not_send_msg: To not send message (False) or store content (list) instead
550 :return: None if ok or raises EngineException with the problem
554 def delete(self
, session
, _id
, dry_run
=False, not_send_msg
=None):
556 Delete item by its internal _id
557 :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
558 :param _id: server internal id
559 :param dry_run: make checking but do not delete
560 :param not_send_msg: To not send message (False) or store content (list) instead
561 :return: operation id (None if there is not operation), raise exception if error or not found, conflict, ...
564 # To allow addressing projects and users by name AS WELL AS by _id
565 if not self
.multiproject
:
568 filter_q
= self
._get
_project
_filter
(session
)
569 filter_q
[self
.id_field(self
.topic
, _id
)] = _id
570 item_content
= self
.db
.get_one(self
.topic
, filter_q
)
572 self
.check_conflict_on_del(session
, _id
, item_content
)
576 if self
.multiproject
and session
["project_id"]:
577 # remove reference from project_read if there are more projects referencing it. If it last one,
578 # do not remove reference, but delete
579 other_projects_referencing
= next(
582 for p
in item_content
["_admin"]["projects_read"]
583 if p
not in session
["project_id"] and p
!= "ANY"
588 # check if there are projects referencing it (apart from ANY, that means, public)....
589 if other_projects_referencing
:
590 # remove references but not delete
592 "_admin.projects_read": session
["project_id"],
593 "_admin.projects_write": session
["project_id"],
596 self
.topic
, filter_q
, update_dict
=None, pull_list
=update_dict_pull
603 for p
in item_content
["_admin"]["projects_write"]
604 if p
== "ANY" or p
in session
["project_id"]
609 raise EngineException(
610 "You have not write permission to delete it",
611 http_code
=HTTPStatus
.UNAUTHORIZED
,
615 self
.db
.del_one(self
.topic
, filter_q
)
616 self
.delete_extra(session
, _id
, item_content
, not_send_msg
=not_send_msg
)
617 self
._send
_msg
("deleted", {"_id": _id
}, not_send_msg
=not_send_msg
)
620 def edit(self
, session
, _id
, indata
=None, kwargs
=None, content
=None):
622 Change the content of an item
623 :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
624 :param _id: server internal id
625 :param indata: contains the changes to apply
626 :param kwargs: modifies indata
627 :param content: original content of the item
628 :return: op_id: operation id if this is processed asynchronously, None otherwise
630 indata
= self
._remove
_envelop
(indata
)
632 # Override descriptor with query string kwargs
634 self
._update
_input
_with
_kwargs
(indata
, kwargs
)
636 if indata
and session
.get("set_project"):
637 raise EngineException(
638 "Cannot edit content and set to project (query string SET_PROJECT) at same time",
639 HTTPStatus
.UNPROCESSABLE_ENTITY
,
641 # TODO self._check_edition(session, indata, _id, force)
643 content
= self
.show(session
, _id
)
644 indata
= self
._validate
_input
_edit
(indata
, content
, force
=session
["force"])
645 deep_update_rfc7396(content
, indata
)
647 # To allow project addressing by name AS WELL AS _id. Get the _id, just in case the provided one is a name
648 _id
= content
.get("_id") or _id
650 content
= self
.check_conflict_on_edit(session
, content
, indata
, _id
=_id
)
651 op_id
= self
.format_on_edit(content
, indata
)
653 self
.db
.replace(self
.topic
, _id
, content
)
655 indata
.pop("_admin", None)
657 indata
["op_id"] = op_id
659 self
._send
_msg
("edited", indata
)
661 except ValidationError
as e
:
662 raise EngineException(e
, HTTPStatus
.UNPROCESSABLE_ENTITY
)