Modify note access methods

While iterating on the next steps of using notes, it became clear that
several changes to the output and access methods for notes needed
enhancements. This change introduces a new way to access a note's URL
information via a new API/CLI, while removing the resolution of URLs
from the existing note output. This supports the concept of "builddata"
coming back with sizes of 800kb or more - which really can never work
out inline in other data, especially in cases where there is
multiplicity of the information across many items.

New API: GET /notedetails/{id}
CLI: shipyard get notedetails/{id} and/or shipyard get notedetails {id}
Returns the resolution of the URL for a note, outputting the raw info as
the response (not structured in a JSON response).
The CLI will attempt to minimally format the response if it has inline
\n characters by replacing them will real newlines in the output (if the
output-format is set to either cli or format.  Raw format will be
returned as-is.

The existing notes responses are changed to not include the resolution
of the URL information inline, but rather provide the text:

  Details at notedetails/{id}

The CLI will interpret this and present:

  - Info available with 'describe notedetails/09876543210987654321098765'

This is an attempt to inform the user to access the note details that
way - luckily the API and CLI align on the term notedetails, as the word
details works well enough in the singular form presented by the CLI and
the plural form used by the API.
The ID returned is the unique id of the note (ULID format).

Notes that have no URL will return a 404 response from the API (and
an appropriately formatted value from the CLI).

This approach solves an issue beyond the large inline values from URLs;
providing a means to NOT resolve the URLs except in a one-at-a-time way.
Long lists of notes will no longer have the risk of long waits nor
needing of parallelization of retrieval of URLs for notes.

This change introduces an API-side sorting of notes by timestamp,
providing a chronological presentation of the information that may or
may not match the ULID or insertion ordering of the notes.

Additional feedback from peers about the output of noted indicated that
the CLI formatting of notes in general was in need of visual tuning. As
such, this change introduces changes to the formatting of the output
of notes from the CLI:

-  Notes for describing an item will be presented with a more specific
   header, e.g.: Action Notes: or Step Notes: instead of simply Notes.

-  Tables with notes will change the header from "Notes" to "Footnotes"
   give the user a better marker that the notes follow the current
   table.

-  Table footnotes will be presented in a table format similar to
   the following, with headings matching the kind of note being
   produced.

   Step Footnotes    Note
   (1)               > blah blah blah
                     > yakkity yakkity
   (2)               > stuff stuff stuff stuff stuff stuff stuff
                     stuff stuff stuff
                       - Info available with 'describe notedetails/...
                     > things things things

Change-Id: I1680505d5c555b2293419179ade995b0e8484e6d
This commit is contained in:
Bryan Strassner 2018-10-13 10:15:16 -05:00
parent 06de84e0ab
commit 5a9abc73dd
23 changed files with 766 additions and 237 deletions

View File

@ -372,6 +372,7 @@ conf:
workflow_orchestrator:get_renderedconfigdocs: rule:admin_read_access workflow_orchestrator:get_renderedconfigdocs: rule:admin_read_access
workflow_orchestrator:list_workflows: rule:admin_read_access workflow_orchestrator:list_workflows: rule:admin_read_access
workflow_orchestrator:get_workflow: rule:admin_read_access workflow_orchestrator:get_workflow: rule:admin_read_access
workflow_orchestrator:get_notedetails: rule: admin_read_access
workflow_orchestrator:get_site_statuses: rule:admin_read_access workflow_orchestrator:get_site_statuses: rule:admin_read_access
workflow_orchestrator:action_deploy_site: rule:admin_create workflow_orchestrator:action_deploy_site: rule:admin_create
workflow_orchestrator:action_update_site: rule:admin_create workflow_orchestrator:action_update_site: rule:admin_create

View File

@ -26,6 +26,7 @@ Shipyard functionality:
3. Airflow Monitoring 3. Airflow Monitoring
4. Site Statuses 4. Site Statuses
5. Logs Retrieval 5. Logs Retrieval
6. Notes Handling
Standards used by the API Standards used by the API
------------------------- -------------------------
@ -1205,5 +1206,68 @@ Example
[2018-04-11 07:30:43,945] {{base_task_runner.py:98}} INFO - Subtask: [2018-04-11 07:30:43,944] {{python_operator.py:90}} INFO - Done. Returned value was: None [2018-04-11 07:30:43,945] {{base_task_runner.py:98}} INFO - Subtask: [2018-04-11 07:30:43,944] {{python_operator.py:90}} INFO - Done. Returned value was: None
[2018-04-11 07:30:43,992] {{base_task_runner.py:98}} INFO - Subtask: """) [2018-04-11 07:30:43,992] {{base_task_runner.py:98}} INFO - Subtask: """)
Notes Handling API
------------------
The notes facilities of Shipyard are primarily interwoven in other APIs. This
endpoint adds the ability to retrieve additional information associated with a
note. The first use case for this API is the retrieval of builddata from
Drydock, which can be many hundreds of kilobytes of text.
/v1.0/notedetails/{note_id}
~~~~~~~~~~~~~~~~~~~~~~~~~~~
Retrieves the note details that are associated via URL with a note at the time
of note creation. Unlike some responses from Shipyard, this API returns the
remote information as-is, as the response body, without any further wrapping in
a JSON structure.
Entity Structure
^^^^^^^^^^^^^^^^
Raw text of the note's associated information.
GET /v1.0/notedetails/{node_id}
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Looks up the specified note and follows the associated URL to retrieve
information related to the note.
Query parameters
''''''''''''''''
N/A
Responses
'''''''''
200 OK
Accompanied by the text looked up from the note's associated URL
400 Bad Request
When the note_id is not a valid ULID value.
404 Not Found
When the note does not exist, or the note does not have a URL associated.
500 Internal Server Error
When the remote source of the information cannot be accessed, or if there is
a misconfiguration of the type of note preventing appropriate authorization
checking.
Example
'''''''
::
curl -D - \
-X GET $URL/api/v1.0/notedetails/01CASSSZT7CP1F0NKHCAJBCJGR \
-H "X-Auth-Token:$TOKEN"
HTTP/1.1 200 OK
x-shipyard-req: 49f74418-22b3-4629-8ddb-259bdfccf2fd
Potentially a lot of information here
.. _API Conventions: https://airshipit.readthedocs.io/en/latest/api-conventions.html .. _API Conventions: https://airshipit.readthedocs.io/en/latest/api-conventions.html

View File

