foreman_openbolt 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +190 -19
  3. data/Rakefile +17 -93
  4. data/app/controllers/foreman_openbolt/task_controller.rb +61 -49
  5. data/app/lib/actions/foreman_openbolt/cleanup_proxy_artifacts.rb +11 -10
  6. data/app/lib/actions/foreman_openbolt/poll_task_status.rb +70 -60
  7. data/app/models/foreman_openbolt/task_job.rb +16 -17
  8. data/config/routes.rb +0 -1
  9. data/lib/foreman_openbolt/engine.rb +11 -11
  10. data/lib/foreman_openbolt/version.rb +1 -1
  11. data/lib/proxy_api/openbolt.rb +25 -9
  12. data/lib/tasks/foreman_openbolt_tasks.rake +1 -22
  13. data/locale/gemspec.rb +1 -1
  14. data/package.json +11 -15
  15. data/test/acceptance/acceptance_helper.rb +146 -0
  16. data/test/acceptance/docker/docker-compose.yml +69 -0
  17. data/test/acceptance/docker/foreman/Dockerfile +45 -0
  18. data/test/acceptance/docker/foreman/entrypoint.sh +26 -0
  19. data/test/acceptance/docker/target/Dockerfile +29 -0
  20. data/test/acceptance/docker/target/entrypoint.sh +11 -0
  21. data/test/acceptance/fixtures/modules/acceptance/tasks/complex_params.json +30 -0
  22. data/test/acceptance/fixtures/modules/acceptance/tasks/complex_params.sh +16 -0
  23. data/test/acceptance/fixtures/modules/acceptance/tasks/echo.json +13 -0
  24. data/test/acceptance/fixtures/modules/acceptance/tasks/echo.sh +3 -0
  25. data/test/acceptance/fixtures/modules/acceptance/tasks/failing_task.json +8 -0
  26. data/test/acceptance/fixtures/modules/acceptance/tasks/failing_task.sh +3 -0
  27. data/test/acceptance/fixtures/modules/acceptance/tasks/noop_task.json +8 -0
  28. data/test/acceptance/fixtures/modules/acceptance/tasks/noop_task.sh +2 -0
  29. data/test/acceptance/fixtures/modules/acceptance/tasks/slow_task.json +14 -0
  30. data/test/acceptance/fixtures/modules/acceptance/tasks/slow_task.sh +3 -0
  31. data/test/acceptance/fixtures/modules/acceptance/tasks/target_conditional.json +13 -0
  32. data/test/acceptance/fixtures/modules/acceptance/tasks/target_conditional.sh +9 -0
  33. data/test/acceptance/fixtures/openbolt.yml +7 -0
  34. data/test/acceptance/tests/error_handling_test.rb +40 -0
  35. data/test/acceptance/tests/host_selector_test.rb +31 -0
  36. data/test/acceptance/tests/launch_task_test.rb +96 -0
  37. data/test/acceptance/tests/parameter_table_test.rb +61 -0
  38. data/test/acceptance/tests/settings_test.rb +95 -0
  39. data/test/acceptance/tests/ssh_options_test.rb +77 -0
  40. data/test/acceptance/tests/task_execution_test.rb +40 -0
  41. data/test/acceptance/tests/task_history_test.rb +84 -0
  42. data/test/acceptance/tests/transport_options_test.rb +121 -0
  43. data/test/test_plugin_helper.rb +12 -3
  44. data/test/unit/controllers/task_controller_test.rb +351 -0
  45. data/test/unit/docker/Dockerfile +47 -0
  46. data/test/unit/docker/docker-compose.yml +33 -0
  47. data/test/unit/docker/entrypoint.sh +4 -0
  48. data/test/unit/factories/foreman_openbolt_factories.rb +39 -0
  49. data/test/unit/lib/actions/cleanup_proxy_artifacts_test.rb +51 -0
  50. data/test/unit/lib/actions/poll_task_status_test.rb +141 -0
  51. data/test/unit/lib/proxy_api/openbolt_test.rb +174 -0
  52. data/test/unit/models/task_job_test.rb +278 -0
  53. data/webpack/__mocks__/foremanReact/common/I18n.js +15 -0
  54. data/webpack/__mocks__/foremanReact/components/ToastsList/index.js +6 -0
  55. data/webpack/__mocks__/foremanReact/redux/API/index.js +11 -0
  56. data/webpack/src/Components/LaunchTask/FieldTable.js +8 -5
  57. data/webpack/src/Components/LaunchTask/HostSelector/SearchSelect.js +74 -62
  58. data/webpack/src/Components/LaunchTask/HostSelector/SelectedChips.js +11 -13
  59. data/webpack/src/Components/LaunchTask/HostSelector/index.js +28 -33
  60. data/webpack/src/Components/LaunchTask/OpenBoltOptionsSection.js +3 -2
  61. data/webpack/src/Components/LaunchTask/ParameterField.js +2 -0
  62. data/webpack/src/Components/LaunchTask/SmartProxySelect.js +2 -1
  63. data/webpack/src/Components/LaunchTask/TaskSelect.js +3 -3
  64. data/webpack/src/Components/LaunchTask/__tests__/EmptyContent.test.js +10 -0
  65. data/webpack/src/Components/LaunchTask/__tests__/LaunchTask.test.js +83 -0
  66. data/webpack/src/Components/LaunchTask/__tests__/ParameterField.test.js +86 -0
  67. data/webpack/src/Components/LaunchTask/__tests__/ParametersSection.test.js +50 -0
  68. data/webpack/src/Components/LaunchTask/__tests__/SmartProxySelect.test.js +63 -0
  69. data/webpack/src/Components/LaunchTask/__tests__/TaskSelect.test.js +39 -0
  70. data/webpack/src/Components/LaunchTask/hooks/__tests__/useOpenBoltOptions.test.js +90 -0
  71. data/webpack/src/Components/LaunchTask/hooks/__tests__/useSmartProxies.test.js +69 -0
  72. data/webpack/src/Components/LaunchTask/hooks/__tests__/useTasksData.test.js +103 -0
  73. data/webpack/src/Components/LaunchTask/hooks/useOpenBoltOptions.js +9 -11
  74. data/webpack/src/Components/LaunchTask/hooks/useSmartProxies.js +12 -13
  75. data/webpack/src/Components/LaunchTask/hooks/useTasksData.js +6 -13
  76. data/webpack/src/Components/LaunchTask/index.js +9 -27
  77. data/webpack/src/Components/TaskExecution/ExecutionDetails.js +29 -29
  78. data/webpack/src/Components/TaskExecution/ExecutionDisplay.js +9 -10
  79. data/webpack/src/Components/TaskExecution/LoadingIndicator.js +7 -2
  80. data/webpack/src/Components/TaskExecution/ResultDisplay.js +13 -17
  81. data/webpack/src/Components/TaskExecution/TaskDetails.js +58 -67
  82. data/webpack/src/Components/TaskExecution/__tests__/ExecutionDetails.test.js +47 -0
  83. data/webpack/src/Components/TaskExecution/__tests__/ExecutionDisplay.test.js +29 -0
  84. data/webpack/src/Components/TaskExecution/__tests__/LoadingIndicator.test.js +25 -0
  85. data/webpack/src/Components/TaskExecution/__tests__/ResultDisplay.test.js +28 -0
  86. data/webpack/src/Components/TaskExecution/__tests__/TaskDetails.test.js +38 -0
  87. data/webpack/src/Components/TaskExecution/__tests__/TaskExecution.test.js +80 -0
  88. data/webpack/src/Components/TaskExecution/hooks/__tests__/useJobPolling.test.js +177 -0
  89. data/webpack/src/Components/TaskExecution/hooks/useJobPolling.js +34 -33
  90. data/webpack/src/Components/TaskExecution/index.js +10 -12
  91. data/webpack/src/Components/TaskHistory/TaskPopover.js +9 -12
  92. data/webpack/src/Components/TaskHistory/__tests__/TaskHistory.test.js +109 -0
  93. data/webpack/src/Components/TaskHistory/__tests__/TaskPopover.test.js +26 -0
  94. data/webpack/src/Components/TaskHistory/index.js +21 -29
  95. data/webpack/src/Components/common/HostsPopover.js +12 -3
  96. data/webpack/src/Components/common/__tests__/HostsPopover.test.js +20 -0
  97. data/webpack/src/Components/common/__tests__/helpers.test.js +135 -0
  98. data/webpack/src/Components/common/helpers.js +34 -5
  99. data/webpack/test_setup.js +34 -11
  100. metadata +65 -87
  101. data/test/factories/foreman_openbolt_factories.rb +0 -7
  102. data/test/unit/foreman_openbolt_test.rb +0 -13
  103. data/webpack/global_test_setup.js +0 -11
  104. data/webpack/webpack.config.js +0 -7
