From a2c972e513a9aeafc32dbef52979d0df3fe5d9c5 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Thu, 13 Oct 2016 12:44:13 -0400 Subject: [PATCH 1/2] Workflow status original commit --- awx/api/serializers.py | 31 +++++++-------- ...rkflow_job_template_workflow_nodes_list.md | 14 +++++++ awx/api/views.py | 14 +++++++ .../0045_v310_workflow_failure_condition.py | 24 ++++++++++++ awx/main/models/workflow.py | 25 +++++++++++- awx/main/scheduler/__init__.py | 8 ++-- .../tests/functional/models/test_workflow.py | 39 +++++++++++++++++++ .../tests/unit/models/test_workflow_unit.py | 4 +- 8 files changed, 138 insertions(+), 21 deletions(-) create mode 100644 awx/api/templates/api/workflow_job_template_workflow_nodes_list.md create mode 100644 awx/main/migrations/0045_v310_workflow_failure_condition.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index d6aa39f706..d681fac426 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -2242,18 +2242,22 @@ class WorkflowJobListSerializer(WorkflowJobSerializer, UnifiedJobListSerializer) pass class WorkflowNodeBaseSerializer(BaseSerializer): - job_type = serializers.SerializerMethodField() - job_tags = serializers.SerializerMethodField() - limit = serializers.SerializerMethodField() - skip_tags = serializers.SerializerMethodField() + job_type = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None) + job_tags = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None) + limit = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None) + skip_tags = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None) success_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True) failure_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True) always_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + fail_on_job_failure = serializers.BooleanField( + help_text=('If set to true, and if the job runs and fails, ' + 'the workflow is marked as failed.'), + default=True) class Meta: fields = ('*', '-name', '-description', 'id', 'url', 'related', 'unified_job_template', 'success_nodes', 'failure_nodes', 'always_nodes', - 'inventory', 'credential', 'job_type', 'job_tags', 'skip_tags', 'limit', 'skip_tags') + 'inventory', 'credential', 'job_type', 'job_tags', 'skip_tags', 'limit', 'skip_tags', 'fail_on_job_failure') def get_related(self, obj): res = super(WorkflowNodeBaseSerializer, self).get_related(obj) @@ -2261,17 +2265,12 @@ class WorkflowNodeBaseSerializer(BaseSerializer): res['unified_job_template'] = obj.unified_job_template.get_absolute_url() return res - def get_job_type(self, obj): - return obj.char_prompts.get('job_type', None) - - def get_job_tags(self, obj): - return obj.char_prompts.get('job_tags', None) - - def get_skip_tags(self, obj): - return obj.char_prompts.get('skip_tags', None) - - def get_limit(self, obj): - return obj.char_prompts.get('limit', None) + def validate(self, attrs): + # char_prompts go through different validation, so remove them here + for fd in ['job_type', 'job_tags', 'skip_tags', 'limit']: + if fd in attrs: + attrs.pop(fd) + return super(WorkflowNodeBaseSerializer, self).validate(attrs) class WorkflowJobTemplateNodeSerializer(WorkflowNodeBaseSerializer): diff --git a/awx/api/templates/api/workflow_job_template_workflow_nodes_list.md b/awx/api/templates/api/workflow_job_template_workflow_nodes_list.md new file mode 100644 index 0000000000..e6b078a23f --- /dev/null +++ b/awx/api/templates/api/workflow_job_template_workflow_nodes_list.md @@ -0,0 +1,14 @@ +# Workflow Job Template Workflow Node List + +Workflow nodes reference templates to execute and define the ordering +in which to execute them. After a job in this workflow finishes, +the subsequent actions are to: + + - run nodes contained in "failure_nodes" or "always_nodes" if job failed + - run nodes contained in "success_nodes" or "always_nodes" if job succeeded + +The workflow is marked as failed if any jobs run as part of that workflow fail +and have the field `fail_on_job_failure` set to true. If not, the workflow +job is marked as successful. + +{% include "api/sub_list_create_api_view.md" %} \ No newline at end of file diff --git a/awx/api/views.py b/awx/api/views.py index 66980e141d..d22c030965 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -2404,6 +2404,7 @@ class JobTemplateLabelList(DeleteLastUnattachLabelMixin, SubListCreateAttachDeta serializer_class = LabelSerializer parent_model = JobTemplate relationship = 'labels' + new_in_300 = True def post(self, request, *args, **kwargs): # If a label already exists in the database, attach it instead of erroring out @@ -2697,6 +2698,7 @@ class WorkflowJobTemplateList(ListCreateAPIView): model = WorkflowJobTemplate serializer_class = WorkflowJobTemplateListSerializer always_allow_superuser = False + new_in_310 = True # TODO: RBAC ''' @@ -2714,10 +2716,12 @@ class WorkflowJobTemplateDetail(RetrieveUpdateDestroyAPIView): model = WorkflowJobTemplate serializer_class = WorkflowJobTemplateSerializer always_allow_superuser = False + new_in_310 = True class WorkflowJobTemplateLabelList(JobTemplateLabelList): parent_model = WorkflowJobTemplate + new_in_310 = True # TODO: @@ -2725,6 +2729,7 @@ class WorkflowJobTemplateLaunch(GenericAPIView): model = WorkflowJobTemplate serializer_class = EmptySerializer + new_in_310 = True def get(self, request, *args, **kwargs): data = {} @@ -2750,6 +2755,12 @@ class WorkflowJobTemplateWorkflowNodesList(SubListCreateAPIView): parent_model = WorkflowJobTemplate relationship = 'workflow_job_template_nodes' parent_key = 'workflow_job_template' + new_in_310 = True + + def update_raw_data(self, data): + for fd in ['job_type', 'job_tags', 'skip_tags', 'limit', 'skip_tags']: + data[fd] = None + return super(WorkflowJobTemplateWorkflowNodesList, self).update_raw_data(data) # TODO: class WorkflowJobTemplateJobsList(SubListAPIView): @@ -2765,12 +2776,14 @@ class WorkflowJobList(ListCreateAPIView): model = WorkflowJob serializer_class = WorkflowJobListSerializer + new_in_310 = True # TODO: class WorkflowJobDetail(RetrieveDestroyAPIView): model = WorkflowJob serializer_class = WorkflowJobSerializer + new_in_310 = True class WorkflowJobWorkflowNodesList(SubListAPIView): @@ -2780,6 +2793,7 @@ class WorkflowJobWorkflowNodesList(SubListAPIView): parent_model = WorkflowJob relationship = 'workflow_job_nodes' parent_key = 'workflow_job' + new_in_310 = True class SystemJobTemplateList(ListAPIView): diff --git a/awx/main/migrations/0045_v310_workflow_failure_condition.py b/awx/main/migrations/0045_v310_workflow_failure_condition.py new file mode 100644 index 0000000000..9bafa0feb6 --- /dev/null +++ b/awx/main/migrations/0045_v310_workflow_failure_condition.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0044_v310_project_playbook_files'), + ] + + operations = [ + migrations.AddField( + model_name='workflowjobnode', + name='fail_on_job_failure', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='workflowjobtemplatenode', + name='fail_on_job_failure', + field=models.BooleanField(default=True), + ), + ] diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 2848b38a4a..3bc0608cfc 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -60,6 +60,10 @@ class WorkflowNodeBase(CreatedModifiedModel): default=None, on_delete=models.SET_NULL, ) + fail_on_job_failure = models.BooleanField( + blank=True, + default=True, + ) # Prompting-related fields inventory = models.ForeignKey( 'Inventory', @@ -93,6 +97,22 @@ class WorkflowNodeBase(CreatedModifiedModel): data[fd] = self.char_prompts[fd] return data + @property + def job_type(self): + return self.char_prompts.get('job_type', None) + + @property + def job_tags(self): + return self.char_prompts.get('job_tags', None) + + @property + def skip_tags(self): + return self.char_prompts.get('skip_tags', None) + + @property + def limit(self): + return self.char_prompts.get('limit', None) + def get_prompts_warnings(self): ujt_obj = self.unified_job_template if ujt_obj is None: @@ -137,7 +157,7 @@ class WorkflowNodeBase(CreatedModifiedModel): Return field names that should be copied from template node to job node. ''' return ['workflow_job', 'unified_job_template', - 'inventory', 'credential', 'char_prompts'] + 'inventory', 'credential', 'char_prompts', 'fail_on_job_failure'] class WorkflowJobTemplateNode(WorkflowNodeBase): # TODO: Ensure the API forces workflow_job_template being set @@ -383,6 +403,9 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, JobNotificationMixin, Workflow from awx.main.tasks import RunWorkflowJob return RunWorkflowJob + def _has_failed(self): + return self.workflow_job_nodes.filter(job__status='failed', fail_on_job_failure=True).exists() + def socketio_emit_data(self): return {} diff --git a/awx/main/scheduler/__init__.py b/awx/main/scheduler/__init__.py index b93ce6956e..6ecdc09b37 100644 --- a/awx/main/scheduler/__init__.py +++ b/awx/main/scheduler/__init__.py @@ -73,10 +73,12 @@ def process_finished_workflow_jobs(workflow_jobs): dag = WorkflowDAG(workflow_job) if dag.is_workflow_done(): with transaction.atomic(): - # TODO: detect if wfj failed - workflow_job.status = 'completed' + if workflow_job._has_failed(): + workflow_job.status = 'failed' + else: + workflow_job.status = 'successful' workflow_job.save() - workflow_job.websocket_emit_status('completed') + workflow_job.websocket_emit_status(workflow_job.status) def rebuild_graph(): """Regenerate the task graph by refreshing known tasks from Tower, purging diff --git a/awx/main/tests/functional/models/test_workflow.py b/awx/main/tests/functional/models/test_workflow.py index cc61c34ed9..aa622d996a 100644 --- a/awx/main/tests/functional/models/test_workflow.py +++ b/awx/main/tests/functional/models/test_workflow.py @@ -90,3 +90,42 @@ class TestWorkflowJobTemplate: assert len(parent_qs) == 1 assert parent_qs[0] == wfjt.workflow_job_template_nodes.all()[1] +@pytest.mark.django_db +class TestWorkflowJobFailure: + @pytest.fixture + def wfj(self): + return WorkflowJob.objects.create(name='test-wf-job') + + def test_workflow_has_failed(self, wfj): + """ + Test that a single failed node with fail_on_job_failure = true + leads to the entire WF being marked as failed + """ + job = Job.objects.create(name='test-job', status='failed') + # Node has a failed job connected + WorkflowJobNode.objects.create(workflow_job=wfj, job=job) + assert wfj._has_failed() + + def test_workflow_not_failed_unran_job(self, wfj): + """ + Test that an un-ran node will not mark workflow job as failed + """ + WorkflowJobNode.objects.create(workflow_job=wfj) + assert not wfj._has_failed() + + def test_workflow_not_failed_successful_job(self, wfj): + """ + Test that a sucessful node will not mark workflow job as failed + """ + job = Job.objects.create(name='test-job', status='successful') + WorkflowJobNode.objects.create(workflow_job=wfj, job=job) + assert not wfj._has_failed() + + def test_workflow_not_failed_failed_job_but_okay(self, wfj): + """ + Test that a failed node will not mark workflow job as failed + if the fail_on_job_failure is set to false + """ + job = Job.objects.create(name='test-job', status='failed') + WorkflowJobNode.objects.create(workflow_job=wfj, job=job, fail_on_job_failure=False) + assert not wfj._has_failed() diff --git a/awx/main/tests/unit/models/test_workflow_unit.py b/awx/main/tests/unit/models/test_workflow_unit.py index 9df61ffe97..bc7e9b5bce 100644 --- a/awx/main/tests/unit/models/test_workflow_unit.py +++ b/awx/main/tests/unit/models/test_workflow_unit.py @@ -139,6 +139,7 @@ class TestWorkflowJobCreate: char_prompts=wfjt_node_no_prompts.char_prompts, inventory=None, credential=None, unified_job_template=wfjt_node_no_prompts.unified_job_template, + fail_on_job_failure=True, workflow_job=workflow_job_unit) def test_create_with_prompts(self, wfjt_node_with_prompts, workflow_job_unit, mocker): @@ -150,6 +151,7 @@ class TestWorkflowJobCreate: inventory=wfjt_node_with_prompts.inventory, credential=wfjt_node_with_prompts.credential, unified_job_template=wfjt_node_with_prompts.unified_job_template, + fail_on_job_failure=True, workflow_job=workflow_job_unit) @mock.patch('awx.main.models.workflow.WorkflowNodeBase.get_parent_nodes', lambda self: []) @@ -215,7 +217,7 @@ class TestWorkflowWarnings: def test_warn_scan_errors_node_prompts(self, job_node_with_prompts): job_node_with_prompts.unified_job_template.job_type = 'scan' - job_node_with_prompts.job_type = 'run' + job_node_with_prompts.char_prompts['job_type'] = 'run' job_node_with_prompts.inventory = Inventory(name='different-inventory', pk=23) assert 'ignored' in job_node_with_prompts.get_prompts_warnings() assert 'job_type' in job_node_with_prompts.get_prompts_warnings()['ignored'] From 6e228248c1d47651a5efa0b41ef2f21bab129b44 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Thu, 27 Oct 2016 12:58:27 -0400 Subject: [PATCH 2/2] remove fail_on_job_failure from the workflow status PR --- awx/api/serializers.py | 6 +---- ...rkflow_job_template_workflow_nodes_list.md | 7 +++--- .../0045_v310_workflow_failure_condition.py | 24 ------------------- awx/main/models/workflow.py | 8 ++----- .../tests/functional/models/test_workflow.py | 17 ++++--------- .../tests/unit/models/test_workflow_unit.py | 2 -- 6 files changed, 12 insertions(+), 52 deletions(-) delete mode 100644 awx/main/migrations/0045_v310_workflow_failure_condition.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index d681fac426..ad84b9e421 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -2249,15 +2249,11 @@ class WorkflowNodeBaseSerializer(BaseSerializer): success_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True) failure_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True) always_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True) - fail_on_job_failure = serializers.BooleanField( - help_text=('If set to true, and if the job runs and fails, ' - 'the workflow is marked as failed.'), - default=True) class Meta: fields = ('*', '-name', '-description', 'id', 'url', 'related', 'unified_job_template', 'success_nodes', 'failure_nodes', 'always_nodes', - 'inventory', 'credential', 'job_type', 'job_tags', 'skip_tags', 'limit', 'skip_tags', 'fail_on_job_failure') + 'inventory', 'credential', 'job_type', 'job_tags', 'skip_tags', 'limit', 'skip_tags') def get_related(self, obj): res = super(WorkflowNodeBaseSerializer, self).get_related(obj) diff --git a/awx/api/templates/api/workflow_job_template_workflow_nodes_list.md b/awx/api/templates/api/workflow_job_template_workflow_nodes_list.md index e6b078a23f..9e5d0f688f 100644 --- a/awx/api/templates/api/workflow_job_template_workflow_nodes_list.md +++ b/awx/api/templates/api/workflow_job_template_workflow_nodes_list.md @@ -7,8 +7,9 @@ the subsequent actions are to: - run nodes contained in "failure_nodes" or "always_nodes" if job failed - run nodes contained in "success_nodes" or "always_nodes" if job succeeded -The workflow is marked as failed if any jobs run as part of that workflow fail -and have the field `fail_on_job_failure` set to true. If not, the workflow -job is marked as successful. +The workflow job is marked as `successful` if all of the jobs running as +a part of the workflow job have completed, and the workflow job has not +been canceled. Even if a job within the workflow has failed, the workflow +job will not be marked as failed. {% include "api/sub_list_create_api_view.md" %} \ No newline at end of file diff --git a/awx/main/migrations/0045_v310_workflow_failure_condition.py b/awx/main/migrations/0045_v310_workflow_failure_condition.py deleted file mode 100644 index 9bafa0feb6..0000000000 --- a/awx/main/migrations/0045_v310_workflow_failure_condition.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('main', '0044_v310_project_playbook_files'), - ] - - operations = [ - migrations.AddField( - model_name='workflowjobnode', - name='fail_on_job_failure', - field=models.BooleanField(default=True), - ), - migrations.AddField( - model_name='workflowjobtemplatenode', - name='fail_on_job_failure', - field=models.BooleanField(default=True), - ), - ] diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 3bc0608cfc..50739829fe 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -60,10 +60,6 @@ class WorkflowNodeBase(CreatedModifiedModel): default=None, on_delete=models.SET_NULL, ) - fail_on_job_failure = models.BooleanField( - blank=True, - default=True, - ) # Prompting-related fields inventory = models.ForeignKey( 'Inventory', @@ -157,7 +153,7 @@ class WorkflowNodeBase(CreatedModifiedModel): Return field names that should be copied from template node to job node. ''' return ['workflow_job', 'unified_job_template', - 'inventory', 'credential', 'char_prompts', 'fail_on_job_failure'] + 'inventory', 'credential', 'char_prompts'] class WorkflowJobTemplateNode(WorkflowNodeBase): # TODO: Ensure the API forces workflow_job_template being set @@ -404,7 +400,7 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, JobNotificationMixin, Workflow return RunWorkflowJob def _has_failed(self): - return self.workflow_job_nodes.filter(job__status='failed', fail_on_job_failure=True).exists() + return False def socketio_emit_data(self): return {} diff --git a/awx/main/tests/functional/models/test_workflow.py b/awx/main/tests/functional/models/test_workflow.py index aa622d996a..0c3d5d88be 100644 --- a/awx/main/tests/functional/models/test_workflow.py +++ b/awx/main/tests/functional/models/test_workflow.py @@ -92,20 +92,14 @@ class TestWorkflowJobTemplate: @pytest.mark.django_db class TestWorkflowJobFailure: + """ + Tests to re-implement if workflow failure status is introduced in + a future Tower version. + """ @pytest.fixture def wfj(self): return WorkflowJob.objects.create(name='test-wf-job') - def test_workflow_has_failed(self, wfj): - """ - Test that a single failed node with fail_on_job_failure = true - leads to the entire WF being marked as failed - """ - job = Job.objects.create(name='test-job', status='failed') - # Node has a failed job connected - WorkflowJobNode.objects.create(workflow_job=wfj, job=job) - assert wfj._has_failed() - def test_workflow_not_failed_unran_job(self, wfj): """ Test that an un-ran node will not mark workflow job as failed @@ -124,8 +118,7 @@ class TestWorkflowJobFailure: def test_workflow_not_failed_failed_job_but_okay(self, wfj): """ Test that a failed node will not mark workflow job as failed - if the fail_on_job_failure is set to false """ job = Job.objects.create(name='test-job', status='failed') - WorkflowJobNode.objects.create(workflow_job=wfj, job=job, fail_on_job_failure=False) + WorkflowJobNode.objects.create(workflow_job=wfj, job=job) assert not wfj._has_failed() diff --git a/awx/main/tests/unit/models/test_workflow_unit.py b/awx/main/tests/unit/models/test_workflow_unit.py index bc7e9b5bce..add8379727 100644 --- a/awx/main/tests/unit/models/test_workflow_unit.py +++ b/awx/main/tests/unit/models/test_workflow_unit.py @@ -139,7 +139,6 @@ class TestWorkflowJobCreate: char_prompts=wfjt_node_no_prompts.char_prompts, inventory=None, credential=None, unified_job_template=wfjt_node_no_prompts.unified_job_template, - fail_on_job_failure=True, workflow_job=workflow_job_unit) def test_create_with_prompts(self, wfjt_node_with_prompts, workflow_job_unit, mocker): @@ -151,7 +150,6 @@ class TestWorkflowJobCreate: inventory=wfjt_node_with_prompts.inventory, credential=wfjt_node_with_prompts.credential, unified_job_template=wfjt_node_with_prompts.unified_job_template, - fail_on_job_failure=True, workflow_job=workflow_job_unit) @mock.patch('awx.main.models.workflow.WorkflowNodeBase.get_parent_nodes', lambda self: [])