foreman_remote_execution 16.3.2 → 16.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a7254f1da767b1e48ba82c93e57cd093611b42cb302e9eca41f0bb844a0d1883
4
- data.tar.gz: c19c6c596d7e531794003be27795b665ad4e2ef9055e94a51c4d6c50c361a75f
3
+ metadata.gz: 2db78eb6979bd486194e94b58c14978db6d08c02f675a042bd9827c9101b0639
4
+ data.tar.gz: c9060344c28a222b93fed7f26794fde15827030ae7b3b6197a66b9f86413b656
5
5
  SHA512:
6
- metadata.gz: 792c2e9e61c5e42e471832f47503189eab7edb69a2f4c857a1ab9b659eef114da16fc89b08d78f891b600997f92221cbe0651249fbe953f8ad0f9bd5d9de4c37
7
- data.tar.gz: 0e3797e25d92fb9e39606913937c0cb9a266f48c1780141969927362e095bd253a75426cf7e6309c14f4d981d29b77ff5aea018ad3a8f0b14fe49818d2c46050
6
+ metadata.gz: 478f4cc9af2467020b4498bdd757d9f3b33a5e233c65b0b941cb0eda2deacf0689077907298e111c7cc1ec13e64508c9cd33d0870df70fe8a7247dd99e9433a9
7
+ data.tar.gz: f2bf38fe739e5043ba595448090a4231a52ef923464eec41b470d71c8ccdf3f83c8faec3a47627c5a606601d343b42302093a3a22474f57dded90375933a506e
@@ -11,6 +11,15 @@ module ::ProxyAPI
11
11
  raise ProxyException.new(url, e, N_('Unable to fetch public key'))
12
12
  end
13
13
 
14
+ def ca_pubkey
15
+ get('ca_pubkey')&.strip
16
+ rescue RestClient::ResourceNotFound => e
17
+ Rails.logger.warn(format(N_("Unable to fetch CA public key: %{error}"), error: e.message))
18
+ nil
19
+ rescue => e
20
+ raise ProxyException.new(url, e, N_('Unable to fetch CA public key'))
21
+ end
22
+
14
23
  def drop_from_known_hosts(hostname)
15
24
  delete('known_hosts/' + hostname)
16
25
  rescue => e
@@ -108,7 +108,12 @@ module ForemanRemoteExecution
108
108
  end
109
109
 
110
110
  def remote_execution_ssh_keys
111
- remote_execution_proxies(%w(SSH Script), false).values.flatten.uniq.map { |proxy| proxy.pubkey }.compact.uniq
111
+ # only include public keys from SSH proxies that don't have SSH cert verification configured
112
+ remote_execution_proxies(%w(SSH Script), false).values.flatten.uniq.map { |proxy| proxy.pubkey if proxy.ca_pubkey.blank? }.compact.uniq
113
+ end
114
+
115
+ def remote_execution_ssh_ca_keys
116
+ remote_execution_proxies(%w(SSH Script), false).values.flatten.uniq.map { |proxy| proxy.ca_pubkey }.compact.uniq
112
117
  end
113
118
 
114
119
  def drop_execution_interface_cache
@@ -139,10 +144,13 @@ module ForemanRemoteExecution
139
144
 
140
145
  def extend_host_params_hash(params)
141
146
  keys = remote_execution_ssh_keys
147
+ ca_keys = remote_execution_ssh_ca_keys
142
148
  source = 'global'
143
- if keys.present?
144
- value, safe_value = params.fetch('remote_execution_ssh_keys', {}).values_at(:value, :safe_value).map { |v| [v].flatten.compact }
145
- params['remote_execution_ssh_keys'] = {:value => value + keys, :safe_value => safe_value + keys, :source => source}
149
+ {keys: keys, ca_keys: ca_keys}.each do |key_set_name, key_set|
150
+ if key_set.present?
151
+ value, safe_value = params.fetch("remote_execution_ssh_#{key_set_name}", {}).values_at(:value, :safe_value).map { |v| [v].flatten.compact }
152
+ params["remote_execution_ssh_#{key_set_name}"] = {:value => value + key_set, :safe_value => safe_value + key_set, :source => source}
153
+ end
146
154
  end
147
155
  [:remote_execution_ssh_user, :remote_execution_effective_user_method,
148
156
  :remote_execution_connect_by_ip].each do |key|
@@ -11,6 +11,10 @@ module ForemanRemoteExecution
11
11
  self[:pubkey] || update_pubkey
