foreman_openbolt 1.2.0 → 1.3.0
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.
- checksums.yaml +4 -4
- data/README.md +1 -0
- data/Rakefile +32 -0
- data/app/controllers/api/v2/openbolt_jobs_controller.rb +77 -0
- data/app/controllers/api/v2/openbolt_tasks_controller.rb +83 -0
- data/app/controllers/concerns/foreman_openbolt/common.rb +121 -0
- data/app/controllers/concerns/foreman_openbolt/jobs.rb +84 -0
- data/app/controllers/concerns/foreman_openbolt/tasks.rb +140 -0
- data/app/controllers/foreman_openbolt/task_controller.rb +12 -291
- data/app/lib/actions/foreman_openbolt/cleanup_proxy_artifacts.rb +4 -2
- data/app/lib/actions/foreman_openbolt/poll_task_status.rb +48 -29
- data/app/models/foreman_openbolt/task_job.rb +9 -2
- data/config/routes.rb +39 -4
- data/db/migrate/20260824000000_remove_view_smart_proxies_openbolt_permission.rb +18 -0
- data/lib/foreman_openbolt/engine.rb +15 -7
- data/lib/foreman_openbolt/version.rb +1 -1
- data/lib/proxy_api/openbolt.rb +64 -13
- data/package.json +1 -1
- data/test/acceptance/acceptance_helper.rb +13 -0
- data/test/acceptance/api_acceptance_helper.rb +109 -0
- data/test/acceptance/tests/api/authn_authz_test.rb +82 -0
- data/test/acceptance/tests/api/jobs_test.rb +77 -0
- data/test/acceptance/tests/api/launch_test.rb +79 -0
- data/test/acceptance/tests/api/proxy_tasks_test.rb +51 -0
- data/test/unit/controllers/api/v2/openbolt_jobs_controller_test.rb +251 -0
- data/test/unit/controllers/api/v2/openbolt_tasks_controller_test.rb +438 -0
- data/test/unit/controllers/task_controller_test.rb +197 -39
- data/test/unit/docker/Dockerfile +1 -0
- data/test/unit/lib/actions/poll_task_status_test.rb +112 -34
- data/test/unit/lib/proxy_api/openbolt_test.rb +121 -6
- data/test/unit/models/task_job_test.rb +11 -0
- data/webpack/src/Components/LaunchTask/__tests__/LaunchTask.test.js +44 -1
- data/webpack/src/Components/LaunchTask/hooks/__tests__/useOpenBoltOptions.test.js +3 -0
- data/webpack/src/Components/LaunchTask/hooks/__tests__/useTasksData.test.js +5 -4
- data/webpack/src/Components/LaunchTask/hooks/useOpenBoltOptions.js +1 -1
- data/webpack/src/Components/LaunchTask/hooks/useTasksData.js +4 -1
- data/webpack/src/Components/LaunchTask/index.js +21 -6
- data/webpack/src/Components/TaskExecution/__tests__/TaskExecution.test.js +1 -1
- data/webpack/src/Components/TaskExecution/hooks/__tests__/useJobPolling.test.js +8 -8
- data/webpack/src/Components/TaskExecution/hooks/useJobPolling.js +3 -3
- data/webpack/src/Components/TaskHistory/__tests__/TaskHistory.test.js +6 -6
- data/webpack/src/Components/TaskHistory/index.js +3 -3
- metadata +17 -4
data/lib/proxy_api/openbolt.rb
CHANGED
|
@@ -4,13 +4,19 @@ require 'json'
|
|
|
4
4
|
|
|
5
5
|
module ProxyAPI
|
|
6
6
|
class Openbolt < Resource
|
|
7
|
+
# A 200 response carrying the proxy's {"error": {...}} result. This is a final,
|
|
8
|
+
# permanent error, unlike a transport-level ProxyException, so callers should not retry.
|
|
9
|
+
class ProxyReportedError < ProxyException; end
|
|
10
|
+
|
|
7
11
|
def initialize(args)
|
|
8
12
|
@url = args[:url]
|
|
9
13
|
super
|
|
10
14
|
end
|
|
11
15
|
|
|
12
16
|
def fetch_tasks
|
|
13
|
-
@tasks =
|
|
17
|
+
@tasks = with_proxy_error_handling('fetch_tasks') do
|
|
18
|
+
parse_response(get('/openbolt/tasks'), 'fetch_tasks')
|
|
19
|
+
end
|
|
14
20
|
end
|
|
15
21
|
|
|
16
22
|
def tasks
|
|
@@ -18,7 +24,9 @@ module ProxyAPI
|
|
|
18
24
|
end
|
|
19
25
|
|
|
20
26
|
def reload_tasks
|
|
21
|
-
@tasks =
|
|
27
|
+
@tasks = with_proxy_error_handling('reload_tasks') do
|
|
28
|
+
parse_response(get('/openbolt/tasks/reload'), 'reload_tasks')
|
|
29
|
+
end
|
|
22
30
|
end
|
|
23
31
|
|
|
24
32
|
def task_names
|
|
@@ -26,29 +34,41 @@ module ProxyAPI
|
|
|
26
34
|
end
|
|
27
35
|
|
|
28
36
|
def openbolt_options
|
|
29
|
-
@openbolt_options ||=
|
|
37
|
+
@openbolt_options ||= with_proxy_error_handling('openbolt_options') do
|
|
38
|
+
parse_response(get('/openbolt/tasks/options'), 'openbolt_options')
|
|
39
|
+
end
|
|
30
40
|
end
|
|
31
41
|
|
|
42
|
+
# Passes the proxy's error result through so a rejected launch renders
|
|
43
|
+
# as a 400 at the caller, not a 502.
|
|
32
44
|
def launch_task(name:, targets:, parameters: {}, options: {})
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
with_transport_errors_wrapped('launch_task') do
|
|
46
|
+
response = post({
|
|
47
|
+
name: name,
|
|
48
|
+
targets: targets,
|
|
49
|
+
parameters: parameters,
|
|
50
|
+
options: options,
|
|
51
|
+
}.to_json, '/openbolt/launch/task')
|
|
52
|
+
parse_response(response, 'launch_task')
|
|
53
|
+
end
|
|
40
54
|
end
|
|
41
55
|
|
|
42
56
|
def job_status(job_id:)
|
|
43
|
-
|
|
57
|
+
with_proxy_error_handling('job_status') do
|
|
58
|
+
parse_response(get("/openbolt/job/#{job_id}/status"), 'job_status')
|
|
59
|
+
end
|
|
44
60
|
end
|
|
45
61
|
|
|
46
62
|
def job_result(job_id:)
|
|
47
|
-
|
|
63
|
+
with_proxy_error_handling('job_result') do
|
|
64
|
+
parse_response(get("/openbolt/job/#{job_id}/result"), 'job_result')
|
|
65
|
+
end
|
|
48
66
|
end
|
|
49
67
|
|
|
50
68
|
def delete_job_artifacts(job_id:)
|
|
51
|
-
|
|
69
|
+
with_proxy_error_handling('delete_job_artifacts') do
|
|
70
|
+
parse_response(delete("/openbolt/job/#{job_id}/artifacts"), 'delete_job_artifacts')
|
|
71
|
+
end
|
|
52
72
|
end
|
|
53
73
|
|
|
54
74
|
def parse_response(response, operation)
|
|
@@ -75,5 +95,36 @@ module ProxyAPI
|
|
|
75
95
|
"Response body (first 500 chars): #{body.to_s[0..500]}"
|
|
76
96
|
)
|
|
77
97
|
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# Rewraps transport-layer failures as ProxyException so callers never
|
|
102
|
+
# see raw RestClient::Exception / Errno::* and there's only one type
|
|
103
|
+
# of exception to handle for this kind of error.
|
|
104
|
+
def with_transport_errors_wrapped(operation)
|
|
105
|
+
yield
|
|
106
|
+
rescue RestClient::Exception, SystemCallError, SocketError, OpenSSL::SSL::SSLError => e
|
|
107
|
+
raise ProxyException.new(
|
|
108
|
+
@url, e,
|
|
109
|
+
"Transport error during #{operation}: #{e.message}"
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Raises ProxyReportedError when a 200 body carries the proxy's error
|
|
114
|
+
# result, which is always {"error": {"message": ...}}. Requiring that
|
|
115
|
+
# exact shape avoids false positives on real data, like a task named
|
|
116
|
+
# 'error' in the fetch_tasks response.
|
|
117
|
+
def with_proxy_error_handling(operation, &block)
|
|
118
|
+
result = with_transport_errors_wrapped(operation, &block)
|
|
119
|
+
error = result.is_a?(Hash) ? result['error'] : nil
|
|
120
|
+
if error.is_a?(Hash) && error.key?('message')
|
|
121
|
+
detail = error['message']
|
|
122
|
+
raise ProxyReportedError.new(
|
|
123
|
+
@url, RuntimeError.new(detail.to_s),
|
|
124
|
+
"Smart Proxy reported error during #{operation}: #{detail}"
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
result
|
|
128
|
+
end
|
|
78
129
|
end
|
|
79
130
|
end
|
data/package.json
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# Suppress a noisy deprecation warning emitted by Capybara 3.40.0 on Ruby 3.4+:
|
|
4
|
+
# URI::RFC3986_PARSER.make_regexp is obsolete. Use URI::RFC2396_PARSER.make_regexp explicitly.
|
|
5
|
+
# Fixed upstream in teamcapybara/capybara#2781 but not yet released. Remove this
|
|
6
|
+
# block once Capybara ships a release with that PR.
|
|
7
|
+
module SuppressCapybaraURIDeprecation
|
|
8
|
+
TARGET = 'URI::RFC3986_PARSER.make_regexp is obsolete'
|
|
9
|
+
def warn(message, **)
|
|
10
|
+
return if message.to_s.include?(TARGET)
|
|
11
|
+
super
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
Warning.singleton_class.prepend(SuppressCapybaraURIDeprecation)
|
|
15
|
+
|
|
3
16
|
require 'capybara'
|
|
4
17
|
require 'capybara/dsl'
|
|
5
18
|
require 'selenium-webdriver'
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'test/unit'
|
|
6
|
+
|
|
7
|
+
# Base class for API-driven acceptance tests. Unlike AcceptanceTestCase's
|
|
8
|
+
# Capybara suite, these hit the Foreman API from the host via Faraday.
|
|
9
|
+
# Defaults to https://localhost with the Host header set to the Foreman FQDN.
|
|
10
|
+
class ApiAcceptanceTestCase < Test::Unit::TestCase
|
|
11
|
+
FOREMAN_API_URL = ENV.fetch('FOREMAN_API_URL', 'https://localhost')
|
|
12
|
+
FOREMAN_FQDN = 'foreman.example.com'
|
|
13
|
+
ADMIN_USER = ENV.fetch('FOREMAN_USER', 'admin')
|
|
14
|
+
ADMIN_PASS = ENV.fetch('FOREMAN_PASS', 'changeme')
|
|
15
|
+
|
|
16
|
+
TERMINAL_STATUSES = %w[success failure exception invalid].freeze
|
|
17
|
+
|
|
18
|
+
def setup
|
|
19
|
+
@api = build_client(ADMIN_USER, ADMIN_PASS)
|
|
20
|
+
@created_user_ids = []
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def teardown
|
|
24
|
+
@created_user_ids.each do |user_id|
|
|
25
|
+
@api.delete("/api/v2/users/#{user_id}")
|
|
26
|
+
rescue StandardError
|
|
27
|
+
# Best effort, not a big deal if something fails
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def build_client(user, pass)
|
|
32
|
+
Faraday.new(url: FOREMAN_API_URL,
|
|
33
|
+
ssl: { verify: false },
|
|
34
|
+
request: { timeout: 30 },
|
|
35
|
+
headers: {
|
|
36
|
+
'Host' => FOREMAN_FQDN,
|
|
37
|
+
'Content-Type' => 'application/json',
|
|
38
|
+
}) do |conn|
|
|
39
|
+
conn.request :authorization, :basic, user, pass
|
|
40
|
+
conn.request :json
|
|
41
|
+
conn.response :json, content_type: /\bjson$/
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Foreman 404s are either a nested {error: {message: ...}} body or a
|
|
46
|
+
# flat {message: ...} body.
|
|
47
|
+
def error_message(body)
|
|
48
|
+
return nil unless body.is_a?(Hash)
|
|
49
|
+
body.dig('error', 'message') || body['message']
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The proxy id is not stable across rebuilds, so resolve by name.
|
|
53
|
+
def smart_proxy_id
|
|
54
|
+
@smart_proxy_id ||= begin
|
|
55
|
+
resp = @api.get('/api/v2/smart_proxies', search: "name=#{FOREMAN_FQDN}")
|
|
56
|
+
id = resp.body.dig('results', 0, 'id')
|
|
57
|
+
flunk "Could not find smart proxy '#{FOREMAN_FQDN}' (status #{resp.status}): #{resp.body.inspect}" unless id
|
|
58
|
+
id
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Launches the task and polls until the status is terminal. Returns the job_id.
|
|
63
|
+
def launch_and_wait_for(task:, params: {}, targets: 'target1.example.com', timeout: 120)
|
|
64
|
+
launch_resp = @api.post('/api/v2/openbolt/launch/task',
|
|
65
|
+
smart_proxy_id: smart_proxy_id,
|
|
66
|
+
task_name: task,
|
|
67
|
+
targets: targets,
|
|
68
|
+
parameters: params,
|
|
69
|
+
options: { 'host-key-check' => false, 'user' => 'openbolt', 'private-key' => '/opt/foreman-proxy/.ssh/id_rsa' })
|
|
70
|
+
assert_equal 201, launch_resp.status,
|
|
71
|
+
"launch_task failed (#{launch_resp.status}): #{launch_resp.body.inspect}"
|
|
72
|
+
job_id = launch_resp.body['job_id']
|
|
73
|
+
flunk "launch_task returned no job_id: #{launch_resp.body.inspect}" unless job_id
|
|
74
|
+
|
|
75
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
76
|
+
loop do
|
|
77
|
+
status_resp = @api.get("/api/v2/openbolt/jobs/#{job_id}/status")
|
|
78
|
+
assert_equal 200, status_resp.status,
|
|
79
|
+
"status poll failed (#{status_resp.status}): #{status_resp.body.inspect}"
|
|
80
|
+
status = status_resp.body['status']
|
|
81
|
+
return job_id if TERMINAL_STATUSES.include?(status)
|
|
82
|
+
flunk "timed out after #{timeout}s waiting for job #{job_id} (last status: #{status})" if
|
|
83
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
84
|
+
sleep 2
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Yields a client for a fresh role-less user, which is deleted in teardown.
|
|
89
|
+
def with_unprivileged_user
|
|
90
|
+
login = "acc_unpriv_#{Process.pid}_#{rand(1_000_000)}"
|
|
91
|
+
password = 'TempPass1!'
|
|
92
|
+
resp = @api.post('/api/v2/users',
|
|
93
|
+
user: {
|
|
94
|
+
login: login,
|
|
95
|
+
password: password,
|
|
96
|
+
firstname: 'Acc',
|
|
97
|
+
lastname: 'Unpriv',
|
|
98
|
+
mail: "#{login}@example.com",
|
|
99
|
+
auth_source_id: 1,
|
|
100
|
+
admin: false,
|
|
101
|
+
roles: [],
|
|
102
|
+
})
|
|
103
|
+
assert_equal 201, resp.status,
|
|
104
|
+
"user creation failed (#{resp.status}): #{resp.body.inspect}"
|
|
105
|
+
user_id = resp.body['id']
|
|
106
|
+
@created_user_ids << user_id
|
|
107
|
+
yield build_client(login, password)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../api_acceptance_helper'
|
|
4
|
+
|
|
5
|
+
class ApiAuthnAuthzTest < ApiAcceptanceTestCase
|
|
6
|
+
def test_no_credentials_returns401
|
|
7
|
+
anonymous = Faraday.new(url: FOREMAN_API_URL,
|
|
8
|
+
ssl: { verify: false },
|
|
9
|
+
request: { timeout: 30 },
|
|
10
|
+
headers: { 'Host' => FOREMAN_FQDN }) do |conn|
|
|
11
|
+
conn.request :json
|
|
12
|
+
conn.response :json, content_type: /\bjson$/
|
|
13
|
+
end
|
|
14
|
+
resp = anonymous.get('/api/v2/openbolt/jobs')
|
|
15
|
+
assert_equal 401, resp.status, resp.body.inspect
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def test_wrong_credentials_returns401
|
|
19
|
+
wrong = build_client(ADMIN_USER, 'definitely-not-the-password')
|
|
20
|
+
resp = wrong.get('/api/v2/openbolt/jobs')
|
|
21
|
+
assert_equal 401, resp.status, resp.body.inspect
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def test_unprivileged_user_is_forbidden_from_jobs
|
|
25
|
+
with_unprivileged_user do |client|
|
|
26
|
+
resp = client.get('/api/v2/openbolt/jobs')
|
|
27
|
+
assert_equal 403, resp.status, resp.body.inspect
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def test_unprivileged_user_is_forbidden_from_tasks
|
|
32
|
+
with_unprivileged_user do |client|
|
|
33
|
+
resp = client.get("/api/v2/openbolt/smart_proxies/#{smart_proxy_id}/tasks")
|
|
34
|
+
assert_equal 403, resp.status, resp.body.inspect
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def test_unprivileged_user_is_forbidden_from_reload_tasks
|
|
39
|
+
with_unprivileged_user do |client|
|
|
40
|
+
resp = client.post("/api/v2/openbolt/smart_proxies/#{smart_proxy_id}/tasks/reload")
|
|
41
|
+
assert_equal 403, resp.status, resp.body.inspect
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def test_unprivileged_user_is_forbidden_from_task_options
|
|
46
|
+
with_unprivileged_user do |client|
|
|
47
|
+
resp = client.get("/api/v2/openbolt/smart_proxies/#{smart_proxy_id}/tasks/options")
|
|
48
|
+
assert_equal 403, resp.status, resp.body.inspect
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def test_unprivileged_user_is_forbidden_from_launch_task
|
|
53
|
+
with_unprivileged_user do |client|
|
|
54
|
+
resp = client.post('/api/v2/openbolt/launch/task',
|
|
55
|
+
smart_proxy_id: smart_proxy_id,
|
|
56
|
+
task_name: 'acceptance::noop_task',
|
|
57
|
+
targets: 'target1.example.com',
|
|
58
|
+
parameters: {})
|
|
59
|
+
assert_equal 403, resp.status, resp.body.inspect
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def test_unprivileged_user_is_forbidden_from_job_status
|
|
64
|
+
# Seed a real job so the route resolves. The 403 should fire before
|
|
65
|
+
# the action body looks up the TaskJob.
|
|
66
|
+
job_id = launch_and_wait_for(task: 'acceptance::noop_task')
|
|
67
|
+
|
|
68
|
+
with_unprivileged_user do |client|
|
|
69
|
+
resp = client.get("/api/v2/openbolt/jobs/#{job_id}/status")
|
|
70
|
+
assert_equal 403, resp.status, resp.body.inspect
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def test_unprivileged_user_is_forbidden_from_job_result
|
|
75
|
+
job_id = launch_and_wait_for(task: 'acceptance::noop_task')
|
|
76
|
+
|
|
77
|
+
with_unprivileged_user do |client|
|
|
78
|
+
resp = client.get("/api/v2/openbolt/jobs/#{job_id}/result")
|
|
79
|
+
assert_equal 403, resp.status, resp.body.inspect
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../api_acceptance_helper'
|
|
4
|
+
|
|
5
|
+
class ApiJobsTest < ApiAcceptanceTestCase
|
|
6
|
+
def test_jobs_returns_paginated_envelope_with_kind
|
|
7
|
+
job_id = launch_and_wait_for(task: 'acceptance::noop_task')
|
|
8
|
+
|
|
9
|
+
resp = @api.get('/api/v2/openbolt/jobs')
|
|
10
|
+
assert_equal 200, resp.status, resp.body.inspect
|
|
11
|
+
|
|
12
|
+
assert resp.body.key?('total'), 'Expected total key in pagination envelope'
|
|
13
|
+
assert resp.body.key?('page'), 'Expected page key in pagination envelope'
|
|
14
|
+
assert resp.body.key?('per_page'), 'Expected per_page key in pagination envelope'
|
|
15
|
+
|
|
16
|
+
seeded = resp.body['results'].find { |row| row['job_id'] == job_id }
|
|
17
|
+
flunk "Newly launched job #{job_id} not present in /jobs response" unless seeded
|
|
18
|
+
assert_equal 'task', seeded['kind']
|
|
19
|
+
assert_includes %w[success failure exception invalid], seeded['status']
|
|
20
|
+
assert seeded['smart_proxy'].is_a?(Hash), "Expected smart_proxy hash, got: #{seeded['smart_proxy'].inspect}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def test_jobs_per_page_all_returns_every_row_in_one_page
|
|
24
|
+
# Trigger one more job to guarantee at least 1 row.
|
|
25
|
+
launch_and_wait_for(task: 'acceptance::noop_task')
|
|
26
|
+
|
|
27
|
+
resp = @api.get('/api/v2/openbolt/jobs', per_page: 'all')
|
|
28
|
+
assert_equal 200, resp.status, resp.body.inspect
|
|
29
|
+
assert_equal resp.body['total'], resp.body['results'].length,
|
|
30
|
+
'per_page=all should return every recorded job in one page'
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def test_jobs_caps_per_page_at100
|
|
34
|
+
resp = @api.get('/api/v2/openbolt/jobs', per_page: 500)
|
|
35
|
+
assert_equal 200, resp.status, resp.body.inspect
|
|
36
|
+
assert_equal 100, resp.body['per_page']
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def test_status_returns_job_status_payload_with_kind
|
|
40
|
+
job_id = launch_and_wait_for(task: 'acceptance::noop_task')
|
|
41
|
+
|
|
42
|
+
resp = @api.get("/api/v2/openbolt/jobs/#{job_id}/status")
|
|
43
|
+
assert_equal 200, resp.status, resp.body.inspect
|
|
44
|
+
assert_equal 'task', resp.body['kind']
|
|
45
|
+
assert_includes %w[success failure exception invalid], resp.body['status']
|
|
46
|
+
assert_equal 'acceptance::noop_task', resp.body['name']
|
|
47
|
+
assert resp.body['smart_proxy'].is_a?(Hash)
|
|
48
|
+
assert_equal smart_proxy_id, resp.body.dig('smart_proxy', 'id')
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def test_status_returns_404_for_unknown_job_id
|
|
52
|
+
resp = @api.get('/api/v2/openbolt/jobs/nonexistent-job-id/status')
|
|
53
|
+
assert_equal 404, resp.status, resp.body.inspect
|
|
54
|
+
# error_message handles both 404 envelope shapes Foreman produces
|
|
55
|
+
# (nested via error_layout, flat via not_found(string)).
|
|
56
|
+
assert_match(/not found/i, error_message(resp.body).to_s)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def test_result_returns_command_value_log_for_completed_job
|
|
60
|
+
job_id = launch_and_wait_for(task: 'acceptance::noop_task')
|
|
61
|
+
|
|
62
|
+
resp = @api.get("/api/v2/openbolt/jobs/#{job_id}/result")
|
|
63
|
+
assert_equal 200, resp.status, resp.body.inspect
|
|
64
|
+
assert_equal 'task', resp.body['kind']
|
|
65
|
+
assert_equal 'success', resp.body['status'], resp.body.inspect
|
|
66
|
+
assert resp.body.key?('command'), 'expected command key in result payload'
|
|
67
|
+
assert resp.body.key?('value'), 'expected value key in result payload'
|
|
68
|
+
assert resp.body.key?('log'), 'expected log key in result payload'
|
|
69
|
+
assert_match(/acceptance::noop_task/, resp.body['command'].to_s)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def test_result_returns_404_for_unknown_job_id
|
|
73
|
+
resp = @api.get('/api/v2/openbolt/jobs/nonexistent-job-id/result')
|
|
74
|
+
assert_equal 404, resp.status, resp.body.inspect
|
|
75
|
+
assert_match(/not found/i, error_message(resp.body).to_s)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../api_acceptance_helper'
|
|
4
|
+
|
|
5
|
+
class ApiLaunchTest < ApiAcceptanceTestCase
|
|
6
|
+
def test_launch_returns_job_id_and_kind
|
|
7
|
+
resp = @api.post('/api/v2/openbolt/launch/task',
|
|
8
|
+
smart_proxy_id: smart_proxy_id,
|
|
9
|
+
task_name: 'acceptance::noop_task',
|
|
10
|
+
targets: 'target1.example.com',
|
|
11
|
+
parameters: {})
|
|
12
|
+
assert_equal 201, resp.status, resp.body.inspect
|
|
13
|
+
assert resp.body['job_id'].is_a?(String) && !resp.body['job_id'].empty?,
|
|
14
|
+
"expected non-empty job_id, got: #{resp.body.inspect}"
|
|
15
|
+
assert_equal 'task', resp.body['kind']
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def test_launch_returns_400_when_smart_proxy_id_is_missing
|
|
19
|
+
resp = @api.post('/api/v2/openbolt/launch/task',
|
|
20
|
+
task_name: 'acceptance::noop_task',
|
|
21
|
+
targets: 'target1.example.com',
|
|
22
|
+
parameters: {})
|
|
23
|
+
assert_equal 400, resp.status, resp.body.inspect
|
|
24
|
+
assert_kind_of Hash, resp.body['error']
|
|
25
|
+
assert_equal 'Smart Proxy ID is required', resp.body.dig('error', 'message')
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def test_launch_returns_400_when_task_name_is_missing
|
|
29
|
+
resp = @api.post('/api/v2/openbolt/launch/task',
|
|
30
|
+
smart_proxy_id: smart_proxy_id,
|
|
31
|
+
targets: 'target1.example.com',
|
|
32
|
+
parameters: {})
|
|
33
|
+
assert_equal 400, resp.status, resp.body.inspect
|
|
34
|
+
assert_match(/Task name and targets cannot be empty/, resp.body.dig('error', 'message').to_s)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def test_launch_returns_400_when_targets_is_missing
|
|
38
|
+
resp = @api.post('/api/v2/openbolt/launch/task',
|
|
39
|
+
smart_proxy_id: smart_proxy_id,
|
|
40
|
+
task_name: 'acceptance::noop_task',
|
|
41
|
+
parameters: {})
|
|
42
|
+
assert_equal 400, resp.status, resp.body.inspect
|
|
43
|
+
assert_match(/Task name and targets cannot be empty/, resp.body.dig('error', 'message').to_s)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def test_launch_returns_400_when_task_does_not_exist_on_proxy
|
|
47
|
+
# The proxy responds with {error: ...} for an unknown task.
|
|
48
|
+
# ForemanOpenbolt::Tasks#dispatch_task re-raises that as a
|
|
49
|
+
# LaunchError, which the API controller renders as 400.
|
|
50
|
+
resp = @api.post('/api/v2/openbolt/launch/task',
|
|
51
|
+
smart_proxy_id: smart_proxy_id,
|
|
52
|
+
task_name: 'acceptance::does_not_exist',
|
|
53
|
+
targets: 'target1.example.com',
|
|
54
|
+
parameters: {})
|
|
55
|
+
assert_equal 400, resp.status, resp.body.inspect
|
|
56
|
+
assert_match(/Task execution failed/, resp.body.dig('error', 'message').to_s)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# End-to-end smoke test running launch, status polling, and result fetch with
|
|
60
|
+
# real OpenBolt execution against real targets. If this passes, the wiring
|
|
61
|
+
# from the API controller all the way through ProxyAPI to smart proxy
|
|
62
|
+
# to bolt to PollTaskStatus to TaskJob is working.
|
|
63
|
+
def test_echo_task_end_to_end
|
|
64
|
+
message = "acceptance api e2e #{Time.now.to_i}"
|
|
65
|
+
job_id = launch_and_wait_for(
|
|
66
|
+
task: 'acceptance::echo',
|
|
67
|
+
params: { 'message' => message }
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
result_resp = @api.get("/api/v2/openbolt/jobs/#{job_id}/result")
|
|
71
|
+
assert_equal 200, result_resp.status, result_resp.body.inspect
|
|
72
|
+
assert_equal 'success', result_resp.body['status'], result_resp.body.inspect
|
|
73
|
+
assert_equal 'task', result_resp.body['kind']
|
|
74
|
+
assert_match(/acceptance::echo/, result_resp.body['command'].to_s)
|
|
75
|
+
# The task returns {message, hostname} and the value is the unmodified
|
|
76
|
+
# OpenBolt result hash (per-target results indexed by target).
|
|
77
|
+
assert_match(/#{Regexp.escape(message)}/, result_resp.body['value'].to_s)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../api_acceptance_helper'
|
|
4
|
+
|
|
5
|
+
class ApiProxyTasksTest < ApiAcceptanceTestCase
|
|
6
|
+
def test_tasks_lists_the_acceptance_fixture_tasks
|
|
7
|
+
resp = @api.get("/api/v2/openbolt/smart_proxies/#{smart_proxy_id}/tasks")
|
|
8
|
+
assert_equal 200, resp.status, resp.body.inspect
|
|
9
|
+
|
|
10
|
+
task_names = resp.body.keys
|
|
11
|
+
%w[acceptance::echo acceptance::noop_task acceptance::failing_task
|
|
12
|
+
acceptance::complex_params].each do |expected|
|
|
13
|
+
assert_includes task_names, expected,
|
|
14
|
+
"expected proxy task list to include '#{expected}', got: #{task_names.inspect}"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def test_tasks_returns_404_for_unknown_smart_proxy
|
|
19
|
+
resp = @api.get('/api/v2/openbolt/smart_proxies/999999/tasks')
|
|
20
|
+
assert_equal 404, resp.status, resp.body.inspect
|
|
21
|
+
assert_match(/not found/i, error_message(resp.body).to_s)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def test_reload_tasks_returns_reloaded_list
|
|
25
|
+
resp = @api.post("/api/v2/openbolt/smart_proxies/#{smart_proxy_id}/tasks/reload")
|
|
26
|
+
assert_equal 200, resp.status, resp.body.inspect
|
|
27
|
+
assert_includes resp.body.keys, 'acceptance::echo'
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def test_reload_tasks_returns_404_for_unknown_smart_proxy
|
|
31
|
+
resp = @api.post('/api/v2/openbolt/smart_proxies/999999/tasks/reload')
|
|
32
|
+
assert_equal 404, resp.status, resp.body.inspect
|
|
33
|
+
assert_match(/not found/i, error_message(resp.body).to_s)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def test_task_options_returns_options_with_setting_defaults_merged
|
|
37
|
+
# acceptance:up sets these three Foreman settings before tests run.
|
|
38
|
+
# The endpoint should reflect them as the 'default' for each option.
|
|
39
|
+
resp = @api.get("/api/v2/openbolt/smart_proxies/#{smart_proxy_id}/tasks/options")
|
|
40
|
+
assert_equal 200, resp.status, resp.body.inspect
|
|
41
|
+
|
|
42
|
+
assert_equal 'openbolt', resp.body.dig('user', 'default')
|
|
43
|
+
assert_equal '/opt/foreman-proxy/.ssh/id_rsa', resp.body.dig('private-key', 'default')
|
|
44
|
+
assert_equal false, resp.body.dig('host-key-check', 'default')
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def test_task_options_returns_404_for_unknown_smart_proxy
|
|
48
|
+
resp = @api.get('/api/v2/openbolt/smart_proxies/999999/tasks/options')
|
|
49
|
+
assert_equal 404, resp.status, resp.body.inspect
|
|
50
|
+
end
|
|
51
|
+
end
|