@@ -6,10 +6,8 @@
6
6
  module Actions
7
7
  module ForemanOpenbolt
8
8
  class PollTaskStatus < Actions::EntryAction
9
- include Actions::RecurringAction
10
-
11
9
  POLL_INTERVAL = 5.seconds
12
- RETRY_LIMIT = 60 # Number of retries before giving up (5 minutes)
10
+ RETRY_LIMIT = 60 # 5 minutes at 5-second intervals
13
11
 
14
12
  # Set up the action when it is first scheduled, storing
15
13
  # IDs needed to get information from the proxy.
@@ -24,52 +22,54 @@ module Actions
24
22
  if event.nil? || event.to_sym == :poll
25
23
  poll_and_reschedule
26
24
  else
27
- error("Received unknown event '#{event}' for OpenBolt job #{input[:job_id]}. Finishing the action.")
25
+ log("Received unknown event '#{event}' for OpenBolt job #{input[:job_id]}", :error)
28
26
  finish
29
27
  end
30
28
  end
31
29
 
32
- def finalize
33
- Rails.logger.info("Finalized polling for OpenBolt job #{input[:job_id]}")
30
+ def log(msg, level = :debug)
31
+ output[:log] ||= []
32
+ output[:log] << "[#{Time.now.getlocal.strftime('%Y-%m-%d %H:%M:%S')}] [#{level.upcase}] #{msg}"
33
+ Rails.logger.send(level, msg)
34
34
  end