12
12
  end
13
13
 
14
+ def ca_pubkey
15
+ self[:ca_pubkey] || update_ca_pubkey
16
+ end
17
+
14
18
  def update_pubkey
15
19
  return unless has_feature?(%w(SSH Script))
16
20
 
@@ -19,6 +23,15 @@ module ForemanRemoteExecution
19
23
  key
20
24
  end
21
25
 
26
+ def update_ca_pubkey
27
+ return unless has_feature?(%w(SSH Script))
28
+
29
+ # smart proxy is not required to have a CA pubkey, in which case an empty string is returned
30
+ key = ::ProxyAPI::RemoteExecutionSSH.new(:url => url).ca_pubkey&.presence
31
+ self.update_attribute(:ca_pubkey, key)
32
+ key
33
+ end
34
+
22
35
  def drop_host_from_known_hosts(host)
23
36
  ::ProxyAPI::RemoteExecutionSSH.new(:url => url).drop_from_known_hosts(host)
24
37
  end
@@ -26,6 +39,7 @@ module ForemanRemoteExecution
26
39
  def refresh
27
40
  errors = super
28
41
  update_pubkey
42
+ update_ca_pubkey
29
43
  errors
30
44
  end
31
45
  end
@@ -0,0 +1 @@
1
+ attribute :ca_pubkey => :remote_execution_ca_pubkey
@@ -0,0 +1,5 @@
1
+ class AddCAPubKeyToSmartProxy < ActiveRecord::Migration[7.0]
2
+ def change
3
+ add_column :smart_proxies, :ca_pubkey, :text
4
+ end
5
+ end
@@ -213,6 +213,7 @@ Foreman::Plugin.register :foreman_remote_execution do
213
213
  extend_template_helpers ForemanRemoteExecution::RendererMethods
214
214
 
215
215
  extend_rabl_template 'api/v2/smart_proxies/main', 'api/v2/smart_proxies/pubkey'
216
+ extend_rabl_template 'api/v2/smart_proxies/main', 'api/v2/smart_proxies/ca_pubkey'
216
217
  extend_rabl_template 'api/v2/interfaces/main', 'api/v2/interfaces/execution_flag'
217
218
  extend_rabl_template 'api/v2/subnets/show', 'api/v2/subnets/remote_execution_proxies'
218
219
  extend_rabl_template 'api/v2/hosts/main', 'api/v2/host/main'
@@ -1,3 +1,3 @@
1
1
  module ForemanRemoteExecution
2
- VERSION = '16.3.2'.freeze
2
+ VERSION = '16.4.0'.freeze
3
3
  end
@@ -12,6 +12,7 @@ class ForemanRemoteExecutionHostExtensionsTest < ActiveSupport::TestCase
12
12
 
13
13
  before do
14
14
  SmartProxy.any_instance.stubs(:pubkey).returns(sshkey)
15
+ SmartProxy.any_instance.stubs(:ca_pubkey).returns(nil)
15
16
  Setting[:remote_execution_ssh_user] = 'root'
16
17
  Setting[:remote_execution_effective_user_method] = 'sudo'
17
18
  end
@@ -61,6 +62,48 @@ class ForemanRemoteExecutionHostExtensionsTest < ActiveSupport::TestCase
61
62
  end
62
63
  end
63
64
 
