Merge ~sylvain-pineau/checkbox-ng:remote_deamon_controller into checkbox-ng:master
- Git
- lp:~sylvain-pineau/checkbox-ng
- remote_deamon_controller
- Merge into master
Status: | Work in progress |
---|---|
Proposed branch: | ~sylvain-pineau/checkbox-ng:remote_deamon_controller |
Merge into: | checkbox-ng:master |
Diff against target: |
442 lines (+227/-50) 7 files modified
checkbox_ng/launcher/remote.py (+8/-1) checkbox_ng/launcher/subcommands.py (+13/-1) plainbox/impl/ctrl.py (+62/-1) plainbox/impl/runner.py (+47/-40) plainbox/impl/session/assistant.py (+67/-1) plainbox/impl/session/remote_assistant.py (+28/-4) plainbox/impl/test_ctrl.py (+2/-2) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Jonathan Cave (community) | Needs Information | ||
Review via email: mp+360477@code.launchpad.net |
Commit message
Description of the change
The code that use the slave daemon to act as a new controller for root job commands
Chris Wayne (cwayne) wrote : | # |
I'm looking at it as an intermediary step until full local-remote is ready
for widespread use
On Thu, Dec 13, 2018, 11:11 AM Jonathan Cave <<email address hidden>
wrote:
> Review: Needs Information
>
> I'm not sure I fully understand the concept of what this is looking to do
> yet.
>
> The intention is to "hand over" jobs that want to run as the root user to
> the remote-slave service? Why is this much better that just using localhost
> remote (assuming know bugs are fixed)? The obvious disadvantage being that
> if you start a session locally, you can't disconnect the front end and then
> re-connect somewhere else.
> --
>
> https:/
> Your team Checkbox Administrators is subscribed to branch
> checkbox-ng:master.
>
Sylvain Pineau (sylvain-pineau) wrote : | # |
Remote for everyone is the prio #1, back to in progress. I'll re assess the need after xmas.
Unmerged commits
- ca2e28b... by Sylvain Pineau
-
subcommands:run: Add support for sideloaded providers
- 322a34f... by Sylvain Pineau
-
runner: Only run the job command via remote, not the full job
Doing so keeps all the logic to skip or not jobs on master
- 3ff0225... by Sylvain Pineau
-
session:assistant: log "Finalizing session" events as info not warning msg
Too noisy when used with remote slave
- 7472231... by Sylvain Pineau
-
remote: Properly render stderr stream lines in red
- 1e8da85... by Sylvain Pineau
-
remote_assistant: Fix logger name
- 255d44a... by Sylvain Pineau
-
ctrl: New execution controller that gains root by using the remote deamon
Preview Diff
1 | diff --git a/checkbox_ng/launcher/remote.py b/checkbox_ng/launcher/remote.py |
2 | index 70bdc60..34e8fec 100644 |
3 | --- a/checkbox_ng/launcher/remote.py |
4 | +++ b/checkbox_ng/launcher/remote.py |
5 | @@ -84,6 +84,9 @@ class SimpleUI(NormalUI, MainLoopStage): |
6 | def green_text(text, end='\n'): |
7 | print(SimpleUI.C.GREEN(text), end) |
8 | |
9 | + def red_text(text, end='\n'): |
10 | + print(SimpleUI.C.RED(text), end) |
11 | + |
12 | def horiz_line(): |
13 | print(SimpleUI.C.WHITE('-' * 80)) |
14 | |
15 | @@ -431,7 +434,11 @@ class RemoteMaster(Command, ReportsStage, MainLoopStage): |
16 | while True: |
17 | state, payload = self.sa.monitor_job() |
18 | if payload and not self._is_bootstrapping: |
19 | - SimpleUI.green_text(payload, end='') |
20 | + for line in payload: |
21 | + if line[0] == 'stderr': |
22 | + SimpleUI.red_text(payload, end='') |
23 | + else: |
24 | + SimpleUI.green_text(payload, end='') |
25 | if state == 'running': |
26 | time.sleep(0.5) |
27 | while True: |
28 | diff --git a/checkbox_ng/launcher/subcommands.py b/checkbox_ng/launcher/subcommands.py |
29 | index 169d38a..4e4c2cf 100644 |
30 | --- a/checkbox_ng/launcher/subcommands.py |
31 | +++ b/checkbox_ng/launcher/subcommands.py |
32 | @@ -771,8 +771,20 @@ class Run(Command, MainLoopStage): |
33 | "0.99", |
34 | ["restartable"], |
35 | ) |
36 | + # side-load providers local-providers |
37 | + side_load_path = os.path.expandvars(os.path.join( |
38 | + '/home', '$USER', 'providers')) |
39 | + additional_providers = () |
40 | + if os.path.exists(side_load_path): |
41 | + print(self._C.RED(_( |
42 | + "WARNING: using side-loaded providers"))) |
43 | + os.environ['PROVIDERPATH'] = '' |
44 | + embedded_providers = EmbeddedProvider1PlugInCollection( |
45 | + side_load_path) |
46 | + additional_providers = embedded_providers.get_all_plugin_objects() |
47 | self._configure_restart() |
48 | - try_selecting_providers(self.sa, '*') |
49 | + try_selecting_providers( |
50 | + self.sa, '*', additional_providers=additional_providers) |
51 | self.sa.start_new_session(self.ctx.args.title or 'checkbox-run') |
52 | tps = self.sa.get_test_plans() |
53 | self._configure_report() |
54 | diff --git a/plainbox/impl/ctrl.py b/plainbox/impl/ctrl.py |
55 | index ef08867..72a78b7 100644 |
56 | --- a/plainbox/impl/ctrl.py |
57 | +++ b/plainbox/impl/ctrl.py |
58 | @@ -44,6 +44,7 @@ import itertools |
59 | import json |
60 | import logging |
61 | import os |
62 | +import time |
63 | import tempfile |
64 | import subprocess |
65 | import sys |
66 | @@ -55,6 +56,7 @@ from plainbox.abc import IJobResult |
67 | from plainbox.abc import ISessionStateController |
68 | from plainbox.i18n import gettext as _ |
69 | from plainbox.impl import get_plainbox_dir |
70 | +from plainbox.impl.color import Colorizer |
71 | from plainbox.impl.depmgr import DependencyDuplicateError |
72 | from plainbox.impl.depmgr import DependencyMissingError |
73 | from plainbox.impl.resource import ExpressionCannotEvaluateError |
74 | @@ -74,11 +76,14 @@ from plainbox.impl.unit.unit import MissingParam |
75 | from plainbox.impl.validation import Severity |
76 | from plainbox.vendor import morris |
77 | from plainbox.vendor import extcmd |
78 | +from plainbox.vendor import rpyc |
79 | + |
80 | |
81 | __all__ = [ |
82 | 'CheckBoxSessionStateController', |
83 | 'RootViaPTL1ExecutionController', |
84 | 'RootViaPkexecExecutionController', |
85 | + 'RootViaRemoteDeamonController', |
86 | 'RootViaSudoExecutionController', |
87 | 'UserJobExecutionController', |
88 | 'checkbox_session_state_ctrl', |
89 | @@ -810,7 +815,7 @@ class UserJobExecutionController(CheckBoxExecutionController): |
90 | return -1 |
91 | if job.user is not None: |
92 | if os.getuid() == 0: |
93 | - return 4 |
94 | + return 5 |
95 | else: |
96 | return -1 |
97 | return 1 |
98 | @@ -1305,6 +1310,62 @@ class RootViaPkexecExecutionController( |
99 | return 0 |
100 | |
101 | |
102 | +class RootViaRemoteDeamonController( |
103 | + CheckBoxDifferentialExecutionController): |
104 | + """ |
105 | + Execution controller that gains root by using the remote deamon. |
106 | + """ |
107 | + |
108 | + _REMOTE_CONN = None |
109 | + |
110 | + C = Colorizer() |
111 | + |
112 | + def execute_remote_job(self, job, job_state, config): |
113 | + sa = self._REMOTE_CONN.root.get_sa() |
114 | + sa._reset_sa() |
115 | + configuration = dict() |
116 | + configuration['launcher'] = None |
117 | + sa.start_session(configuration) |
118 | + sa._sa.hand_pick_jobs([job.id]) |
119 | + sa.save_todo_list([job.id]) |
120 | + sa.run_job_command(job.id) |
121 | + while True: |
122 | + state, payload = sa.monitor_job() |
123 | + if payload: |
124 | + for line in payload: |
125 | + if line[0] == 'stderr': |
126 | + print(self.C.RED(line[1]), end='') |
127 | + else: |
128 | + print(self.C.GREEN(line[1]), end='') |
129 | + if state == 'running': |
130 | + time.sleep(0.1) |
131 | + else: |
132 | + result = sa.finish_job_command() |
133 | + sa.finalize_session() |
134 | + return result |
135 | + |
136 | + def get_execution_command(self, job, job_state, config, session_dir, |
137 | + nest_dir): |
138 | + return None |
139 | + |
140 | + def get_checkbox_score(self, job): |
141 | + """ |
142 | + Compute how applicable this controller is for the specified job. |
143 | + |
144 | + :returns: |
145 | + 4 for jobs with a user override, zero otherwise |
146 | + """ |
147 | + if job.user is not None: |
148 | + try: |
149 | + conn = rpyc.connect('localhost', 18871) |
150 | + self._REMOTE_CONN = conn |
151 | + except OSError: |
152 | + return -1 |
153 | + return 4 |
154 | + else: |
155 | + return 0 |
156 | + |
157 | + |
158 | class RootViaSudoExecutionController( |
159 | CheckBoxDifferentialExecutionController): |
160 | """ |
161 | diff --git a/plainbox/impl/runner.py b/plainbox/impl/runner.py |
162 | index 7807a8f..03fff56 100644 |
163 | --- a/plainbox/impl/runner.py |
164 | +++ b/plainbox/impl/runner.py |
165 | @@ -309,28 +309,21 @@ class JobRunner(IJobRunner): |
166 | self._session_dir = session_dir |
167 | if execution_ctrl_list is None: |
168 | logger.debug("execution_ctrl_list not passed to JobRunner") |
169 | - if sys.platform == 'linux' or sys.platform == 'linux2': |
170 | - from plainbox.impl.ctrl import RootViaPkexecExecutionController |
171 | - from plainbox.impl.ctrl import RootViaPTL1ExecutionController |
172 | - from plainbox.impl.ctrl import RootViaSudoExecutionController |
173 | - from plainbox.impl.ctrl import UserJobExecutionController |
174 | - from plainbox.impl.ctrl import QmlJobExecutionController |
175 | - execution_ctrl_list = [ |
176 | - RootViaPTL1ExecutionController(provider_list), |
177 | - RootViaPkexecExecutionController(provider_list), |
178 | - # XXX: maybe this one should be only used on command line |
179 | - RootViaSudoExecutionController(provider_list), |
180 | - UserJobExecutionController(provider_list), |
181 | - QmlJobExecutionController(provider_list), |
182 | - ] |
183 | - elif sys.platform == 'win32': |
184 | - from plainbox.impl.ctrl import UserJobExecutionController |
185 | - execution_ctrl_list = [ |
186 | - UserJobExecutionController(provider_list) |
187 | - ] |
188 | - else: |
189 | - logger.warning("Unsupported platform: %s", sys.platform) |
190 | - execution_ctrl_list = [] |
191 | + from plainbox.impl.ctrl import RootViaPkexecExecutionController |
192 | + from plainbox.impl.ctrl import RootViaPTL1ExecutionController |
193 | + from plainbox.impl.ctrl import RootViaRemoteDeamonController |
194 | + from plainbox.impl.ctrl import RootViaSudoExecutionController |
195 | + from plainbox.impl.ctrl import UserJobExecutionController |
196 | + from plainbox.impl.ctrl import QmlJobExecutionController |
197 | + execution_ctrl_list = [ |
198 | + RootViaPTL1ExecutionController(provider_list), |
199 | + RootViaPkexecExecutionController(provider_list), |
200 | + RootViaRemoteDeamonController(provider_list), |
201 | + # XXX: maybe this one should be only used on command line |
202 | + RootViaSudoExecutionController(provider_list), |
203 | + UserJobExecutionController(provider_list), |
204 | + QmlJobExecutionController(provider_list), |
205 | + ] |
206 | self._jobs_io_log_dir = jobs_io_log_dir |
207 | # NOTE: deprecated |
208 | self._command_io_delegate = command_io_delegate |
209 | @@ -787,24 +780,38 @@ class JobRunner(IJobRunner): |
210 | return JobResultBuilder( |
211 | outcome=IJobResult.OUTCOME_NOT_SUPPORTED, |
212 | comments=_('No suitable execution controller is available)')) |
213 | - # Run the embedded command |
214 | - start_time = time.time() |
215 | - return_code, record_path = self._run_command( |
216 | - job, job_state, config, ctrl) |
217 | - execution_duration = time.time() - start_time |
218 | - # Convert the return of the command to the outcome of the job |
219 | - if return_code == 0: |
220 | - outcome = IJobResult.OUTCOME_PASS |
221 | - elif return_code < 0: |
222 | - outcome = IJobResult.OUTCOME_CRASH |
223 | - else: |
224 | - outcome = IJobResult.OUTCOME_FAIL |
225 | - # Create a result object and return it |
226 | - return JobResultBuilder( |
227 | - outcome=outcome, |
228 | - return_code=return_code, |
229 | - io_log_filename=record_path, |
230 | - execution_duration=execution_duration) |
231 | + try: |
232 | + result = ctrl.execute_remote_job(job, job_state, config) |
233 | + params = {} |
234 | + io_log_filename = getattr( |
235 | + result, 'io_log_filename', None) |
236 | + if io_log_filename: |
237 | + params['io_log_filename'] = io_log_filename |
238 | + if result.return_code is not None: |
239 | + params['return_code'] = result.return_code |
240 | + if result.execution_duration: |
241 | + params['execution_duration'] = result.execution_duration |
242 | + params['outcome'] = result.outcome |
243 | + return JobResultBuilder(**params) |
244 | + except AttributeError: |
245 | + # Run the embedded command |
246 | + start_time = time.time() |
247 | + return_code, record_path = self._run_command( |
248 | + job, job_state, config, ctrl) |
249 | + execution_duration = time.time() - start_time |
250 | + # Convert the return of the command to the outcome of the job |
251 | + if return_code == 0: |
252 | + outcome = IJobResult.OUTCOME_PASS |
253 | + elif return_code < 0: |
254 | + outcome = IJobResult.OUTCOME_CRASH |
255 | + else: |
256 | + outcome = IJobResult.OUTCOME_FAIL |
257 | + # Create a result object and return it |
258 | + return JobResultBuilder( |
259 | + outcome=outcome, |
260 | + return_code=return_code, |
261 | + io_log_filename=record_path, |
262 | + execution_duration=execution_duration) |
263 | |
264 | def _prepare_io_handling(self, job, config): |
265 | ui_io_delegate = self._command_io_delegate |
266 | diff --git a/plainbox/impl/session/assistant.py b/plainbox/impl/session/assistant.py |
267 | index d85c467..2cebb4f 100644 |
268 | --- a/plainbox/impl/session/assistant.py |
269 | +++ b/plainbox/impl/session/assistant.py |
270 | @@ -1278,6 +1278,72 @@ class SessionAssistant: |
271 | if jsm[job.id].result.outcome is None |
272 | ] |
273 | |
274 | + #@raises(ValueError, TypeError, UnexpectedMethodCall) |
275 | + def run_job_command( |
276 | + self, job_id: str, ui: 'Union[str, IJobRunnerUI]', |
277 | + native: bool |
278 | + ) -> 'JobResultBuilder': |
279 | + """ |
280 | + Run a job with the specific identifier. |
281 | + """ |
282 | + if isinstance(ui, IJobRunnerUI): |
283 | + pass |
284 | + elif isinstance(ui, str): |
285 | + if ui == 'silent': |
286 | + ui = _SilentUI() |
287 | + elif ui == 'piano': |
288 | + ui = _PianoUI() |
289 | + else: |
290 | + raise ValueError("unknown user interface: {!r}".format(ui)) |
291 | + else: |
292 | + raise TypeError("incorrect UI type") |
293 | + start_time = time.time() |
294 | + job_state = self._context.state.job_state_map[job_id] |
295 | + job = job_state.job |
296 | + ui.considering_job(job, job_state) |
297 | + ui.about_to_start_running(job, job_state) |
298 | + self._context.state.metadata.running_job_name = job.id |
299 | + self._manager.checkpoint() |
300 | + autorestart = (self._restart_strategy is not None and |
301 | + 'autorestart' in job.get_flag_set()) |
302 | + if autorestart: |
303 | + restart_cmd = ' '.join( |
304 | + shlex.quote(cmd_part) |
305 | + for cmd_part in self._restart_cmd_callback( |
306 | + self._manager.storage.id)) |
307 | + self._restart_strategy.prime_application_restart( |
308 | + self._app_id, self._manager.storage.id, restart_cmd) |
309 | + ui.started_running(job, job_state) |
310 | + if 'noreturn' in job.get_flag_set(): |
311 | + # 'share' the information how to respawn the application |
312 | + # once all the test actions are performed. |
313 | + # tests can read this from $PLAINBOX_PROVIDER_SHARE envvar |
314 | + checkbox_data_dir = os.path.join( |
315 | + self.get_session_dir(), 'CHECKBOX_DATA') |
316 | + if not os.path.exists(checkbox_data_dir): |
317 | + os.mkdir(checkbox_data_dir) |
318 | + respawn_cmd_file = os.path.join( |
319 | + checkbox_data_dir, '__respawn_checkbox') |
320 | + if self._restart_cmd_callback: |
321 | + with open(respawn_cmd_file, 'wt') as f: |
322 | + f.writelines(self._restart_cmd_callback( |
323 | + self.get_session_id())) |
324 | + if not native: |
325 | + builder = self._runner.run_job( |
326 | + job, job_state, self._config, ui |
327 | + ).get_builder() |
328 | + else: |
329 | + builder = JobResultBuilder( |
330 | + outcome=IJobResult.OUTCOME_UNDECIDED, |
331 | + ) |
332 | + builder.execution_duration = time.time() - start_time |
333 | + if autorestart: |
334 | + self._restart_strategy.diffuse_application_restart( |
335 | + self._app_id) |
336 | + self._manager.checkpoint() |
337 | + ui.finished_running(job, job_state, builder.get_result()) |
338 | + return builder |
339 | + |
340 | @raises(ValueError, TypeError, UnexpectedMethodCall) |
341 | def run_job( |
342 | self, job_id: str, ui: 'Union[str, IJobRunnerUI]', |
343 | @@ -1501,7 +1567,7 @@ class SessionAssistant: |
344 | # leave the same usage expectations |
345 | return |
346 | if SessionMetaData.FLAG_SUBMITTED not in self._metadata.flags: |
347 | - _logger.warning("Finalizing session that hasn't been submitted " |
348 | + _logger.info("Finalizing session that hasn't been submitted " |
349 | "anywhere: %s", self._manager.storage.id) |
350 | self._metadata.flags.remove(SessionMetaData.FLAG_INCOMPLETE) |
351 | self._manager.checkpoint() |
352 | diff --git a/plainbox/impl/session/remote_assistant.py b/plainbox/impl/session/remote_assistant.py |
353 | index e1a9930..fa28ae9 100644 |
354 | --- a/plainbox/impl/session/remote_assistant.py |
355 | +++ b/plainbox/impl/session/remote_assistant.py |
356 | @@ -42,7 +42,7 @@ from checkbox_ng.launcher.run import SilentUI |
357 | |
358 | _ = gettext.gettext |
359 | |
360 | -_logger = logging.getLogger("plainbox.session.assistant2") |
361 | +_logger = logging.getLogger("plainbox.session.remote_assistant") |
362 | |
363 | Interaction = namedtuple('Interaction', ['kind', 'message', 'extra']) |
364 | |
365 | @@ -74,7 +74,8 @@ class BufferedUI(SilentUI): |
366 | self.clear_buffers() |
367 | |
368 | def got_program_output(self, stream_name, line): |
369 | - self._queue.put(line.decode(sys.stdout.encoding, 'replace')) |
370 | + self._queue.put( |
371 | + (stream_name, line.decode(sys.stdout.encoding, 'replace'))) |
372 | self._whole_queue.put(line) |
373 | |
374 | def whole_output(self): |
375 | @@ -85,9 +86,9 @@ class BufferedUI(SilentUI): |
376 | |
377 | def get_output(self): |
378 | """Returns all the output queued up since previous call.""" |
379 | - output = '' |
380 | + output = [] |
381 | while not self._queue.empty(): |
382 | - output += self._queue.get() |
383 | + output.append(self._queue.get()) |
384 | return output |
385 | |
386 | def clear_buffers(self): |
387 | @@ -270,6 +271,18 @@ class RemoteSessionAssistant(): |
388 | self._state = TestsSelected |
389 | |
390 | @allowed_when(TestsSelected) |
391 | + def run_job_command(self, job_id): |
392 | + _logger.debug("run_job: %r", job_id) |
393 | + self._job_index = self._jobs_count - len( |
394 | + self._sa.get_dynamic_todo_list()) + 1 |
395 | + self._currently_running_job = job_id |
396 | + job = self._sa.get_job(job_id) |
397 | + if job.command: |
398 | + self._state = Running |
399 | + self._be = BackgroundExecutor( |
400 | + self, job_id, self._sa.run_job_command) |
401 | + |
402 | + @allowed_when(TestsSelected) |
403 | def run_job(self, job_id): |
404 | """ |
405 | Depending on the type of the job, run_job can yield different number |
406 | @@ -434,6 +447,17 @@ class RemoteSessionAssistant(): |
407 | self._state = Idle |
408 | else: |
409 | self._state = TestsSelected |
410 | + |
411 | + def finish_job_command(self): |
412 | + # assert the thread completed |
413 | + self.session_change_lock.acquire(blocking=False) |
414 | + self._session_change_lock.release() |
415 | + result = self._be.wait().get_result() |
416 | + if self._state != Bootstrapping: |
417 | + if not self._sa.get_dynamic_todo_list(): |
418 | + self._state = Idle |
419 | + else: |
420 | + self._state = TestsSelected |
421 | return result |
422 | |
423 | def get_jobs_repr(self, job_ids, offset=0): |
424 | diff --git a/plainbox/impl/test_ctrl.py b/plainbox/impl/test_ctrl.py |
425 | index a211b0e..63cd01e 100644 |
426 | --- a/plainbox/impl/test_ctrl.py |
427 | +++ b/plainbox/impl/test_ctrl.py |
428 | @@ -613,12 +613,12 @@ class UserJobExecutionControllerTests(CheckBoxExecutionControllerTestsMixIn, |
429 | @mock.patch('os.getuid') |
430 | def test_get_checkbox_score_as_root(self, mock_getuid, mock_plat): |
431 | """ |
432 | - verify that score for jobs with an user override is 4 if I am root |
433 | + verify that score for jobs with an user override is 5 if I am root |
434 | """ |
435 | mock_plat.return_value = 'linux' |
436 | mock_getuid.return_value = 0 # Pretend to be root |
437 | self.job.user = 'root' |
438 | - self.assertEqual(self.ctrl.get_checkbox_score(self.job), 4) |
439 | + self.assertEqual(self.ctrl.get_checkbox_score(self.job), 5) |
440 | |
441 | @mock.patch.dict('os.environ', clear=True) |
442 | def test_get_execution_environment_resets_locales(self): |
I'm not sure I fully understand the concept of what this is looking to do yet.
The intention is to "hand over" jobs that want to run as the root user to the remote-slave service? Why is this much better that just using localhost remote (assuming know bugs are fixed)? The obvious disadvantage being that if you start a session locally, you can't disconnect the front end and then re-connect somewhere else.