35
35
 
36
- private
37
-
38
- def append_output(key, message)
39
- output[key] ||= []
40
- output[key] << message
36
+ def exception(msg, e)
37
+ log("#{msg}: #{e.class}: #{e.message}", :error)
38
+ log(e.backtrace.join("\n"), :error) if e.backtrace
41
39
  end
42
40
 
43
- def log(message)
44
- append_output(:log, "[#{Time.now.getlocal.strftime('%Y-%m-%d %H:%M:%S')}] #{message}")
41
+ def finish
42
+ log("Polling finished for OpenBolt job #{input[:job_id]}")
45
43
  end
46
44
 
47
- def error(message)
48
- append_output(:error, message)
49
- end
45
+ def extract_proxy_error(response)
46
+ error_value = response&.dig('error')
47
+ return nil if error_value.nil? || (error_value.respond_to?(:empty?) && error_value.empty?)
50
48
 
51
- def exception(e)
52
- append_output(:exception, e.message)
53
- append_output(:exception_backtrace, e.backtrace.join("\n"))
54
- end
55
-
56
- def finish
57
- log("Polling finished for OpenBolt job #{input[:job_id]}")
49
+ error_value.is_a?(Hash) ? error_value['message'] || error_value.to_s : error_value.to_s
58
50
  end
59
51
 
60
52
  def poll_and_reschedule
61
53
  job_id = input[:job_id]
54
+ task_job = ::ForemanOpenbolt::TaskJob.find_by(job_id: job_id)
62
55
 
63
- # If task doesn't exist or is already complete, finish
64
- if task_job.nil? || task_job.completed?
56
+ if task_job.nil?
57
+ log("TaskJob record not found for job #{job_id}", :error)
58
+ finish
59
+ return
60
+ end
61
+
62
+ if task_job.completed?
65
63
  finish
66
64
  return
67
65
  end
68
66
 
69
67
  # If the smart proxy has been deleted somehow or is unknown,
70
68
  # we can't poll for status, so finish.
71
- if proxy.nil?
72
- error("Smart Proxy with ID #{input[:proxy_id]} not found for OpenBolt job #{job_id}. Finishing the action.")
69
+ proxy = ::SmartProxy.find_by(id: input[:proxy_id])
70
+ unless proxy
71
+ log("Smart Proxy with ID #{input[:proxy_id]} not found for OpenBolt job #{job_id}", :error)
72
+ task_job.update!(status: 'exception')
73
73
  finish
74
74
  return
75
75
  end
@@ -80,24 +80,42 @@ module Actions
80
80
  # Fetch current status
81
81
  status_result = api.job_status(job_id: job_id)
82
82
 
83
- # Update status if changed
84
- if status_result && status_result['status']
85
- input[:retry_count] = 0
86
- task_job.update!(status: status_result['status'])
87
- log("Status: #{status_result['status']}")
88
-
89
- # If completed, fetch full results
90
- if task_job.completed?
91
- result = api.job_result(job_id: job_id)
92
- if result
93
- task_job.update_from_proxy_result!(result)
94
- log("OpenBolt job #{job_id} completed with status '#{task_job.status}'")
95
- else
96
- log("WARNING: No result returned from proxy for completed OpenBolt job #{job_id}")
97
- end
98
- finish
99
- return
83
+ proxy_error = extract_proxy_error(status_result)
84
+ if proxy_error
85
+ log("Proxy returned error for job #{job_id}: #{proxy_error}", :error)
86
+ task_job.update!(status: 'exception')
87
+ finish
88
+ return
89
+ end
90
+
91
+ unless status_result&.dig('status')
92
+ log("Proxy returned response without status for job #{job_id}: #{status_result.inspect}", :error)
93
+ task_job.update!(status: 'exception')
94
+ finish
95
+ return
96
+ end
97
+
98
+ input[:retry_count] = 0
99
+ new_status = status_result['status']
100
+ if new_status == task_job.status
101
+ log("Poll for OpenBolt job #{job_id}: status=#{new_status}")
102
+ else
103
+ previous_status = task_job.status
104
+ task_job.update!(status: new_status)
105
+ log("OpenBolt job #{job_id} status changed from '#{previous_status}' to '#{new_status}'", :info)
106
+ end
107
+
108
+ # If completed, fetch full results
109
+ if task_job.completed?
110
+ result = api.job_result(job_id: job_id)
111
+ if result.present?
112
+ task_job.update_from_proxy_result!(result)
113
+ log("OpenBolt job #{job_id} completed with status '#{task_job.status}'", :info)
114
+ else
115
+ log("No result returned from proxy for completed OpenBolt job #{job_id}", :error)
100
116
  end