65
+ describe 'has ssh CA key configured' do
66
+ let(:host) { FactoryBot.create(:host, :with_execution) }
67
+ let(:sshkey) { 'ssh-rsa AAAAB3NzaC1yc2EAAAABJQ foo@example.com' }
68
+ let(:ca_sshkey) { 'ssh-rsa AAAAB3NzaC1yc2EAAAABJE bar@example.com' }
69
+
70
+ before do
71
+ SmartProxy.any_instance.stubs(:pubkey).returns(sshkey)
72
+ SmartProxy.any_instance.stubs(:ca_pubkey).returns(ca_sshkey)
73
+ Setting[:remote_execution_ssh_user] = 'root'
74
+ Setting[:remote_execution_effective_user_method] = 'sudo'
75
+ end
76
+
77
+ it 'has CA ssh keys in the parameters' do
78
+ assert_includes host.remote_execution_ssh_ca_keys, ca_sshkey
79
+ end
80
+
81
+ it 'excludes ssh keys from proxies that have SSH CA key configured' do
82
+ assert_empty host.remote_execution_ssh_keys
83
+ end
84
+
85
+ it 'merges ssh CA keys from host parameters and proxies' do
86
+ key = 'ssh-rsa not-even-a-key something@somewhere.com'
87
+ host.host_parameters << FactoryBot.create(:host_parameter, :host => host, :name => 'remote_execution_ssh_ca_keys', :value => [key])
88
+ assert_includes host.host_param('remote_execution_ssh_ca_keys'), key
89
+ assert_includes host.host_param('remote_execution_ssh_ca_keys'), ca_sshkey
90
+ end
91
+
92
+ it 'has ssh CA keys in the parameters even when no user specified' do
93
+ FactoryBot.create(:smart_proxy, :ssh)
94
+ host.interfaces.first.subnet.remote_execution_proxies.clear
95
+ User.current = nil
96
+ assert_includes host.remote_execution_ssh_ca_keys, ca_sshkey
97
+ end
98
+
99
+ it 'merges ssh CA key as a string from host parameters and proxies' do
100
+ key = 'ssh-rsa not-even-a-key something@somewhere.com'
101
+ host.host_parameters << FactoryBot.create(:host_parameter, :host => host, :name => 'remote_execution_ssh_ca_keys', :value => key)
102
+ assert_includes host.host_param('remote_execution_ssh_ca_keys'), key
103
+ assert_includes host.host_param('remote_execution_ssh_ca_keys'), ca_sshkey
104
+ end
105
+ end
106
+
64
107
  context 'host has multiple nics' do
65
108
  let(:host) { FactoryBot.build(:host, :with_execution) }
66
109
 
@@ -24,7 +24,13 @@ import TableIndexPage from 'foremanReact/components/PF4/TableIndexPage/TableInde
24
24
  import { getControllerSearchProps } from 'foremanReact/constants';
25
25
  import { Icon } from 'patternfly-react';
26
26
  import PropTypes from 'prop-types';
27
- import React, { useEffect, useMemo, useState, useRef } from 'react';
27
+ import React, {
28
+ useEffect,
29
+ useMemo,
30
+ useState,
31
+ useRef,
32
+ useCallback,
33
+ } from 'react';
28
34
  import { FormattedMessage } from 'react-intl';
29
35
  import { useHistory } from 'react-router-dom';
30
36
  import { useForemanSettings } from 'foremanReact/Root/Context/ForemanContext';
@@ -131,19 +137,22 @@ const JobInvocationHostTable = ({
131
137
  });
132
138
 
133
139
  // Search filter
134
- const constructFilter = (filter = initialFilter, search = urlSearchQuery) => {
135
- const dropdownFilterClause =
136
- filter && filter !== 'all_statuses'
137
- ? `job_invocation.result = ${filter}`
138
- : null;
139
- const parts = [dropdownFilterClause, search];
140
- return parts
141
- .filter(x => x)
142
- .map(fragment => `(${fragment})`)
143
- .join(' AND ');
144
- };
140
+ const constructFilter = useCallback(
141
+ (filter = initialFilter, search = urlSearchQuery) => {
142
+ const dropdownFilterClause =
143
+ filter && filter !== 'all_statuses'
144
+ ? `job_invocation.result = ${filter}`
145
+ : null;
146
+ const parts = [dropdownFilterClause, search];
147
+ return parts
148
+ .filter(x => x)
149
+ .map(fragment => `(${fragment})`)
150
+ .join(' AND ');
151
+ },
152
+ [initialFilter, urlSearchQuery]
153
+ );
145
154
 