@ -383,6 +383,10 @@ Retrieves the detailed information about the supplied namespaced item
Equivalent to: Equivalent to:
shipyard describe action 01BTG32JW87G0YKA1K29TKNAFX shipyard describe action 01BTG32JW87G0YKA1K29TKNAFX
shipyard describe notedetails/01BTG32JW87G0YKA1KA9TBNA32
Equivalent to:
shipyard describe notedetails 01BTG32JW87G0YKA1KA9TBNA32
shipyard describe step/01BTG32JW87G0YKA1K29TKNAFX/preflight shipyard describe step/01BTG32JW87G0YKA1K29TKNAFX/preflight
Equivalent to: Equivalent to:
shipyard describe step preflight --action=01BTG32JW87G0YKA1K29TKNAFX shipyard describe step preflight --action=01BTG32JW87G0YKA1K29TKNAFX
@ -413,7 +417,6 @@ Retrieves the detailed information about the supplied action id.
Sample Sample
^^^^^^ ^^^^^^
:: ::
$ shipyard describe action/01BZZK07NF04XPC5F4SCTHNPKN $ shipyard describe action/01BZZK07NF04XPC5F4SCTHNPKN
@ -426,7 +429,7 @@ Sample
Context Marker: 71d4112e-8b6d-44e8-9617-d9587231ffba Context Marker: 71d4112e-8b6d-44e8-9617-d9587231ffba
User: shipyard User: shipyard
Steps Index State Notes Steps Index State Footnotes
step/01BZZK07NF04XPC5F4SCTHNPKN/action_xcom 1 success step/01BZZK07NF04XPC5F4SCTHNPKN/action_xcom 1 success
step/01BZZK07NF04XPC5F4SCTHNPKN/dag_concurrency_check 2 success step/01BZZK07NF04XPC5F4SCTHNPKN/dag_concurrency_check 2 success
step/01BZZK07NF04XPC5F4SCTHNPKN/deckhand_get_design_version 3 failed (1) step/01BZZK07NF04XPC5F4SCTHNPKN/deckhand_get_design_version 3 failed (1)
@ -435,17 +438,39 @@ Sample
step/01BZZK07NF04XPC5F4SCTHNPKN/deckhand_get_design_version 6 failed step/01BZZK07NF04XPC5F4SCTHNPKN/deckhand_get_design_version 6 failed
step/01BZZK07NF04XPC5F4SCTHNPKN/drydock_build 7 None step/01BZZK07NF04XPC5F4SCTHNPKN/drydock_build 7 None
(1): Step Footnotes Note
(1) > step metadata: deckhand_get_design_version(2017-11-27 20:34:34.443053): Unable to determine version
step metadata: deckhand_get_design_version(2017-11-27 20:34:34.443053): Unable to determine version - Info available with 'describe notedetails/09876543210987654321098765'
Commands User Datetime Commands User Datetime
invoke shipyard 2017-11-27 20:34:34.443053+00:00 invoke shipyard 2017-11-27 20:34:34.443053+00:00
Validations: None Validations: None
Notes: Action Notes:
action metadata: 01BZZK07NF04XPC5F4SCTHNPKN(2017-11-27 20:34:24.610604): Invoked using revision 3 > action metadata: 01BZZK07NF04XPC5F4SCTHNPKN(2017-11-27 20:34:24.610604): Invoked using revision 3
describe notedetails
~~~~~~~~~~~~~~~~~~~~
Retrieves extended information related to a note.
::
shipyard describe notedetails <note_id>
Example:
shipyard describe notedetails/01BTG32JW87G0YKA1KA9TBNA32
<note_id>
The id of the note referenced as having more details in a separate response
Sample
^^^^^^
::
$ shipyard describe notedetails/01BTG32JW87G0YKA1KA9TBNA32
<a potentially large amount of data as returned by the source of info>
describe step describe step
~~~~~~~~~~~~~ ~~~~~~~~~~~~~
@ -469,7 +494,6 @@ Retrieves the step details associated with an action and step.
Sample Sample
^^^^^^ ^^^^^^
:: ::
$ shipyard describe step/01BZZK07NF04XPC5F4SCTHNPKN/action_xcom $ shipyard describe step/01BZZK07NF04XPC5F4SCTHNPKN/action_xcom
@ -483,6 +507,10 @@ Sample
Try Number: 1 Try Number: 1
Operator: PythonOperator Operator: PythonOperator
Step Notes:
> step metadata: deckhand_get_design_version(2017-11-27 20:34:34.443053): Unable to determine version
- Info available with 'describe notedetails/09876543210987654321098765'
describe validation describe validation
~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~
@ -508,7 +536,6 @@ validation id
Sample Sample
^^^^^^ ^^^^^^
:: ::
TBD TBD
@ -533,7 +560,6 @@ workflow engine.
Sample Sample
^^^^^^ ^^^^^^
:: ::
$ shipyard describe workflow deploy_site__2017-11-27T20:34:33.000000 $ shipyard describe workflow deploy_site__2017-11-27T20:34:33.000000
@ -590,17 +616,13 @@ Sample
:: ::
$ shipyard get actions $ shipyard get actions
Name Action Lifecycle Execution Time Step Succ/Fail/Oth Notes Name Action Lifecycle Execution Time Step Succ/Fail/Oth Footnotes
deploy_site action/01BTP9T2WCE1PAJR2DWYXG805V Failed 2017-09-23T02:42:12 12/1/3 (1) deploy_site action/01BTP9T2WCE1PAJR2DWYXG805V Failed 2017-09-23T02:42:12 12/1/3 (1)
update_site action/01BZZKMW60DV2CJZ858QZ93HRS Processing 2017-09-23T04:12:21 6/0/10 (2) update_site action/01BZZKMW60DV2CJZ858QZ93HRS Processing 2017-09-23T04:12:21 6/0/10 (2)
(1): Action Footnotes Note
(1) > action metadata:01BTP9T2WCE1PAJR2DWYXG805V(2017-09-23 02:42:23.346534): Invoked with revision 3
action metadata:01BTP9T2WCE1PAJR2DWYXG805V(2017-09-23 02:42:23.346534): Invoked with revision 3 (2) > action metadata:01BZZKMW60DV2CJZ858QZ93HRS(2017-09-23 04:12:31.465342): Invoked with revision 4
(2):
action metadata:01BZZKMW60DV2CJZ858QZ93HRS(2017-09-23 04:12:31.465342): Invoked with revision 4
get configdocs get configdocs
@ -653,8 +675,8 @@ differences between the 'committed' and 'buffer' revision (default behavior).
prior commit. If no documents have been loaded into the buffer for this prior commit. If no documents have been loaded into the buffer for this
collection, this will return an empty response (default) collection, this will return an empty response (default)
Samples Sample
^^^^^^^ ^^^^^^
:: ::
@ -877,7 +899,6 @@ is an optional parameter.
Sample Sample
^^^^^^ ^^^^^^
:: ::
$ shipyard logs step/01C9VVQSCFS7V9QB5GBS3WFVSE/action_xcom $ shipyard logs step/01C9VVQSCFS7V9QB5GBS3WFVSE/action_xcom
@ -928,7 +949,6 @@ Provides topical help for shipyard.
Sample Sample
^^^^^^ ^^^^^^
:: ::
$ shipyard help $ shipyard help

View File

@ -62,6 +62,11 @@
# GET /api/v1.0/workflows/{id} # GET /api/v1.0/workflows/{id}
#"workflow_orchestrator:get_workflow": "rule:admin_required" #"workflow_orchestrator:get_workflow": "rule:admin_required"
# Retrieve the details for a note. Further authorization is required
# depending on the topic of the note
# GET /api/v1.0/notedetails/{note_id}
#"workflow_orchestrator:get_notedetails": "rule:admin_required"
# Retrieve the statuses for the site # Retrieve the statuses for the site
# GET /api/v1.0/site_statuses # GET /api/v1.0/site_statuses
#"workflow_orchestrator:get_site_statuses": "rule:admin_required" #"workflow_orchestrator:get_site_statuses": "rule:admin_required"

View File

@ -62,6 +62,11 @@
# GET /api/v1.0/workflows/{id} # GET /api/v1.0/workflows/{id}
#"workflow_orchestrator:get_workflow": "rule:admin_required" #"workflow_orchestrator:get_workflow": "rule:admin_required"
# Retrieve the details for a note. Further authorization is required
# depending on the topic of the note
# GET /api/v1.0/notedetails/{note_id}
#"workflow_orchestrator:get_notedetails": "rule:admin_required"
# Retrieve the statuses for the site # Retrieve the statuses for the site
# GET /api/v1.0/site_statuses # GET /api/v1.0/site_statuses
#"workflow_orchestrator:get_site_statuses": "rule:admin_required" #"workflow_orchestrator:get_site_statuses": "rule:admin_required"

View File

@ -42,3 +42,28 @@ class NotesStorageError(NotesError):
Raised when there is an error attempting to store a note. Raised when there is an error attempting to store a note.
""" """
pass pass
class NoteNotFoundError(NotesRetrievalError):
"""NoteNotFoundError
Raised when a note is looked up directly and not found
"""
pass
class NoteURLNotSpecifiedError(NotesRetrievalError):
"""NoteURLNotSpecifiedError
Raised when a note's url info is requested for a note that has no URL
"""
pass
class NoteURLRetrievalError(NotesRetrievalError):
"""NoteURLRetrievalError
Raised when there is an error retrieving the URL information for a note
that has a URL specified.
"""
pass

View File