117
+ finish
118
+ return
101
119
  end
102
120
 
103
121
  # Still running, schedule next poll in 5 seconds
@@ -105,14 +123,14 @@ module Actions
105
123
  world.clock.ping(suspended_action, POLL_INTERVAL.from_now.to_time, :poll)
106
124
  end
107
125
  rescue StandardError => e
108
- error("Error polling task status for job #{job_id}")
109
- exception(e)
126
+ exception("Error polling task status for job #{job_id}", e)
110
127
 
111
128
  retry_count = (input[:retry_count] || 0) + 1
112
129
  input[:retry_count] = retry_count
113
130
 
114
131
  if retry_count > RETRY_LIMIT
115
- error("Could not successfully poll task status for job #{job_id} after #{retry_count} attempts. Giving up.")
132
+ log("Polling gave up for job #{job_id} after #{retry_count} attempts", :error)
133
+ task_job.update!(status: 'exception')
116
134
  finish
117
135
  return
118
136
  end
@@ -132,19 +150,11 @@ module Actions
132
150
  end
133
151
 
134
152
  def humanized_input
135
- input.slice(:job_id, :proxy_id).merge(
136
- # Using & to handle possible nil values just in case
137
- proxy_name: proxy&.name,
138
- task_name: task_job&.task_name
139
- )
140
- end
141
-
142
- def task_job
143
- @task_job ||= ::ForemanOpenbolt::TaskJob.find_by(job_id: input[:job_id])
144
- end
145
-
146
- def proxy
147
- @proxy ||= ::SmartProxy.find_by(id: input[:proxy_id])
153
+ proxy_name = ::SmartProxy.find_by(id: input[:proxy_id])&.name || '(unknown)'
154
+ task_name = ::ForemanOpenbolt::TaskJob.find_by(job_id: input[:job_id])&.task_name
155
+ parts = ["job #{input[:job_id]} on #{proxy_name}"]
156
+ parts << "task: #{task_name}" if task_name
157
+ parts.join(', ')
148
158
  end
149
159
  end
150
160
  end
@@ -18,7 +18,6 @@ module ForemanOpenbolt
18
18
  validates :job_id, presence: true, uniqueness: true
19
19
  validates :status, inclusion: { in: STATUSES }
20
20
  validates :targets, presence: true
21
- validates :submitted_at, presence: true
22
21
 
23
22
  # Scopes
24
23
  scope :running, -> { where(status: RUNNING_STATUSES) }
@@ -27,7 +26,6 @@ module ForemanOpenbolt
27
26
  scope :for_proxy, ->(proxy) { where(smart_proxy: proxy) }
28
27
 
29
28
  # Callbacks
30
- before_validation :set_submitted_at, on: :create
31
29
  before_update :set_completed_at, if: :status_changed_to_completed?
32
30
  after_update :cleanup_proxy_artifacts, if: :saved_result_and_log?
33
31
 
@@ -41,8 +39,8 @@ module ForemanOpenbolt
41
39
  targets: targets,
42
40
  task_parameters: parameters,
43
41
  openbolt_options: options,
44
- status: 'pending'
45
- # submitted_at is set by callback
42
+ status: 'pending',
43
+ submitted_at: Time.current
46
44
  )
47
45
  end
48
46
 
@@ -61,7 +59,10 @@ module ForemanOpenbolt
61
59
 
62
60
  # Result/log will already be scrubbed by the proxy
63
61
  def update_from_proxy_result!(proxy_result)
64
- return if proxy_result.blank?
62
+ if proxy_result.blank?
63
+ Rails.logger.warn("Received blank proxy result for job #{job_id}, skipping update")
64
+ return
65
+ end
65
66
 
66
67
  transaction do
67
68
  self.status = proxy_result['status'] if proxy_result['status'].present?
@@ -80,12 +81,6 @@ module ForemanOpenbolt
80
81
  targets&.join(', ') || ''