146
- const handleResponse = (data, key) => {
155
+ const handleResponse = useCallback((data, key) => {
147
156
  if (key === JOB_INVOCATION_HOSTS) {
148
157
  const ids = data.data.results.map(i => i.id);
149
158
 
@@ -152,76 +161,94 @@ const JobInvocationHostTable = ({
152
161
  }
153
162
 
154
163
  setStatus(STATUS_UPPERCASE.RESOLVED);
155
- };
164
+ }, []);
156
165
 
157
166
  // Call hosts data with params
158
- const makeApiCall = (requestParams, callParams = {}) => {
159
- dispatch(
160
- APIActions.get({
161
- key: callParams.key ?? ALL_JOB_HOSTS,
162
- url: callParams.url ?? `/api/job_invocations/${id}/hosts`,
163
- params: requestParams,
164
- handleSuccess: data => handleResponse(data, callParams.key),
165
- handleError: () => setStatus(STATUS_UPPERCASE.ERROR),
166
- errorToast: ({ response }) =>
167
- response?.data?.error?.full_messages?.[0] || response,
168
- })
169
- );
170
- };
167
+ const makeApiCall = useCallback(
168
+ (requestParams, callParams = {}) => {
169
+ dispatch(
170
+ APIActions.get({
171
+ key: callParams.key ?? ALL_JOB_HOSTS,
172
+ url: callParams.url ?? `/api/job_invocations/${id}/hosts`,
173
+ params: requestParams,
174
+ handleSuccess: data => handleResponse(data, callParams.key),
175
+ handleError: () => setStatus(STATUS_UPPERCASE.ERROR),
176
+ errorToast: ({ response }) =>
177
+ response?.data?.error?.full_messages?.[0] || response,
178
+ })
179
+ );
180
+ },
181
+ [dispatch, id, handleResponse]
182
+ );
171
183
 
172
- const filterApiCall = newAPIOptions => {
173
- const newParams = newAPIOptions?.params ?? newAPIOptions ?? {};
184
+ const filterApiCall = useCallback(
185
+ newAPIOptions => {
186
+ const newParams = newAPIOptions?.params ?? newAPIOptions ?? {};
174
187
 
175
- const filterSearch = constructFilter(
176
- initialFilter,
177
- newParams.search ?? urlSearchQuery
178
- );
188
+ const filterSearch = constructFilter(
189
+ initialFilter,
190
+ newParams.search ?? urlSearchQuery
191
+ );
179
192
 
180
- const finalParams = {
181
- ...defaultParams,
182
- ...newParams,
183
- };
193
+ const finalParams = {
194
+ ...defaultParams,
195
+ ...newParams,
196
+ };
184
197
 
185
- if (filterSearch === AWAITING_STATUS_FILTER) {
186
- finalParams.awaiting = 'true';
187
- } else if (filterSearch !== '') {
188
- finalParams.search = filterSearch;
189
- }
198
+ if (filterSearch === AWAITING_STATUS_FILTER) {
199
+ finalParams.awaiting = 'true';
200
+ } else if (filterSearch !== '') {
201
+ finalParams.search = filterSearch;
202
+ }
190
203
 
191
- makeApiCall(finalParams, { key: JOB_INVOCATION_HOSTS });
204
+ makeApiCall(finalParams, { key: JOB_INVOCATION_HOSTS });
192
205
 
193
- const urlSearchParams = new URLSearchParams(window.location.search);
206
+ const urlSearchParams = new URLSearchParams(window.location.search);
194
207
 
195
- ['page', 'per_page', 'order'].forEach(key => {
196
- if (finalParams[key]) urlSearchParams.set(key, finalParams[key]);
197
- });
208
+ ['page', 'per_page', 'order'].forEach(key => {
209
+ if (finalParams[key]) urlSearchParams.set(key, finalParams[key]);
210
+ });
198
211
 
199
- history.push({ search: urlSearchParams.toString() });
200
- };
212
+ history.push({ search: urlSearchParams.toString() });
213
+ },
214
+ [
215
+ initialFilter,
216
+ urlSearchQuery,
217
+ defaultParams,
218
+ makeApiCall,
219
+ history,
220
+ constructFilter,
221
+ ]
222
+ );
201
223
 
202
224
  // Filter change
203
- const handleFilterChange = newFilter => {
204
- onFilterUpdate(newFilter);
205
- };
225
+ const handleFilterChange = useCallback(
226
+ newFilter => {
227
+ onFilterUpdate(newFilter);
228
+ },
229
+ [onFilterUpdate]
230
+ );
206
231
 
207
232
  // Effects
208
233
  // run after mount
234
+ const initializedRef = useRef(false);
209
235
  useEffect(() => {
210
- // Job Invo template load
211
- makeApiCall(
212
- {},
213
- {
214
- url: `/job_invocations/${id}/hosts`,
215
- key: LIST_TEMPLATE_INVOCATIONS,
236
+ if (!initializedRef.current) {
237
+ // Job Invo template load
238
+ makeApiCall(
239
+ {},
240
+ {
241
+ url: `/job_invocations/${id}/hosts`,
242
+ key: LIST_TEMPLATE_INVOCATIONS,
243
+ }
244
+ );
245
+
246
+ if (initialFilter === '') {
247
+ onFilterUpdate('all_statuses');
216
248
  }
217
- );
218
-
219
- if (initialFilter === '') {
220
- onFilterUpdate('all_statuses');
249
+ initializedRef.current = true;
221
250
  }
222
-
223
- // eslint-disable-next-line react-hooks/exhaustive-deps
224
- }, []);
251
+ }, [makeApiCall, id, initialFilter, onFilterUpdate]);
225
252
 
226
253
  useEffect(() => {
227
254
  if (initialFilter !== '') filterApiCall();
@@ -230,8 +257,7 @@ const JobInvocationHostTable = ({
230
257
  prevStatusLabel.current = statusLabel;
231
258
  filterApiCall();
232
259
  }
233
- // eslint-disable-next-line react-hooks/exhaustive-deps
234
- }, [initialFilter, statusLabel, id]);
260
+ }, [initialFilter, statusLabel, id, filterApiCall]);
235
261
 
236
262
  const {
237
263
  updateSearchQuery: updateSearchQueryBulk,
@@ -79,15 +79,14 @@ const JobInvocationDetailPage = ({
79
79
  return () => {
80
80
  dispatch(stopInterval(JOB_INVOCATION_KEY));
81
81
  };
82
- // eslint-disable-next-line react-hooks/exhaustive-deps
83
82
  }, [dispatch, id, finished, autoRefresh]);
84
83
 
84
+ const taskId = task?.id;
85
85
  useEffect(() => {
86
- if (task?.id !== undefined) {
87
- dispatch(getTask(`${task?.id}`));
86
+ if (taskId !== undefined) {
87
+ dispatch(getTask(`${taskId}`));
88
88
  }
89
- // eslint-disable-next-line react-hooks/exhaustive-deps
90
- }, [dispatch, task?.id]);
89
+ }, [dispatch, taskId]);
91
90
 
92
91
  const pageStatus =
93
92
  items.id === undefined
@@ -89,9 +89,6 @@ export const JobWizard = ({ rerunData }) => {
89
89
  concurrency_control = {},
90
90
  },
91
91
  }) => {
92
- if (category !== job_category) {
93
- setCategory(job_category);
94
- }
95
92
  const advancedTemplateValues = {};
96
93
  const defaultTemplateValues = {};
97
94
  const inputs = template_inputs;
@@ -131,8 +128,7 @@ export const JobWizard = ({ rerunData }) => {
131
128
  };
132
129
  });