@ -30,6 +30,8 @@ from requests.exceptions import HTTPError
from requests.exceptions import RequestException from requests.exceptions import RequestException
import ulid import ulid
from .errors import NoteURLNotSpecifiedError
from .errors import NoteURLRetrievalError
from .errors import NotesInitializationError from .errors import NotesInitializationError
from .errors import NotesRetrievalError from .errors import NotesRetrievalError
from .errors import NotesStorageError from .errors import NotesStorageError
@ -93,8 +95,9 @@ class NotesManager:
immediately upon creation, if true immediately upon creation, if true
""" """
n = Note(assoc_id, subject, sub_type, note_val, n = Note(assoc_id, subject, sub_type, note_val,
verbosity=None, link_url=None, is_auth_link=None, verbosity=verbosity, link_url=link_url,
note_id=None, note_timestamp=None) is_auth_link=is_auth_link, note_id=note_id,
note_timestamp=note_timestamp)
if store: if store:
return self.store(n) return self.store(n)
else: else:
@ -122,6 +125,8 @@ class NotesManager:
"""Retrieve a list of notes """Retrieve a list of notes
:param query: a query object to retrieve notes :param query: a query object to retrieve notes
:returns: a list of notes matchin the query, or [] if there are no
notes matching the query.
""" """
try: try:
notes = list(self.storage.retrieve(query)) notes = list(self.storage.retrieve(query))
@ -132,45 +137,41 @@ class NotesManager:
raise NotesRetrievalError( raise NotesRetrievalError(
"Unhandled error during retrieval of notes" "Unhandled error during retrieval of notes"
) )
# Get the auth token once per retrieve, not once per note. for note in notes:
if notes: if note.link_url:
auth_token = self.get_token() note.resolved_url_value = (
# resolve the note urls "Details at notedetails/{}".format(note.note_id))
# TODO: threaded?
for note in notes:
self._resolve_note_url(note, auth_token)
return notes return notes
def _resolve_note_url(self, note, auth_token): def retrieve_by_id(self, note_id):
"""Resolve and set the value obtained from the URL for a Note. """Return a single note looked up by the specified note_id
:param note: the Note object to retreive and set the value for. :param note_id: the ULID of the note to retrieve.
:param auth_token: the authorization token set as a header for the URL :raises NoteNotFoundError: if there is no note matching the requested
request if one is indicated as needed by the note. note_id
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.
""" """
return self.storage.retrieve_by_id(note_id)
def get_note_url_info(self, note):
"""Resolve and return the value obtained from the URL for a Note.
:param note: the note object or id of the note to retreive and set the
value for.
:returns: The contents retrieved from the note's URL.
:raises NoteNotFoundError: when the note (id) specified does not match
a known note
:raises NoteURLNotSpecifiedError: when the note has no url specified
:raises NoteURLRetrievalError: when there is an error using the
note's specified URL.
"""
# if the note is not a note, try to fetch it like an ID
if not isinstance(note, Note): if not isinstance(note, Note):
LOG.debug( note = self.retrieve_by_id(note)
"Note is None or not a Note object. URL will not be resolved"
)
return
if not note.link_url: if not note.link_url:
LOG.debug("Note %s has no link to resolve", note.note_id) LOG.debug("Note %s has no URL to resolve.", note.note_id)
return raise NoteURLNotSpecifiedError()
auth_token = self.get_token()
contents = None contents = None
try: try:
headers = {} headers = {}
@ -186,32 +187,11 @@ class NotesManager:
response.raise_for_status() response.raise_for_status()
# Set the valid response text to the note # Set the valid response text to the note
note.resolved_url_value = response.text return response.text
except HTTPError as he: except (HTTPError, RequestException) as lookup_err:
# A bad status code - don't stop, but log and indicate in note. LOG.exception(lookup_err)
LOG.info( raise NoteURLRetrievalError()
"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: class Note:
@ -316,3 +296,15 @@ class NotesStorage(metaclass=abc.ABCMeta):
results. results.
""" """
pass pass
@abc.abstractmethod
def retrieve_by_id(self, note_id):
"""Lookup a note by note_id
:param note_id: The ID of the note to retrieve
:returns: a single Note object matching the id or None if there is no
note matching the ID.
:raises NotesRetrievalError: if there is a failure to retrieve the
note
"""
pass

View File

@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
# #
from enum import Enum
import logging import logging
from .notes import MAX_VERBOSITY from .notes import MAX_VERBOSITY
@ -20,41 +21,105 @@ from .notes import Query
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
# Constants and magic numbers for actions:
# [7:33] to slice a string like:
#
# Text: action/12345678901234567890123456
# | |
# Position: 0....5.|..1....1....2....2....3..|.3
# | 0 5 0 5 0 | 5
# | |
# (7) ACTION_ID_START |
# (33) ACTION_ID_END
#
# matching the patterns in this helper.
#
ACTION_KEY_PATTERN = "action/{}"
ACTION_LOOKUP_PATTERN = "action/"
ACTION_ID_START = 7
ACTION_ID_END = 33
# Constants and magic numbers for steps: class _NoteType:
# [32:] to slice a step name pattern """Define the patterns and pertinent positions of information for a note
# step/{action_id}/{step_name}
# :param root: the string used as the initial identifier for the type
# Text: step/12345678901234567890123456/my_step :param key_pattern_count: the number of variable positions in the key.
# Position: 0....5....1....1....2....2....3||..3....4 E.g.: a value of 3, and a root of "tacos" would generate a key_pattern
# | 0 5 0 5 0|| 5 0 of "tacos/{}/{}/{}", while a value of 0 would generate "tacos".
# | |\ The lookup_pattern for a type is also drived from the key_pattern_count
# (5) STEP_ACTION_ID_START | \ by subtracting 1 (minimum 0), such that a value of 3 and a root of
# | (32) STEP_ID_START "tacos" would generate a lookup_pattern of "tacos/{}/{}/
# (31) STEP_ACTION_ID_END :param id_start: the start location in the assoc_id of a note of this type
# where the id of the item it is associated with appears.
STEP_KEY_PATTERN = "step/{}/{}" :param id_end: the optional end location in the assoc_id for locating the
STEP_LOOKUP_PATTERN = "step/{}" id from the assoc_id of a note of this type. This is only valid for
STEP_ACTION_ID_START = 5 items that have a fixed length key.
STEP_ACTION_ID_END = 31 :param default_subtype: the default sub_type specified upon creation of a
STEP_ID_START = 32 note of this type.
"""
def __init__(self, root, key_pattern_count, id_start,
id_end=None, default_subtype="metadata"):
self.root = root
self.base_pattern = "{}/".format(root)
self.kp_count = key_pattern_count
self.key_pattern = "{}{}".format(root, "/{}" * self.kp_count)
self.lp_count = self.kp_count - 1 if self.kp_count > 0 else 0
self.lookup_pattern = "{}/{}".format(root, "{}/" * self.lp_count)
self.id_start = id_start
self.id_end = id_end
self.default_subtype = default_subtype
class NoteType(Enum):
# Action
#
# Text: action/12345678901234567890123456
# | |
# Position: 0....5.|..1....1....2....2....3..|.3
# | 0 5 0 5 0 | 5
# | |
# (7) ACTION_ID_START |
# (33) ACTION_ID_END
ACTION = _NoteType(
root="action",
key_pattern_count=1,
id_start=7,
id_end=33,
default_subtype="action metadata")
# Step
#
# Text: step/12345678901234567890123456/my_step
# | ||
# Position: 0....5....1....1....2....2....3||..3....4
# | 0 5 0 5 0|| 5 0
# | |\
# (5) STEP_ACTION_ID_START | \
# | (32) STEP_ID_START
# (31) STEP_ACTION_ID_END
STEP = _NoteType(
root="step",
key_pattern_count=2,
id_start=32,
default_subtype="step metadata")
OTHER = _NoteType(root="", key_pattern_count=0, id_start=0)
@property
def base_pattern(self):
return self.value.base_pattern
@property
def key_pattern(self):
return self.value.key_pattern
@property
def lookup_pattern(self):
return self.value.lookup_pattern
@property
def id_start(self):
return self.value.id_start
@property
def id_end(self):
return self.value.id_end
@property
def default_subtype(self):
return self.value.default_subtype
@classmethod
def get_type(cls, note):
for tp in cls:
if note.assoc_id.startswith(tp.value.base_pattern):
return tp
return cls.OTHER
class NotesHelper: class NotesHelper:
@ -68,7 +133,7 @@ class NotesHelper:
def _failsafe_make_note(self, assoc_id, subject, sub_type, note_val, def _failsafe_make_note(self, assoc_id, subject, sub_type, note_val,
verbosity=MIN_VERBOSITY, link_url=None, verbosity=MIN_VERBOSITY, link_url=None,
is_auth_link=None): is_auth_link=None, note_timestamp=None):
"""LOG and continue on any note creation failure""" """LOG and continue on any note creation failure"""
try: try:
self.nm.create( self.nm.create(
@ -79,6 +144,7 @@ class NotesHelper:
verbosity=verbosity, verbosity=verbosity,
link_url=link_url, link_url=link_url,
is_auth_link=is_auth_link, is_auth_link=is_auth_link,
note_timestamp=note_timestamp
) )
except Exception as ex: except Exception as ex:
LOG.warn( LOG.warn(
@ -105,13 +171,45 @@ class NotesHelper:
LOG.exception(ex) LOG.exception(ex)
return [] return []
#
# Retrieve notes by note ID
#
def get_note(self, note_id):
"""Return a single note looked up by the specified note_id
:param note_id: the ULID of the note to retrieve.
:raises NoteNotFoundError: if there is no note matching the requested
note_id
"""
return self.nm.retrieve_by_id(note_id)
def get_note_details(self, note):
"""Return the note details for the specified note
:param note: the Note object with additional details to retrieve.
"""
return self.nm.get_note_url_info(note)
def get_note_assoc_id_type(self, note):
"""Return the type of note based on the assoc_id
:param note: The note to examine
The purpose of this method is to use the standard formats (e.g.:
action and step) supported by this helper to get the type of note.
This value can be used by a client to enforce access rules to the note
based on the item it is related to.
"""
return NoteType.get_type(note)
# #
# Action notes helper methods # Action notes helper methods
# #
def make_action_note(self, action_id, note_val, subject=None, def make_action_note(self, action_id, note_val, subject=None,
sub_type=None, verbosity=MIN_VERBOSITY, link_url=None, sub_type=None, verbosity=MIN_VERBOSITY, link_url=None,
is_auth_link=None): is_auth_link=None, note_timestamp=None):
"""Creates an action note using a convention for the note's assoc_id """Creates an action note using a convention for the note's assoc_id
:param action_id: the ULID id of an action :param action_id: the ULID id of an action
@ -127,12 +225,14 @@ class NotesHelper:
:param is_auth_link: optional, defaults to False, indicating if there :param is_auth_link: optional, defaults to False, indicating if there
is a need to send a Shipyard service account token with the is a need to send a Shipyard service account token with the
request to the optional URL request to the optional URL
:param note_timestamp: the parseable string timestamp to associate with
this note. Optional, defaults to the creation time of the note.
""" """
assoc_id = ACTION_KEY_PATTERN.format(action_id) assoc_id = NoteType.ACTION.key_pattern.format(action_id)
if subject is None: if subject is None:
subject = action_id subject = action_id
if sub_type is None: if sub_type is None:
sub_type = "action metadata" sub_type = NoteType.ACTION.default_subtype
self._failsafe_make_note( self._failsafe_make_note(
assoc_id=assoc_id, assoc_id=assoc_id,
@ -142,6 +242,7 @@ class NotesHelper:
verbosity=verbosity, verbosity=verbosity,
link_url=link_url, link_url=link_url,
is_auth_link=is_auth_link, is_auth_link=is_auth_link,
note_timestamp=note_timestamp
) )
def get_all_action_notes(self, verbosity=MIN_VERBOSITY): def get_all_action_notes(self, verbosity=MIN_VERBOSITY):
@ -150,19 +251,17 @@ class NotesHelper:
:param verbosity: optional integer, 0-5, the maximum verbosity level :param verbosity: optional integer, 0-5, the maximum verbosity level
to retrieve, defaults to 1 (most summary level) to retrieve, defaults to 1 (most summary level)
if set to less than 1, returns {}, skipping any retrieval if set to less than 1, returns {}, skipping any retrieval
Warning: if there are a lot of URL links in notes, this could take a
long time. The default verbosity of 1 attempts to avoid this as there
is less expectation of URL links on summary notes.
""" """
notes = self._failsafe_get_notes( notes = self._failsafe_get_notes(
assoc_id_pattern=ACTION_LOOKUP_PATTERN, assoc_id_pattern=NoteType.ACTION.lookup_pattern,
verbosity=verbosity, verbosity=verbosity,
exact_match=False exact_match=False
) )
note_dict = {} note_dict = {}
id_s = NoteType.ACTION.id_start
id_e = NoteType.ACTION.id_end
for n in notes: for n in notes:
action_id = n.assoc_id[ACTION_ID_START:ACTION_ID_END] action_id = n.assoc_id[id_s:id_e]
if action_id not in note_dict: if action_id not in note_dict:
note_dict[action_id] = [] note_dict[action_id] = []
note_dict[action_id].append(n) note_dict[action_id].append(n)
@ -178,7 +277,7 @@ class NotesHelper:
""" """
return self._failsafe_get_notes( return self._failsafe_get_notes(
assoc_id_pattern=ACTION_KEY_PATTERN.format(action_id), assoc_id_pattern=NoteType.ACTION.key_pattern.format(action_id),
verbosity=verbosity, verbosity=verbosity,
exact_match=True exact_match=True
) )
@ -189,7 +288,7 @@ class NotesHelper:
def make_step_note(self, action_id, step_id, note_val, subject=None, def make_step_note(self, action_id, step_id, note_val, subject=None,
sub_type=None, verbosity=MIN_VERBOSITY, link_url=None, sub_type=None, verbosity=MIN_VERBOSITY, link_url=None,
is_auth_link=None): is_auth_link=None, note_timestamp=None):
"""Creates an action note using a convention for the note's assoc_id """Creates an action note using a convention for the note's assoc_id
:param action_id: the ULID id of the action containing the note :param action_id: the ULID id of the action containing the note
@ -206,12 +305,14 @@ class NotesHelper:
:param is_auth_link: optional, defaults to False, indicating if there :param is_auth_link: optional, defaults to False, indicating if there
is a need to send a Shipyard service account token with the is a need to send a Shipyard service account token with the
request to the optional URL request to the optional URL
:param note_timestamp: the parseable string timestamp to associate with
this note. Optional, defaults to the creation time of the note.
""" """
assoc_id = STEP_KEY_PATTERN.format(action_id, step_id) assoc_id = NoteType.STEP.key_pattern.format(action_id, step_id)
if subject is None: if subject is None:
subject = step_id subject = step_id
if sub_type is None: if sub_type is None:
sub_type = "step metadata" sub_type = NoteType.STEP.default_subtype
self._failsafe_make_note( self._failsafe_make_note(
assoc_id=assoc_id, assoc_id=assoc_id,
@ -221,6 +322,7 @@ class NotesHelper:
verbosity=verbosity, verbosity=verbosity,
link_url=link_url, link_url=link_url,
is_auth_link=is_auth_link, is_auth_link=is_auth_link,
note_timestamp=note_timestamp
) )
def get_all_step_notes_for_action(self, action_id, def get_all_step_notes_for_action(self, action_id,
@ -233,13 +335,14 @@ class NotesHelper:
if set to less than 1, returns {}, skipping any retrieval if set to less than 1, returns {}, skipping any retrieval
""" """
notes = self._failsafe_get_notes( notes = self._failsafe_get_notes(
assoc_id_pattern=STEP_LOOKUP_PATTERN.format(action_id), assoc_id_pattern=NoteType.STEP.lookup_pattern.format(action_id),
verbosity=verbosity, verbosity=verbosity,
exact_match=False exact_match=False
) )
note_dict = {} note_dict = {}
id_s = NoteType.STEP.id_start
for n in notes: for n in notes:
step_id = n.assoc_id[STEP_ID_START:] step_id = n.assoc_id[id_s:]
if step_id not in note_dict: if step_id not in note_dict:
note_dict[step_id] = [] note_dict[step_id] = []
note_dict[step_id].append(n) note_dict[step_id].append(n)
@ -256,7 +359,8 @@ class NotesHelper:
""" """
return self._failsafe_get_notes( return self._failsafe_get_notes(
assoc_id_pattern=STEP_KEY_PATTERN.format(action_id, step_id), assoc_id_pattern=NoteType.STEP.key_pattern.format(
action_id, step_id),
verbosity=verbosity, verbosity=verbosity,
exact_match=True exact_match=True
) )

View File

@ -34,6 +34,7 @@ from sqlalchemy.orm import sessionmaker
from .notes import Note from .notes import Note
from .notes import NotesStorage from .notes import NotesStorage
from .errors import NoteNotFoundError
from .errors import NotesError from .errors import NotesError
from .errors import NotesInitializationError from .errors import NotesInitializationError
@ -128,19 +129,27 @@ class ShipyardSQLNotesStorage(NotesStorage):
TNote.assoc_id == a_id_pat, TNote.assoc_id == a_id_pat,
TNote.verbosity <= max_verb TNote.verbosity <= max_verb
) )
) ).order_by(TNote.note_timestamp)
else: else:
n_qry = session.query(TNote).filter( n_qry = session.query(TNote).filter(
and_( and_(
TNote.assoc_id.like(a_id_pat + '%'), TNote.assoc_id.like(a_id_pat + '%'),
TNote.verbosity <= max_verb TNote.verbosity <= max_verb
) )
) ).order_by(TNote.note_timestamp)
db_notes = n_qry.all() db_notes = n_qry.all()
for tn in db_notes: for tn in db_notes:
r_notes.append(self._map(tn, Note)) r_notes.append(self._map(tn, Note))
return r_notes return r_notes
def retrieve_by_id(self, note_id):
with self.session_scope() as session:
note = session.query(TNote).filter(
TNote.note_id == note_id).one_or_none()
if not note:
raise NoteNotFoundError()
return self._map(note, Note)
def _map(self, src, target_type): def _map(self, src, target_type):
"""Maps a Note object to/from a TNote object. """Maps a Note object to/from a TNote object.

