Merge ~sylvain-pineau/checkbox-ng:mini-me into checkbox-ng:remote-api-bump

Proposed by Sylvain Pineau
Status: Superseded
Proposed branch: ~sylvain-pineau/checkbox-ng:mini-me
Merge into: checkbox-ng:remote-api-bump
Diff against target: 477 lines (+368/-1) (has conflicts)
6 files modified
checkbox_ng/launcher/checkbox_cli.py (+2/-0)
checkbox_ng/launcher/mini_me.py (+327/-0)
plainbox/impl/execution.py (+7/-0)
plainbox/impl/session/assistant.py (+6/-0)
plainbox/impl/session/remote_assistant.py (+25/-0)
plainbox/impl/session/restart.py (+1/-1)
Conflict in plainbox/impl/session/remote_assistant.py
Reviewer Review Type Date Requested Status
Taihsiang Ho Needs Fixing
Review via email: mp+415465@code.launchpad.net

This proposal has been superseded by a proposal from 2022-02-15.

Description of the change

the checkbox-cli mini-me command (see the commits for details)

How to test?

The standalone command (quick an easy):

$ checkbox-cli mini-me --host 192.168.1.33 com.canonical.certification::usb/detect

Nested inside an existing checkbox remote/service session:

1. Prepare the following launcher (adjust the target host ip of course):

    [launcher]
    app_id = com.canonical.certification:checkbox-test
    launcher_version = 1
    stock_reports = text, submission_files

    [test plan]
    unit = com.canonical.certification::demo
    forced = yes

    [test selection]
    forced = yes

    [ui]
    output = hide-resource-and-attachment

    [environment]
    STRESS_BOOT_ITERATIONS=2
    STRESS_BOOT_WAIT_DELAY=10
    STRESS_BOOT_WAKEUP_DELAY=15
    TARGET_HOST=192.168.1.33

2. A small test plan like this:

    id: demo
    unit: test plan
    _name: demo tests
    _description:
     demo tests
    include:
     demo/usb/detect

3. A demo job calling the new command:

    plugin: shell
    category_id: com.canonical.plainbox::usb
    id: demo/usb/detect
    command:
     checkbox-cli mini-me com.canonical.certification::usb/detect
    _summary: Display USB devices attached to SUT
    _description: Detects and shows USB devices attached to this system.

4. Install checkbox-ng and the checkbox provider from the dev ppa on a spare system

5. From your laptop, start a checkbox service (after adding the test plan and the new job to one of your local providers, side-loading one is a good idea):

    sudo checkbox-cli service

6. Finally execute your launcher file with:

    checkbox-cli master 127.0.0.1 ./mylauncher

If everything goes well, the usb/detect output should list devices from your spare system

To post a comment you must log in.
Revision history for this message
Taihsiang Ho (tai271828) wrote :

Some diff-like unwanted lines are still in the merge proposal. See the inline comments below.

review: Needs Fixing

Unmerged commits

fc1d1f2... by Sylvain Pineau

mini-me: new checkbox-cli command

The "remote" version of the "local" `checkbox-cli run` command.

Supported pattern:
- Only a single job id is supported
- Only automated tests, no manual or semi-auto plugins
- Test plan ids are rejected

Selection of the target host:
- Target machine IP can be provided with either --host or the env var TARGET_HOST

Standard streams
- Remote stdout/stderr are printed locally on the the same local streams
- No colorizer

Session management:
- Ephemeral sessions on service side, removed after job execution
- launcher settings are transferred to the TARGET_HOST

73f7921... by Sylvain Pineau

session:remote_assistant: Expose the hand_pick_jobs() method

7a8cf0d... by Sylvain Pineau

session:assistant: add a method to save the launcher file in the session dir

save_launcher_file is also exposed over rpyc

f9918ab... by Sylvain Pineau

execution.py: Set a new env var PLAINBOX_SESSION_DIR

Full path to the current session directory

707dbcf... by Sylvain Pineau

execution.py: Set PYTHONUNBUFFERED for all child processes

055cc34... by Sylvain Pineau

fix: restart - Disable the checkbox-ng.service if __respawn_checkbox is used