133
130
  },
134
- // eslint-disable-next-line react-hooks/exhaustive-deps
135
- [category.length]
131
+ [setTemplateValues, setAdvancedValues]
136
132
  );
137
133
  useEffect(() => {
138
134
  if (rerunData) {
@@ -153,8 +149,7 @@ export const JobWizard = ({ rerunData }) => {
153
149
  },
154
150
  });
155
151
  }
156
- // eslint-disable-next-line react-hooks/exhaustive-deps
157
- }, [rerunData]);
152
+ }, [rerunData, setDefaults]);
158
153
  useEffect(() => {
159
154
  if (jobTemplateID) {
160
155
  dispatch(
@@ -199,8 +194,14 @@ export const JobWizard = ({ rerunData }) => {
199
194
  })
200
195
  );
201
196
  }
202
- // eslint-disable-next-line react-hooks/exhaustive-deps
203
- }, [rerunData, jobTemplateID, dispatch]);
197
+ }, [
198
+ rerunData,
199
+ jobTemplateID,
200
+ dispatch,
201
+ setDefaults,
202
+ setTemplateValues,
203
+ setAdvancedValues,
204
+ ]);
204
205
 
205
206
  const [isStartsBeforeError, setIsStartsBeforeError] = useState(false);
206
207
  const [isStartsAtError, setIsStartsAtError] = useState(false);
@@ -514,7 +515,7 @@ JobWizard.propTypes = {
514
515
  }),
515
516
  execution_timeout_interval: PropTypes.number,
516
517
  time_to_pickup: PropTypes.number,
517
- remote_execution_feature_id: PropTypes.string,
518
+ remote_execution_feature_id: PropTypes.number,
518
519
  template_invocations: PropTypes.arrayOf(
519
520
  PropTypes.shape({
520
521
  template_id: PropTypes.number,
@@ -95,6 +95,14 @@ export const useAutoFill = ({
95
95
  });
96
96
  }
97
97
  }
98
- // eslint-disable-next-line react-hooks/exhaustive-deps
99
- }, [fills]);
98
+ }, [
99
+ fills,
100
+ setFills,
101
+ setSelectedTargets,
102
+ setHostsSearchQuery,
103
+ setJobTemplateID,
104
+ setTemplateValues,
105
+ setAdvancedValues,
106
+ dispatch,
107
+ ]);
100
108
  };