81
82
  end
82
83
 
83
- private
84
-
85
- def set_submitted_at
86
- self.submitted_at ||= Time.current
87
- end
88
-
89
84
  def set_completed_at
90
85
  self.completed_at ||= Time.current if completed?
91
86
  end
@@ -98,13 +93,17 @@ module ForemanOpenbolt
98
93
  saved_change_to_result? || saved_change_to_log?
99
94
  end
100
95
 
96
+ # Schedule cleanup of proxy artifacts if we have successfully saved the results
101
97
  def cleanup_proxy_artifacts
102
- return unless result.present? && log.present?
103
- # Schedule cleanup of proxy artifacts if we have successfully saved the results
104
- # ForemanTasks.async_task(::Actions::ForemanOpenbolt::CleanupProxyArtifacts,
105
- # smart_proxy_id,
106
- # job_id)
107
- # Rails.logger.info("Scheduled cleanup for job #{job_id} on proxy #{smart_proxy_id}")
98
+ return unless completed?
99
+
100
+ ForemanTasks.async_task(::Actions::ForemanOpenbolt::CleanupProxyArtifacts,
101
+ smart_proxy_id,
102
+ job_id)
103
+ Rails.logger.debug { "Scheduled cleanup for job #{job_id} on proxy #{smart_proxy_id}" }
104
+ rescue StandardError => e
105
+ Rails.logger.error("Failed to schedule cleanup for job #{job_id}: #{e.class}: #{e.message}")
106
+ Rails.logger.error(e.backtrace.join("\n")) if e.backtrace
108
107
  end
109
108
  end
110
109
  end
data/config/routes.rb CHANGED
@@ -16,7 +16,6 @@ ForemanOpenbolt::Engine.routes.draw do
16
16
 
17
17
  # Task job management endpoints
18
18
  get 'fetch_task_history', to: 'task#fetch_task_history'
19
- get 'fetch_task_history/:id', to: 'task#show', as: :task_job
20
19
  end
21
20
 
22
21
  Foreman::Application.routes.draw do
@@ -15,7 +15,7 @@ module ForemanOpenbolt
15
15
  initializer 'foreman_openbolt.register_plugin', before: :finisher_hook do |app|
16
16
  app.reloader.to_prepare do
17
17
  Foreman::Plugin.register :foreman_openbolt do
18
- requires_foreman '>= 3.14.0'
18
+ requires_foreman '>= 3.17.0'
19
19
  register_gettext
20
20
 
21
21
  settings do
@@ -33,15 +33,15 @@ module ForemanOpenbolt
33
33
 
34
34
  # rubocop:disable Lint/ConstantDefinitionInBlock
35
35
  TRANSPORTS = {
36
- 'ssh': N_('SSH'),
37
- 'winrm': N_('WinRM'),
36
+ ssh: N_('SSH'),
37
+ winrm: N_('WinRM'),
38
38
  }.freeze
39
39
  LOG_LEVELS = {
40
- 'error': N_('Error'),
41
- 'warning': N_('Warning'),
42
- 'info': N_('Info'),
43
- 'debug': N_('Debug'),
44
- 'trace': N_('Trace'),
40
+ error: N_('Error'),
41
+ warning: N_('Warning'),
42
+ info: N_('Info'),
43
+ debug: N_('Debug'),
44
+ trace: N_('Trace'),
45
45
  }.freeze
46
46
  # rubocop:enable Lint/ConstantDefinitionInBlock
47
47
 
@@ -62,7 +62,7 @@ module ForemanOpenbolt
62
62
  default: false,
63
63
  full_name: N_('Verbose'),
64
64
  description: N_(
65
- 'Run the OpenBolt command with the --verbose flag. This prints additional information ' +
65
+ 'Run the OpenBolt command with the --verbose flag. This prints additional information ' \
66
66
  'during OpenBolt execution and will print any out::verbose plan statements.'
67
67
  )
68
68
  setting 'openbolt_noop',
@@ -98,7 +98,7 @@ module ForemanOpenbolt
98
98
  default: '',
99
99
  full_name: N_('SSH Private Key'),
100
100
  description: N_(
101
- 'Path on the smart proxy host to the private key used for SSH authentication. This key must be ' +
101
+ 'Path on the smart proxy host to the private key used for SSH authentication. This key must be ' \
102
102
  'readable by the foreman-proxy user.'
103
103
  )
104
104
  setting 'openbolt_run-as',
@@ -106,7 +106,7 @@ module ForemanOpenbolt
106
106
  default: '',
107
107
  full_name: N_('SSH Run As User'),