Disable the service provided by checkbox-ng if another mechanism handles
the session restart.
e.g pm_test currently manages several reboots in a single job.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/checkbox_ng/launcher/checkbox_cli.py b/checkbox_ng/launcher/checkbox_cli.py
index 9740eb8..0ae7c14 100644
--- a/checkbox_ng/launcher/checkbox_cli.py
+++ b/checkbox_ng/launcher/checkbox_cli.py
@@ -38,6 +38,7 @@ from checkbox_ng.launcher.check_config import CheckConfig
38from checkbox_ng.launcher.merge_reports import MergeReports38from checkbox_ng.launcher.merge_reports import MergeReports
39from checkbox_ng.launcher.merge_submissions import MergeSubmissions39from checkbox_ng.launcher.merge_submissions import MergeSubmissions
40from checkbox_ng.launcher.master import RemoteMaster40from checkbox_ng.launcher.master import RemoteMaster
41from checkbox_ng.launcher.mini_me import RemoteMe
41from checkbox_ng.launcher.slave import RemoteSlave42from checkbox_ng.launcher.slave import RemoteSlave
4243
4344
@@ -67,6 +68,7 @@ def main():
67 'tp-export': TestPlanExport,68 'tp-export': TestPlanExport,
68 'service': RemoteSlave,69 'service': RemoteSlave,
69 'remote': RemoteMaster,70 'remote': RemoteMaster,
71 'mini-me': RemoteMe,
70 }72 }
71 deprecated_commands = {73 deprecated_commands = {
72 'slave': 'service',74 'slave': 'service',
diff --git a/checkbox_ng/launcher/mini_me.py b/checkbox_ng/launcher/mini_me.py
73new file mode 10064475new file mode 100644
index 0000000..266ed80
--- /dev/null
+++ b/checkbox_ng/launcher/mini_me.py
@@ -0,0 +1,327 @@
1# This file is part of Checkbox.
2#
3# Copyright 2022 Canonical Ltd.
4# Written by:
5# Sylvain Pineau <sylvain.pineau@canonical.com>
6#
7# Checkbox is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License version 3,
9# as published by the Free Software Foundation.
10#
11# Checkbox is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14# GNU General Public License for more details.
15#
16# You should have received a copy of the GNU General Public License
17# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
18"""
19This module contains implementation of the mini me end of the remote execution
20functionality.
21"""
22import contextlib
23import ipaddress
24import json
25import logging
26import os
27import select
28import socket
29import time
30import sys
31
32from functools import partial
33
34from plainbox.impl.session.remote_assistant import RemoteSessionAssistant
35from plainbox.vendor import rpyc
36from checkbox_ng.launcher.stages import MainLoopStage
37from checkbox_ng.launcher.stages import ReportsStage
38from tqdm import tqdm
39_logger = logging.getLogger("mini-me")
40
41
42def environ_or_required(key):
43 """Mapping for argparse to supply required or default from $ENV."""
44 if os.environ.get(key):
45 return {'default': os.environ.get(key)}
46 else:
47 return {'required': True}
48
49
50class RemoteMe(ReportsStage, MainLoopStage):
51 """
52 Control remote slave instance
53
54 This class implements the part that presents UI to the operator and
55 steers the session.
56 """
57
58 name = 'remote-me'
59
60 @property
61 def is_interactive(self):
62 return False
63
64 @property
65 def C(self):
66 return None
67
68 @property
69 def sa(self):
70 return self._sa
71
72 def invoked(self, ctx):
73 self._launcher_text = ''
74 self._is_bootstrapping = False
75 self._target_host = ctx.args.host
76 self._normal_user = ''
77 timeout = 600
78 deadline = time.time() + timeout
79 port = ctx.args.port
80 self.selection = [ctx.args.PATTERN]
81 if not ipaddress.ip_address(ctx.args.host).is_loopback:
82 _logger.info("Connecting to {}:{}. Timeout: {}s".format(
83 ctx.args.host, port, timeout))
84 while time.time() < deadline:
85 try:
86 self.connect_and_run(ctx.args.host, port)
87 break
88 except (ConnectionRefusedError, socket.timeout, OSError):
89 print('.', end='', flush=True)
90 time.sleep(1)
91 else:
92 print("\nConnection timed out.")
93
94 def connect_and_run(self, host, port=18871):
95 config = rpyc.core.protocol.DEFAULT_CONFIG.copy()
96 config['allow_all_attrs'] = True
97 config['sync_request_timeout'] = 120
98 keep_running = False
99 server_msg = None
100 self._prepare_transports()
101 interrupted = False
102 while True:
103 try:
104 if interrupted:
105 _logger.info("remote: Session interrupted")
106 interrupted = False # we are handling the interruption ATM
107 # next line can raise exception due to connection being
108 # lost so let's set the default behavior to quitting
109 keep_running = False
110 keep_running = self._handle_interrupt()
111 if not keep_running:
112 break
113 conn = rpyc.connect(host, port, config=config)
114 keep_running = True
115
116 def quitter(msg):
117 # this will be called when the slave decides to disconnect
118 # this master
119 nonlocal server_msg
120 nonlocal keep_running
121 keep_running = False
122 server_msg = msg
123 with contextlib.suppress(AttributeError):
124 # TODO: REMOTE_API
125 # when bumping the remote api make this bit obligatory
126 # i.e. remove the suppressing
127 conn.root.register_master_blaster(quitter)
128 self._sa = conn.root.get_sa()
129 self.sa.conn = conn
130 # TODO: REMOTE API RAPI: Remove this API on the next RAPI bump
131 # the check and bailout is not needed if the slave as up to
132 # date as this master, so after bumping RAPI we can assume
133 # that slave is always passwordless
134 if not self.sa.passwordless_sudo:
135 raise SystemExit(
136 "This version of Checkbox requires the service"
137 " to be run as root")
138 try:
139 slave_api_version = self.sa.get_remote_api_version()
140 except AttributeError:
141 raise SystemExit("Service doesn't declare Remote API"
142 " version. Update Checkbox on the"
143 " SUT!")
144 master_api_version = RemoteSessionAssistant.REMOTE_API_VERSION
145 if slave_api_version != master_api_version:
146 raise SystemExit(
147 "Remote API version mismatch. Service "
148 "uses: {}. Remote uses: {}".format(
149 slave_api_version, master_api_version))
150 state, payload = self.sa.whats_up()
151 _logger.info("remote: Main dispatch with state: %s", state)
152 keep_running = {
153 'idle': self.new_session,
154 'running': self.wait_and_continue,
155 'finalizing': self.finish_session,
156 'testsselected': partial(
157 self.run_jobs, resumed_session_info=payload),
158 'bootstrapping': self.restart,
159 'started': self.restart,
160 'interacting': partial(
161 self.resume_interacting, interaction=payload),
162 }[state]()
163 except EOFError as exc:
164 if keep_running:
165 print("Connection lost!")
166 # this is yucky but it works, in case of explicit
167 # connection closing by the slave we get this msg
168 _logger.info("remote: Connection lost due to: %s", exc)
169 if str(exc) == 'stream has been closed':
170 print('Service explicitly disconnected you. Possible '
171 'reason: new remote connected to the service')
172 break
173 print(exc)
174 time.sleep(1)
175 else:
176 # if keep_running got set to False it means that the
177 # network interruption was planned, AKA slave disconnected
178 # this master
179 print(server_msg)
180 break
181 except (ConnectionRefusedError, socket.timeout, OSError) as exc:
182 _logger.info("remote: Connection lost due to: %s", exc)
183 if not keep_running:
184 raise
185 # it's reconnecting, so we can ignore refuses
186 print('.', flush=True)
187 time.sleep(0.5)
188 except KeyboardInterrupt:
189 interrupted = True
190
191 if not keep_running:
192 break
193
194 def new_session(self):
195 _logger.info("remote: Starting new session.")
196 configuration = dict()
197 session_dir = os.environ.get('PLAINBOX_SESSION_DIR', '/tmp')
198 launcher_file = os.path.join(session_dir, 'launcher')
199 if os.path.isfile(launcher_file):
200 with open(launcher_file, 'rt', encoding='UTF-8') as f:
201 self._launcher_text = f.read()
202 configuration['launcher'] = self._launcher_text
203 configuration['normal_user'] = self._normal_user
204 self.sa.start_session(configuration)
205 self.sa.hand_pick_jobs(self.selection)
206 self.run_jobs()
207
208 def register_arguments(self, parser):
209 parser.add_argument('--host', help="target host",
210 **environ_or_required('TARGET_HOST'))
211 parser.add_argument('--port', type=int, default=18871, help=(
212 "port to connect to"))
213 parser.add_argument(
214 'PATTERN',
215 help="run a job matching the given regular expression")
216
217 def _handle_interrupt(self):
218 """
219 Returns True if the remote should keep running.
220 And False if it should quit.
221 """
222 self._sa.terminate()
223 return False
224
225 def cleanup_ephemeral_session(self):
226 storage = self.sa.manager.storage
227 self.sa.finalize_session()
228 storage.remove()
229
230 def finish_session(self):
231 self.cleanup_ephemeral_session()
232 return_code = self.job_result.return_code
233 if return_code:
234 raise SystemExit(return_code)
235
236 def wait_and_continue(self):
237 progress = self.sa.whats_up()[1]
238 print("Rejoined session.")
239 print("In progress: {} ({}/{})".format(
240 progress[2], progress[0], progress[1]))
241 self.wait_for_job()
242 self.run_jobs()
243
244 def _handle_last_job_after_resume(self, resumed_session_info):
245 jobs_repr = json.loads(
246 self.sa.get_jobs_repr([resumed_session_info['last_job']]))
247 job = jobs_repr[-1]
248 self.job_result = self.sa.get_job_result(job['id'])
249
250 def run_jobs(self, resumed_session_info=None):
251 if resumed_session_info and resumed_session_info['last_job']:
252 self._handle_last_job_after_resume(resumed_session_info)
253 _logger.info("remote: Running jobs.")
254 jobs = self.sa.get_session_progress()
255 _logger.debug("remote: Jobs to be run:\n%s",
256 '\n'.join([' ' + job for job in jobs]))
257 total_num = len(jobs['done']) + len(jobs['todo'])
258 if total_num > 1:
259 self.cleanup_ephemeral_session()
260 raise SystemExit("More than one job to run!")
261 if total_num == 0:
262 self.cleanup_ephemeral_session()
263 raise SystemExit(
264 "No job found with this id: {}".format(self.selection[0]))
265 jobs_repr = json.loads(
266 self.sa.get_jobs_repr(jobs['todo'], len(jobs['done'])))
267
268 self._run_jobs(jobs_repr, total_num)
269 self.finish_session()
270
271 def resume_interacting(self, interaction):
272 self.sa.remember_users_response('rollback')
273 self.run_jobs()
274
275 def wait_for_job(self, dont_finish=False):
276 _logger.info("remote: Waiting for job to finish.")
277 while True:
278 state, payload = self.sa.monitor_job()
279 if payload and not self._is_bootstrapping:
280 for line in payload.splitlines():
281 if line.startswith('stderr'):
282 print(line[6:], file=sys.stderr)
283 else:
284 print(line[6:])
285 if state == 'running':
286 time.sleep(0.5)
287 while True:
288 res = select.select([sys.stdin], [], [], 0)
289 if not res[0]:
290 break
291 # XXX: this assumes that sys.stdin is chunked in lines
292 buff = res[0][0].readline()
293 self.sa.transmit_input(buff)
294 if not buff:
295 break
296 else:
297 if dont_finish:
298 return
299 self.finish_job()
300 break
301
302 def finish_job(self, result=None):
303 _logger.info("remote: Finishing job with a result: %s", result)
304 self.job_result = self.sa.finish_job(result)
305
306 def abandon(self):
307 _logger.info("remote: Abandoning session.")
308 self.sa.finalize_session()
309
310 def restart(self):
311 _logger.info("remote: Restarting session.")
312 self.abandon()
313 self.new_session()
314
315 def _run_jobs(self, jobs_repr, total_num=0):
316 for job in jobs_repr:
317 # print("ID: {0}".format(job['id']))
318 if 'user' in job['plugin'] or 'manual' in job['plugin']:
319 self.cleanup_ephemeral_session()
320 raise SystemExit("Not an automated job: {0}".format(job['id']))
321 next_job = False
322 while next_job is False:
323 list(self.sa.run_job(job['id']))
324 self.wait_for_job()
325 break
326 if next_job:
327 continue
diff --git a/plainbox/impl/execution.py b/plainbox/impl/execution.py
index e1aa65b..06c19a1 100644
--- a/plainbox/impl/execution.py
+++ b/plainbox/impl/execution.py
@@ -468,6 +468,11 @@ def get_execution_environment(job, environ, session_id, nest_dir):
468 # Add per-session shared state directory468 # Add per-session shared state directory
469 env['PLAINBOX_SESSION_SHARE'] = WellKnownDirsHelper.session_share(469 env['PLAINBOX_SESSION_SHARE'] = WellKnownDirsHelper.session_share(
470 session_id)470 session_id)
471 # Add the session directory
472 env['PLAINBOX_SESSION_DIR'] = WellKnownDirsHelper.session_dir(
473 session_id)
474 # Force the stdout and stderr streams to be unbuffered
475 env['PYTHONUNBUFFERED'] = "1"
471476
472 def set_if_not_none(envvar, source):477 def set_if_not_none(envvar, source):
473 """Update env if the source variable is not None"""478 """Update env if the source variable is not None"""
@@ -542,6 +547,8 @@ def get_differential_execution_environment(job, environ, session_id, nest_dir,
542 for key, value in base_env.items():547 for key, value in base_env.items():
543 if key in copy_vars or key.startswith('SNAP'):548 if key in copy_vars or key.startswith('SNAP'):
544 delta_env[key] = value549 delta_env[key] = value
550 # Force the stdout and stderr streams to be unbuffered
551 delta_env['PYTHONUNBUFFERED'] = "1"
545 return delta_env552 return delta_env
546553
547554
diff --git a/plainbox/impl/session/assistant.py b/plainbox/impl/session/assistant.py
index c560828..253148e 100644
--- a/plainbox/impl/session/assistant.py
+++ b/plainbox/impl/session/assistant.py
@@ -613,6 +613,12 @@ class SessionAssistant:
613 self._context.state.metadata.app_blob = updated_blob613 self._context.state.metadata.app_blob = updated_blob
614 self._manager.checkpoint()614 self._manager.checkpoint()
615615
616 def save_launcher_file(self, launcher_text):
617 session_dir = WellKnownDirsHelper.session_dir(self.get_session_id())
618 launcher_file = os.path.join(session_dir, 'launcher')
619 with open(launcher_file, 'wt', encoding='UTF-8') as f:
620 f.write(launcher_text)
621
616 @morris.signal622 @morris.signal
617 def session_available(self, session_id):623 def session_available(self, session_id):
618 """624 """
diff --git a/plainbox/impl/session/remote_assistant.py b/plainbox/impl/session/remote_assistant.py
index 96f5de4..6681c9b 100644
--- a/plainbox/impl/session/remote_assistant.py
+++ b/plainbox/impl/session/remote_assistant.py
@@ -31,6 +31,7 @@ from subprocess import CalledProcessError, check_output
3131
32from plainbox.impl.config import Configuration32from plainbox.impl.config import Configuration
33from plainbox.impl.execution import UnifiedRunner33from plainbox.impl.execution import UnifiedRunner
34from plainbox.impl.session import SessionMetaData
34from plainbox.impl.session.assistant import SessionAssistant35from plainbox.impl.session.assistant import SessionAssistant
35from plainbox.impl.session.assistant import SA_RESTARTABLE36from plainbox.impl.session.assistant import SA_RESTARTABLE
36from plainbox.impl.session.jobs import InhibitionCause37from plainbox.impl.session.jobs import InhibitionCause
@@ -185,6 +186,9 @@ class RemoteSessionAssistant():
185 def update_app_blob(self, app_blob):186 def update_app_blob(self, app_blob):
186 self._sa.update_app_blob(app_blob)187 self._sa.update_app_blob(app_blob)
187188
189 def save_launcher_file(self, launcher_text):
190 self._sa.save_launcher_file(launcher_text)
191
188 def allowed_when(*states):192 def allowed_when(*states):
189 def wrap(f):193 def wrap(f):
190 def fun(self, *args):194 def fun(self, *args):
@@ -301,6 +305,7 @@ class RemoteSessionAssistant():
301 'effective_normal_user': self._normal_user,305 'effective_normal_user': self._normal_user,
302 }).encode("UTF-8")306 }).encode("UTF-8")
303 self._sa.update_app_blob(new_blob)307 self._sa.update_app_blob(new_blob)
308 self._sa.save_launcher_file(configuration['launcher'])
304 self._sa.configure_application_restart(self._cmd_callback)309 self._sa.configure_application_restart(self._cmd_callback)
305310
306 self._session_id = self._sa.get_session_id()311 self._session_id = self._sa.get_session_id()
@@ -333,6 +338,11 @@ class RemoteSessionAssistant():
333 def get_bootstrapping_todo_list(self):338 def get_bootstrapping_todo_list(self):
334 return self._sa.get_bootstrap_todo_list()339 return self._sa.get_bootstrap_todo_list()
335340
341 @allowed_when(Started)
342 def hand_pick_jobs(self, id_patterns):
343 self._sa.hand_pick_jobs(id_patterns)
344 self._state = TestsSelected
345
336 def finish_bootstrap(self):346 def finish_bootstrap(self):
337 self._sa.finish_bootstrap()347 self._sa.finish_bootstrap()
338 self._state = Bootstrapped348 self._state = Bootstrapped
@@ -646,6 +656,7 @@ class RemoteSessionAssistant():
646 'extra_env': self.prepare_extra_env,656 'extra_env': self.prepare_extra_env,
647 }657 }
648 meta = self._sa.resume_session(session_id, runner_kwargs=runner_kwargs)658 meta = self._sa.resume_session(session_id, runner_kwargs=runner_kwargs)
659<<<<<<< plainbox/impl/session/remote_assistant.py
649 app_blob = json.loads(meta.app_blob.decode("UTF-8"))660 app_blob = json.loads(meta.app_blob.decode("UTF-8"))
650 launcher = app_blob['launcher']661 launcher = app_blob['launcher']
651 self._launcher = Configuration.from_text(launcher, 'Remote launcher')662 self._launcher = Configuration.from_text(launcher, 'Remote launcher')
@@ -659,6 +670,20 @@ class RemoteSessionAssistant():
659 test_plan_id = app_blob['testplan_id']670 test_plan_id = app_blob['testplan_id']
660 self._sa.select_test_plan(test_plan_id)671 self._sa.select_test_plan(test_plan_id)
661 self._sa.bootstrap()672 self._sa.bootstrap()
673=======
674 if SessionMetaData.FLAG_TESTPLANLESS not in meta.flags:
675 app_blob = json.loads(meta.app_blob.decode("UTF-8"))
676 launcher = app_blob['launcher']
677 self._launcher.read_string(launcher, False)
678 self._sa.use_alternate_configuration(self._launcher)
679 self._normal_user = app_blob.get(
680 'effective_normal_user', self._launcher.normal_user)
681 _logger.info(
682 "normal_user after loading metadata: %r", self._normal_user)
683 test_plan_id = app_blob['testplan_id']
684 self._sa.select_test_plan(test_plan_id)
685 self._sa.bootstrap()
686>>>>>>> plainbox/impl/session/remote_assistant.py
662 self._last_job = meta.running_job_name687 self._last_job = meta.running_job_name
663688
664 result_dict = {689 result_dict = {
diff --git a/plainbox/impl/session/restart.py b/plainbox/impl/session/restart.py
index b3c72cd..a79741b 100644
--- a/plainbox/impl/session/restart.py
+++ b/plainbox/impl/session/restart.py
@@ -255,7 +255,7 @@ class RemoteDebRestartStrategy(RemoteSnappyRestartStrategy):
255 with open(self.session_resume_filename, 'wt') as f:255 with open(self.session_resume_filename, 'wt') as f:
256 f.write(session_id)256 f.write(session_id)
257 os.fsync(f.fileno())257 os.fsync(f.fileno())
258 if cmd == self.service_name:258 if cmd != self.service_name:
259 subprocess.call(['systemctl', 'disable', self.service_name])259 subprocess.call(['systemctl', 'disable', self.service_name])
260260
261261

Subscribers

People subscribed via source and target branches