@@ -57,7 +57,7 @@ Array [
57
57
  "port": null,
58
58
  "preventInvalidHostname": false,
59
59
  "protocol": null,
60
- "query": "resource=ForemanTasks%3A%3ATask&name=some+search",
60
+ "query": "resource=ForemanTasks%3A%3ATask",
61
61
  "urn": null,
62
62
  "username": null,
63
63
  },
@@ -55,8 +55,13 @@ const ConnectedCategoryAndTemplate = ({
55
55
  })
56
56
  );
57
57
  }
58
- // eslint-disable-next-line react-hooks/exhaustive-deps
59
- }, [jobCategoriesStatus]);
58
+ }, [
59
+ jobCategoriesStatus,
60
+ dispatch,
61
+ isCategoryPreselected,
62
+ setCategory,
63
+ setJobTemplate,
64
+ ]);
60
65
 
61
66
  const jobCategories = useSelector(selectJobCategories);
62
67
  const jobTemplatesSearch = useSelector(selectJobTemplatesSearch);
@@ -73,22 +78,29 @@ const ConnectedCategoryAndTemplate = ({
73
78
  per_page: 'all',
74
79
  }),
75
80
  handleSuccess: response => {
76
- if (!jobTemplate)
77
- setJobTemplate(
78
- current =>
79
- current ||
80
- Number(
81
- filterJobTemplates(response?.data?.results)[0]?.id
82
- ) ||
83
- null
84
- );
81
+ const filteredTemplates = filterJobTemplates(
82
+ response?.data?.results
83
+ );
84
+ setJobTemplate(current => {
85
+ // Check if current template is in the new category's template list.
86
+ // This preserves the user's selection when changing categories on rerun,
87
+ // preventing the category from flashing and reverting back (Issue #38899).
88
+ // We check the state value (current) rather than the prop to avoid race conditions.
89
+ if (
90
+ current &&
91
+ filteredTemplates.some(template => template.id === current)
92
+ ) {
93
+ return current;
94
+ }
95
+ // Otherwise, select the first template from the new category
96
+ return Number(filteredTemplates[0]?.id) || null;
97
+ });
85
98
  },
86
99
  })
87
100
  );
88
101
  }
89
102
  }
90
- // eslint-disable-next-line react-hooks/exhaustive-deps
91
- }, [category, dispatch]);
103
+ }, [category, dispatch, jobTemplatesSearch, setJobTemplate]);
92
104
 
93
105
  const jobTemplates = useSelector(selectJobTemplates);
94
106
 
@@ -1,4 +1,4 @@
1
- import React from 'react';
1
+ import React, { useCallback } from 'react';
2
2
  import { useSelector, useDispatch } from 'react-redux';
3
3
  import URI from 'urijs';
4
4
  import { SelectVariant } from '@patternfly/react-core/deprecated';
@@ -8,16 +8,21 @@ import { SearchSelect } from '../form/SearchSelect';
8
8
 
9
9
  export const useNameSearchAPI = (apiKey, url) => {
10
10
  const dispatch = useDispatch();
11
- const uri = new URI(url);
12
- const onSearch = search =>
13
- dispatch(
14
- get({
15
- key: apiKey,
16
- url: uri.addSearch({
17
- search: `name~"${search}"`,
18
- }),
19
- })
20
- );
11
+
12
+ const onSearch = useCallback(
13
+ search => {
14
+ const uri = new URI(url);
15
+ dispatch(
16
+ get({
17
+ key: apiKey,
18
+ url: uri.addSearch({
19
+ search: `name~"${search}"`,
20
+ }),
21
+ })
22
+ );
23
+ },
24
+ [dispatch, apiKey, url]
25
+ );
21
26
 
22
27
  const response = useSelector(state => selectResponse(state, apiKey));
23
28
  const isLoading = useSelector(state => selectIsLoading(state, apiKey));
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect } from 'react';
1
+ import React, { useState, useEffect, useRef, useCallback } from 'react';
2
2
  import PropTypes from 'prop-types';