View File

@ -13,6 +13,7 @@
# limitations under the License. # limitations under the License.
# #
"""Implementations of the NotesStorage base class""" """Implementations of the NotesStorage base class"""
from .errors import NoteNotFoundError
from .notes import NotesStorage from .notes import NotesStorage
@ -41,4 +42,11 @@ class MemoryNotesStorage(NotesStorage):
if (note.assoc_id.startswith(pat) and if (note.assoc_id.startswith(pat) and
note.verbosity <= max_verb): note.verbosity <= max_verb):
notes.append(note) notes.append(note)
notes.sort(key=lambda x: x.note_timestamp)
return notes return notes
def retrieve_by_id(self, note_id):
note = self.storage.get(note_id)
if not note:
raise NoteNotFoundError()
return note

View File

@ -42,6 +42,7 @@ from shipyard_airflow.control.middleware.common_params import \
CommonParametersMiddleware CommonParametersMiddleware
from shipyard_airflow.control.middleware.context import ContextMiddleware from shipyard_airflow.control.middleware.context import ContextMiddleware
from shipyard_airflow.control.middleware.logging_mw import LoggingMiddleware from shipyard_airflow.control.middleware.logging_mw import LoggingMiddleware
from shipyard_airflow.control.notes.notedetails_api import NoteDetailsResource
from shipyard_airflow.control.status.status_api import StatusResource from shipyard_airflow.control.status.status_api import StatusResource
from shipyard_airflow.errors import (AppError, default_error_serializer, from shipyard_airflow.errors import (AppError, default_error_serializer,
default_exception_handler) default_exception_handler)
@ -78,6 +79,7 @@ def start_api():
('/configdocs', ConfigDocsStatusResource()), ('/configdocs', ConfigDocsStatusResource()),
('/configdocs/{collection_id}', ConfigDocsResource()), ('/configdocs/{collection_id}', ConfigDocsResource()),
('/commitconfigdocs', CommitConfigDocsResource()), ('/commitconfigdocs', CommitConfigDocsResource()),
('/notedetails/{note_id}', NoteDetailsResource()),
('/renderedconfigdocs', RenderedConfigDocsResource()), ('/renderedconfigdocs', RenderedConfigDocsResource()),
('/workflows', WorkflowResource()), ('/workflows', WorkflowResource()),
('/workflows/{workflow_id}', WorkflowIdResource()), ('/workflows/{workflow_id}', WorkflowIdResource()),