108
108
  description: N_(
109
- 'The user to run commands as on the target host. This requires that the user specified ' +
109
+ 'The user to run commands as on the target host. This requires that the user specified ' \
110
110
  'in the "user" option has permission to run commands as this user.'
111
111
  )
112
112
  setting 'openbolt_sudo-password',
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ForemanOpenbolt
4
- VERSION = '1.0.0'
4
+ VERSION = '1.1.1'
5
5
  end
@@ -6,11 +6,11 @@ module ProxyAPI
6
6
  class Openbolt < Resource
7
7
  def initialize(args)
8
8
  @url = args[:url]
9
- super args
9
+ super
10
10
  end
11
11
 
12
12
  def fetch_tasks
13
- @tasks = JSON.parse(get('/openbolt/tasks').body)
13
+ @tasks = parse_response(get('/openbolt/tasks'), 'fetch_tasks')
14
14
  end
15
15
 
16
16
  def tasks
@@ -18,7 +18,7 @@ module ProxyAPI
18
18
  end
19
19
 
20
20
  def reload_tasks
21
- @tasks = JSON.parse(get('/openbolt/tasks/reload').body)
21
+ @tasks = parse_response(get('/openbolt/tasks/reload'), 'reload_tasks')
22
22
  end
23
23
 
24
24
  def task_names
@@ -26,28 +26,44 @@ module ProxyAPI
26
26
  end
27
27
 
28
28
  def openbolt_options
29
- @openbolt_options ||= JSON.parse(get('/openbolt/tasks/options').body)
29
+ @openbolt_options ||= parse_response(get('/openbolt/tasks/options'), 'openbolt_options')
30
30
  end
31
31
 
32
32
  def launch_task(name:, targets:, parameters: {}, options: {})
33
- JSON.parse(post({
33
+ response = post({
34
34
  name: name,
35
35
  targets: targets,
36
36
  parameters: parameters,
37
37
  options: options,
38
- }.to_json, '/openbolt/launch/task').body)
38
+ }.to_json, '/openbolt/launch/task')
39
+ parse_response(response, 'launch_task')
39
40
  end
40
41
 
41
42
  def job_status(job_id:)
42
- JSON.parse(get("/openbolt/job/#{job_id}/status").body)
43
+ parse_response(get("/openbolt/job/#{job_id}/status"), 'job_status')
43
44
  end
44
45
 
45
46
  def job_result(job_id:)
46
- JSON.parse(get("/openbolt/job/#{job_id}/result").body)
47
+ parse_response(get("/openbolt/job/#{job_id}/result"), 'job_result')
47
48
  end
48
49
 
49
50
  def delete_job_artifacts(job_id:)
50
- JSON.parse(delete("/openbolt/job/#{job_id}/artifacts").body)
51
+ parse_response(delete("/openbolt/job/#{job_id}/artifacts"), 'delete_job_artifacts')
52
+ end
53
+
54
+ def parse_response(response, operation)
55
+ raise ProxyException.new(@url, nil, "No response from Smart Proxy during #{operation}") unless response
56
+
57
+ body = response.body
58
+ raise ProxyException.new(@url, nil, "Empty response body from Smart Proxy during #{operation}") if body.nil?
59
+
60
+ JSON.parse(body)
61
+ rescue JSON::ParserError => e
62
+ raise ProxyException.new(
63
+ @url, nil,
64
+ "Invalid JSON from Smart Proxy during #{operation}: #{e.message}. " \
65
+ "Response body (first 500 chars): #{body.to_s[0..500]}"
66
+ )
51
67
  end
52
68
  end
53
69
  end
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'rake/testtask'
4
-
5
- # Tasks
6
3
  namespace :openbolt do
7
4
  desc 'Refresh smart proxy features to detect OpenBolt feature'
8
5
  task refresh_proxies: :environment do
@@ -20,7 +17,7 @@ namespace :openbolt do
20
17
  end
21
18
  end
22
19
 
23
- if proxies.count.zero?
20
+ if proxies.none?
24
21
  puts "No smart proxies found"
25
22
  else
26
23
  openbolt_count = proxies.with_features('OpenBolt').count
@@ -28,21 +25,3 @@ namespace :openbolt do
28
25
  end
29
26
  end
30
27
  end
31
-
32
- # Tests
33
- namespace :test do
34
- desc 'Test ForemanOpenbolt'
35
- Rake::TestTask.new(:foreman_openbolt) do |t|
36
- test_dir = File.expand_path('../../test', __dir__)
37
- t.libs << 'test'
38
- t.libs << test_dir
39
- t.pattern = "#{test_dir}/**/*_test.rb"
40
- t.verbose = true
41
- t.warning = false
42
- end
43
- end
44
-
45
- Rake::Task[:test].enhance ['test:foreman_openbolt']
46
-
47
- load 'tasks/jenkins.rake'
48
- Rake::Task['jenkins:unit'].enhance ['test:foreman_openbolt', 'foreman_openbolt:rubocop'] if Rake::Task.task_defined?(:'jenkins:unit')
data/locale/gemspec.rb CHANGED
@@ -2,6 +2,6 @@
2
2
 
