shipyard/src/bin/shipyard_airflow/shipyard_airflow/common/notes/notes.py

319 lines
12 KiB
Python

# Copyright 2018 AT&T Intellectual Property. All other 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.
#
"""Notes
A reusable component allowing for recording and retreiving information related
to arbitrarily useage-based keys (the usage makes the association keys to their
string-based liking). The intention for Notes is generally for additional
non-fielded information that may be of interest to a user. Notes are not
intended to store info that would drive code paths or decisions. This is not
an arbitrary use key-value database.
"""
import abc
from datetime import datetime
import logging
import requests
from requests.exceptions import HTTPError
from requests.exceptions import RequestException
import ulid
from .errors import NotesInitializationError
from .errors import NotesRetrievalError
from .errors import NotesStorageError
LOG = logging.getLogger(__name__)
MAX_VERBOSITY = 5
MIN_VERBOSITY = 1
class NotesManager:
"""Interface to store and retrieve notes
:param storage: A NotesStorage object to store and retrieve notes for a
specific storage mechanism. e.g. Database, Service
:param get_token: A method that returns an auth token that will be used
as the X-Auth-Token header when resolving url-based notes
:param connect_timeout: optional, The maximum time waiting to connect to
a URL. Defaults to 3 seconds
:param read_timeout: optional, The maximum time waiting to read the info
from a URL. Defaults to 10 seconds
Example usage:
nm = NotesManager(SQLNotesStorage("connection_info"), get_url)
a_note = nm.store(Note(...params...))
notes = list(nm.retrieve(Query("some/id")))
"""
def __init__(self, storage, get_token, connect_timeout=None,
read_timeout=None):
if not isinstance(storage, NotesStorage):
raise NotesInitializationError(
"Storage object is not suitable for use with Notes"
)
self.storage = storage
LOG.info(
"Initializing Notes with storage mechanism: %s",
storage.__class__.__name__
)
if not callable(get_token):
raise NotesInitializationError(
"Parameter get_token is not suitable for use with Notes. "
"Must be a callable."
)
self.get_token = get_token
# connect and read timeouts default to 3 and 10 seconds
self.connect_timeout = connect_timeout or 3
self.read_timeout = read_timeout or 10
def create(self, assoc_id, subject, sub_type, note_val,
verbosity=None, link_url=None, is_auth_link=None,
note_id=None, note_timestamp=None, store=True):
"""Creates and stores a Note object from parameters
Passthrough helper method to avoid additional imports for the Note
class. Most of the parameters match that of the Note constructor.
See: func:`notes.Note.__init__`
Additional Parameters:
:param store: optinal, default=True, invoke the store method
immediately upon creation, if true
"""
n = Note(assoc_id, subject, sub_type, note_val,
verbosity=None, link_url=None, is_auth_link=None,
note_id=None, note_timestamp=None)
if store:
return self.store(n)
else:
return n
def store(self, note):
"""Store a note
:param note: A Note object to store
:returns: The note, as it was after storage
"""
if note.verbosity < MIN_VERBOSITY or note.verbosity > MAX_VERBOSITY:
raise NotesStorageError(
"Verbosity of notes must range from {} "
"to {} (most verbose)".format(MIN_VERBOSITY, MAX_VERBOSITY))
try:
return self.storage.store(note)
except NotesStorageError:
raise
except Exception as ex:
LOG.exception(ex)
raise NotesStorageError("Unhandled error during storage of a note")
def retrieve(self, query):
"""Retrieve a list of notes
:param query: a query object to retrieve notes
"""
try:
notes = list(self.storage.retrieve(query))
except NotesRetrievalError:
raise
except Exception as ex:
LOG.exception(ex)
raise NotesRetrievalError(
"Unhandled error during retrieval of notes"
)
# Get the auth token once per retrieve, not once per note.
if notes:
auth_token = self.get_token()
# resolve the note urls
# TODO: threaded?
for note in notes:
self._resolve_note_url(note, auth_token)
return notes
def _resolve_note_url(self, note, auth_token):
"""Resolve and set the value obtained from the URL for a Note.
:param note: the Note object to retreive and set the value for.
:param auth_token: the authorization token set as a header for the URL
request if one is indicated as needed by the note.
If there is data retrieved at the note's url, set the
resolved_url_value with those contents.
If there is no url for the note, return, with resolved_url_value as
None
If there is no data retrieved, resolved_url_value for the note remains
None
If there is an error related to retreiving the note's url value, the
resolved_url_value is set to a placeholder value indicating that the
value could not be obtained.
"""
if not isinstance(note, Note):
LOG.debug(
"Note is None or not a Note object. URL will not be resolved"
)
return
if not note.link_url:
LOG.debug("Note %s has no link to resolve", note.note_id)
return
contents = None
try:
headers = {}
# Don't pass credentials if not needed.
if note.is_auth_link:
headers['X-Auth-Token'] = auth_token
response = requests.get(
note.link_url,
headers=headers,
timeout=(self.connect_timeout, self.read_timeout))
response.raise_for_status()
# Set the valid response text to the note
note.resolved_url_value = response.text
except HTTPError as he:
# A bad status code - don't stop, but log and indicate in note.
LOG.info(
"Note %s has a url returning a bad status code: %s",
note.note_id, response.status_code
)
note.resolved_url_value = (
"Note contents could not be retrieved. URL lookup failed "
"with status code: {}"
).format(response.status_code)
except RequestException as rex:
# A more serious exception; log and indicate in the note
LOG.exception(rex)
note.resolved_url_value = (
"Note contents could not be retrieved. URL lookup was unable "
"to complete"
)
except Exception as ex:
# Whatever's left, log and indicate in the note
LOG.exception(ex)
note.resolved_url_value = (
"Note contents could not be retrieved due to unexpected "
"circumstances"
)
class Note:
"""Model object representing a note
:param assoc_id: arbitrary value like action/xxxxxxx or
step/xxxxxxx/step_name, useful for lookup, set by note creator
Limit: 128 characters
:param subject: arbitrary value to be used as the subject of the note,
useful to a human e.g. mtn15r11n0001, set by note creator
Limit: 128 characters
:param sub_type: arbitrary value used to qualify the subject of the note,
e.g. Node, Action, Step, set by note creator
Limit: 128 characters
:param note_val: the text value of the note, the contents of info to be
displayed as note
:param link_url: optional url that should be followed when the note is
retrieved to append to its value
:param is_auth_link: boolean if Shipyard's service ID auth credentials are
needed to make the call to follow the link default=false
:param verbosity: integer, 1-5 indicating the verbosity level, default = 1
:param note_id: ULID that uniquely represents a note. Users are not
expected to pass a value for the ID of a note, it will be assigned
:param note_timestamp: String representation of the timestamp for the note
"""
def __init__(self, assoc_id, subject, sub_type, note_val,
verbosity=None, link_url=None, is_auth_link=None,
note_id=None, note_timestamp=None):
self.assoc_id = assoc_id
self.subject = subject
self.sub_type = sub_type
self.note_val = note_val
self.verbosity = verbosity or MIN_VERBOSITY
self.link_url = link_url
self.is_auth_link = is_auth_link or False
self.note_id = note_id or ulid.ulid()
self.note_timestamp = note_timestamp or str(datetime.utcnow())
self._resolved_url_value = None
@property
def resolved_url_value(self):
return self._resolved_url_value
@resolved_url_value.setter
def resolved_url_value(self, value):
self._resolved_url_value = value
def view(self):
"""Returns the user-facing dictionary version of the Note"""
return {
'assoc_id': self.assoc_id,
'subject': self.subject,
'sub_type': self.sub_type,
'note_val': self.note_val,
'verbosity': self.verbosity,
'note_id': self.note_id,
'note_timestamp': self.note_timestamp,
'resolved_url_value': self.resolved_url_value,
}
class Query:
"""Model object for a query to retrieve notes
:param assoc_id_pattern: The pattern to match to the assoc_id for a note.
:param max_verbosity: optional integer 1-5, defaults to 5 (everything)
:param exact_match: boolean, defaults to False. If true, the
assoc_id_pattern will be used precisely, otherwise assoc_id_pattern
will be matched to the start of the assoc_id for notes.
"""
def __init__(self, assoc_id_pattern, max_verbosity=None, exact_match=None):
self.assoc_id_pattern = assoc_id_pattern
self.max_verbosity = max_verbosity or MAX_VERBOSITY
self.exact_match = exact_match or False
class NotesStorage(metaclass=abc.ABCMeta):
"""NotesStorage abstract base class
Defines the interface for NotesStorage implementations that provide the
specific mappings of Note objects to the target data store.
"""
@abc.abstractmethod
def store(self, note):
"""Store a Note object, return the note object as stored
:param note: a Note object
:returns: A single Note object, as was persisted.
:raises NotesStorageError: When there is a failure to create the note.
"""
pass
@abc.abstractmethod
def retrieve(self, query):
"""Query for a list of Note objects
:param query: a Notes Query object representing the notes to be
retrieved.
:returns: List of Note objects matching the query
:raises NotesRetrievalError: when there is a failure to retrieve notes,
however an empty list is expected to be returned in the case of no
results.
"""
pass