Exemple #1
0
def test_check_intermediate_commit():
    helper_no_revs = ConfigdocsHelper(CTX)
    helper_no_revs.deckhand.get_revision_list = lambda: []

    helper_no_intermidiate_commits = ConfigdocsHelper(CTX)
    revs = yaml.load("""
---
  - id: 1
    url: https://deckhand/api/v1.0/revisions/1
    createdAt: 2018-04-30T21:23Z
    buckets: [mop]
    tags: [committed, site-action-success]
    validationPolicies:
      site-deploy-validation:
        status: succeeded
  - id: 2
    url: https://deckhand/api/v1.0/revisions/2
    createdAt: 2018-04-30T23:35Z
    buckets: [flop, mop]
    tags: [committed, site-action-failure]
    validationPolicies:
      site-deploy-validation:
        status: succeeded
...
""")
    helper_no_intermidiate_commits.deckhand.get_revision_list = lambda: revs
    revs_interm = copy.deepcopy(revs)
    revs_interm[0]['tags'] = ['committed']

    helper_with_intermidiate_commits = ConfigdocsHelper(CTX)
    helper_with_intermidiate_commits.deckhand.get_revision_list = \
        lambda: revs_interm

    assert not helper_no_revs.check_intermediate_commit()
    assert not helper_no_intermidiate_commits.check_intermediate_commit()
    assert helper_with_intermidiate_commits.check_intermediate_commit()