3
3
  import {
4
4
  Select,
@@ -25,28 +25,30 @@ export const ResourceSelect = ({
25
25
  const { perPage } = useForemanSettings();
26
26
  const maxResults = perPage;
27
27
  const dispatch = useDispatch();
28
- const uri = new URI(url);
29
- const onSearch = search => {
30
- dispatch(
31
- get({
32
- key: apiKey,
33
- url: uri.addSearch(search),
34
- })
35
- );
36
- };
28
+ const onSearch = useCallback(
29
+ search => {
30
+ const uri = new URI(url);
31
+ dispatch(
32
+ get({
33
+ key: apiKey,
34
+ url: uri.addSearch(search),
35
+ })
36
+ );
37
+ },
38
+ [dispatch, apiKey, url]
39
+ );
37
40
 
38
41
  const response = useSelector(state => selectResponse(state, apiKey));
39
42
  const isLoading = useSelector(state => selectIsLoading(state, apiKey));
40
43
  const [isOpen, setIsOpen] = useState(false);
41
44
  const [typingTimeout, setTypingTimeout] = useState(null);
45
+ const initializedRef = useRef(false);
42
46
  useEffect(() => {
43
- onSearch(selected ? { id: selected } : {});
44
- if (typingTimeout) {
45
- return () => clearTimeout(typingTimeout);
47
+ if (!initializedRef.current) {
48
+ onSearch(selected ? { id: selected } : {});
49
+ initializedRef.current = true;
46
50
  }
47
- return undefined;
48
- // eslint-disable-next-line react-hooks/exhaustive-deps
49
- }, []);
51
+ }, [onSearch, selected]);
50
52
  let selectOptions = [];
51
53
  if (response.subtotal > maxResults) {
52
54
  selectOptions = [
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect } from 'react';
1
+ import React, { useState, useEffect, useRef } from 'react';
2
2
  import PropTypes from 'prop-types';
3
3
  import {
4
4
  Select,
@@ -25,14 +25,13 @@ export const SearchSelect = ({
25
25
  const [onSearch, response, isLoading] = useNameSearch(apiKey, url);
26
26
  const [isOpen, setIsOpen] = useState(false);
27
27
  const [typingTimeout, setTypingTimeout] = useState(null);
28
+ const initializedRef = useRef(false);
28
29
  useEffect(() => {
29
- onSearch(selected.name || '');
30
- if (typingTimeout) {
31
- return () => clearTimeout(typingTimeout);
30
+ if (!initializedRef.current) {
31
+ onSearch(selected.name || '');
32
+ initializedRef.current = true;
32
33
  }
33
- return undefined;
34
- // eslint-disable-next-line react-hooks/exhaustive-deps
35
- }, []);
34
+ }, [onSearch, selected.name]);
36
35
  let selectOptions = [];
37
36
  if (response.subtotal > maxResults) {
38
37
  selectOptions = [
@@ -46,8 +46,6 @@ export const useValidation = ({ advancedValues, templateValues }) => {
46
46
  setValid(currValid => ({ ...currValid, advanced: false }));
47
47
  }
48
48
  });
49
-
50
- // eslint-disable-next-line react-hooks/exhaustive-deps
51
- }, [advancedValues, templateValues]);
49
+ }, [advancedValues, templateValues, templateInputs, advancedTemplateInputs]);
52
50
  return [valid, setValid];
53
51
  };
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useState } from 'react';
1
+ import React, { useEffect, useState, useCallback, useRef } from 'react';
2
2
  import { useSelector, useDispatch } from 'react-redux';
3
3
 
4
4
  import { get } from 'foremanReact/redux/API';