View File

@ -0,0 +1,105 @@
# 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.
import falcon
from shipyard_airflow.common.notes.errors import NoteNotFoundError
from shipyard_airflow.common.notes.errors import NoteURLNotSpecifiedError
from shipyard_airflow.common.notes.errors import NoteURLRetrievalError
from shipyard_airflow.common.notes.notes_helper import NoteType
from shipyard_airflow.control.base import BaseResource
from shipyard_airflow.control.helpers.notes import NOTES as notes_helper
from shipyard_airflow.errors import ApiError
from shipyard_airflow.errors import InvalidFormatError
from shipyard_airflow import policy
NOTE_TYPE_RBAC = {
NoteType.ACTION: policy.GET_ACTION,
NoteType.STEP: policy.GET_ACTION_STEP,
# Anything else uses only the already checked GET_NOTEDETAILS
# new known note types should be added to the notes helper and also
# represented here.
NoteType.OTHER: None
}
# /api/v1.0/notedetails/{note_id}
class NoteDetailsResource(BaseResource):
"""Resource to service requests for note details"""
@policy.ApiEnforcer(policy.GET_NOTEDETAILS)
def on_get(self, req, resp, **kwargs):
"""Retrieves additional information for a note.
Using the specified note_id, looks up any additional information
for a note
"""
note_id = kwargs['note_id']
self.validate_note_id(note_id)
note = self.get_note_with_access_check(req.context, note_id)
resp.body = self.get_note_details(note)
resp.status = falcon.HTTP_200
def validate_note_id(self, note_id):
if not len(note_id) == 26:
raise InvalidFormatError(
title="Notes ID values are 26 character ULID values",
description="Invalid note_id: {} in URL".format(note_id)
)
def get_note_with_access_check(self, context, note_id):
"""Retrieve the note and checks user access to the note
:param context: the request context
:param note_id: the id of the note to retrieve.
:returns: the note
"""
try:
note = notes_helper.get_note(note_id)
note_type = notes_helper.get_note_assoc_id_type(note)
if note_type not in NOTE_TYPE_RBAC:
raise ApiError(
title="Unable to check permission for note type",
description=(
"Shipyard is not correctly identifying note type "
"for note {}".format(note_id)),
status=falcon.HTTP_500,
retry=False)
policy.check_auth(context, NOTE_TYPE_RBAC[note_type])
return note
except NoteNotFoundError:
raise ApiError(
title="No note found",
description=("Note {} is not found".format(note_id)),
status=falcon.HTTP_404)
def get_note_details(self, note):
"""Retrieve the note details from the notes_helper
:param note: the note with extended information
"""
try:
return notes_helper.get_note_details(note)
except NoteURLNotSpecifiedError:
raise ApiError(
title="No further note details are available",
description=("Note {} has no additional information to "
"return".format(note.note_id)),
status=falcon.HTTP_404)
except NoteURLRetrievalError:
raise ApiError(
title="Unable to retrieve URL information for note",
description=("Note {} has additional information, but it "
"cannot be accessed by Shipyard at this "
"time".format(note.note_id)),
status=falcon.HTTP_500)

View File