Exemple #2
0
class ActionsResource(BaseResource):
    """
    The actions resource represent the asyncrhonous invocations of shipyard
    """

    @policy.ApiEnforcer('workflow_orchestrator:list_actions')
    def on_get(self, req, resp, **kwargs):
        """
        Return actions that have been invoked through shipyard.
        :returns: a json array of action entities
        """
        resp.body = self.to_json(self.get_all_actions())
        resp.status = falcon.HTTP_200

    @policy.ApiEnforcer('workflow_orchestrator:create_action')
    def on_post(self, req, resp, **kwargs):
        """
        Accept an action into shipyard
        """
        # The 'allow-intermediate-commits' query parameter is set to False
        # unless explicitly set to True
        allow_intermediate_commits = (
            req.get_param_as_bool(name='allow-intermediate-commits'))

        input_action = self.req_json(req, validate_json_schema=ACTION)
        action = self.create_action(
            action=input_action,
            context=req.context,
            allow_intermediate_commits=allow_intermediate_commits)

        LOG.info("Id %s generated for action %s", action['id'], action['name'])
        # respond with the action and location for checking status
        resp.status = falcon.HTTP_201
        resp.body = self.to_json(action)
        resp.location = '/api/v1.0/actions/{}'.format(action['id'])

    def create_action(self, action, context, allow_intermediate_commits=False):
        action_mappings = _action_mappings()
        # use uuid assigned for this request as the id of the action.
        action['id'] = ulid.ulid()
        # the invoking user
        action['user'] = context.user
        # add current timestamp (UTC) to the action.
        action['timestamp'] = str(datetime.utcnow())
        # validate that action is supported.
        LOG.info("Attempting action: %s", action['name'])
        if action['name'] not in action_mappings:
            raise ApiError(
                title='Unable to start action',
                description='Unsupported Action: {}'.format(action['name']))

        dag = action_mappings.get(action['name'])['dag']
        action['dag_id'] = dag

        # Set up configdocs_helper
        self.configdocs_helper = ConfigdocsHelper(context)

        # Retrieve last committed design revision
        action['committed_rev_id'] = self.get_committed_design_version()

        # Check for intermediate commit
        self.check_intermediate_commit_revision(allow_intermediate_commits)

        # populate action parameters if they are not set
        if 'parameters' not in action:
            action['parameters'] = {}

        # validate if there is any validation to do
        for validator in action_mappings.get(action['name'])['validators']:
            # validators will raise ApiError if they are not validated.
            validator(action)

        # invoke airflow, get the dag's date
        dag_execution_date = self.invoke_airflow_dag(
            dag_id=dag, action=action, context=context)
        # set values on the action
        action['dag_execution_date'] = dag_execution_date
        action['dag_status'] = 'SCHEDULED'

        # context_marker is the uuid from the request context
        action['context_marker'] = context.request_id

        # insert the action into the shipyard db
        self.insert_action(action=action)
        self.audit_control_command_db({
            'id': ulid.ulid(),
            'action_id': action['id'],
            'command': 'invoke',
            'user': context.user
        })

        return action

    def get_all_actions(self):
        """
        Interacts with airflow and the shipyard database to return the list of
        actions invoked through shipyard.
        """
        # fetch actions from the shipyard db
        all_actions = self.get_action_map()
        # fetch the associated dags, steps from the airflow db
        all_dag_runs = self.get_dag_run_map()
        all_tasks = self.get_all_tasks_db()

        # correlate the actions and dags into a list of action entites
        actions = []

        for action_id, action in all_actions.items():
            dag_key = action['dag_id'] + action['dag_execution_date']
            dag_key_id = action['dag_id']
            dag_key_date = action['dag_execution_date']
            # locate the dag run associated
            dag_state = all_dag_runs.get(dag_key, {}).get('state', None)
            # get the dag status from the dag run state
            action['dag_status'] = dag_state
            action['action_lifecycle'] = determine_lifecycle(dag_state)
            # get the steps summary
            action_tasks = [
                step for step in all_tasks
                if step['dag_id'].startswith(dag_key_id) and
                step['execution_date'].strftime(
                    '%Y-%m-%dT%H:%M:%S') == dag_key_date
            ]
            action['steps'] = format_action_steps(action_id, action_tasks)
            actions.append(action)

        return actions

    def get_action_map(self):
        """
        maps an array of dictionaries to a dictonary of the same results by id
        :returns: a dictionary of dictionaries keyed by action id
        """
        return {action['id']: action for action in self.get_all_actions_db()}

    def get_all_actions_db(self):
        """
        Wrapper for call to the shipyard database to get all actions
        :returns: a list of dictionaries keyed by action id
        """
        return SHIPYARD_DB.get_all_submitted_actions()

    def get_dag_run_map(self):
        """
        Maps an array of dag runs to a keyed dictionary
        :returns: a dictionary of dictionaries keyed by dag_id and
                  execution_date
        """
        return {
            run['dag_id'] +
            run['execution_date'].strftime('%Y-%m-%dT%H:%M:%S'): run
            for run in self.get_all_dag_runs_db()
        }

    def get_all_dag_runs_db(self):
        """
        Wrapper for call to the airflow db to get all dag runs
        :returns: a list of dictionaries representing dag runs in airflow
        """
        return AIRFLOW_DB.get_all_dag_runs()

    def get_all_tasks_db(self):
        """
        Wrapper for call to the airflow db to get all tasks
        :returns: a list of task dictionaries
        """
        return AIRFLOW_DB.get_all_tasks()

    def insert_action(self, action):
        """
        Wrapper for call to the shipyard db to insert an action
        """
        return SHIPYARD_DB.insert_action(action)

    def audit_control_command_db(self, action_audit):
        """
        Wrapper for the shipyard db call to record an audit of the
        action control taken
        """
        return SHIPYARD_DB.insert_action_command_audit(action_audit)

    def invoke_airflow_dag(self, dag_id, action, context):
        """
        Call airflow, and invoke a dag
        :param dag_id: the name of the dag to invoke
        :param action: the action structure to invoke the dag with
        """
        # TODO(bryan-strassner) refactor the mechanics of this method to an
        #     airflow api client module

        # Retrieve URL
        web_server_url = CONF.base.web_server
        c_timeout = CONF.base.airflow_api_connect_timeout
        r_timeout = CONF.base.airflow_api_read_timeout

        if 'Error' in web_server_url:
            raise ApiError(
                title='Unable to invoke workflow',
                description=('Airflow URL not found by Shipyard. '
                             'Shipyard configuration is missing web_server '
                             'value'),
                status=falcon.HTTP_503,
                retry=True, )
        else:
            conf_value = {'action': action}
            # "conf" - JSON string that gets pickled into the DagRun's
            # conf attribute
            req_url = ('{}admin/rest_api/api?api=trigger_dag&dag_id={}'
                       '&conf={}'.format(web_server_url,
                                         dag_id, self.to_json(conf_value)))

            try:
                resp = requests.get(req_url, timeout=(c_timeout, r_timeout))
                LOG.info('Response code from Airflow trigger_dag: %s',
                         resp.status_code)
                # any 4xx/5xx will be HTTPError, which are RequestException
                resp.raise_for_status()
                response = resp.json()
                LOG.info('Response from Airflow trigger_dag: %s', response)
            except RequestException as rex:
                LOG.error("Request to airflow failed: %s", rex.args)
                raise ApiError(
                    title='Unable to complete request to Airflow',
                    description=(
                        'Airflow could not be contacted properly by Shipyard.'
                    ),
                    status=falcon.HTTP_503,
                    error_list=[{
                        'message': str(type(rex))
                    }],
                    retry=True, )

            dag_time = self._exhume_date(dag_id,
                                         response['output']['stdout'])
            dag_execution_date = dag_time.strftime('%Y-%m-%dT%H:%M:%S')
            return dag_execution_date

    def _exhume_date(self, dag_id, log_string):
        # TODO(bryan-strassner) refactor this to an airflow api client module

        # we are unable to use the response time because that
        # does not match the time when the dag was recorded.
        # We have to parse the stdout returned to find the
        # Created <DagRun {dag_id} @ {timestamp}
        # e.g.
        # ...- Created <DagRun deploy_site @ 2017-09-22 22:16:14: man...
        # split on "Created <DagRun deploy_site @ ", then ': "
        # should get to the desired date string.
        #
        # returns the date found in a date object
        log_split = log_string.split('Created <DagRun {} @ '.format(dag_id))
        if len(log_split) < 2:
            raise ApiError(
                title='Unable to determine if workflow has started',
                description=(
                    'Airflow has not responded with parseable output. '
                    'Shipyard is unable to determine run timestamp'),
                status=falcon.HTTP_500,
                error_list=[{
                    'message': log_string
                }],
                retry=True
            )
        else:
            # everything before the ': ' should be a date/time
            date_split = log_split[1].split(': ')[0]
            try:
                return parse(date_split, ignoretz=True)
            except ValueError as valerr:
                raise ApiError(
                    title='Unable to determine if workflow has started',
                    description=('Airflow has not responded with parseable '
                                 'output. Shipyard is unable to determine run '
                                 'timestamp'),
                    status=falcon.HTTP_500,
                    error_list=[{
                        'message': 'value {} has caused {}'.format(date_split,
                                                                   valerr)
                    }],
                    retry=True,
                )

    def get_committed_design_version(self):

        LOG.info("Checking for committed revision in Deckhand...")
        committed_rev_id = self.configdocs_helper.get_revision_id(
            configdocs_helper.COMMITTED
        )

        if committed_rev_id:
            LOG.info("The committed revision in Deckhand is %d",
                     committed_rev_id)

            return committed_rev_id

        else:
            raise ApiError(
                title='Unable to locate any committed revision in Deckhand',
                description='No committed version found in Deckhand',
                status=falcon.HTTP_404,
                retry=False)

    def check_intermediate_commit_revision(self,
                                           allow_intermediate_commits=False):

        LOG.info("Checking for intermediate committed revision in Deckhand...")
        intermediate_commits = (
            self.configdocs_helper.check_intermediate_commit())

        if intermediate_commits and not allow_intermediate_commits:

            raise ApiError(
                title='Intermediate Commit Detected',
                description=(
                    'The current committed revision of documents has '
                    'other prior commits that have not been used as '
                    'part of a site action, e.g. update_site. If you '
                    'are aware and these other commits are intended, '
                    'please rerun this action with the option '
                    '`allow-intermediate-commits=True`'),
                status=falcon.HTTP_409,
                retry=False
            )