3
3
  # Matches foreman_openbolt.gemspec
4
4
  _(
5
- 'This plugin adds OpenBolt integration into Foreman, allowing users to run tasks ' +
5
+ 'This plugin adds OpenBolt integration into Foreman, allowing users to run tasks ' \
6
6
  'present in their environment.'
7
7
  )
data/package.json CHANGED
@@ -1,15 +1,13 @@
1
1
  {
2
2
  "name": "foreman_openbolt",
3
- "version": "0.1.1",
3
+ "version": "1.1.1",
4
4
  "description": "OpenBolt integration into Foreman",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
7
  "lint": "tfm-lint --plugin -d /webpack",
8
8
  "test": "tfm-test --config jest.config.js",
9
9
  "test:watch": "tfm-test --plugin --watchAll",
10
- "test:current": "tfm-test --plugin --watch",
11
- "publish-coverage": "tfm-publish-coverage",
12
- "create-react-component": "yo react-domain"
10
+ "test:current": "tfm-test --plugin --watch"
13
11
  },
14
12
  "repository": {
15
13
  "type": "git",
@@ -19,23 +17,21 @@
19
17
  "url": "http://projects.theforeman.org/projects/foreman_openbolt/issues"
20
18
  },
21
19
  "peerDependencies": {
22
- "@theforeman/vendor": ">= 6.0.0"
20
+ "@theforeman/vendor": ">= 15.0.1"
23
21
  },
24
22
  "dependencies": {
25
23
  "react-intl": "^2.8.0"
26
24
  },
27
25
  "devDependencies": {
28
26
  "@babel/core": "^7.7.0",
29
- "@sheerun/mutationobserver-shim": "^0.3.3",
30
- "@theforeman/builder": ">= 6.0.0",
31
- "@theforeman/eslint-plugin-foreman": "6.0.0",
32
- "@theforeman/find-foreman": "^4.8.0",
33
- "@theforeman/test": "^8.0.0",
34
- "@theforeman/vendor-dev": "^6.0.0",
27
+ "@testing-library/react": "^10.4.9",
28
+ "@testing-library/react-hooks": "^3.7.0",
29
+ "@theforeman/builder": ">= 15.0.1",
30
+ "@theforeman/eslint-plugin-foreman": ">= 15.0.1",
31
+ "@theforeman/test": ">= 15.0.1",
32
+ "@theforeman/vendor-dev": ">= 15.0.1",
35
33
  "babel-eslint": "^10.0.3",
36
- "eslint": "^6.7.2",
37
- "prettier": "^1.19.1",
38
- "stylelint-config-standard": "^18.0.0",
39
- "stylelint": "^9.3.0"
34
+ "eslint": "^6.8.0",
35
+ "prettier": "^1.19.1"
40
36
  }
