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
@@ -0,0 +1,351 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_plugin_helper'
4
+
5
+ class TaskControllerTest < ActionController::TestCase
6
+ tests ForemanOpenbolt::TaskController
7
+
8
+ setup do
9
+ @routes = ForemanOpenbolt::Engine.routes
10
+ @proxy = FactoryBot.create(:smart_proxy)
11
+ @session = set_session_user
12
+ WebMock.reset!
13
+ end
14
+
15
+ teardown do
16
+ WebMock.reset!
17
+ end
18
+
19
+ context 'fetch_tasks' do
20
+ test 'returns tasks from proxy as JSON' do
21
+ tasks = { 'mymod::install' => { 'description' => 'Install a package' } }
22
+ stub_request(:get, "#{@proxy.url}/openbolt/tasks")
23
+ .to_return(status: 200, body: tasks.to_json, headers: { 'Content-Type' => 'application/json' })
24
+
25
+ get :fetch_tasks, params: { proxy_id: @proxy.id }, session: @session
26
+ assert_response :success
27
+ assert_equal tasks, JSON.parse(response.body)
28
+ end
29
+
30
+ test 'returns error when proxy_id is missing' do
31
+ get :fetch_tasks, session: @session
32
+ assert_response :bad_request
33
+ body = JSON.parse(response.body)
34
+ assert_match(/Smart Proxy ID is required/, body['error'])
35
+ end
36
+
37
+ test 'returns error when proxy not found' do
38
+ get :fetch_tasks, params: { proxy_id: -1 }, session: @session
39
+ assert_response :not_found
40
+ end
41
+
42
+ test 'returns internal_server_error when proxy is unreachable' do
43
+ stub_request(:get, "#{@proxy.url}/openbolt/tasks").to_timeout
44
+
45
+ get :fetch_tasks, params: { proxy_id: @proxy.id }, session: @session
46
+ assert_response :internal_server_error
47
+ end
48
+ end
49
+
50
+ context 'reload_tasks' do
51
+ test 'returns reloaded tasks from proxy' do
52
+ tasks = { 'new::task' => {} }
53
+ stub_request(:get, "#{@proxy.url}/openbolt/tasks/reload")
54
+ .to_return(status: 200, body: tasks.to_json, headers: { 'Content-Type' => 'application/json' })
55
+
56
+ get :reload_tasks, params: { proxy_id: @proxy.id }, session: @session
57
+ assert_response :success
58
+ assert_equal tasks, JSON.parse(response.body)
59
+ end
60
+ end
61
+
62
+ context 'launch_task' do
63
+ setup do
64
+ @tasks = { 'mymod::install' => { 'description' => 'Install a package' } }
65
+ stub_request(:get, "#{@proxy.url}/openbolt/tasks")
66
+ .to_return(status: 200, body: @tasks.to_json, headers: { 'Content-Type' => 'application/json' })
67
+ stub_request(:post, "#{@proxy.url}/openbolt/launch/task")
68
+ .to_return(status: 200, body: { 'id' => 'launched-job-1' }.to_json,
69
+ headers: { 'Content-Type' => 'application/json' })
70
+ ForemanTasks.stubs(:async_task)
71
+ end
72
+
73
+ test 'launches task and returns job_id' do
74
+ post :launch_task, params: {
75
+ proxy_id: @proxy.id,
76
+ task_name: 'mymod::install',
77
+ targets: 'host1.example.com',
78
+ params: { 'name' => 'nginx' },
79
+ options: { 'transport' => 'ssh' },
80
+ }, session: @session
81
+
82
+ assert_response :success
83
+ body = JSON.parse(response.body)
84
+ assert_equal 'launched-job-1', body['job_id']
85
+ end
86
+
87
+ test 'schedules polling after creating task' do
88
+ ForemanTasks.expects(:async_task).with(
89
+ Actions::ForemanOpenbolt::PollTaskStatus,
90
+ 'launched-job-1',
91
+ @proxy.id
92
+ )
93
+
94
+ post :launch_task, params: {
95
+ proxy_id: @proxy.id,
96
+ task_name: 'mymod::install',
97
+ targets: 'host1.example.com',
98
+ }, session: @session
99
+
100
+ assert_response :success
101
+ end
102
+
103
+ test 'creates a TaskJob record' do
104
+ assert_difference('ForemanOpenbolt::TaskJob.count', 1) do
105
+ post :launch_task, params: {
106
+ proxy_id: @proxy.id,
107
+ task_name: 'mymod::install',
108
+ targets: 'host1.example.com,host2.example.com',
109
+ }, session: @session
110
+ end
111
+
112
+ job = ForemanOpenbolt::TaskJob.last
113
+ assert_equal 'mymod::install', job.task_name
114
+ assert_equal %w[host1.example.com host2.example.com], job.targets
115
+ assert_equal 'pending', job.status
116
+ end
117
+
118
+ test 'returns error when task_name is missing' do
119
+ post :launch_task, params: { proxy_id: @proxy.id, targets: 'host1' }, session: @session
120
+ assert_response :bad_request
121
+ assert_match(/task_name/, JSON.parse(response.body)['error'])
122
+ end
123
+
124
+ test 'returns error when targets is missing' do
125
+ post :launch_task, params: { proxy_id: @proxy.id, task_name: 'test::task' }, session: @session
126
+ assert_response :bad_request
127
+ assert_match(/targets/, JSON.parse(response.body)['error'])
128
+ end
129
+
130
+ test 'returns error when proxy returns error in response' do
131
+ stub_request(:post, "#{@proxy.url}/openbolt/launch/task")
132
+ .to_return(status: 200, body: { 'error' => 'Task not found' }.to_json,
133
+ headers: { 'Content-Type' => 'application/json' })
134
+
135
+ post :launch_task, params: {
136
+ proxy_id: @proxy.id,
137
+ task_name: 'missing::task',
138
+ targets: 'host1',
139
+ }, session: @session
140
+ assert_response :bad_request
141
+ assert_match(/Task execution failed/, JSON.parse(response.body)['error'])
142
+ end
143
+
144
+ test 'returns error when proxy returns no job ID' do
145
+ stub_request(:post, "#{@proxy.url}/openbolt/launch/task")
146
+ .to_return(status: 200, body: { 'status' => 'ok' }.to_json,
147
+ headers: { 'Content-Type' => 'application/json' })
148
+
149
+ post :launch_task, params: {
150
+ proxy_id: @proxy.id,
151
+ task_name: 'test::task',
152
+ targets: 'host1',
153
+ }, session: @session
154
+ assert_response :bad_request
155
+ assert_match(/No job ID returned/, JSON.parse(response.body)['error'])
156
+ end
157
+ end
158
+
159
+ context 'job_status' do
160
+ test 'returns serialized task job' do
161
+ job = FactoryBot.create(:task_job, :running, smart_proxy: @proxy)
162
+
163
+ get :job_status, params: { job_id: job.job_id }, session: @session
164
+ assert_response :success
165
+
166
+ body = JSON.parse(response.body)
167
+ assert_equal 'running', body['status']
168
+ assert_equal job.task_name, body['task_name']
169
+ assert_equal job.targets, body['targets']
170
+ assert_equal @proxy.id, body['smart_proxy']['id']
171
+ assert_equal @proxy.name, body['smart_proxy']['name']
172
+ end
173
+
174
+ test 'returns error when job_id is missing' do
175
+ get :job_status, session: @session
176
+ assert_response :bad_request
177
+ end
178
+
179
+ test 'returns not_found when job does not exist' do
180
+ get :job_status, params: { job_id: 'nonexistent' }, session: @session
181
+ assert_response :not_found
182
+ end
183
+ end
184
+
185
+ context 'job_result' do
186
+ test 'returns result and log for completed job' do
187
+ ForemanTasks.stubs(:async_task)
188
+ job = FactoryBot.create(:task_job, :success, smart_proxy: @proxy)
189
+
190
+ get :job_result, params: { job_id: job.job_id }, session: @session
191
+ assert_response :success
192
+
193
+ body = JSON.parse(response.body)
194
+ assert_equal 'success', body['status']
195
+ assert_equal job.result, body['value']
196
+ assert_equal job.log, body['log']
197
+ assert_equal job.command, body['command']
198
+ end
199
+
200
+ test 'returns not_found when job does not exist' do
201
+ get :job_result, params: { job_id: 'nonexistent' }, session: @session
202
+ assert_response :not_found
203
+ end
204
+ end
205
+
206
+ context 'launch_task with encrypted options' do
207
+ setup do
208
+ @tasks = { 'mymod::install' => { 'description' => 'Install a package' } }
209
+ stub_request(:get, "#{@proxy.url}/openbolt/tasks")
210
+ .to_return(status: 200, body: @tasks.to_json, headers: { 'Content-Type' => 'application/json' })
211
+ stub_request(:post, "#{@proxy.url}/openbolt/launch/task")
212
+ .to_return(status: 200, body: { 'id' => 'encrypted-job-1' }.to_json,
213
+ headers: { 'Content-Type' => 'application/json' })
214
+ ForemanTasks.stubs(:async_task)
215
+ end
216
+
217
+ test 'replaces encrypted placeholder with real setting value before sending to proxy' do
218
+ Setting['openbolt_password'] = 'real-secret-password'
219
+
220
+ post :launch_task, params: {
221
+ proxy_id: @proxy.id,
222
+ task_name: 'mymod::install',
223
+ targets: 'host1.example.com',
224
+ options: { 'password' => '[Use saved encrypted default]', 'transport' => 'ssh' },
225
+ }, session: @session
226
+
227
+ assert_response :success
228
+
229
+ # Verify the real value was sent to the proxy (not the placeholder)
230
+ proxy_request = WebMock::RequestRegistry.instance
231
+ .requested_signatures
232
+ .hash
233
+ .keys
234
+ .find { |sig| sig.uri.path == '/openbolt/launch/task' }
235
+ sent_body = JSON.parse(proxy_request.body)
236
+ assert_equal 'real-secret-password', sent_body['options']['password']
237
+ end
238
+
239
+ test 'scrubs encrypted options before storing in database' do
240
+ Setting['openbolt_password'] = 'real-secret-password'
241
+
242
+ post :launch_task, params: {
243
+ proxy_id: @proxy.id,
244
+ task_name: 'mymod::install',
245
+ targets: 'host1.example.com',
246
+ options: { 'password' => '[Use saved encrypted default]', 'transport' => 'ssh' },
247
+ }, session: @session
248
+
249
+ assert_response :success
250
+ job = ForemanOpenbolt::TaskJob.find_by(job_id: 'encrypted-job-1')
251
+ assert_equal '*****', job.openbolt_options['password']
252
+ assert_equal 'ssh', job.openbolt_options['transport']
253
+ end
254
+
255
+ test 'returns error when encrypted placeholder used for nonexistent setting' do
256
+ post :launch_task, params: {
257
+ proxy_id: @proxy.id,
258
+ task_name: 'mymod::install',
259
+ targets: 'host1.example.com',
260
+ options: { 'nonexistent-option' => '[Use saved encrypted default]' },
261
+ }, session: @session
262
+
263
+ assert_response :bad_request
264
+ assert_match(/No saved value for encrypted option/, JSON.parse(response.body)['error'])
265
+ end
266
+
267
+ test 'passes non-encrypted options through unchanged' do
268
+ post :launch_task, params: {
269
+ proxy_id: @proxy.id,
270
+ task_name: 'mymod::install',
271
+ targets: 'host1.example.com',
272
+ options: { 'transport' => 'ssh', 'user' => 'admin' },
273
+ }, session: @session
274
+
275
+ assert_response :success
276
+ job = ForemanOpenbolt::TaskJob.find_by(job_id: 'encrypted-job-1')
277
+ assert_equal 'ssh', job.openbolt_options['transport']
278
+ assert_equal 'admin', job.openbolt_options['user']
279
+ end
280
+ end
281
+
282
+ context 'fetch_openbolt_options with settings defaults' do
283
+ test 'merges Foreman setting defaults into proxy options' do
284
+ Setting['openbolt_transport'] = 'winrm'
285
+ Setting['openbolt_user'] = 'admin'
286
+
287
+ proxy_options = {
288
+ 'transport' => { 'type' => %w[ssh winrm] },
289
+ 'user' => { 'type' => 'string' },
290
+ 'verbose' => { 'type' => 'boolean' },
291
+ }
292
+ stub_request(:get, "#{@proxy.url}/openbolt/tasks/options")
293
+ .to_return(status: 200, body: proxy_options.to_json, headers: { 'Content-Type' => 'application/json' })
294
+
295
+ get :fetch_openbolt_options, params: { proxy_id: @proxy.id }, session: @session
296
+ assert_response :success
297
+
298
+ body = JSON.parse(response.body)
299
+ assert_equal 'winrm', body['transport']['default']
300
+ assert_equal 'admin', body['user']['default']
301
+ assert_equal false, body['verbose']['default']
302
+ end
303
+
304
+ test 'shows encrypted placeholder instead of real value for encrypted settings' do
305
+ Setting['openbolt_password'] = 'secret-value'
306
+
307
+ proxy_options = {
308
+ 'password' => { 'type' => 'string', 'sensitive' => true },
309
+ }
310
+ stub_request(:get, "#{@proxy.url}/openbolt/tasks/options")
311
+ .to_return(status: 200, body: proxy_options.to_json, headers: { 'Content-Type' => 'application/json' })
312
+
313
+ get :fetch_openbolt_options, params: { proxy_id: @proxy.id }, session: @session
314
+ assert_response :success
315
+
316
+ body = JSON.parse(response.body)
317
+ assert_equal '[Use saved encrypted default]', body['password']['default']
318
+ end
319
+ end
320
+
321
+ context 'fetch_task_history' do
322
+ test 'returns paginated task history' do
323
+ 3.times { FactoryBot.create(:task_job, smart_proxy: @proxy) }
324
+
325
+ get :fetch_task_history, params: { page: 1, per_page: 2 }, session: @session
326
+ assert_response :success
327
+
328
+ body = JSON.parse(response.body)
329
+ assert_equal 2, body['results'].length
330
+ assert_equal 3, body['total']
331
+ assert_equal 1, body['page']
332
+ assert_equal 2, body['per_page']
333
+ end
334
+
335
+ test 'caps per_page at 100' do
336
+ get :fetch_task_history, params: { per_page: 200 }, session: @session
337
+ assert_response :success
338
+
339
+ body = JSON.parse(response.body)
340
+ assert_equal 100, body['per_page']
341
+ end
342
+
343
+ test 'defaults per_page to 20' do
344
+ get :fetch_task_history, session: @session
345
+ assert_response :success
346
+
347
+ body = JSON.parse(response.body)
348
+ assert_equal 20, body['per_page']
349
+ end
350
+ end
351
+ end
@@ -0,0 +1,47 @@
1
+ # Base test image: Foreman + system deps on Rocky Linux 9.
2
+ # This image is stable for a given Foreman version and does not
3
+ # depend on any foreman_openbolt code. All plugin wiring happens
4
+ # at runtime via the rake test:up task.
5
+ #
6
+ # Build:
7
+ # FOREMAN_BRANCH=3.18-stable docker compose -f test/unit/docker/docker-compose.yml build test
8
+
9
+ FROM rockylinux:9
10
+
11
+ # System packages -- cached across all Foreman versions.
12
+ RUN dnf module enable -y nodejs:22 postgresql:16 && \
13
+ dnf install -y \
14
+ ruby ruby-devel rubygem-bundler \
15
+ gcc gcc-c++ make \
16
+ postgresql-devel postgresql \
17
+ libffi-devel \
18
+ nodejs npm \
19
+ git-core \
20
+ redhat-rpm-config && \
21
+ dnf clean all
22
+
23
+ ARG FOREMAN_BRANCH=3.18-stable
24
+
25
+ # Clone Foreman at the specified branch.
26
+ RUN git init /opt/foreman
27
+
28
+ WORKDIR /opt/foreman
29
+
30
+ RUN git remote add origin https://github.com/theforeman/foreman.git && \
31
+ git fetch --depth=1 origin ${FOREMAN_BRANCH} && \
32
+ git checkout FETCH_HEAD
33
+
34
+ # Configure database for the test container.
35
+ RUN printf "test:\n adapter: postgresql\n host: db\n port: 5432\n username: foreman\n password: foreman\n database: foreman_test\n encoding: utf8\n pool: 5\n" \
36
+ > config/database.yml
37
+
38
+ # Install Foreman's own gems (without the plugin). This is the
39
+ # expensive layer but only changes when the Foreman version changes.
40
+ ENV BUNDLE_WITHOUT="release console journald libvirt ec2 openstack vmware telemetry"
41
+ ENV BUNDLE_PATH="/opt/bundle"
42
+ RUN bundle install --jobs=4
43
+
44
+ COPY test/unit/docker/entrypoint.sh /entrypoint.sh
45
+ RUN chmod +x /entrypoint.sh
46
+
47
+ ENTRYPOINT ["/entrypoint.sh"]
@@ -0,0 +1,33 @@
1
+ name: foreman-openbolt-unit
2
+
3
+ services:
4
+ db:
5
+ image: postgres:16-alpine
6
+ environment:
7
+ POSTGRES_USER: foreman
8
+ POSTGRES_PASSWORD: foreman
9
+ POSTGRES_DB: foreman_test
10
+ tmpfs: /var/lib/postgresql/data
11
+ healthcheck:
12
+ test: ["CMD-SHELL", "pg_isready -U foreman"]
13
+ interval: 2s
14
+ timeout: 5s
15
+ retries: 10
16
+
17
+ test:
18
+ image: foreman-openbolt-test:${FOREMAN_BRANCH:-3.18-stable}
19
+ build:
20
+ context: ../../..
21
+ dockerfile: test/unit/docker/Dockerfile
22
+ args:
23
+ FOREMAN_BRANCH: ${FOREMAN_BRANCH:-3.18-stable}
24
+ depends_on:
25
+ db:
26
+ condition: service_healthy
27
+ environment:
28
+ RAILS_ENV: test
29
+ DATABASE_URL: postgresql://foreman:foreman@db:5432/foreman_test
30
+ volumes:
31
+ - ../../..:/opt/foreman_openbolt
32
+ working_dir: /opt/foreman
33
+ command: ["tail", "-f", "/dev/null"]
@@ -0,0 +1,4 @@
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ exec "$@"
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ FactoryBot.define do
4
+ factory :task_job, class: 'ForemanOpenbolt::TaskJob' do
5
+ sequence(:job_id) { |n| "test-job-#{n}" }
6
+ association :smart_proxy
7
+ task_name { 'mymod::install' }
8
+ task_description { 'Install a package on the target host' }
9
+ status { 'pending' }
10
+ targets { ['host1.example.com', 'host2.example.com'] }
11
+ submitted_at { Time.current }
12
+ task_parameters { { 'name' => 'nginx' } }
13
+ openbolt_options { { 'transport' => 'ssh' } }
14
+
15
+ trait :running do
16
+ status { 'running' }
17
+ end
18
+
19
+ trait :success do
20
+ status { 'success' }
21
+ completed_at { Time.current }
22
+ result { { 'items' => [{ 'target' => 'host1.example.com', 'status' => 'success' }] } }
23
+ log { 'Started: task mymod::install\nFinished: success' }
24
+ command { 'bolt task run mymod::install --targets host1.example.com' }
25
+ end
26
+
27
+ trait :failure do
28
+ status { 'failure' }
29
+ completed_at { Time.current }
30
+ result { { 'items' => [{ 'target' => 'host1.example.com', 'status' => 'failure' }] } }
31
+ log { 'Started: task mymod::install\nFinished: failure' }
32
+ end
33
+
34
+ trait :exception do
35
+ status { 'exception' }
36
+ completed_at { Time.current }
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_plugin_helper'
4
+
5
+ class CleanupProxyArtifactsTest < ForemanOpenbolt::PluginTestCase
6
+ include Dynflow::Testing
7
+
8
+ context 'plan' do
9
+ test 'stores proxy_id and job_id in input' do
10
+ action = create_and_plan_action(Actions::ForemanOpenbolt::CleanupProxyArtifacts, 42, 'job-123')
11
+ assert_equal 42, action.input[:proxy_id]
12
+ assert_equal 'job-123', action.input[:job_id]
13
+ end
14
+ end
15
+
16
+ context 'rescue_strategy' do
17
+ test 'uses Skip rescue strategy' do
18
+ action = create_action(Actions::ForemanOpenbolt::CleanupProxyArtifacts)
19
+ assert_equal Dynflow::Action::Rescue::Skip, action.rescue_strategy
20
+ end
21
+ end
22
+
23
+ context 'run' do
24
+ test 'calls delete_job_artifacts when proxy exists' do
25
+ proxy = FactoryBot.create(:smart_proxy)
26
+ stub = stub_request(:delete, "#{proxy.url}/openbolt/job/job-456/artifacts")
27
+ .to_return(status: 200, body: '{"deleted": true}', headers: { 'Content-Type' => 'application/json' })
28
+
29
+ action = create_and_plan_action(Actions::ForemanOpenbolt::CleanupProxyArtifacts, proxy.id, 'job-456')
30
+ run_action(action)
31
+
32
+ assert_requested(stub)
33
+ end
34
+
35
+ test 'does not call API when proxy not found' do
36
+ stub = stub_request(:delete, %r{openbolt/job})
37
+ action = create_and_plan_action(Actions::ForemanOpenbolt::CleanupProxyArtifacts, -1, 'job-456')
38
+ run_action(action)
39
+ assert_not_requested(stub)
40
+ end
41
+
42
+ test 'does not raise when API call fails' do
43
+ proxy = FactoryBot.create(:smart_proxy)
44
+ stub_request(:delete, "#{proxy.url}/openbolt/job/job-456/artifacts")
45
+ .to_return(status: 500, body: 'Internal Server Error')
46
+
47
+ action = create_and_plan_action(Actions::ForemanOpenbolt::CleanupProxyArtifacts, proxy.id, 'job-456')
48
+ assert_nothing_raised { run_action(action) }
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_plugin_helper'
4
+
5
+ class PollTaskStatusTest < ForemanOpenbolt::PluginTestCase
6
+ include Dynflow::Testing
7
+
8
+ context 'extract_proxy_error' do
9
+ setup do
10
+ @action = create_action(Actions::ForemanOpenbolt::PollTaskStatus)
11
+ end
12
+
13
+ test 'returns nil for nil response' do
14
+ assert_nil @action.extract_proxy_error(nil)
15
+ end
16
+
17
+ test 'returns nil when no error key' do
18
+ assert_nil @action.extract_proxy_error({ 'status' => 'running' })
19
+ end
20
+
21
+ test 'returns nil for empty error string' do
22
+ assert_nil @action.extract_proxy_error({ 'error' => '' })
23
+ end
24
+
25
+ test 'returns string error directly' do
26
+ assert_equal 'something broke', @action.extract_proxy_error({ 'error' => 'something broke' })
27
+ end
28
+
29
+ test 'returns message from hash error' do
30
+ assert_equal 'detailed error', @action.extract_proxy_error({ 'error' => { 'message' => 'detailed error' } })
31
+ end
32
+
33
+ test 'returns stringified hash when no message key' do
34
+ error_hash = { 'code' => 500 }
35
+ result = @action.extract_proxy_error({ 'error' => error_hash })
36
+ assert_equal error_hash.to_s, result
37
+ end
38
+ end
39
+
40
+ context 'plan' do
41
+ test 'stores job_id and proxy_id in input' do
42
+ action = create_and_plan_action(Actions::ForemanOpenbolt::PollTaskStatus, 'job-123', 42)
43
+ assert_equal 'job-123', action.input[:job_id]
44
+ assert_equal 42, action.input[:proxy_id]
45
+ end
46
+ end
47
+
48
+ context 'rescue_strategy' do
49
+ test 'uses Skip rescue strategy' do
50
+ action = create_action(Actions::ForemanOpenbolt::PollTaskStatus)
51
+ assert_equal Dynflow::Action::Rescue::Skip, action.rescue_strategy
52
+ end
53
+ end
54
+
55
+ context 'poll_and_reschedule' do
56
+ setup do
57
+ @proxy = FactoryBot.create(:smart_proxy)
58
+ @job = FactoryBot.create(:task_job, :running, smart_proxy: @proxy)
59
+ end
60
+
61
+ test 'polls proxy and keeps status when unchanged' do
62
+ status_stub = stub_request(:get, "#{@proxy.url}/openbolt/job/#{@job.job_id}/status")
63
+ .to_return(status: 200, body: { 'status' => 'running' }.to_json,
64
+ headers: { 'Content-Type' => 'application/json' })
65
+
66
+ action = create_and_plan_action(Actions::ForemanOpenbolt::PollTaskStatus, @job.job_id, @proxy.id)
67
+ run_action(action)
68
+
69
+ assert_requested(status_stub)
70
+ assert_equal 'running', @job.reload.status
71
+ end
72
+
73
+ test 'fetches result when job completes' do
74
+ stub_request(:get, "#{@proxy.url}/openbolt/job/#{@job.job_id}/status")
75
+ .to_return(status: 200, body: { 'status' => 'success' }.to_json,
76
+ headers: { 'Content-Type' => 'application/json' })
77
+
78
+ result_body = { 'status' => 'success', 'value' => { 'items' => [] }, 'log' => 'done',
79
+ 'command' => 'bolt task run test' }
80
+ stub_request(:get, "#{@proxy.url}/openbolt/job/#{@job.job_id}/result")
81
+ .to_return(status: 200, body: result_body.to_json,
82
+ headers: { 'Content-Type' => 'application/json' })
83
+
84
+ ForemanTasks.stubs(:async_task)
85
+ action = create_and_plan_action(Actions::ForemanOpenbolt::PollTaskStatus, @job.job_id, @proxy.id)
86
+ run_action(action)
87
+
88
+ @job.reload
89
+ assert_equal 'success', @job.status
90
+ assert_equal({ 'items' => [] }, @job.result)
91
+ assert_equal 'done', @job.log
92
+ end
93
+
94
+ test 'finishes cleanly when task job not found' do
95
+ action = create_and_plan_action(Actions::ForemanOpenbolt::PollTaskStatus, 'nonexistent-id', @proxy.id)
96
+ assert_nothing_raised { run_action(action) }
97
+ end
98
+
99
+ test 'marks exception when proxy not found' do
100
+ action = create_and_plan_action(Actions::ForemanOpenbolt::PollTaskStatus, @job.job_id, -1)
101
+ run_action(action)
102
+
103
+ assert_equal 'exception', @job.reload.status
104
+ end
105
+
106
+ test 'marks exception immediately on proxy application error' do
107
+ status_stub = stub_request(:get, "#{@proxy.url}/openbolt/job/#{@job.job_id}/status")
108
+ .to_return(status: 200, body: { 'error' => { 'message' => 'Job not found: test-job' } }.to_json,
109
+ headers: { 'Content-Type' => 'application/json' })
110
+
111
+ action = create_and_plan_action(Actions::ForemanOpenbolt::PollTaskStatus, @job.job_id, @proxy.id)
112
+ run_action(action)
113
+
114
+ assert_requested(status_stub)
115
+ assert_equal 'exception', @job.reload.status
116
+ end
117
+
118
+ test 'marks exception immediately when proxy response has no status' do
119
+ stub_request(:get, "#{@proxy.url}/openbolt/job/#{@job.job_id}/status")
120
+ .to_return(status: 200, body: { 'unexpected' => 'data' }.to_json,
121
+ headers: { 'Content-Type' => 'application/json' })
122
+
123
+ action = create_and_plan_action(Actions::ForemanOpenbolt::PollTaskStatus, @job.job_id, @proxy.id)
124
+ run_action(action)
125
+
126
+ assert_equal 'exception', @job.reload.status
127
+ end
128
+
129
+ test 'marks job as exception after exhausting retry limit' do
130
+ stub_request(:get, "#{@proxy.url}/openbolt/job/#{@job.job_id}/status")
131
+ .to_return(status: 500, body: 'Internal Server Error')
132
+
133
+ action = create_and_plan_action(Actions::ForemanOpenbolt::PollTaskStatus, @job.job_id, @proxy.id)
134
+ # Set retry count to just above the limit so the next error triggers exhaustion
135
+ action.input[:retry_count] = Actions::ForemanOpenbolt::PollTaskStatus::RETRY_LIMIT
136
+ run_action(action)
137
+
138
+ assert_equal 'exception', @job.reload.status
139
+ end
140
+ end
141
+ end