Merge ~sylvain-pineau/checkbox-ng:remote-interact-fixes into checkbox-ng:master
- Git
- lp:~sylvain-pineau/checkbox-ng
- remote-interact-fixes
- Merge into master
Status: | Work in progress |
---|---|
Proposed branch: | ~sylvain-pineau/checkbox-ng:remote-interact-fixes |
Merge into: | checkbox-ng:master |
Diff against target: |
304 lines (+106/-52) 4 files modified
checkbox_ng/launcher/master.py (+79/-34) plainbox/impl/execution.py (+2/-0) plainbox/impl/session/assistant.py (+2/-0) plainbox/impl/session/remote_assistant.py (+23/-18) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Checkbox Developers | Pending | ||
Review via email:
|
Commit message
Description of the change
Multiple fixes to support all interactions type for manual jobs (skip, rerun).
Fix user interact types jobs where command output was not streamed resulting in a re-connection after 30s.
Unmerged commits
- 32b2d59... by Sylvain Pineau
-
remote:sa: pep8
- 05688f1... by Sylvain Pineau
-
remote:master: extend the socket timeout to 60s
- 37b1d3b... by Sylvain Pineau
-
remote: Fix user-interact jobs command not streamed and rerun interactions
- Properly catch ReRunJob exception on master side
- Always yield something from the run_job generator
- on master keep looping until next_job is False
- skip results are now handled on master side (interaction.kind == 'skip') - b2083cf... by Sylvain Pineau
-
session:
remote_ assistant: Only sleep 0.01s to stream faster - 8fa0e77... by Sylvain Pineau
-
session:assistant: Ensure all jobs can write to CHECKBOX_DATA
- 21efbb1... by Sylvain Pineau
-
execution: Ensure all jobs can write to CHECKBOX_DATA
- 2f78a4f... by Sylvain Pineau
-
master: Add a --user to specify the target (i.e slave) normal user
- c6bdeb3... by Sylvain Pineau
-
master: Hide connection info when running on localhost
- e41ebe0... by Sylvain Pineau
-
master: Use getpass.getuser() to find the normal user when running on localhost
- f897c6e... by Sylvain Pineau
-
execution: pep8
Preview Diff
1 | diff --git a/checkbox_ng/launcher/master.py b/checkbox_ng/launcher/master.py | |||
2 | index 7fb87f7..de46dc9 100644 | |||
3 | --- a/checkbox_ng/launcher/master.py | |||
4 | +++ b/checkbox_ng/launcher/master.py | |||
5 | @@ -19,7 +19,9 @@ | |||
6 | 19 | This module contains implementation of the master end of the remote execution | 19 | This module contains implementation of the master end of the remote execution |
7 | 20 | functionality. | 20 | functionality. |
8 | 21 | """ | 21 | """ |
9 | 22 | import getpass | ||
10 | 22 | import gettext | 23 | import gettext |
11 | 24 | import ipaddress | ||
12 | 23 | import logging | 25 | import logging |
13 | 24 | import os | 26 | import os |
14 | 25 | import select | 27 | import select |
15 | @@ -43,7 +45,7 @@ from checkbox_ng.urwid_ui import CategoryBrowser | |||
16 | 43 | from checkbox_ng.urwid_ui import ReRunBrowser | 45 | from checkbox_ng.urwid_ui import ReRunBrowser |
17 | 44 | from checkbox_ng.urwid_ui import interrupt_dialog | 46 | from checkbox_ng.urwid_ui import interrupt_dialog |
18 | 45 | from checkbox_ng.urwid_ui import resume_dialog | 47 | from checkbox_ng.urwid_ui import resume_dialog |
20 | 46 | from checkbox_ng.launcher.run import NormalUI | 48 | from checkbox_ng.launcher.run import NormalUI, ReRunJob |
21 | 47 | from checkbox_ng.launcher.stages import MainLoopStage | 49 | from checkbox_ng.launcher.stages import MainLoopStage |
22 | 48 | from checkbox_ng.launcher.stages import ReportsStage | 50 | from checkbox_ng.launcher.stages import ReportsStage |
23 | 49 | _ = gettext.gettext | 51 | _ = gettext.gettext |
24 | @@ -122,6 +124,7 @@ class RemoteMaster(Command, ReportsStage, MainLoopStage): | |||
25 | 122 | self._is_bootstrapping = False | 124 | self._is_bootstrapping = False |
26 | 123 | self._target_host = ctx.args.host | 125 | self._target_host = ctx.args.host |
27 | 124 | self._sudo_provider = None | 126 | self._sudo_provider = None |
28 | 127 | self._normal_user = '' | ||
29 | 125 | self.launcher = DefaultLauncherDefinition() | 128 | self.launcher = DefaultLauncherDefinition() |
30 | 126 | if ctx.args.launcher: | 129 | if ctx.args.launcher: |
31 | 127 | expanded_path = os.path.expanduser(ctx.args.launcher) | 130 | expanded_path = os.path.expanduser(ctx.args.launcher) |
32 | @@ -131,11 +134,18 @@ class RemoteMaster(Command, ReportsStage, MainLoopStage): | |||
33 | 131 | with open(expanded_path, 'rt') as f: | 134 | with open(expanded_path, 'rt') as f: |
34 | 132 | self._launcher_text = f.read() | 135 | self._launcher_text = f.read() |
35 | 133 | self.launcher.read_string(self._launcher_text) | 136 | self.launcher.read_string(self._launcher_text) |
36 | 137 | if 'local' in ctx.args.host: | ||
37 | 138 | ctx.args.host = '127.0.0.1' | ||
38 | 139 | if ipaddress.ip_address(ctx.args.host).is_loopback: | ||
39 | 140 | self._normal_user = getpass.getuser() | ||
40 | 141 | if ctx.args.user: | ||
41 | 142 | self._normal_user = ctx.args.user | ||
42 | 134 | timeout = 600 | 143 | timeout = 600 |
43 | 135 | deadline = time.time() + timeout | 144 | deadline = time.time() + timeout |
44 | 136 | port = ctx.args.port | 145 | port = ctx.args.port |
47 | 137 | print(_("Connecting to {}:{}. Timeout: {}s").format( | 146 | if not ipaddress.ip_address(ctx.args.host).is_loopback: |
48 | 138 | ctx.args.host, port, timeout)) | 147 | print(_("Connecting to {}:{}. Timeout: {}s").format( |
49 | 148 | ctx.args.host, port, timeout)) | ||
50 | 139 | while time.time() < deadline: | 149 | while time.time() < deadline: |
51 | 140 | try: | 150 | try: |
52 | 141 | self.connect_and_run(ctx.args.host, port) | 151 | self.connect_and_run(ctx.args.host, port) |
53 | @@ -149,6 +159,7 @@ class RemoteMaster(Command, ReportsStage, MainLoopStage): | |||
54 | 149 | def connect_and_run(self, host, port=18871): | 159 | def connect_and_run(self, host, port=18871): |
55 | 150 | config = rpyc.core.protocol.DEFAULT_CONFIG.copy() | 160 | config = rpyc.core.protocol.DEFAULT_CONFIG.copy() |
56 | 151 | config['allow_all_attrs'] = True | 161 | config['allow_all_attrs'] = True |
57 | 162 | config['sync_request_timeout'] = 60 | ||
58 | 152 | keep_running = False | 163 | keep_running = False |
59 | 153 | self._prepare_transports() | 164 | self._prepare_transports() |
60 | 154 | interrupted = False | 165 | interrupted = False |
61 | @@ -217,6 +228,7 @@ class RemoteMaster(Command, ReportsStage, MainLoopStage): | |||
62 | 217 | _logger.info("master: Starting new session.") | 228 | _logger.info("master: Starting new session.") |
63 | 218 | configuration = dict() | 229 | configuration = dict() |
64 | 219 | configuration['launcher'] = self._launcher_text | 230 | configuration['launcher'] = self._launcher_text |
65 | 231 | configuration['normal_user'] = self._normal_user | ||
66 | 220 | 232 | ||
67 | 221 | tps = self.sa.start_session(configuration) | 233 | tps = self.sa.start_session(configuration) |
68 | 222 | _logger.debug("master: Session started. Available TPs:\n%s", | 234 | _logger.debug("master: Session started. Available TPs:\n%s", |
69 | @@ -296,6 +308,8 @@ class RemoteMaster(Command, ReportsStage, MainLoopStage): | |||
70 | 296 | "launcher definition file to use")) | 308 | "launcher definition file to use")) |
71 | 297 | parser.add_argument('--port', type=int, default=18871, help=_( | 309 | parser.add_argument('--port', type=int, default=18871, help=_( |
72 | 298 | "port to connect to")) | 310 | "port to connect to")) |
73 | 311 | parser.add_argument('-u', '--user', help=_( | ||
74 | 312 | "normal user to run non-root jobs")) | ||
75 | 299 | 313 | ||
76 | 300 | def _handle_interrupt(self): | 314 | def _handle_interrupt(self): |
77 | 301 | """ | 315 | """ |
78 | @@ -478,37 +492,68 @@ class RemoteMaster(Command, ReportsStage, MainLoopStage): | |||
79 | 478 | print(_("Category: {0}").format(job['category_name'])) | 492 | print(_("Category: {0}").format(job['category_name'])) |
80 | 479 | SimpleUI.horiz_line() | 493 | SimpleUI.horiz_line() |
81 | 480 | next_job = False | 494 | next_job = False |
95 | 481 | for interaction in self.sa.run_job(job['id']): | 495 | while next_job is False: |
96 | 482 | if interaction.kind == 'sudo_input': | 496 | for interaction in self.sa.run_job(job['id']): |
97 | 483 | self.sa.save_password( | 497 | if interaction.kind == 'sudo_input': |
98 | 484 | self._sudo_provider.encrypted_password) | 498 | self.sa.save_password( |
99 | 485 | if interaction.kind == 'purpose': | 499 | self._sudo_provider.encrypted_password) |
100 | 486 | SimpleUI.description(_('Purpose:'), interaction.message) | 500 | if interaction.kind == 'purpose': |
101 | 487 | elif interaction.kind in ['description', 'steps']: | 501 | SimpleUI.description(_('Purpose:'), |
102 | 488 | SimpleUI.description(_('Steps:'), interaction.message) | 502 | interaction.message) |
103 | 489 | if job['command'] is None: | 503 | elif interaction.kind == 'description': |
104 | 490 | cmd = 'run' | 504 | SimpleUI.description(_('Description:'), |
105 | 491 | else: | 505 | interaction.message) |
106 | 492 | cmd = SimpleUI(None).wait_for_interaction_prompt(None) | 506 | if job['command'] is None: |
107 | 493 | if cmd == 'skip': | 507 | cmd = 'run' |
108 | 508 | else: | ||
109 | 509 | cmd = SimpleUI( | ||
110 | 510 | None).wait_for_interaction_prompt(None) | ||
111 | 511 | if cmd == 'skip': | ||
112 | 512 | next_job = True | ||
113 | 513 | self.sa.remember_users_response(cmd) | ||
114 | 514 | self.wait_for_job(dont_finish=True) | ||
115 | 515 | elif interaction.kind in 'steps': | ||
116 | 516 | SimpleUI.description(_('Steps:'), interaction.message) | ||
117 | 517 | if job['command'] is None: | ||
118 | 518 | cmd = 'run' | ||
119 | 519 | else: | ||
120 | 520 | cmd = SimpleUI( | ||
121 | 521 | None).wait_for_interaction_prompt(None) | ||
122 | 522 | if cmd == 'skip': | ||
123 | 523 | next_job = True | ||
124 | 524 | self.sa.remember_users_response(cmd) | ||
125 | 525 | elif interaction.kind == 'verification': | ||
126 | 526 | self.wait_for_job(dont_finish=True) | ||
127 | 527 | if interaction.message: | ||
128 | 528 | SimpleUI.description( | ||
129 | 529 | _('Verification:'), interaction.message) | ||
130 | 530 | JobAdapter = namedtuple('job_adapter', ['command']) | ||
131 | 531 | job_lite = JobAdapter(job['command']) | ||
132 | 532 | try: | ||
133 | 533 | cmd = SimpleUI(None)._interaction_callback( | ||
134 | 534 | job_lite, interaction.extra._builder) | ||
135 | 535 | self.sa.remember_users_response(cmd) | ||
136 | 536 | self.finish_job( | ||
137 | 537 | interaction.extra._builder.get_result()) | ||
138 | 538 | next_job = True | ||
139 | 539 | break | ||
140 | 540 | except ReRunJob: | ||
141 | 541 | next_job = False | ||
142 | 542 | self.sa.rerun_job( | ||
143 | 543 | job['id'], | ||
144 | 544 | interaction.extra._builder.get_result()) | ||
145 | 545 | break | ||
146 | 546 | elif interaction.kind == 'comment': | ||
147 | 547 | new_comment = input(SimpleUI.C.BLUE( | ||
148 | 548 | _('Please enter your comments:') + '\n')) | ||
149 | 549 | self.sa.remember_users_response(new_comment + '\n') | ||
150 | 550 | elif interaction.kind == 'skip': | ||
151 | 551 | self.finish_job( | ||
152 | 552 | interaction.extra._builder.get_result()) | ||
153 | 494 | next_job = True | 553 | next_job = True |
171 | 495 | self.sa.remember_users_response(cmd) | 554 | break |
172 | 496 | elif interaction.kind == 'verification': | 555 | else: |
173 | 497 | self.wait_for_job(dont_finish=True) | 556 | self.wait_for_job() |
174 | 498 | if interaction.message: | 557 | break |
158 | 499 | SimpleUI.description( | ||
159 | 500 | _('Verification:'), interaction.message) | ||
160 | 501 | JobAdapter = namedtuple('job_adapter', ['command']) | ||
161 | 502 | job = JobAdapter(job['command']) | ||
162 | 503 | cmd = SimpleUI(None)._interaction_callback( | ||
163 | 504 | job, interaction.extra) | ||
164 | 505 | self.sa.remember_users_response(cmd) | ||
165 | 506 | self.finish_job(interaction.extra.get_result()) | ||
166 | 507 | next_job = True | ||
167 | 508 | elif interaction.kind == 'comment': | ||
168 | 509 | new_comment = input(SimpleUI.C.BLUE( | ||
169 | 510 | _('Please enter your comments:') + '\n')) | ||
170 | 511 | self.sa.remember_users_response(new_comment + '\n') | ||
175 | 512 | if next_job: | 558 | if next_job: |
176 | 513 | continue | 559 | continue |
177 | 514 | self.wait_for_job() | ||
178 | diff --git a/plainbox/impl/execution.py b/plainbox/impl/execution.py | |||
179 | index 38c1221..8c93f16 100644 | |||
180 | --- a/plainbox/impl/execution.py | |||
181 | +++ b/plainbox/impl/execution.py | |||
182 | @@ -265,7 +265,9 @@ class UnifiedRunner(IJobRunner): | |||
183 | 265 | extcmd_popen._delegate.on_end(proc.returncode) | 265 | extcmd_popen._delegate.on_end(proc.returncode) |
184 | 266 | return proc.returncode | 266 | return proc.returncode |
185 | 267 | if not os.path.isdir(os.path.join(self._session_dir, "CHECKBOX_DATA")): | 267 | if not os.path.isdir(os.path.join(self._session_dir, "CHECKBOX_DATA")): |
186 | 268 | oldmask = os.umask(000) | ||
187 | 268 | os.makedirs(os.path.join(self._session_dir, "CHECKBOX_DATA")) | 269 | os.makedirs(os.path.join(self._session_dir, "CHECKBOX_DATA")) |
188 | 270 | os.umask(oldmask) | ||
189 | 269 | # Setup the executable nest directory | 271 | # Setup the executable nest directory |
190 | 270 | with self.configured_filesystem(job) as nest_dir: | 272 | with self.configured_filesystem(job) as nest_dir: |
191 | 271 | # Get the command and the environment. | 273 | # Get the command and the environment. |
192 | diff --git a/plainbox/impl/session/assistant.py b/plainbox/impl/session/assistant.py | |||
193 | index abcd58c..f941793 100644 | |||
194 | --- a/plainbox/impl/session/assistant.py | |||
195 | +++ b/plainbox/impl/session/assistant.py | |||
196 | @@ -1321,7 +1321,9 @@ class SessionAssistant: | |||
197 | 1321 | checkbox_data_dir = os.path.join( | 1321 | checkbox_data_dir = os.path.join( |
198 | 1322 | self.get_session_dir(), 'CHECKBOX_DATA') | 1322 | self.get_session_dir(), 'CHECKBOX_DATA') |
199 | 1323 | if not os.path.exists(checkbox_data_dir): | 1323 | if not os.path.exists(checkbox_data_dir): |
200 | 1324 | oldmask = os.umask(000) | ||
201 | 1324 | os.mkdir(checkbox_data_dir) | 1325 | os.mkdir(checkbox_data_dir) |
202 | 1326 | os.umask(oldmask) | ||
203 | 1325 | respawn_cmd_file = os.path.join( | 1327 | respawn_cmd_file = os.path.join( |
204 | 1326 | checkbox_data_dir, '__respawn_checkbox') | 1328 | checkbox_data_dir, '__respawn_checkbox') |
205 | 1327 | if self._restart_cmd_callback: | 1329 | if self._restart_cmd_callback: |
206 | diff --git a/plainbox/impl/session/remote_assistant.py b/plainbox/impl/session/remote_assistant.py | |||
207 | index 35a5b11..48b2ac0 100644 | |||
208 | --- a/plainbox/impl/session/remote_assistant.py | |||
209 | +++ b/plainbox/impl/session/remote_assistant.py | |||
210 | @@ -25,12 +25,10 @@ import time | |||
211 | 25 | import sys | 25 | import sys |
212 | 26 | from collections import namedtuple | 26 | from collections import namedtuple |
213 | 27 | from threading import Thread, Lock | 27 | from threading import Thread, Lock |
214 | 28 | from subprocess import DEVNULL, CalledProcessError, check_call | ||
215 | 29 | 28 | ||
216 | 30 | from plainbox.impl.execution import UnifiedRunner | 29 | from plainbox.impl.execution import UnifiedRunner |
217 | 31 | from plainbox.impl.session.assistant import SessionAssistant | 30 | from plainbox.impl.session.assistant import SessionAssistant |
218 | 32 | from plainbox.impl.session.assistant import SA_RESTARTABLE | 31 | from plainbox.impl.session.assistant import SA_RESTARTABLE |
219 | 33 | from plainbox.impl.session.jobs import InhibitionCause | ||
220 | 34 | from plainbox.impl.secure.sudo_broker import SudoBroker, EphemeralKey | 32 | from plainbox.impl.secure.sudo_broker import SudoBroker, EphemeralKey |
221 | 35 | from plainbox.impl.secure.sudo_broker import is_passwordless_sudo | 33 | from plainbox.impl.secure.sudo_broker import is_passwordless_sudo |
222 | 36 | from plainbox.impl.secure.sudo_broker import validate_pass | 34 | from plainbox.impl.secure.sudo_broker import validate_pass |
223 | @@ -103,7 +101,7 @@ class BackgroundExecutor(Thread): | |||
224 | 103 | def wait(self): | 101 | def wait(self): |
225 | 104 | while self.is_alive() or not self._started_real_run: | 102 | while self.is_alive() or not self._started_real_run: |
226 | 105 | # return control to RPC server | 103 | # return control to RPC server |
228 | 106 | time.sleep(0.1) | 104 | time.sleep(0.01) |
229 | 107 | self.join() | 105 | self.join() |
230 | 108 | return self._builder | 106 | return self._builder |
231 | 109 | 107 | ||
232 | @@ -213,6 +211,8 @@ class RemoteSessionAssistant(): | |||
233 | 213 | self._sa.load_providers() | 211 | self._sa.load_providers() |
234 | 214 | 212 | ||
235 | 215 | self._normal_user = self._launcher.normal_user | 213 | self._normal_user = self._launcher.normal_user |
236 | 214 | if configuration['normal_user']: | ||
237 | 215 | self._normal_user = configuration['normal_user'] | ||
238 | 216 | pass_provider = (None if self._passwordless_sudo else | 216 | pass_provider = (None if self._passwordless_sudo else |
239 | 217 | self.get_decrypted_password) | 217 | self.get_decrypted_password) |
240 | 218 | runner_kwargs = { | 218 | runner_kwargs = { |
241 | @@ -275,6 +275,13 @@ class RemoteSessionAssistant(): | |||
242 | 275 | self._jobs_count = len(self._sa.get_dynamic_todo_list()) | 275 | self._jobs_count = len(self._sa.get_dynamic_todo_list()) |
243 | 276 | self._state = TestsSelected | 276 | self._state = TestsSelected |
244 | 277 | 277 | ||
245 | 278 | @allowed_when(Interacting) | ||
246 | 279 | def rerun_job(self, job_id, result): | ||
247 | 280 | self._sa.use_job_result(job_id, result) | ||
248 | 281 | self.session_change_lock.acquire(blocking=False) | ||
249 | 282 | self.session_change_lock.release() | ||
250 | 283 | self._state = TestsSelected | ||
251 | 284 | |||
252 | 278 | @allowed_when(TestsSelected) | 285 | @allowed_when(TestsSelected) |
253 | 279 | def run_job(self, job_id): | 286 | def run_job(self, job_id): |
254 | 280 | """ | 287 | """ |
255 | @@ -299,7 +306,8 @@ class RemoteSessionAssistant(): | |||
256 | 299 | yield from self.interact( | 306 | yield from self.interact( |
257 | 300 | Interaction('purpose', job.tr_purpose())) | 307 | Interaction('purpose', job.tr_purpose())) |
258 | 301 | if job.tr_steps(): | 308 | if job.tr_steps(): |
260 | 302 | yield from self.interact(Interaction('steps', job.tr_steps())) | 309 | yield from self.interact( |
261 | 310 | Interaction('steps', job.tr_steps())) | ||
262 | 303 | if self._last_response == 'comment': | 311 | if self._last_response == 'comment': |
263 | 304 | yield from self.interact(Interaction('comment')) | 312 | yield from self.interact(Interaction('comment')) |
264 | 305 | if self._last_response: | 313 | if self._last_response: |
265 | @@ -307,13 +315,16 @@ class RemoteSessionAssistant(): | |||
266 | 307 | may_comment = True | 315 | may_comment = True |
267 | 308 | continue | 316 | continue |
268 | 309 | if self._last_response == 'skip': | 317 | if self._last_response == 'skip': |
276 | 310 | result_builder = JobResultBuilder( | 318 | def skipped_builder(*args, **kwargs): |
277 | 311 | outcome=IJobResult.OUTCOME_SKIP, | 319 | result_builder = JobResultBuilder( |
278 | 312 | comments=_("Explicitly skipped before execution")) | 320 | outcome=IJobResult.OUTCOME_SKIP, |
279 | 313 | if self._current_comments != "": | 321 | comments=_("Explicitly skipped before execution")) |
280 | 314 | result_builder.comments = self._current_comments | 322 | if self._current_comments != "": |
281 | 315 | self.finish_job(result_builder.get_result()) | 323 | result_builder.comments = self._current_comments |
282 | 316 | return | 324 | return result_builder |
283 | 325 | self._be = BackgroundExecutor(self, job_id, skipped_builder) | ||
284 | 326 | yield from self.interact( | ||
285 | 327 | Interaction('skip', job.verification, self._be)) | ||
286 | 317 | if job.command: | 328 | if job.command: |
287 | 318 | if (job.user and not self._passwordless_sudo | 329 | if (job.user and not self._passwordless_sudo |
288 | 319 | and not self._sudo_password): | 330 | and not self._sudo_password): |
289 | @@ -338,14 +349,8 @@ class RemoteSessionAssistant(): | |||
290 | 338 | self._be = BackgroundExecutor(self, job_id, undecided_builder) | 349 | self._be = BackgroundExecutor(self, job_id, undecided_builder) |
291 | 339 | if self._sa.get_job(self._currently_running_job).plugin in [ | 350 | if self._sa.get_job(self._currently_running_job).plugin in [ |
292 | 340 | 'manual', 'user-interact-verify']: | 351 | 'manual', 'user-interact-verify']: |
293 | 341 | rb = self._be.wait() | ||
294 | 342 | # by this point the ui will handle adding comments via | ||
295 | 343 | # ResultBuilder.add_comment method that adds \n in front | ||
296 | 344 | # of the addition, let's rstrip it | ||
297 | 345 | rb.comments = self._current_comments.rstrip() | ||
298 | 346 | yield from self.interact( | 352 | yield from self.interact( |
301 | 347 | Interaction('verification', job.verification, rb)) | 353 | Interaction('verification', job.verification, self._be)) |
300 | 348 | self.finish_job(rb.get_result()) | ||
302 | 349 | 354 | ||
303 | 350 | @allowed_when(Started, Bootstrapping) | 355 | @allowed_when(Started, Bootstrapping) |
304 | 351 | def run_bootstrapping_job(self, job_id): | 356 | def run_bootstrapping_job(self, job_id): |