41
37
  }
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'capybara'
4
+ require 'capybara/dsl'
5
+ require 'selenium-webdriver'
6
+ require 'test/unit'
7
+
8
+ # Base class for acceptance tests using Capybara + Selenium Chrome.
9
+ # Connects to Foreman through a remote ChromeDriver container and
10
+ # exercises the plugin UI as a real user would.
11
+ class AcceptanceTestCase < Test::Unit::TestCase
12
+ include Capybara::DSL
13
+
14
+ # Chrome runs in a separate container and reaches Foreman via the Docker
15
+ # network service name. The test runner connects to ChromeDriver via the
16
+ # exposed port 4444 on the host.
17
+ FOREMAN_URL = ENV.fetch('FOREMAN_URL', 'https://foreman')
18
+ FOREMAN_USER = ENV.fetch('FOREMAN_USER', 'admin')
19
+ FOREMAN_PASS = ENV.fetch('FOREMAN_PASS', 'changeme')
20
+ CHROMEDRIVER_URL = ENV.fetch('CHROMEDRIVER_URL', 'http://localhost:4444')
21
+
22
+ def setup
23
+ Capybara.app_host = FOREMAN_URL
24
+ Capybara.run_server = false
25
+ Capybara.default_max_wait_time = 15
26
+
27
+ Capybara.register_driver :remote_chrome do |app|
28
+ options = Selenium::WebDriver::Chrome::Options.new
29
+ options.add_argument('--headless') unless ENV['HEADFUL']
30
+ options.add_argument('--no-sandbox')
31
+ options.add_argument('--disable-dev-shm-usage')
32
+ options.add_argument('--disable-gpu')
33
+ options.add_argument('--window-size=1280,720')
34
+ options.add_argument('--ignore-certificate-errors')
35
+
36
+ Capybara::Selenium::Driver.new(
37
+ app,
38
+ browser: :remote,
39
+ url: CHROMEDRIVER_URL,
40
+ options: options
41
+ )
42
+ end
43
+
44
+ Capybara.default_driver = :remote_chrome
45
+ Capybara.javascript_driver = :remote_chrome
46
+ end
47
+
48
+ def teardown
49
+ visit '/users/logout'
50
+ Capybara.reset_sessions!
51
+ end
52
+
53
+ def foreman_login(user: FOREMAN_USER, password: FOREMAN_PASS)
54
+ visit '/users/login'
55
+ fill_in 'login_login', with: user
56
+ fill_in 'login_password', with: password
57
+ click_button 'Log In'
58
+ end
59
+
60
+ # --- Launch page helpers ---
61
+
62
+ def select_first_proxy
63
+ assert_selector '#smart-proxy-input option', minimum: 2, wait: 15
64
+ proxy_option = first('#smart-proxy-input option:not([value=""])')
65
+ select proxy_option.text, from: 'smart-proxy-input'
66
+ end
67
+
68
+ def select_hosts_via_search(query)
69
+ find('[aria-label="Select host targeting method"]').click
70
+ find('[data-ouia-component-id="host_methods"]').find('li', text: 'Search query').click
71
+ search_input = find('.foreman-search-field input[type="text"]', wait: 10)
72
+ search_input.fill_in with: query
73
+ end
74
+
75
+ def launch_task_via_ui(task_name, targets: 'target', params: {})
76
+ visit '/foreman_openbolt/page_launch_task'
77
+ assert_selector '#smart-proxy-input', wait: 15
78
+
79
+ select_first_proxy
80
+ select_hosts_via_search(targets)
81
+
82
+ assert_selector '#task-name-input option', minimum: 2, wait: 15
83
+ select task_name, from: 'task-name-input'
84
+
85
+ params.each do |name, value|
86
+ assert_selector "#param_#{name}", wait: 10
87
+ fill_in "param_#{name}", with: value
88
+ end
89
+
90
+ click_button 'Launch Task'
91
+ assert_selector 'h1', text: 'Task Execution', wait: 15
92
+ end
93
+
94
+ def assert_task_completed
95
+ assert_selector '.pf-v5-c-label', text: /Success|Complete/i, wait: 120
96
+ end
97
+
98
+ def assert_task_failed
99
+ assert_selector '.pf-v5-c-label', text: /Failed|Failure|Error/i, wait: 120
100
+ end
101
+
102
+ def assert_result_contains(text)
103
+ assert_selector '.pf-v5-c-code-block__code', text: text, wait: 15
104
+ end
105
+
106
+ def assert_result_has_content
107
+ assert_no_selector '.pf-v5-c-empty-state', text: 'No result data', wait: 15
108
+ assert_selector '.pf-v5-c-code-block__code', wait: 15
109
+ end
110
+
111
+ def assert_log_contains(text)
112
+ # Click the "Log Output" tab to see the bolt command and log
113
+ find('.pf-v5-c-tabs__link', text: 'Log Output').click
114
+ assert_selector '.pf-v5-c-code-block__code', text: text, wait: 15
115
+ end
116
+
117
+ # OpenBolt options and task parameters both use param_ prefix for field IDs
118
+ def set_openbolt_option(name, value)
119
+ field = find("#param_#{name}", wait: 10)
120
+ if field.tag_name == 'select'
121
+ field.select value
122
+ elsif field['type'] == 'checkbox'
123
+ value ? field.check : field.uncheck
124
+ else
125
+ field.fill_in with: value
126
+ end
127
+ end
128
+
129
+ # --- Settings page helpers ---
130
+
131
+ # Update a Foreman setting through the /settings UI. Each setting row
132
+ # has an inline edit button with id=<setting_name> that reveals an
133
+ # input with id=setting-input-<setting_name> and a submit button with
134
+ # ouiaId=submit-edit-btn (see Foreman's SettingValueCell /
135
+ # SettingValueEdit components).
136
+ def update_foreman_setting(setting_name, new_value, category: 'openbolt')
137
+ visit '/settings'
138
+ find("a[href='##{category}_settings_tab']", wait: 10).click
139
+
140
+ within("##{category}_settings_tab", wait: 10) do
141
+ find("button##{setting_name}", wait: 10).click
142
+ find("#setting-input-#{setting_name}", wait: 10).fill_in with: new_value
143
+ find("[data-ouia-component-id='submit-edit-btn']").click
144
+ end
145
+ end
146
+ end