@@ -50,38 +50,55 @@ const WrappedTargetingHosts = () => {
50
50
  const [apiUrl, setApiUrl] = useState(getApiUrl(searchQuery, pagination));
51
51
  const intervalExists = useSelector(selectIntervalExists);
52
52
 
53
- const handleSearch = (query, status) => {
54
- const defaultPagination = { page: 1, per_page: pagination.per_page };
55
- stopApiInterval();
56
-
57
- setApiUrl(getApiUrl(buildSearchQuery(query, status), defaultPagination));
58
- setSearchQuery(query);
59
- setPagination(defaultPagination);
60
- };
61
-
62
- const handlePagination = args => {
63
- stopApiInterval();
64
- setPagination(args);
65
- setApiUrl(getApiUrl(buildSearchQuery(searchQuery, statusFilter), args));
66
- };
67
-
68
- const stopApiInterval = () => {
53
+ const stopApiInterval = useCallback(() => {
69
54
  if (intervalExists) {
70
55
  dispatch(stopInterval(TARGETING_HOSTS));
71
56
  }
72
- };
73
-
74
- const getData = url =>
75
- withInterval(
76
- get({
77
- key: TARGETING_HOSTS,
78
- url,
79
- handleError: () => {
80
- dispatch(stopInterval(TARGETING_HOSTS));
81
- },
82
- }),
83
- 1000
84
- );
57
+ }, [dispatch, intervalExists]);
58
+
59
+ // Use ref to avoid infinite loop from handleSearch depending on pagination.per_page
60
+ const perPageRef = useRef(pagination.per_page);
61
+
62
+ const handleSearch = useCallback(
63
+ (query, status) => {
64
+ const defaultPagination = { page: 1, per_page: perPageRef.current };
65
+ stopApiInterval();
66
+
67
+ setApiUrl(getApiUrl(buildSearchQuery(query, status), defaultPagination));
68
+ setSearchQuery(query);
69
+ setPagination(defaultPagination);
70
+ },
71
+ [stopApiInterval]
72
+ );
73
+
74
+ // Keep ref in sync with pagination
75
+ useEffect(() => {
76
+ perPageRef.current = pagination.per_page;
77
+ }, [pagination.per_page]);
78
+
79
+ const handlePagination = useCallback(
80
+ args => {
81
+ stopApiInterval();
82
+ setPagination(args);
83
+ setApiUrl(getApiUrl(buildSearchQuery(searchQuery, statusFilter), args));
84
+ },
85
+ [searchQuery, statusFilter, stopApiInterval]
86
+ );
87
+
88
+ const getData = useCallback(
89
+ url =>
90
+ withInterval(
91
+ get({
92
+ key: TARGETING_HOSTS,
93
+ url,
94
+ handleError: () => {
95
+ dispatch(stopInterval(TARGETING_HOSTS));
96
+ },
97
+ }),
98
+ 1000
99
+ ),
100
+ [dispatch]
101
+ );
85
102
 
86
103
  useEffect(() => {
87
104
  dispatch(getData(apiUrl));
@@ -93,11 +110,11 @@ const WrappedTargetingHosts = () => {
93
110
  return () => {
94
111
  dispatch(stopInterval(TARGETING_HOSTS));
95
112
  };
96
- }, [dispatch, apiUrl, autoRefresh]);
113
+ }, [dispatch, apiUrl, autoRefresh, getData]);
97
114
 
98
115
  useEffect(() => {
99
116
  handleSearch(searchQuery, statusFilter);
100
- }, [statusFilter, searchQuery]);
117
+ }, [statusFilter, searchQuery, handleSearch]);
101
118
 
102
119
  return (
103
120
  <TargetingHostsPage
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foreman_remote_execution
3
3
  version: !ruby/object:Gem::Version
4
- version: 16.3.2
4
+ version: 16.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Foreman Remote Execution team
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-12-08 00:00:00.000000000 Z
10
+ date: 2026-01-06 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: deface
@@ -223,6 +223,7 @@ files:
223
223
  - app/views/api/v2/remote_execution_features/index.json.rabl
224
224
  - app/views/api/v2/remote_execution_features/main.json.rabl
225
225
  - app/views/api/v2/remote_execution_features/show.json.rabl
226
+ - app/views/api/v2/smart_proxies/ca_pubkey.json.rabl
226
227
  - app/views/api/v2/smart_proxies/pubkey.json.rabl
227
228
  - app/views/api/v2/subnets/remote_execution_proxies.json.rabl
228
229
  - app/views/api/v2/template_invocations/base.json.rabl
@@ -340,6 +341,7 @@ files:
340
341
  - db/migrate/20240522093412_add_smart_proxy_id_to_template_invocation.rb
341
342
  - db/migrate/20240522093413_migrate_smart_proxy_ids_to_template_invocations.rb
342
343
  - db/migrate/20241126150849_remove_remote_execution_workers_pool_size.rb
344
+ - db/migrate/20250606125543_add_ca_pub_key_to_smart_proxy.rb
343
345
  - db/seeds.d/100-assign_features_with_templates.rb
344
346
  - db/seeds.d/20-permissions.rb
345
347
  - db/seeds.d/50-notification_blueprints.rb
@@ -606,7 +608,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
606
608
  - !ruby/object:Gem::Version
607
609
  version: '0'
608
610
  requirements: []
609
- rubygems_version: 3.6.9
611
+ rubygems_version: 4.0.3
610
612
  specification_version: 4
611
613
  summary: A plugin bringing remote execution to the Foreman, completing the config
612
614
  management functionality with remote management functionality.