diff --git a/app/views/foreman_tasks/api/tasks/dependency_summary.json.rabl b/app/views/foreman_tasks/api/tasks/dependency_summary.json.rabl new file mode 100644 index 000000000..fc96c4a54 --- /dev/null +++ b/app/views/foreman_tasks/api/tasks/dependency_summary.json.rabl @@ -0,0 +1,4 @@ +object @task + +attributes :id, :action, :state, :result +node(:humanized) { @task.humanized[:action] } diff --git a/app/views/foreman_tasks/api/tasks/details.json.rabl b/app/views/foreman_tasks/api/tasks/details.json.rabl index 31f277704..a0b869b7f 100644 --- a/app/views/foreman_tasks/api/tasks/details.json.rabl +++ b/app/views/foreman_tasks/api/tasks/details.json.rabl @@ -18,3 +18,23 @@ node(:links) do end node(:username_path) { username_link_task(@task.owner, @task.username) } node(:dynflow_enable_console) { Setting['dynflow_enable_console'] } +node(:depends_on) do + if @task.execution_plan + dynflow_uuids = ForemanTasks.dynflow.world.persistence.find_execution_plan_dependencies(@task.execution_plan.id) + ForemanTasks::Task.where(external_id: dynflow_uuids).map do |task| + partial('foreman_tasks/api/tasks/dependency_summary', :object => task) + end + else + [] + end +end +node(:blocks) do + if @task.execution_plan + dynflow_uuids = ForemanTasks.dynflow.world.persistence.find_blocked_execution_plans(@task.execution_plan.id) + ForemanTasks::Task.where(external_id: dynflow_uuids).map do |task| + partial('foreman_tasks/api/tasks/dependency_summary', :object => task) + end + else + [] + end +end diff --git a/lib/foreman_tasks.rb b/lib/foreman_tasks.rb index 1ff2f455e..794b94f25 100644 --- a/lib/foreman_tasks.rb +++ b/lib/foreman_tasks.rb @@ -62,6 +62,30 @@ def self.delay(action, delay_options, *args) ForemanTasks::Task::DynflowTask.where(:external_id => result.id).first! end + # Chain a task to wait for dependency task(s) to finish before executing. + # The chained task remains 'scheduled' until all dependencies reach 'stopped' state. + # + # @param dependencies [ForemanTasks::Task, Array, ActiveRecord::Relation] + # Dependency ForemanTasks task object(s) or an ActiveRecord relation of tasks. + # @param action [Class] Action class to execute + # @param args Arguments to pass to the action + # @return [ForemanTasks::Task::DynflowTask] The chained task + def self.chain(dependencies, action, *args) + plan_uuids = + if dependencies.is_a?(ActiveRecord::Relation) + dependencies.pluck(:external_id) + else + Array(dependencies).map(&:external_id) + end + + if plan_uuids.any?(&:blank?) + raise ArgumentError, 'All dependency tasks must have external_id set' + end + + result = dynflow.world.chain(plan_uuids, action, *args) + ForemanTasks::Task::DynflowTask.where(:external_id => result.id).first! + end + def self.register_scheduled_task(task_class, cronline) ForemanTasks::RecurringLogic.transaction(isolation: :serializable) do return if ForemanTasks::RecurringLogic.joins(:tasks) diff --git a/lib/foreman_tasks/triggers.rb b/lib/foreman_tasks/triggers.rb index cc4d24026..a99dd4a0b 100644 --- a/lib/foreman_tasks/triggers.rb +++ b/lib/foreman_tasks/triggers.rb @@ -25,5 +25,9 @@ def sync_task(action, *args, &block) def delay(action, delay_options, *args) foreman_tasks.delay(action, delay_options, *args) end + + def chain(dependencies, action, *args) + foreman_tasks.chain(dependencies, action, *args) + end end end diff --git a/test/unit/chaining_test.rb b/test/unit/chaining_test.rb new file mode 100644 index 000000000..d4ea5434d --- /dev/null +++ b/test/unit/chaining_test.rb @@ -0,0 +1,62 @@ +require 'foreman_tasks_test_helper' + +module ForemanTasks + class ChainingTest < ActiveSupport::TestCase + include ForemanTasks::TestHelpers::WithInThreadExecutor + + before do + User.current = User.where(:login => 'apiadmin').first + end + + it 'creates a scheduled task chained to a dependency task' do + triggered = ForemanTasks.trigger(Support::DummyDynflowAction) + triggered.finished.wait(30) + dependency_task = ForemanTasks::Task::DynflowTask.find_by!(:external_id => triggered.id) + + task = ForemanTasks.chain(dependency_task, Support::DummyDynflowAction) + + assert_kind_of ForemanTasks::Task::DynflowTask, task + assert_predicate task, :scheduled? + + dependencies = ForemanTasks.dynflow.world.persistence.find_execution_plan_dependencies(task.execution_plan.id) + assert_includes dependencies, dependency_task.external_id + end + + it 'accepts multiple dependency tasks' do + triggered_1 = ForemanTasks.trigger(Support::DummyDynflowAction) + triggered_2 = ForemanTasks.trigger(Support::DummyDynflowAction) + triggered_1.finished.wait(30) + triggered_2.finished.wait(30) + dependency_task_1 = ForemanTasks::Task::DynflowTask.find_by!(:external_id => triggered_1.id) + dependency_task_2 = ForemanTasks::Task::DynflowTask.find_by!(:external_id => triggered_2.id) + + task = ForemanTasks.chain([dependency_task_1, dependency_task_2], Support::DummyDynflowAction) + + dependencies = ForemanTasks.dynflow.world.persistence.find_execution_plan_dependencies(task.execution_plan.id) + assert_includes dependencies, dependency_task_1.external_id + assert_includes dependencies, dependency_task_2.external_id + end + + it 'accepts dependency task objects' do + triggered = ForemanTasks.trigger(Support::DummyDynflowAction) + triggered.finished.wait(30) + dependency_task = ForemanTasks::Task::DynflowTask.find_by!(:external_id => triggered.id) + + task = ForemanTasks.chain(dependency_task, Support::DummyDynflowAction) + + dependencies = ForemanTasks.dynflow.world.persistence.find_execution_plan_dependencies(task.execution_plan.id) + assert_includes dependencies, dependency_task.external_id + end + + it 'accepts dependency tasks as a relation' do + triggered = ForemanTasks.trigger(Support::DummyDynflowAction) + triggered.finished.wait(30) + dependency_task = ForemanTasks::Task::DynflowTask.find_by!(:external_id => triggered.id) + + task = ForemanTasks.chain(ForemanTasks::Task::DynflowTask.where(:id => dependency_task.id), Support::DummyDynflowAction) + + dependencies = ForemanTasks.dynflow.world.persistence.find_execution_plan_dependencies(task.execution_plan.id) + assert_includes dependencies, dependency_task.external_id + end + end +end diff --git a/webpack/ForemanTasks/Components/TaskDetails/Components/Dependencies.js b/webpack/ForemanTasks/Components/TaskDetails/Components/Dependencies.js new file mode 100644 index 000000000..8a4743508 --- /dev/null +++ b/webpack/ForemanTasks/Components/TaskDetails/Components/Dependencies.js @@ -0,0 +1,93 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Alert, + AlertVariant, + Grid, + GridItem, + Title, +} from '@patternfly/react-core'; +import { Table, Thead, Tbody, Tr, Th, Td } from '@patternfly/react-table'; +import { translate as __ } from 'foremanReact/common/I18n'; + +const DependencyTable = ({ title, tasks }) => { + const tableId = title.toLowerCase().replace(/\s+/g, '-'); + return ( +
+ + {title} + + {tasks.length === 0 ? ( +

{__('None')}

+ ) : ( + + + + + + + + + + {tasks.map(task => ( + + + + + + ))} + +
{__('Action')}{__('State')}{__('Result')}
+ + {task.humanized || task.action} + + {task.state}{task.result}
+ )} +
+ ); +}; + +DependencyTable.propTypes = { + title: PropTypes.string.isRequired, + tasks: PropTypes.array, +}; + +DependencyTable.defaultProps = { + tasks: [], +}; + +const Dependencies = ({ dependsOn, blocks }) => ( +
+ + {__( + 'This task may have dependencies on other tasks or may be blocking other tasks from executing. Dependencies are established through task chaining relationships.' + )} + +
+ + + + + + + + +
+); + +Dependencies.propTypes = { + dependsOn: PropTypes.array, + blocks: PropTypes.array, +}; + +Dependencies.defaultProps = { + dependsOn: [], + blocks: [], +}; + +export default Dependencies; diff --git a/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/Dependencies.test.js b/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/Dependencies.test.js new file mode 100644 index 000000000..79410d59f --- /dev/null +++ b/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/Dependencies.test.js @@ -0,0 +1,92 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import Dependencies from '../Dependencies'; + +describe('Dependencies', () => { + it('should render with no dependencies', () => { + const wrapper = mount(); + expect(wrapper.find('Alert')).toHaveLength(1); + expect(wrapper.find('DependencyTable')).toHaveLength(2); + expect(wrapper.find('Table')).toHaveLength(0); + expect(wrapper.text()).toContain('None'); + }); + + it('should render with depends_on dependencies', () => { + const dependsOn = [ + { + id: '123', + action: 'Actions::FooBar', + humanized: 'Foo Bar Action', + state: 'stopped', + result: 'success', + }, + { + id: '456', + action: 'Actions::BazQux', + humanized: 'Baz Qux Action', + state: 'running', + result: 'pending', + }, + ]; + const wrapper = mount(); + expect(wrapper.find('Table')).toHaveLength(1); + expect(wrapper.find('Tbody').find('Tr')).toHaveLength(2); + expect(wrapper.text()).toContain('Foo Bar Action'); + expect(wrapper.text()).toContain('Baz Qux Action'); + expect(wrapper.text()).toContain('stopped'); + expect(wrapper.text()).toContain('success'); + }); + + it('should render with blocks dependencies', () => { + const blocks = [ + { + id: '789', + action: 'Actions::Test', + humanized: 'Test Action', + state: 'paused', + result: 'warning', + }, + ]; + const wrapper = mount(); + expect(wrapper.find('Table')).toHaveLength(1); + expect(wrapper.find('Tbody').find('Tr')).toHaveLength(1); + expect(wrapper.text()).toContain('Test Action'); + expect(wrapper.text()).toContain('paused'); + expect(wrapper.text()).toContain('warning'); + }); + + it('should render with both dependency types', () => { + const dependsOn = [ + { + id: '123', + action: 'Actions::Foo', + humanized: 'Foo Action', + state: 'stopped', + result: 'success', + }, + ]; + const blocks = [ + { + id: '456', + action: 'Actions::Bar', + humanized: 'Bar Action', + state: 'running', + result: 'pending', + }, + { + id: '789', + action: 'Actions::Baz', + humanized: 'Baz Action', + state: 'stopped', + result: 'error', + }, + ]; + const wrapper = mount( + + ); + expect(wrapper.find('Table')).toHaveLength(2); + expect(wrapper.text()).toContain('Foo Action'); + expect(wrapper.text()).toContain('Bar Action'); + expect(wrapper.text()).toContain('Baz Action'); + }); +}); diff --git a/webpack/ForemanTasks/Components/TaskDetails/TaskDetails.js b/webpack/ForemanTasks/Components/TaskDetails/TaskDetails.js index 34420dd4a..429a9046f 100644 --- a/webpack/ForemanTasks/Components/TaskDetails/TaskDetails.js +++ b/webpack/ForemanTasks/Components/TaskDetails/TaskDetails.js @@ -9,6 +9,7 @@ import RunningSteps from './Components/RunningSteps'; import Errors from './Components/Errors'; import Locks from './Components/Locks'; import Raw from './Components/Raw'; +import Dependencies from './Components/Dependencies'; import { getTaskID } from './TasksDetailsHelper'; import { TaskSkeleton } from './Components/TaskSkeleton'; @@ -20,6 +21,8 @@ const TaskDetails = ({ runningSteps, locks, links, + dependsOn, + blocks, cancelStep, taskReloadStart, taskReloadStop, @@ -90,7 +93,10 @@ const TaskDetails = ({ - + + + + export const selectIsLoading = state => !!selectAPIByKey(state, FOREMAN_TASK_DETAILS).response && selectStatus(state) === STATUS.PENDING; + +export const selectDependsOn = state => + selectTaskDetailsResponse(state).depends_on || []; + +export const selectBlocks = state => + selectTaskDetailsResponse(state).blocks || []; diff --git a/webpack/ForemanTasks/Components/TaskDetails/__tests__/__snapshots__/TaskDetails.test.js.snap b/webpack/ForemanTasks/Components/TaskDetails/__tests__/__snapshots__/TaskDetails.test.js.snap index 64814a90c..7ed81fd39 100644 --- a/webpack/ForemanTasks/Components/TaskDetails/__tests__/__snapshots__/TaskDetails.test.js.snap +++ b/webpack/ForemanTasks/Components/TaskDetails/__tests__/__snapshots__/TaskDetails.test.js.snap @@ -58,6 +58,16 @@ exports[`TaskDetails rendering render with loading Props 1`] = ` + + + + + + ({ @@ -71,6 +73,8 @@ const mapStateToProps = state => ({ status: selectStatus(state), APIerror: selectAPIError(state), isLoading: selectIsLoading(state), + dependsOn: selectDependsOn(state), + blocks: selectBlocks(state), }); const mapDispatchToProps = dispatch =>