@ -40,6 +40,7 @@ COMMIT_CONFIGDOCS = 'workflow_orchestrator:commit_configdocs'
GET_RENDEREDCONFIGDOCS = 'workflow_orchestrator:get_renderedconfigdocs' GET_RENDEREDCONFIGDOCS = 'workflow_orchestrator:get_renderedconfigdocs'
LIST_WORKFLOWS = 'workflow_orchestrator:list_workflows' LIST_WORKFLOWS = 'workflow_orchestrator:list_workflows'
GET_WORKFLOW = 'workflow_orchestrator:get_workflow' GET_WORKFLOW = 'workflow_orchestrator:get_workflow'
GET_NOTEDETAILS = 'workflow_orchestrator:get_notedetails'
GET_SITE_STATUSES = 'workflow_orchestrator:get_site_statuses' GET_SITE_STATUSES = 'workflow_orchestrator:get_site_statuses'
ACTION_DEPLOY_SITE = 'workflow_orchestrator:action_deploy_site' ACTION_DEPLOY_SITE = 'workflow_orchestrator:action_deploy_site'
ACTION_UPDATE_SITE = 'workflow_orchestrator:action_update_site' ACTION_UPDATE_SITE = 'workflow_orchestrator:action_update_site'
@ -206,6 +207,16 @@ class ShipyardPolicy(object):
'method': 'GET' 'method': 'GET'
}] }]
), ),
policy.DocumentedRuleDefault(
GET_NOTEDETAILS,
RULE_ADMIN_REQUIRED,
('Retrieve the details for a note. Further authorization is '
'required depending on the topic of the note'),
[{
'path': '/api/v1.0/notedetails/{note_id}',
'method': 'GET'
}]
),
policy.DocumentedRuleDefault( policy.DocumentedRuleDefault(
GET_SITE_STATUSES, GET_SITE_STATUSES,
RULE_ADMIN_REQUIRED, RULE_ADMIN_REQUIRED,

View File

@ -20,7 +20,10 @@ import responses
from shipyard_airflow.common.notes.errors import ( from shipyard_airflow.common.notes.errors import (
NotesInitializationError, NotesInitializationError,
NotesRetrievalError, NotesRetrievalError,
NotesStorageError NotesStorageError,
NoteNotFoundError,
NoteURLNotSpecifiedError,
NoteURLRetrievalError
) )
from shipyard_airflow.common.notes.notes import ( from shipyard_airflow.common.notes.notes import (
Note, Note,
@ -42,6 +45,9 @@ class NotesStorageErrorImpl(NotesStorage):
def retrieve(self, query): def retrieve(self, query):
raise Exception("Outta Nowhere") raise Exception("Outta Nowhere")
def retrieve_by_id(self, note_id):
raise Exception("No warning")
class NotesStorageExpectedErrorImpl(NotesStorage): class NotesStorageExpectedErrorImpl(NotesStorage):
def store(self, note): def store(self, note):
@ -50,6 +56,9 @@ class NotesStorageExpectedErrorImpl(NotesStorage):
def retrieve(self, query): def retrieve(self, query):
raise NotesRetrievalError("Expected") raise NotesRetrievalError("Expected")
def retrieve_by_id(self, note_id):
raise NotesRetrievalError("Expected")
class TestNotesManager: class TestNotesManager:
def test_init(self): def test_init(self):
@ -191,23 +200,8 @@ class TestNotesManager:
n_list = nm.retrieve(Query("test1/11111/aaa", exact_match=True)) n_list = nm.retrieve(Query("test1/11111/aaa", exact_match=True))
assert len(n_list) == 1 assert len(n_list) == 1
@responses.activate def test_store_retrieve_url_refs(self):
def test_store_retrieve_urls(self): """Tests that notes retrieved as a list have notedetails refs"""
responses.add(
method="GET",
url="http://test.test",
body="Hello from testland",
status=200,
content_type="text/plain"
)
responses.add(
method="GET",
url="http://test.test2",
body="Hello from testland2",
status=200,
content_type="text/plain"
)
nm = NotesManager(MemoryNotesStorage(), get_token) nm = NotesManager(MemoryNotesStorage(), get_token)
nm.store(Note( nm.store(Note(
assoc_id="test1/11111/aaa", assoc_id="test1/11111/aaa",
@ -226,7 +220,46 @@ class TestNotesManager:
n_list = nm.retrieve(Query("test1")) n_list = nm.retrieve(Query("test1"))
assert len(n_list) == 2 assert len(n_list) == 2
for n in n_list: for n in n_list:
assert n.resolved_url_value.startswith("Hello from testland") assert n.resolved_url_value == (
"Details at notedetails/" + n.note_id)
@responses.activate
def test_store_retrieve_urls(self):
responses.add(
method="GET",
url="http://test.test",
body="Hello from testland",
status=200,
content_type="text/plain"
)
responses.add(
method="GET",
url="http://test.test2",
body="Hello from testland2",
status=200,
content_type="text/plain"
)
nm = NotesManager(MemoryNotesStorage(), get_token)
nm.store(Note(
assoc_id="test1/11111/aaa",
subject="store_retrieve3",
sub_type="test",
note_val="this is my note 1",
link_url="http://test.test/"
))
nm.store(Note(
assoc_id="test1/11111/bbb",
subject="store_retrieve3",
sub_type="test",
note_val="this is my note 2",
link_url="http://test.test2/"
))
n_list = nm.retrieve(Query("test1"))
assert len(n_list) == 2
for n in n_list:
assert n.resolved_url_value.startswith("Details at notedetails/")
assert nm.get_note_url_info(n.note_id).startswith(
"Hello from testland")
with pytest.raises(KeyError): with pytest.raises(KeyError):
auth_hdr = responses.calls[0].request.headers['X-Auth-Token'] auth_hdr = responses.calls[0].request.headers['X-Auth-Token']
@ -265,10 +298,14 @@ class TestNotesManager:
n_list = nm.retrieve(Query("test1")) n_list = nm.retrieve(Query("test1"))
assert len(n_list) == 2 assert len(n_list) == 2
for n in n_list: for n in n_list:
assert n.resolved_url_value == (
"Details at notedetails/" + n.note_id)
if n.assoc_id == "test1/11111/aaa": if n.assoc_id == "test1/11111/aaa":
assert "failed with status code: 404" in n.resolved_url_value with pytest.raises(NoteURLRetrievalError):
nd = nm.get_note_url_info(n.note_id)
else: else:
assert n.resolved_url_value.startswith("Hello from testland") nd = nm.get_note_url_info(n.note_id)
assert nd.startswith("Hello from testland")
@responses.activate @responses.activate
def test_store_retrieve_url_does_not_exist(self): def test_store_retrieve_url_does_not_exist(self):
@ -299,9 +336,33 @@ class TestNotesManager:
assert len(n_list) == 2 assert len(n_list) == 2
for n in n_list: for n in n_list:
if n.assoc_id == "test1/11111/aaa": if n.assoc_id == "test1/11111/aaa":
assert "URL lookup was unable" in n.resolved_url_value with pytest.raises(NoteURLRetrievalError):
nd = nm.get_note_url_info(n.note_id)
else: else:
assert n.resolved_url_value.startswith("Hello from testland") nd = nm.get_note_url_info(n.note_id)
assert nd.startswith("Hello from testland")
def test_store_retrieve_url_not_specified(self):
nm = NotesManager(MemoryNotesStorage(), get_token)
nm.store(Note(
assoc_id="test1/11111/aaa",
subject="store_retrieve3",
sub_type="test",
note_val="this is my note 1",
link_url=""
))
nm.store(Note(
assoc_id="test1/11111/bbb",
subject="store_retrieve3",
sub_type="test",
note_val="this is my note 2",
link_url=None
))
n_list = nm.retrieve(Query("test1"))
assert len(n_list) == 2
for n in n_list:
with pytest.raises(NoteURLNotSpecifiedError):
nd = nm.get_note_url_info(n.note_id)
@responses.activate @responses.activate
def test_store_retrieve_with_auth(self): def test_store_retrieve_with_auth(self):
@ -325,7 +386,8 @@ class TestNotesManager:
n_list = nm.retrieve(Query("test1")) n_list = nm.retrieve(Query("test1"))
assert len(n_list) == 1 assert len(n_list) == 1
for n in n_list: for n in n_list:
assert n.resolved_url_value == "Hello from testland2" nd = nm.get_note_url_info(n.note_id)
assert nd == "Hello from testland2"
auth_hdr = responses.calls[0].request.headers['X-Auth-Token'] auth_hdr = responses.calls[0].request.headers['X-Auth-Token']
assert 'token' == auth_hdr assert 'token' == auth_hdr

View File

@ -34,6 +34,7 @@ class ApiPaths(enum.Enum):
GET_STEP_DETAIL = _BASE_URL + 'actions/{}/steps/{}' GET_STEP_DETAIL = _BASE_URL + 'actions/{}/steps/{}'
GET_STEP_LOG = _BASE_URL + 'actions/{}/steps/{}/logs' GET_STEP_LOG = _BASE_URL + 'actions/{}/steps/{}/logs'
POST_CONTROL_ACTION = _BASE_URL + 'actions/{}/control/{}' POST_CONTROL_ACTION = _BASE_URL + 'actions/{}/control/{}'
GET_NOTEDETAILS = _BASE_URL + 'notedetails/{}'
GET_WORKFLOWS = _BASE_URL + 'workflows' GET_WORKFLOWS = _BASE_URL + 'workflows'
GET_DAG_DETAIL = _BASE_URL + 'workflows/{}' GET_DAG_DETAIL = _BASE_URL + 'workflows/{}'
GET_SITE_STATUSES = _BASE_URL + 'site_statuses' GET_SITE_STATUSES = _BASE_URL + 'site_statuses'
@ -225,6 +226,15 @@ class ShipyardClient(BaseClient):
self.get_endpoint(), action_id, control_verb) self.get_endpoint(), action_id, control_verb)
return self.post_resp(url) return self.post_resp(url)
def get_note_details(self, note_id):
"""Retrieve note details from a note's associated information
:param note_id: The ID of the note having additional information
"""
url = ApiPaths.GET_NOTEDETAILS.value.format(
self.get_endpoint(), note_id)
return self.get_resp(url)
def get_workflows(self, since=None): def get_workflows(self, since=None):
""" """
Queries airflow for DAGs that are running or have run Queries airflow for DAGs that are running or have run

View File

@ -25,7 +25,7 @@ def gen_action_steps(step_list, action_id):
""" """
# Generate the steps table. # Generate the steps table.
steps = format_utils.table_factory( steps = format_utils.table_factory(
field_names=['Steps', 'Index', 'State', 'Notes'] field_names=['Steps', 'Index', 'State', 'Footnotes']
) )
# rendered notes , a list of lists of notes # rendered notes , a list of lists of notes
r_notes = [] r_notes = []
@ -34,7 +34,7 @@ def gen_action_steps(step_list, action_id):
for step in step_list: for step in step_list:
notes = step.get('notes') notes = step.get('notes')
if notes: if notes:
r_notes.append(format_utils.format_notes(notes)) r_notes.append(format_notes(notes))
steps.add_row([ steps.add_row([
'step/{}/{}'.format(action_id, step.get('id')), 'step/{}/{}'.format(action_id, step.get('id')),
step.get('index'), step.get('index'),
@ -44,16 +44,9 @@ def gen_action_steps(step_list, action_id):
else: else:
steps.add_row(['None', '', '', '']) steps.add_row(['None', '', '', ''])
table_string = format_utils.table_get_string(steps) return "{}\n\n{}".format(
format_utils.table_get_string(steps),
if r_notes: notes_table("Step", r_notes))
note_index = 1
for note_list in r_notes:
table_string += "\n\n({}):\n\n{}".format(
note_index, "\n".join(note_list)
)
note_index += 1
return table_string
def gen_action_commands(command_list): def gen_action_commands(command_list):
@ -141,7 +134,7 @@ def gen_action_table(action_list):
""" """
actions = format_utils.table_factory( actions = format_utils.table_factory(
field_names=['Name', 'Action', 'Lifecycle', 'Execution Time', field_names=['Name', 'Action', 'Lifecycle', 'Execution Time',
'Step Succ/Fail/Oth', 'Notes']) 'Step Succ/Fail/Oth', 'Footnotes'])
# list of lists of rendered notes # list of lists of rendered notes
r_notes = [] r_notes = []
if action_list: if action_list:
@ -149,7 +142,7 @@ def gen_action_table(action_list):
for action in sorted(action_list, key=lambda k: k['id']): for action in sorted(action_list, key=lambda k: k['id']):
notes = action.get('notes') notes = action.get('notes')
if notes: if notes:
r_notes.append(format_utils.format_notes(notes)) r_notes.append(format_notes(notes))
actions.add_row([ actions.add_row([
action.get('name'), action.get('name'),
'action/{}'.format(action.get('id')), 'action/{}'.format(action.get('id')),
@ -161,16 +154,9 @@ def gen_action_table(action_list):
else: else:
actions.add_row(['None', '', '', '', '', '']) actions.add_row(['None', '', '', '', '', ''])
table_string = format_utils.table_get_string(actions) return "{}\n\n{}".format(
format_utils.table_get_string(actions),
if r_notes: notes_table("Action", r_notes))
note_index = 1
for note_list in r_notes:
table_string += "\n\n({}):\n\n{}".format(
note_index, "\n".join(note_list)
)
note_index += 1
return table_string
def _step_summary(step_list): def _step_summary(step_list):
@ -370,13 +356,88 @@ def _site_statuses_switcher(status_type):
return call_func return call_func
def gen_detail_notes(dict_with_notes):
def gen_detail_notes(title, dict_with_notes):
"""Generates a standard formatted section of notes """Generates a standard formatted section of notes
:param title: the title for the notes section. E.g.: "Step"
:param dict_with_notes: a dictionary with a possible notes field. :param dict_with_notes: a dictionary with a possible notes field.
:returns: string of notes or empty string if there were no notes :returns: string of notes or empty string if there were no notes
""" """
n_strings = format_utils.format_notes(dict_with_notes.get('notes', [])) n_strings = format_notes(dict_with_notes.get('notes', []))
if n_strings: if n_strings:
return "Notes:\n{}".format("\n".join(n_strings)) return "{} Notes:\n{}".format(title, "\n".join(n_strings))
return "" return ""
def notes_table(title, notes_list):
"""Format a table of notes
:param title: the header for the table. e.g.: "Step"
:param list notes_list: a list of lists of formatted notes:
e.g.:[[note1,note2],[note3]]
The notes ideally have been pre-formatted by "format_notes"
:returns: string of a table e.g.:
Step Notes Note
(1) > note1
- Info avail...
> note2
(2) > note3
If notes_list is empty, returns an empty string.
"""
if not notes_list:
return ""
headers = ["{} Footnotes".format(title), "Note"]
rows = []
index = 1
for notes in notes_list:
rows.append(["({})".format(index), "\n".join(notes)])
index += 1
return format_utils.table_get_string(
format_utils.table_factory(headers, rows))
def format_notes(notes):
"""Formats a list of notes.
:param list notes: The list of note dictionaries to display
:returns: a list of note strings
Assumed note dictionary example:
{
'assoc_id': "action/12345678901234567890123456,
'subject': "12345678901234567890123456",
'sub_type': "action",
'note_val': "message",
'verbosity': 1,
'note_id': "09876543210987654321098765",
'note_timestamp': "2018-10-08 14:23:53.346534",
'resolved_url_value': \
"Details at notedetails/09876543210987654321098765"
}
Resulting in:
> action:12345678901234567890123456(2018-10-08 14:23:53.346534): message
- Info available with 'describe notedetails/09876543210987654321098765'
"""
nl = []
for n in notes:
try:
s = "> {}:{}({}): {}".format(
n['sub_type'],
n['subject'],
n['note_timestamp'],
n['note_val']
)
if n['resolved_url_value']:
s += ("\n - Info available with "
"'describe notedetails/{}'".format(n['note_id']))
except KeyError:
s = "!!! Unparseable Note: {}".format(n)
nl.append(s)
return nl

View File

@ -53,10 +53,48 @@ class DescribeAction(CliAction):
cli_format_common.gen_action_validations( cli_format_common.gen_action_validations(
resp_j.get('validations') resp_j.get('validations')
), ),
cli_format_common.gen_detail_notes(resp_j) cli_format_common.gen_detail_notes("Action", resp_j)
) )
class DescribeNotedetails(CliAction):
"""Action to Describe notedetails"""
def __init__(self, ctx, note_id):
"""Sets parameters."""
super().__init__(ctx)
self.logger.debug(
"DescribeNotedetails action initialized with note_id=%s", note_id)
self.note_id = note_id
def invoke(self):
"""Calls API Client and formats response from API Client"""
self.logger.debug("Calling API Client get_note_details.")
return self.get_api_client().get_note_details(
note_id=self.note_id)
# Handle 404 with default error handler for cli.
cli_handled_err_resp_codes = [400, 404, 500]
# Handle 200 responses using the cli_format_response_handler
cli_handled_succ_resp_codes = [200]
def cli_format_response_handler(self, response):
"""CLI output handler
:param response: a requests response object containing the details
:returns: a string representing a formatted response
Handles 200 responses. If the response contains '\n' characters
(literally), this will attempt to replace with newline characters
"""
resp = response.text
if "\\n" in resp:
return "\n".join(resp.split("\\n"))
else:
return resp
class DescribeStep(CliAction): class DescribeStep(CliAction):
"""Action to Describe Step""" """Action to Describe Step"""
@ -91,7 +129,7 @@ class DescribeStep(CliAction):
resp_j = response.json() resp_j = response.json()
return "{}\n\n{}\n".format( return "{}\n\n{}\n".format(
cli_format_common.gen_action_step_details(resp_j, self.action_id), cli_format_common.gen_action_step_details(resp_j, self.action_id),
cli_format_common.gen_detail_notes(resp_j) cli_format_common.gen_detail_notes("Step", resp_j)
) )

View File

@ -19,6 +19,7 @@ import click
from click_default_group import DefaultGroup from click_default_group import DefaultGroup
from shipyard_client.cli.describe.actions import DescribeAction from shipyard_client.cli.describe.actions import DescribeAction
from shipyard_client.cli.describe.actions import DescribeNotedetails
from shipyard_client.cli.describe.actions import DescribeStep from shipyard_client.cli.describe.actions import DescribeStep
from shipyard_client.cli.describe.actions import DescribeValidation from shipyard_client.cli.describe.actions import DescribeValidation
from shipyard_client.cli.describe.actions import DescribeWorkflow from shipyard_client.cli.describe.actions import DescribeWorkflow
@ -29,7 +30,7 @@ from shipyard_client.cli.input_checks import check_id, check_workflow_id
@click.pass_context @click.pass_context
def describe(ctx): def describe(ctx):
""" """
Describe the action, step, or validation. \n Describe the action, step, note details or validation. \n
For more information on describe commands For more information on describe commands
please enter the describe command followed by '--help' \n please enter the describe command followed by '--help' \n
Example: shipyard describe action --help \n Example: shipyard describe action --help \n
@ -41,7 +42,8 @@ def describe(ctx):
FORMAT: shipyard describe <namespace item> \n FORMAT: shipyard describe <namespace item> \n
EXAMPLE: shipyard describe action/01BTG32JW87G0YKA1K29TKNAFX | shipyard EXAMPLE: shipyard describe action/01BTG32JW87G0YKA1K29TKNAFX | shipyard
describe step/01BTG32JW87G0YKA1K29TKNAFX/preflight | shipyard describe describe step/01BTG32JW87G0YKA1K29TKNAFX/preflight | shipyard describe
validation/01BTG32JW87G0YKA1K29TKNAFX/01BTG3PKBS15KCKFZ56XXXBGF2 validation/01BTG32JW87G0YKA1K29TKNAFX/01BTG3PKBS15KCKFZ56XXXBGF2 | shipyard
describe notedetails/01BTG32JW87G0YKA1KA9AKAAB3
""" """
@ -64,8 +66,11 @@ def describe_default_command(ctx, namespace_item):
elif namespace[0] == 'workflow': elif namespace[0] == 'workflow':
ctx.invoke( ctx.invoke(
describe_workflow, describe_workflow,
workflow_id=namespace[1] workflow_id=namespace[1])
) elif namespace[0] == 'notedetails':
ctx.invoke(
describe_notedetails,
note_id=namespace[1])
else: else:
raise Exception('Invalid namespaced describe action') raise Exception('Invalid namespaced describe action')
except Exception: except Exception:
@ -74,7 +79,8 @@ def describe_default_command(ctx, namespace_item):
"action: action/action id\n" "action: action/action id\n"
"step: step/action id/step id\n" "step: step/action id/step id\n"
"validation: validation/validation id/action id\n" "validation: validation/validation id/action id\n"
"workflow: workflow/workflow id") "workflow: workflow/workflow id\n"
"notedetails: notedetails/note_id")
DESC_ACTION = """ DESC_ACTION = """
@ -102,6 +108,32 @@ def describe_action(ctx, action_id):
click.echo(DescribeAction(ctx, action_id).invoke_and_return_resp()) click.echo(DescribeAction(ctx, action_id).invoke_and_return_resp())
DESC_NOTEDETAILS = """
COMMAND: describe notedetials \n
DESCRIPTION: Retrieves the detailed information that is associated with the
specified note id. \n
FORMAT: shipyard describe notedetails <note id> \n
EXAMPLE: shipyard describe notedetails 01BTG32JW87G0YKA1KA9AKAAB3
"""
SHORT_DESC_NOTEDETAILS = (
"Retrieves the detailed information about the supplied action id.")
@describe.command('notedetails',
help=DESC_NOTEDETAILS, short_help=SHORT_DESC_NOTEDETAILS)
@click.argument('note_id')
@click.pass_context
def describe_notedetails(ctx, note_id):
if not note_id:
ctx.fail("A note id argument must be passed.")
check_id(ctx, note_id)
click.echo(DescribeNotedetails(ctx, note_id).invoke_and_return_resp())
DESC_STEP = """ DESC_STEP = """
COMMAND: describe step \n COMMAND: describe step \n
DESCRIPTION: Retrieves the step details associated with an action and step. \n DESCRIPTION: Retrieves the step details associated with an action and step. \n

View File

@ -234,41 +234,3 @@ def table_get_string(table, title='', vertical_char='|', align='l'):
# vertical_char - Single character string used to draw vertical # vertical_char - Single character string used to draw vertical
# lines. Default is '|'. # lines. Default is '|'.
return table.get_string(title=title, vertical_char=vertical_char) return table.get_string(title=title, vertical_char=vertical_char)
def format_notes(notes):
"""Formats a list of notes.
:param list notes: The list of note dictionaries to display
:returns: a list of note strings
Assumed note dictionary example:
{
'assoc_id': "action/12345678901234567890123456,
'subject': "12345678901234567890123456",
'sub_type': "action",
'note_val': "This is the message",
'verbosity': 1,
'note_id': "09876543210987654321098765",
'note_timestamp': "2018-10-08 14:23:53.346534",
'resolved_url_value': "<html><div>some info</div></html>
}
"""
nl = []
for n in notes:
try:
s = "{}:{}({}): {}".format(
n['sub_type'],
n['subject'],
n['note_timestamp'],
n['note_val']
)
if n['resolved_url_value']:
s += "\n >>> {}".format(
n['resolved_url_value']
)
except KeyError:
s = "!!! Unparseable Note: {}".format(n)
nl.append(s)
return nl

View File

@ -31,13 +31,13 @@ def check_control_action(ctx, action):
ctx.fail('Invalid action. Please enter pause, unpause, or stop.') ctx.fail('Invalid action. Please enter pause, unpause, or stop.')
def check_id(ctx, action_id): def check_id(ctx, ulid_id):
"""Verifies a ULID id is in a valid format""" """Verifies a ULID id is in a valid format"""
if action_id is None: if ulid_id is None:
ctx.fail('Invalid ID. None is not a valid action ID.') ctx.fail('Invalid ID. None is not a valid action ID.')
if len(action_id) != 26: if len(ulid_id) != 26:
ctx.fail('Invalid ID. ID can only be 26 characters.') ctx.fail('Invalid ID. ID can only be 26 characters.')
if not action_id.isalnum(): if not ulid_id.isalnum():
ctx.fail('Invalid ID. ID can only contain letters and numbers.') ctx.fail('Invalid ID. ID can only contain letters and numbers.')

View File

@ -196,6 +196,17 @@ def test_post_control(*args):
shipyard_client.get_endpoint(), action_id, control_verb) shipyard_client.get_endpoint(), action_id, control_verb)
@mock.patch.object(BaseClient, 'post_resp', replace_post_rep)
@mock.patch.object(BaseClient, 'get_resp', replace_get_resp)
@mock.patch.object(BaseClient, 'get_endpoint', replace_get_endpoint)
def test_get_note_details(*args):
shipyard_client = get_api_client()
note_id = "ABC123ABC123ZZABC123ABC123"
result = shipyard_client.get_note_details(note_id)
assert result['url'] == '{}/notedetails/{}'.format(
shipyard_client.get_endpoint(), note_id)
@mock.patch.object(BaseClient, 'post_resp', replace_post_rep) @mock.patch.object(BaseClient, 'post_resp', replace_post_rep)
@mock.patch.object(BaseClient, 'get_resp', replace_get_resp) @mock.patch.object(BaseClient, 'get_resp', replace_get_resp)
@mock.patch.object(BaseClient, 'get_endpoint', replace_get_endpoint) @mock.patch.object(BaseClient, 'get_endpoint', replace_get_endpoint)

View File

@ -104,7 +104,9 @@ def test_get_actions(*args):
assert 'Lifecycle' in response assert 'Lifecycle' in response
assert '2/1/0' in response assert '2/1/0' in response
assert 'This is a note for the concurrency check' not in response assert 'This is a note for the concurrency check' not in response
assert '>>> Your lucky numbers are 1, 3, 5, and Q' in response assert "Action Footnotes" in response
assert (" - Info available with 'describe notedetails/"
"ABCDEFGHIJKLMNOPQRSTUVWXYA'") in response
GET_ACTIONS_API_RESP_UNPARSEABLE_NOTE = """ GET_ACTIONS_API_RESP_UNPARSEABLE_NOTE = """