class SessionAssistant2(): """Remote execution enabling wrapper for the SessionAssistant""" # TODO: the name? def __init__(self, cmd_callback): _logger.debug("__init__()") self._state = Idle self._sa = SessionAssistant('service', api_flags={SA_RESTARTABLE}) self._sa.configure_application_restart(cmd_callback) self._sudo_broker = SudoBroker() self._sudo_password = None self._sa.use_alternate_execution_controllers([ (RootViaSudoWithPassExecutionController, (), { 'password_provider_cls': self.get_decrypted_password }), (UserJobExecutionController, [], {}), ]) self._sa.select_providers('*') self._session_change_lock = Lock() self._operator_lock = Lock() self._be = None self._session_id = "" self._jobs_count = 0 self._job_index = 0 self._currently_running_job = None # XXX: yuck! self.buffered_ui = BufferedUI() self._last_response = None @property def session_change_lock(self): return self._session_change_lock def allowed_when(state): def wrap(f): def fun(self, *args): if self._state != state: raise AssertionError("expected %s, is %s" % (self._state, state)) return f(self, *args) return fun return wrap @allowed_when(Idle) def start_session(self, configuration): _logger.debug("start_session: %r", configuration) self._sa.start_new_session('checkbox-service') self._session_id = self._sa.get_session_id() tps = self._sa.get_test_plans() response = zip(tps, [self._sa.get_test_plan(tp).name for tp in tps]) self._state = Started self._available_testplans = sorted( response, key=lambda x: x[1]) # sorted by name return self._available_testplans @allowed_when(Started) def bootstrap(self, test_plan_id): _logger.debug("bootstrap: %r", test_plan_id) self._sa.update_app_blob( json.dumps({ 'testplan_id': test_plan_id, }).encode("UTF-8")) self._sa.select_test_plan(test_plan_id) self._sa.bootstrap() self._jobs_count = len(self._sa.get_static_todo_list()) self._state = Bootstrapped return self._sa.get_static_todo_list() @allowed_when(Bootstrapped) def run_job(self, job_id): """ Depending on the type of the job, run_job can yield different number of Interaction instances. """ _logger.debug("run_job: %r", job_id) self._job_index = self._jobs_count - len( self._sa.get_dynamic_todo_list()) + 1 self._currently_running_job = job_id job = self._sa.get_job(job_id) if job.plugin in ['manual', 'user-interact-verify', 'user-interact']: self._current_interaction = Interaction('purpose', job.tr_purpose()) yield self._current_interaction if job.user and not self._sudo_password: self._ephemeral_key = EphemeralKey() self._current_interaction = Interaction( 'sudo_input', self._ephemeral_key.public_key) yield self._current_interaction assert (self._sudo_password is not None) self._state = Running self._be = BackgroundExecutor(self, job_id, self._sa.run_job) @allowed_when(Running) def monitor_job(self): """ Check the state of the currently running job. :returns: (state, payload) tuple. Payload conveys detailed info that's characteristic to the current state. """ _logger.debug("monitor_job()") # either return [done, running, awaiting response] # TODO: handle awaiting_response (reading from stdin by the job) if self._be.is_alive(): return ('running', self.buffered_ui.get_output()) else: return ('done', self._be.outcome()) return 'running' if self._be.is_alive() else 'done' def whats_up(self): """ Check what is remote-service up to :returns: (state, payload) tuple. """ _logger.debug("whats_up()") payload = None if self._state == Running: payload = (self._job_index, self._jobs_count, self._currently_running_job) if self._state == Started: payload = self._available_testplans return self._state, payload def get_session_progress(self): """Return list of completed and not completed jobs in a dict.""" _logger.debug("get_session_progress()") return { "done": [], "todo": self._sa.get_dynamic_todo_list(), } def get_master_public_key(self): """Expose the master public key""" return self._sudo_broker.master_public def save_password(self, cyphertext): """Store encrypted password""" self._sudo_password = cyphertext def get_decrypted_password(self): """Return decrypted password""" assert (self._sudo_password) return self._sudo_broker.decrypt_password(self._sudo_password) def finish_job(self): # assert the thread completed self._sa.use_job_result(self._currently_running_job, self._be.wait().get_result()) self._session_change_lock.release() if not self._sa.get_dynamic_todo_list(): self._state = Idle else: self._state = Bootstrapped def get_jobs_repr(self, job_ids): """ Translate jobs into a {'field': 'val'} representations. :param job_ids: list of job ids to get and translate :returns: list of dicts representing jobs """ test_info_list = tuple() for job_id in job_ids: job = self._sa.get_job(job_id) cat_id = self._sa.get_job_state(job.id).effective_category_id duration_txt = _('No estimated duration provided for this job') if job.estimated_duration is not None: duration_txt = '{} {}'.format(job.estimated_duration, _('seconds')) # the next dict is only to get test_info generating code tidier automated_desc = { True: _('this job is fully automated'), False: _('this job requires some manual interaction') } test_info = { "id": job.id, "partial_id": job.partial_id, "name": job.tr_summary(), "category_id": cat_id, "category_name": self._sa.get_category(cat_id).tr_name(), "automated": automated_desc[job.automated], "duration": duration_txt, "description": (job.tr_description() or _('No description provided for this job')), "outcome": self._sa.get_job_state(job.id).result.outcome, } test_info_list = test_info_list + ((test_info, )) return test_info_list def resume_last(self): last = next(self._sa.get_resumable_sessions()) meta = self._sa.resume_session(last.id) app_blob = json.loads(meta.app_blob.decode("UTF-8")) test_plan_id = app_blob['testplan_id'] self._sa.select_test_plan(test_plan_id) self._sa.bootstrap() result = MemoryJobResult({ 'outcome': IJobResult.OUTCOME_PASS, 'comments': _("Passed after resuming execution") }) last_job = meta.running_job_name if last_job: try: self._sa.use_job_result(last_job, result) except KeyError: raise SystemExit(last_job) self._state = Bootstrapped def finalize_session(self): self._sa.finalize_session() self._state = Idle
class CheckboxConvergedApplication(PlainboxApplication): """ Class implementing the whole checkbox-converged application logic. This class exposes methods that can be called by the javascript embedded into many of the QML views. Each method implements a request / response semantics where the request is the set of data passed to python from javascript and the response is the python dictionary returned and processed back on the javascript side. This model follows the similar web development mechanics where the browser can issue asynchronous requests in reaction to user interactions and uses response data to alter the user interface. """ __version__ = (1, 5, 0, 'dev', 0) def __init__(self, launcher_definition=None): if plainbox.__version__ < (0, 22): raise SystemExit("plainbox 0.22 required, you have {}".format( ToolBase.format_version_tuple(plainbox.__version__))) self.assistant = SessionAssistant('checkbox-converged') self.ui = CheckboxConvergedUI() self.index = 0 self._password = None self._timestamp = None self._latest_session = None self._available_test_plans = [] self.test_plan_id = None self.resume_candidate_storage = None self.launcher = None self.assistant.use_alternate_repository( self._get_app_cache_directory()) # Prepare custom execution controller list from plainbox.impl.ctrl import UserJobExecutionController from sudo_with_pass_ctrl import RootViaSudoWithPassExecutionController ctrl_setup_list = [ (RootViaSudoWithPassExecutionController, [self._password_provider], {}), (UserJobExecutionController, [], {}), ] self.assistant.use_alternate_execution_controllers(ctrl_setup_list) if launcher_definition: generic_launcher = LauncherDefinition() generic_launcher.read([launcher_definition]) config_filename = os.path.expandvars( generic_launcher.config_filename) if not os.path.split(config_filename)[0]: configs = [ '/etc/xdg/{}'.format(config_filename), os.path.expanduser('~/.config/{}'.format(config_filename)) ] else: configs = [config_filename] self.launcher = generic_launcher.get_concrete_launcher() configs.append(launcher_definition) self.launcher.read(configs) # Checkbox-Converged supports new launcher syntax, so if we have # LauncherDefinitionLegacy as launcher right now, let's replace it # with a default one if type(self.launcher) == LauncherDefinitionLegacy: self.launcher = DefaultLauncherDefinition() self.assistant.use_alternate_configuration(self.launcher) self._prepare_transports() else: self.launcher = DefaultLauncherDefinition() def __repr__(self): return "app" @view def get_launcher_settings(self): # this pseudo-adapter exists so qml can now know some bits about the # launcher, if you need another setting in the QML fron-end, just add # it to the returned dict below return { 'ui_type': self.launcher.ui_type, } @view def load_providers(self, providers_dir): if self.launcher: self.assistant.select_providers( *self.launcher.providers, additional_providers=self._get_embedded_providers( providers_dir)) else: self.assistant.select_providers( '*', additional_providers=self._get_embedded_providers( providers_dir)) @view def get_version_pair(self): return { 'plainbox_version': ToolBase.format_version_tuple(plainbox.__version__), 'application_version': ToolBase.format_version_tuple(self.__version__) } @view def start_session(self): """Start a new session.""" self.assistant.start_new_session('Checkbox Converged session') self._timestamp = datetime.datetime.utcnow().isoformat() return { 'session_id': self.assistant.get_session_id(), 'session_dir': self.assistant.get_session_dir() } @view def resume_session(self, rerun_last_test, outcome='skip'): """ Resume latest sesssion. :param rerun_last_test: A bool stating whether runtime should repeat the test, that the app was executing when it was interrupted. :param outcome: Outcome to set to the last job run. Option useless when rerunning. """ assert outcome in ['pass', 'skip', 'fail', None] metadata = self.assistant.resume_session(self._latest_session) app_blob = json.loads(metadata.app_blob.decode("UTF-8")) self.index = app_blob['index_in_run_list'] self.test_plan_id = app_blob['test_plan_id'] self.assistant.select_test_plan(self.test_plan_id) self.assistant.bootstrap() if not rerun_last_test: # Skip current test test = self.get_next_test()['result'] test['outcome'] = outcome self.register_test_result(test) return { 'session_id': self._latest_session, 'session_dir': self.assistant.get_session_dir() } @view def clear_session(self): """Reset app-custom state info about the session.""" self.index = 0 self._timestamp = datetime.datetime.utcnow().isoformat() self._finalize_session() @view def is_session_resumable(self): """Check whether there is a session that can be resumed.""" for session_id, session_md in self.assistant.get_resumable_sessions(): if session_md.app_blob is None: continue # we're interested in the latest session only, this is why we # return early self._latest_session = session_id return { 'resumable': True, 'running_job_name': session_md.running_job_name, 'error_encountered': False, } else: return { 'resumable': False, 'error_encountered': False, } @view def get_testplans(self): """Get the list of available test plans.""" if not self._available_test_plans: if self.launcher: if self.launcher.test_plan_forced: self._available_test_plans = [ self.assistant.get_test_plan( self.launcher.test_plan_default_selection) ] else: test_plan_ids = self.assistant.get_test_plans() filtered_tp_ids = set() for filter in self.launcher.test_plan_filters: filtered_tp_ids.update( fnmatch.filter(test_plan_ids, filter)) filtered_tp_ids = list(filtered_tp_ids) filtered_tp_ids.sort(key=lambda tp_id: self.assistant. get_test_plan(tp_id).name) self._available_test_plans = [ self.assistant.get_test_plan(tp_id) for tp_id in filtered_tp_ids ] return { 'testplan_info_list': [{ "mod_id": tp.id, "mod_name": tp.name, "mod_selected": tp.id == self.launcher.test_plan_default_selection, "mod_disabled": False, } for tp in self._available_test_plans], 'forced_selection': self.launcher.test_plan_forced } else: self._available_test_plans = [ self.assistant.get_test_plan(tp_id) for tp_id in self.assistant.get_test_plans() ] return { 'testplan_info_list': [{ "mod_id": tp.id, "mod_name": tp.name, "mod_selected": False, "mod_disabled": False, } for tp in self._available_test_plans], 'forced_selection': False } @view def remember_testplan(self, test_plan_id): """Pick the test plan as the one in force.""" if self.test_plan_id: # test plan has been previously selected. User changed mind, we # have to abandon the session self._finalize_session() self.assistant.start_new_session('Checkbox Converged session') self._timestamp = datetime.datetime.utcnow().isoformat() self.test_plan_id = test_plan_id self.assistant.select_test_plan(test_plan_id) self.assistant.bootstrap() # because session id (and storage) might have changed, let's share this # info with the qml side return { 'session_id': self.assistant.get_session_id(), 'session_dir': self.assistant.get_session_dir() } @view def get_categories(self): """Get categories selection data.""" category_info_list = [{ "mod_id": category.id, "mod_name": category.tr_name(), "mod_selected": True, "mod_disabled": False, } for category in ( self.assistant.get_category(category_id) for category_id in self.assistant.get_participating_categories())] category_info_list.sort(key=lambda ci: (ci['mod_name'])) return { 'category_info_list': category_info_list, 'forced_selection': self.launcher.test_selection_forced } @view def remember_categories(self, selected_id_list): """Save category selection.""" _logger.info("Selected categories: %s", selected_id_list) # Remove previously set filters self.assistant.remove_all_filters() self.assistant.filter_jobs_by_categories(selected_id_list) @view def get_available_tests(self): """ Get all tests for selection purposes. The response object will contain only tests with category matching previously set list. Tests are sorted by (category, name) """ category_names = { cat_id: self.assistant.get_category(cat_id).tr_name() for cat_id in self.assistant.get_participating_categories() } job_units = [ self.assistant.get_job(job_id) for job_id in self.assistant.get_static_todo_list() ] mandatory_jobs = self.assistant.get_mandatory_jobs() test_info_list = [{ "mod_id": job.id, "mod_name": job.tr_summary(), "mod_group": category_names[job.category_id], "mod_selected": True, "mod_disabled": job.id in mandatory_jobs, } for job in job_units] test_info_list.sort(key=lambda ti: (ti['mod_group'], ti['mod_name'])) return { 'test_info_list': test_info_list, 'forced_selection': self.launcher.test_selection_forced } @view def get_rerun_candidates(self): """Get all the tests that might be selected for rerunning.""" def rerun_predicate(job_state): return job_state.result.outcome in ( IJobResult.OUTCOME_FAIL, IJobResult.OUTCOME_CRASH, IJobResult.OUTCOME_NOT_SUPPORTED, IJobResult.OUTCOME_SKIP) rerun_candidates = [] todo_list = self.assistant.get_static_todo_list() job_units = { job_id: self.assistant.get_job(job_id) for job_id in todo_list } job_states = { job_id: self.assistant.get_job_state(job_id) for job_id in todo_list } category_names = { cat_id: self.assistant.get_category(cat_id).tr_name() for cat_id in self.assistant.get_participating_categories() } for job_id, job_state in job_states.items(): if rerun_predicate(job_state): rerun_candidates.append({ "mod_id": job_id, "mod_name": job_units[job_id].tr_summary(), "mod_group": category_names[job_units[job_id].category_id], "mod_selected": False }) return rerun_candidates @view def remember_tests(self, selected_id_list): """Save test selection.""" self.index = 0 self.assistant.use_alternate_selection(selected_id_list) self.assistant.update_app_blob(self._get_app_blob()) _logger.info("Selected tests: %s", selected_id_list) return @view def get_next_test(self): """ Get next text that is scheduled to run. :returns: Dictionary resembling JobDefinition or None if all tests are completed """ todo_list = self.assistant.get_static_todo_list() if self.index < len(todo_list): job = self.assistant.get_job(todo_list[self.index]) description = "" if job.tr_purpose() is not None: description = job.tr_purpose() + "\n" if job.tr_steps() is not None: description += job.tr_steps() if not description: description = job.tr_description() test = { "name": job.tr_summary(), "description": description, "verificationDescription": job.tr_verification() if job.tr_verification() is not None else description, "plugin": job.plugin, "id": job.id, "partial_id": job.partial_id, "user": job.user, "qml_file": job.qml_file, "start_time": time.time(), "test_number": todo_list.index(job.id), "tests_count": len(todo_list), "command": job.command, "flags": job.get_flag_set() } return test else: return {} @view def register_test_result(self, test): """Registers outcome of a test.""" _logger.info("Storing test result: %s", test) job_id = test['id'] builder_kwargs = { 'outcome': test['outcome'], 'comments': test.get('comments', pod.UNSET), 'execution_duration': time.time() - test['start_time'], } if 'result' in test: # if we're registering skipped test as an outcome of resuming # session, the result field of the test object will be missing builder_kwargs['return_code'] = test['result'].return_code builder_kwargs['io_log_filename'] = test['result'].io_log_filename builder_kwargs['io_log'] = test['result'].io_log else: builder_kwargs['return_code'] = 0 result = JobResultBuilder(**builder_kwargs).get_result() self.assistant.use_job_result(job_id, result) self.index += 1 self.assistant.update_app_blob(self._get_app_blob()) @view def run_test_activity(self, test): """Run command associated with given test.""" plugins_handled_natively = ['qml'] res_builder = self.assistant.run_job( test['id'], self.ui, test['plugin'] in plugins_handled_natively) test['outcome'] = res_builder.outcome test['result'] = res_builder return test @view def get_results(self): """Get results object.""" stats = self.assistant.get_summary() return { 'totalPassed': stats[IJobResult.OUTCOME_PASS], 'totalFailed': stats[IJobResult.OUTCOME_FAIL], 'totalSkipped': stats[IJobResult.OUTCOME_SKIP] + stats[IJobResult.OUTCOME_NOT_SUPPORTED] + stats[IJobResult.OUTCOME_UNDECIDED] } @view def export_results(self, output_format, option_list): """Export results to file(s) in the user's 'Documents' directory.""" self.assistant.finalize_session() dirname = self._get_user_directory_documents() return self.assistant.export_to_file(output_format, option_list, dirname) @view def submit_results(self, config): """Submit results to a service configured by config.""" self.assistant.finalize_session() transport = { 'hexr': self.assistant.get_canonical_hexr_transport, 'hexr-staging': (lambda: self.assistant.get_canonical_hexr_transport(staging=True) ), 'c3': (lambda: self.assistant. get_canonical_certification_transport(config['secure_id'])), 'c3-staging': (lambda: self.assistant.get_canonical_certification_transport( config['secure_id'], staging=True)), 'oauth': lambda: self.assistant.get_ubuntu_sso_oauth_transport(config), }[config['type']]() # Default to 'hexr' exporter as it provides xml submission format # (CertificationTransport expects xml format for instance.) submission_format = config.get('submission_format', '2013.com.canonical.plainbox::hexr') submission_options = config.get('submission_options', []) return self.assistant.export_to_transport(submission_format, transport, submission_options) def _prepare_transports(self): self._available_transports = get_all_transports() self.transports = dict() @view def get_certification_transport_config(self): """Returns the c3 (certification) transport configuration.""" for report in self.launcher.stock_reports: self._prepare_stock_report(report) if 'c3' in self.launcher.transports: return self.launcher.transports['c3'] elif 'c3-staging' in self.launcher.transports: return self.launcher.transports['c3-staging'] return {} @view def export_results_with_launcher_settings(self): """ Export results to file(s) in the user's 'Documents' directory. This method follows the launcher reports configuration. """ self.assistant.finalize_session() for report in self.launcher.stock_reports: self._prepare_stock_report(report) # reports are stored in an ordinary dict(), so sorting them ensures # the same order of submitting them between runs. html_url = "" for name, params in sorted(self.launcher.reports.items()): exporter_id = self.launcher.exporters[params['exporter']]['unit'] if self.launcher.transports[params['transport']]['type'] == 'file': path = self.launcher.transports[params['transport']]['path'] cls = self._available_transports['file'] self.transports[params['transport']] = cls(path) transport = self.transports[params['transport']] result = self.assistant.export_to_transport( exporter_id, transport) if (result and 'url' in result and result['url'].endswith('html')): html_url = result['url'] return html_url def _prepare_stock_report(self, report): # this is purposefully not using pythonic dict-keying for better # readability if not self.launcher.transports: self.launcher.transports = dict() if not self.launcher.exporters: self.launcher.exporters = dict() if not self.launcher.reports: self.launcher.reports = dict() if report == 'certification': self.launcher.exporters['hexr'] = { 'unit': '2013.com.canonical.plainbox::hexr' } self.launcher.transports['c3'] = { 'type': 'certification', 'secure_id': self.launcher.transports.get('c3', {}).get('secure_id', None) } self.launcher.reports['upload to certification'] = { 'transport': 'c3', 'exporter': 'hexr' } elif report == 'certification-staging': self.launcher.exporters['hexr'] = { 'unit': '2013.com.canonical.plainbox::hexr' } self.launcher.transports['c3-staging'] = { 'type': 'certification', 'secure_id': self.launcher.transports.get('c3', {}).get('secure_id', None), 'staging': 'yes' } self.launcher.reports['upload to certification-staging'] = { 'transport': 'c3-staging', 'exporter': 'hexr' } elif report == 'submission_files': timestamp = datetime.datetime.utcnow().isoformat() base_dir = self._get_user_directory_documents() for exporter, file_ext in [('hexr', '.xml'), ('html', '.html'), ('xlsx', '.xlsx'), ('tar', '.tar.xz')]: path = os.path.join( base_dir, ''.join(['submission_', timestamp, file_ext])) self.launcher.transports['{}_file'.format(exporter)] = { 'type': 'file', 'path': path } if exporter not in self.launcher.exporters: self.launcher.exporters[exporter] = { 'unit': '2013.com.canonical.plainbox::{}'.format(exporter) } self.launcher.reports['2_{}_file'.format(exporter)] = { 'transport': '{}_file'.format(exporter), 'exporter': '{}'.format(exporter) } @view def drop_permissions(self, app_id, services): # TODO: use XDG once available trust_dbs = { 'camera': '~/.local/share/CameraService/trust.db', 'audio': '~/.local/share/PulseAudio/trust.db', 'location': '~/.local/share/UbuntuLocationServices/trust.db', } sql = 'delete from requests where ApplicationId = (?);' for service in services: conn = None try: conn = sqlite3.connect(os.path.expanduser(trust_dbs[service]), isolation_level='EXCLUSIVE') conn.execute(sql, (app_id, )) conn.commit() finally: if conn: conn.close() @view def get_incomplete_sessions(self): """Get ids of sessions with an 'incomplete' flag.""" self._incomplete_sessions = [ s[0] for s in self.assistant.get_old_sessions(flags={'incomplete'}, allow_not_flagged=False) ] return self._incomplete_sessions @view def delete_old_sessions(self, additional_sessions): """ Delete session storages. :param additional_sessions: List of ids of sessions that should be removed. This function removes all complete sessions (i.e. the ones that session assistant returns when get_old_sessions is run with the default params) with the addition of the ones specified in the ``additional_sessions`` param. """ garbage = [s[0] for s in self.assistant.get_old_sessions()] garbage += additional_sessions self.assistant.delete_sessions(garbage) def _get_user_directory_documents(self): xdg_config_home = os.environ.get('XDG_CONFIG_HOME') or \ os.path.expanduser('~/.config') with open(os.path.join(xdg_config_home, 'user-dirs.dirs')) as f: match = re.search(r'XDG_DOCUMENTS_DIR="(.*)"\n', f.read()) if match: return match.group(1).replace("$HOME", os.getenv("HOME")) else: return os.path.expanduser('~/Documents') def _get_app_cache_directory(self): xdg_cache_home = os.environ.get('XDG_CACHE_HOME') or \ os.path.expanduser('~/.cache') app_id = os.environ.get('APP_ID') if app_id: # Applications will always have write access to directories they # own as determined by the XDG base directory specification. # Specifically: XDG_CACHE_HOME/<APP_PKGNAME> # XDG_RUNTIME_DIR/<APP_PKGNAME> # XDG_RUNTIME_DIR/confined/<APP_PKGNAME> (for TMPDIR) # XDG_DATA_HOME/<APP_PKGNAME> # XDG_CONFIG_HOME/<APP_PKGNAME> # Note that <APP_PKGNAME> is not the APP_ID. In order to easily # handle upgrades and sharing data between executables in the same # app, we use the unversioned app package name for the writable # directories. return os.path.join(xdg_cache_home, app_id.split('_')[0]) else: path = os.path.join(xdg_cache_home, "com.ubuntu.checkbox") if not os.path.exists(path): os.makedirs(path) elif not os.path.isdir(path): # as unlikely as it is, situation where path exists and is a # regular file neeeds to be signalled raise IOError("{} exists and is not a directory".format(path)) return path def _get_app_blob(self): """ Get json dump of with app-specific blob """ return json.dumps({ 'version': 1, 'test_plan_id': self.test_plan_id, 'index_in_run_list': self.index, 'session_timestamp': self._timestamp, }).encode("UTF-8") def _get_embedded_providers(self, providers_dir): """ Get providers included with the app :param providers_dir: Path within application tree from which to load providers :returns: list of loaded providers """ provider_list = [] app_root_dir = os.path.normpath( os.getenv('APP_DIR', os.path.join(os.path.dirname(__file__), '..'))) path = os.path.join(app_root_dir, os.path.normpath(providers_dir)) _logger.info("Loading all providers from %s", path) if os.path.exists(path): embedded_providers = EmbeddedProvider1PlugInCollection(path) provider_list += embedded_providers.get_all_plugin_objects() return provider_list def _password_provider(self): if self._password is None: raise RuntimeError("execute_job called without providing password" " first") return self._password def _finalize_session(self): self.test_plan_id = "" self.assistant.finalize_session() def remember_password(self, password): """ Save password in app instance It deliberately doesn't use view decorator to omit all logging that might happen """ self._password = password
class RemoteSessionAssistant(): """Remote execution enabling wrapper for the SessionAssistant""" REMOTE_API_VERSION = 9 def __init__(self, cmd_callback): _logger.debug("__init__()") self._cmd_callback = cmd_callback self._sudo_broker = SudoBroker() self._sudo_password = None self._session_change_lock = Lock() self._operator_lock = Lock() self.buffered_ui = BufferedUI() self._input_piping = os.pipe() self._passwordless_sudo = is_passwordless_sudo() self.terminate_cb = None self._pipe_from_master = open(self._input_piping[1], 'w') self._pipe_to_subproc = open(self._input_piping[0]) self._reset_sa() self._currently_running_job = None def _reset_sa(self): _logger.info("Resetting RSA") self._state = Idle self._sa = SessionAssistant('service', api_flags={SA_RESTARTABLE}) self._sa.configure_application_restart(self._cmd_callback) self._be = None self._session_id = "" self._jobs_count = 0 self._job_index = 0 self._currently_running_job = None # XXX: yuck! self._last_job = None self._current_comments = "" self._last_response = None self._normal_user = '' self.session_change_lock.acquire(blocking=False) self.session_change_lock.release() @property def session_change_lock(self): return self._session_change_lock @property def config(self): return self._sa.config def allowed_when(*states): def wrap(f): def fun(self, *args): if self._state not in states: raise AssertionError("expected %s, is %s" % (states, self._state)) return f(self, *args) return fun return wrap def interact(self, interaction): self._state = Interacting self._current_interaction = interaction yield self._current_interaction @allowed_when(Interacting) def remember_users_response(self, response): if response == 'rollback': self._currently_running_job = None self.session_change_lock.acquire(blocking=False) self.session_change_lock.release() self._current_comments = "" self._state = TestsSelected return self._last_response = response self._state = Running def _prepare_display_without_psutil(self): try: value = check_output( 'strings /proc/*/environ 2>/dev/null | ' 'grep -m 1 -oP "(?<=DISPLAY=).*"', shell=True, universal_newlines=True).rstrip() return {'DISPLAY': value} except CalledProcessError: return None def prepare_extra_env(self): # If possible also set the DISPLAY env var # i.e when a user desktop session is running for p in psutil.pids(): try: p_environ = psutil.Process(p).environ() p_user = psutil.Process(p).username() except psutil.AccessDenied: continue except AttributeError: # psutil < 4.0.0 doesn't provide Process.environ() return self._prepare_display_without_psutil() except psutil.NoSuchProcess: # quietly ignore the process that died before we had a chance to # read the environment from them continue if ("DISPLAY" in p_environ and p_user != 'gdm'): # gdm uses :1024 return {'DISPLAY': p_environ['DISPLAY']} @allowed_when(Idle) def start_session(self, configuration): self._reset_sa() _logger.debug("start_session: %r", configuration) session_title = 'checkbox-slave' session_desc = 'checkbox-slave session' self._launcher = load_configs() if configuration['launcher']: self._launcher.read_string(configuration['launcher'], False) session_title = self._launcher.session_title session_desc = self._launcher.session_desc self._sa.use_alternate_configuration(self._launcher) self._normal_user = self._launcher.normal_user if configuration['normal_user']: self._normal_user = configuration['normal_user'] pass_provider = (None if self._passwordless_sudo else self.get_decrypted_password) runner_kwargs = { 'normal_user_provider': lambda: self._normal_user, 'password_provider': pass_provider, 'stdin': self._pipe_to_subproc, 'extra_env': self.prepare_extra_env(), } self._sa.start_new_session(session_title, UnifiedRunner, runner_kwargs) self._sa.update_app_blob( json.dumps({ 'description': session_desc, }).encode("UTF-8")) self._sa.update_app_blob( json.dumps({ 'launcher': configuration['launcher'], }).encode("UTF-8")) self._session_id = self._sa.get_session_id() tps = self._sa.get_test_plans() filtered_tps = set() for filter in self._launcher.test_plan_filters: filtered_tps.update(fnmatch.filter(tps, filter)) filtered_tps = list(filtered_tps) response = zip( filtered_tps, [self._sa.get_test_plan(tp).name for tp in filtered_tps]) self._state = Started self._available_testplans = sorted( response, key=lambda x: x[1]) # sorted by name return self._available_testplans @allowed_when(Started) def prepare_bootstrapping(self, test_plan_id): """ Go through the list of bootstrapping jobs, and return True if sudo password will be needed for any bootstrapping job. """ _logger.debug("prepare_bootstrapping: %r", test_plan_id) self._sa.update_app_blob( json.dumps({ 'testplan_id': test_plan_id, }).encode("UTF-8")) self._sa.select_test_plan(test_plan_id) for job_id in self._sa.get_bootstrap_todo_list(): job = self._sa.get_job(job_id) if job.user is not None: # job requires sudo controller return True return False @allowed_when(Started) def get_bootstrapping_todo_list(self): return self._sa.get_bootstrap_todo_list() def finish_bootstrap(self): self._sa.finish_bootstrap() self._state = Bootstrapped if self._launcher.auto_retry: for job_id in self._sa.get_static_todo_list(): job_state = self._sa.get_job_state(job_id) job_state.attempts = self._launcher.max_attempts return self._sa.get_static_todo_list() def get_manifest_repr(self): return self._sa.get_manifest_repr() def save_manifest(self, manifest_answers): return self._sa.save_manifest(manifest_answers) def modify_todo_list(self, chosen_jobs): self._sa.use_alternate_selection(chosen_jobs) def finish_job_selection(self): self._jobs_count = len(self._sa.get_dynamic_todo_list()) self._state = TestsSelected @allowed_when(Interacting) def rerun_job(self, job_id, result): self._sa.use_job_result(job_id, result) self.session_change_lock.acquire(blocking=False) self.session_change_lock.release() self._state = TestsSelected @allowed_when(TestsSelected) def run_job(self, job_id): """ Depending on the type of the job, run_job can yield different number of Interaction instances. """ _logger.debug("run_job: %r", job_id) self._job_index = self._jobs_count - len( self._sa.get_dynamic_todo_list()) + 1 self._currently_running_job = job_id self._current_comments = "" job = self._sa.get_job(job_id) if job.plugin in ['manual', 'user-interact-verify', 'user-interact']: may_comment = True while may_comment: may_comment = False if job.tr_description() and not job.tr_purpose(): yield from self.interact( Interaction('description', job.tr_description())) if job.tr_purpose(): yield from self.interact( Interaction('purpose', job.tr_purpose())) if job.tr_steps(): yield from self.interact( Interaction('steps', job.tr_steps())) if self._last_response == 'comment': yield from self.interact(Interaction('comment')) if self._last_response: self._current_comments += self._last_response may_comment = True continue if self._last_response == 'skip': def skipped_builder(*args, **kwargs): result_builder = JobResultBuilder( outcome=IJobResult.OUTCOME_SKIP, comments=_("Explicitly skipped before execution")) if self._current_comments != "": result_builder.comments = self._current_comments return result_builder self._be = BackgroundExecutor(self, job_id, skipped_builder) yield from self.interact( Interaction('skip', job.verification, self._be)) if job.command: if (job.user and not self._passwordless_sudo and not self._sudo_password): self._ephemeral_key = EphemeralKey() self._current_interaction = Interaction( 'sudo_input', self._ephemeral_key.public_key) pass_is_correct = False while not pass_is_correct: self.state = Interacting yield self._current_interaction pass_is_correct = validate_pass( self._sudo_broker.decrypt_password( self._sudo_password)) if not pass_is_correct: print(_('Sorry, try again.')) assert (self._sudo_password is not None) self._state = Running self._be = BackgroundExecutor(self, job_id, self._sa.run_job) else: def undecided_builder(*args, **kwargs): return JobResultBuilder(outcome=IJobResult.OUTCOME_UNDECIDED) self._be = BackgroundExecutor(self, job_id, undecided_builder) if self._sa.get_job(self._currently_running_job).plugin in [ 'manual', 'user-interact-verify' ]: yield from self.interact( Interaction('verification', job.verification, self._be)) @allowed_when(Started, Bootstrapping) def run_bootstrapping_job(self, job_id): self._currently_running_job = job_id self._state = Bootstrapping self._be = BackgroundExecutor(self, job_id, self._sa.run_job) @allowed_when(Running, Bootstrapping, Interacting, TestsSelected) def monitor_job(self): """ Check the state of the currently running job. :returns: (state, payload) tuple. Payload conveys detailed info that's characteristic to the current state. """ _logger.debug("monitor_job()") # either return [done, running, awaiting response] # TODO: handle awaiting_response (reading from stdin by the job) if self._be and self._be.is_alive(): return ('running', self.buffered_ui.get_output()) else: return ('done', self.buffered_ui.get_output()) def get_remote_api_version(self): return self.REMOTE_API_VERSION def whats_up(self): """ Check what is remote-service up to :returns: (state, payload) tuple. """ _logger.debug("whats_up()") payload = None if self._state == Running: payload = (self._job_index, self._jobs_count, self._currently_running_job) if self._state == TestsSelected and not self._currently_running_job: payload = {'last_job': self._last_job} elif self._state == Started: payload = self._available_testplans elif self._state == Interacting: payload = self._current_interaction elif self._state == Bootstrapped: payload = self._sa.get_static_todo_list() return self._state, payload def terminate(self): if self.terminate_cb: self.terminate_cb() def get_session_progress(self): """Return list of completed and not completed jobs in a dict.""" _logger.debug("get_session_progress()") return { "done": self._sa.get_dynamic_done_list(), "todo": self._sa.get_dynamic_todo_list(), } def get_master_public_key(self): """Expose the master public key""" return self._sudo_broker.master_public def save_password(self, cyphertext): """Store encrypted password""" if validate_pass(self._sudo_broker.decrypt_password(cyphertext)): self._sudo_password = cyphertext return True return False def get_decrypted_password(self): """Return decrypted password""" if self._passwordless_sudo: return '' assert (self._sudo_password) return self._sudo_broker.decrypt_password(self._sudo_password) def finish_job(self, result=None): # assert the thread completed self.session_change_lock.acquire(blocking=False) self.session_change_lock.release() if self._sa.get_job(self._currently_running_job).plugin in [ 'manual', 'user-interact-verify' ] and not result: # for manually verified jobs we don't set the outcome here # it is already determined return if not result: result = self._be.wait().get_result() self._sa.use_job_result(self._currently_running_job, result) if self._state != Bootstrapping: if not self._sa.get_dynamic_todo_list(): if (self._launcher.auto_retry and self.get_rerun_candidates('auto')): self._state = TestsSelected else: self._state = Idle else: self._state = TestsSelected return result def get_rerun_candidates(self, session_type='manual'): return self._sa.get_rerun_candidates(session_type) def prepare_rerun_candidates(self, rerun_candidates): candidates = self._sa.prepare_rerun_candidates(rerun_candidates) self._state = TestsSelected return candidates def get_job_result(self, job_id): return self._sa.get_job_state(job_id).result def get_jobs_repr(self, job_ids, offset=0): """ Translate jobs into a {'field': 'val'} representations. :param job_ids: list of job ids to get and translate :param offset: apply an offset to the job number if for instance the job list is being requested part way through a session :returns: list of dicts representing jobs """ test_info_list = tuple() for job_no, job_id in enumerate(job_ids, start=offset + 1): job = self._sa.get_job(job_id) cat_id = self._sa.get_job_state(job.id).effective_category_id duration_txt = _('No estimated duration provided for this job') if job.estimated_duration is not None: duration_txt = '{} {}'.format(job.estimated_duration, _('seconds')) # the next dict is only to get test_info generating code tidier automated_desc = { True: _('this job is fully automated'), False: _('this job requires some manual interaction') } test_info = { "id": job.id, "partial_id": job.partial_id, "name": job.tr_summary(), "category_id": cat_id, "category_name": self._sa.get_category(cat_id).tr_name(), "automated": automated_desc[job.automated], "duration": duration_txt, "description": (job.tr_description() or _('No description provided for this job')), "outcome": self._sa.get_job_state(job.id).result.outcome, "user": job.user, "command": job.command, "num": job_no, "plugin": job.plugin, } test_info_list = test_info_list + ((test_info, )) return test_info_list def resume_by_id(self, session_id=None): self._launcher = load_configs() resume_candidates = list(self._sa.get_resumable_sessions()) if not session_id: if not resume_candidates: print('No session to resume') return session_id = resume_candidates[0].id if session_id not in [s.id for s in resume_candidates]: print("Requested session not found") return _logger.warning("Resuming session: %r", session_id) self._normal_user = self._launcher.normal_user pass_provider = (None if self._passwordless_sudo else self.get_decrypted_password) runner_kwargs = { 'normal_user_provider': lambda: self._normal_user, 'password_provider': pass_provider, 'stdin': self._pipe_to_subproc, 'extra_env': self.prepare_extra_env(), } meta = self._sa.resume_session(session_id, runner_kwargs=runner_kwargs) app_blob = json.loads(meta.app_blob.decode("UTF-8")) launcher = app_blob['launcher'] self._launcher.read_string(launcher, False) self._sa.use_alternate_configuration(self._launcher) test_plan_id = app_blob['testplan_id'] self._sa.select_test_plan(test_plan_id) self._sa.bootstrap() self._last_job = meta.running_job_name result_dict = { 'outcome': IJobResult.OUTCOME_PASS, 'comments': _("Automatically passed after resuming execution"), } result_path = os.path.join(self._sa.get_session_dir(), 'CHECKBOX_DATA', '__result') if os.path.exists(result_path): try: with open(result_path, 'rt') as f: result_dict = json.load(f) # the only really important field in the result is # 'outcome' so let's make sure it doesn't contain # anything stupid if result_dict.get('outcome') not in [ 'pass', 'fail', 'skip' ]: result_dict['outcome'] = IJobResult.OUTCOME_PASS except json.JSONDecodeError as e: pass result = MemoryJobResult(result_dict) if self._last_job: try: self._sa.use_job_result(self._last_job, result, True) except KeyError: raise SystemExit(self._last_job) # some jobs have already been run, so we need to update the attempts # count for future auto-rerunning if self._launcher.auto_retry: for job_id in [ job.id for job in self.get_rerun_candidates('auto') ]: job_state = self._sa.get_job_state(job_id) job_state.attempts = self._launcher.max_attempts - len( job_state.result_history) self._state = TestsSelected def finalize_session(self): self._sa.finalize_session() self._reset_sa() def transmit_input(self, text): self._pipe_from_master.write(text) self._pipe_from_master.flush() def send_signal(self, signal): if not self._currently_running_job: return target_user = self._sa.get_job(self._currently_running_job).user self._sa.send_signal(signal, target_user) @property def manager(self): return self._sa._manager @property def passwordless_sudo(self): return self._passwordless_sudo @property def sideloaded_providers(self): return self._sa.sideloaded_providers
class RemoteSessionAssistant(): """Remote execution enabling wrapper for the SessionAssistant""" REMOTE_API_VERSION = 11 def __init__(self, cmd_callback): _logger.debug("__init__()") self._cmd_callback = cmd_callback self._session_change_lock = Lock() self._operator_lock = Lock() self._ui = BufferedUI() self._input_piping = os.pipe() self._passwordless_sudo = is_passwordless_sudo() self.terminate_cb = None self._pipe_from_master = open(self._input_piping[1], 'w') self._pipe_to_subproc = open(self._input_piping[0]) self._reset_sa() self._currently_running_job = None def _reset_sa(self): _logger.info("Resetting RSA") self._state = Idle self._sa = SessionAssistant('service', api_flags={SA_RESTARTABLE}) self._be = None self._session_id = "" self._jobs_count = 0 self._job_index = 0 self._currently_running_job = None # XXX: yuck! self._last_job = None self._current_comments = "" self._last_response = None self._normal_user = '' self.session_change_lock.acquire(blocking=False) self.session_change_lock.release() @property def session_change_lock(self): return self._session_change_lock @property def config(self): return self._sa.config def update_app_blob(self, app_blob): self._sa.update_app_blob(app_blob) def allowed_when(*states): def wrap(f): def fun(self, *args): if self._state not in states: raise AssertionError( "expected %s, is %s" % (states, self._state)) return f(self, *args) return fun return wrap def interact(self, interaction): self._state = Interacting self._current_interaction = interaction yield self._current_interaction @allowed_when(Interacting) def remember_users_response(self, response): if response == 'rollback': self._currently_running_job = None self.session_change_lock.acquire(blocking=False) self.session_change_lock.release() self._current_comments = "" self._state = TestsSelected return self._last_response = response self._state = Running def _prepare_display_without_psutil(self): try: display_value = check_output( 'strings /proc/*/environ 2>/dev/null | ' 'grep -m 1 -oP "(?<=DISPLAY=).*"', shell=True, universal_newlines=True).rstrip() xauth_value = check_output( 'strings /proc/*/environ 2>/dev/null | ' 'grep -m 1 -oP "(?<=XAUTHORITY=).*"', shell=True, universal_newlines=True).rstrip() return {'DISPLAY': display_value, 'XAUTHORITY': xauth_value} except CalledProcessError: return None def prepare_extra_env(self): # If possible also set the DISPLAY env var # i.e when a user desktop session is running for p in psutil.pids(): try: p_environ = psutil.Process(p).environ() p_user = psutil.Process(p).username() except psutil.AccessDenied: continue except AttributeError: # psutil < 4.0.0 doesn't provide Process.environ() return self._prepare_display_without_psutil() except psutil.NoSuchProcess: # quietly ignore the process that died before we had a chance # to read the environment from them continue if ( "DISPLAY" in p_environ and "XAUTHORITY" in p_environ and p_user != 'gdm' ): # gdm uses :1024 return { 'DISPLAY': p_environ['DISPLAY'], 'XAUTHORITY': p_environ['XAUTHORITY'] } @allowed_when(Idle) def start_session(self, configuration): self._reset_sa() _logger.info("start_session: %r", configuration) session_title = 'checkbox-slave' session_desc = 'checkbox-slave session' session_type = 'checkbox-slave' self._launcher = load_configs() if configuration['launcher']: self._launcher.read_string(configuration['launcher'], False) if self._launcher.session_title: session_title = self._launcher.session_title if self._launcher.session_desc: session_desc = self._launcher.session_desc self._sa.use_alternate_configuration(self._launcher) if configuration['normal_user']: self._normal_user = configuration['normal_user'] else: self._normal_user = self._launcher.normal_user if not self._normal_user: import pwd try: self._normal_user = pwd.getpwuid(1000).pw_name _logger.warning( ("normal_user not supplied via config(s). " "non-root jobs will run as %s"), self._normal_user) except KeyError: raise RuntimeError( ("normal_user not supplied via config(s). " "Username for uid 1000 not found")) runner_kwargs = { 'normal_user_provider': lambda: self._normal_user, 'stdin': self._pipe_to_subproc, 'extra_env': self.prepare_extra_env(), } self._sa.start_new_session(session_title, UnifiedRunner, runner_kwargs) new_blob = json.dumps({ 'description': session_desc, 'type': session_type, 'launcher': configuration['launcher'], 'effective_normal_user': self._normal_user, }).encode("UTF-8") self._sa.update_app_blob(new_blob) self._sa.configure_application_restart(self._cmd_callback) self._session_id = self._sa.get_session_id() tps = self._sa.get_test_plans() filtered_tps = set() for filter in self._launcher.test_plan_filters: filtered_tps.update(fnmatch.filter(tps, filter)) filtered_tps = list(filtered_tps) response = zip(filtered_tps, [self._sa.get_test_plan( tp).name for tp in filtered_tps]) self._state = Started self._available_testplans = sorted( response, key=lambda x: x[1]) # sorted by name return self._available_testplans @allowed_when(Started) def prepare_bootstrapping(self, test_plan_id): """Save picked test plan to the app blob.""" _logger.debug("prepare_bootstrapping: %r", test_plan_id) self._sa.update_app_blob(json.dumps( {'testplan_id': test_plan_id, }).encode("UTF-8")) self._sa.select_test_plan(test_plan_id) # TODO: REMOTE API RAPI: Change this API on the next RAPI bump # previously the function returned bool signifying the need for sudo # password. With slave being guaranteed to never need it anymor # we can make this funciton return nothing return False @allowed_when(Started) def get_bootstrapping_todo_list(self): return self._sa.get_bootstrap_todo_list() def finish_bootstrap(self): self._sa.finish_bootstrap() self._state = Bootstrapped if self._launcher.auto_retry: for job_id in self._sa.get_static_todo_list(): job_state = self._sa.get_job_state(job_id) job_state.attempts = self._launcher.max_attempts return self._sa.get_static_todo_list() def get_manifest_repr(self): return self._sa.get_manifest_repr() def save_manifest(self, manifest_answers): return self._sa.save_manifest(manifest_answers) def modify_todo_list(self, chosen_jobs): self._sa.use_alternate_selection(chosen_jobs) def finish_job_selection(self): self._jobs_count = len(self._sa.get_dynamic_todo_list()) self._state = TestsSelected @allowed_when(Interacting) def rerun_job(self, job_id, result): self._sa.use_job_result(job_id, result) self.session_change_lock.acquire(blocking=False) self.session_change_lock.release() self._state = TestsSelected def _get_ui_for_job(self, job): show_out = True if self._launcher.output == 'hide-resource-and-attachment': if job.plugin in ('local', 'resource', 'attachment'): show_out = False elif self._launcher.output in ['hide', 'hide-automated']: if job.plugin in ('shell', 'local', 'resource', 'attachment'): show_out = False if 'suppress-output' in job.get_flag_set(): show_out = False if show_out: self._ui = BufferedUI() else: self._ui = RemoteSilentUI() return self._ui @allowed_when(TestsSelected) def run_job(self, job_id): """ Depending on the type of the job, run_job can yield different number of Interaction instances. """ _logger.debug("run_job: %r", job_id) self._job_index = self._jobs_count - len( self._sa.get_dynamic_todo_list()) + 1 self._currently_running_job = job_id self._current_comments = "" job = self._sa.get_job(job_id) job_state = self._sa.get_job_state(job_id) if not job_state.can_start(): outcome = IJobResult.OUTCOME_NOT_SUPPORTED for inhibitor in job_state.readiness_inhibitor_list: if ( inhibitor.cause == InhibitionCause.FAILED_RESOURCE and 'fail-on-resource' in job.get_flag_set() ): outcome = IJobResult.OUTCOME_FAIL break elif inhibitor.cause != InhibitionCause.FAILED_DEP: continue related_job_state = self._sa._context.state.job_state_map[ inhibitor.related_job.id] if related_job_state.result.outcome == IJobResult.OUTCOME_SKIP: outcome = IJobResult.OUTCOME_SKIP def cant_start_builder(*args, **kwargs): result_builder = JobResultBuilder( outcome=outcome, comments=job_state.get_readiness_description()) return result_builder self._be = BackgroundExecutor(self, job_id, cant_start_builder) yield from self.interact(Interaction('skip', None, self._be)) if job.plugin in [ 'manual', 'user-interact-verify', 'user-interact']: may_comment = True while may_comment: may_comment = False if job.tr_description() and not job.tr_purpose(): yield from self.interact( Interaction('description', job.tr_description())) if job.tr_purpose(): yield from self.interact( Interaction('purpose', job.tr_purpose())) if job.tr_steps(): yield from self.interact( Interaction('steps', job.tr_steps())) if self._last_response == 'comment': yield from self.interact(Interaction('comment')) if self._last_response: self._current_comments += self._last_response may_comment = True continue if self._last_response == 'skip': def skipped_builder(*args, **kwargs): result_builder = JobResultBuilder( outcome=IJobResult.OUTCOME_SKIP, comments=_("Explicitly skipped before execution")) if self._current_comments != "": result_builder.comments = self._current_comments return result_builder self._be = BackgroundExecutor( self, job_id, skipped_builder) yield from self.interact( Interaction('skip', job.verification, self._be)) if job.command: self._state = Running ui = self._get_ui_for_job(job) self._be = BackgroundExecutor(self, job_id, self._sa.run_job, ui) else: def undecided_builder(*args, **kwargs): return JobResultBuilder(outcome=IJobResult.OUTCOME_UNDECIDED) self._be = BackgroundExecutor(self, job_id, undecided_builder) if self._sa.get_job(self._currently_running_job).plugin in [ 'manual', 'user-interact-verify']: yield from self.interact( Interaction('verification', job.verification, self._be)) @allowed_when(Started, Bootstrapping) def run_bootstrapping_job(self, job_id): self._currently_running_job = job_id self._state = Bootstrapping self._be = BackgroundExecutor(self, job_id, self._sa.run_job) @allowed_when(Running, Bootstrapping, Interacting, TestsSelected) def monitor_job(self): """ Check the state of the currently running job. :returns: (state, payload) tuple. Payload conveys detailed info that's characteristic to the current state. """ _logger.debug("monitor_job()") # either return [done, running, awaiting response] # TODO: handle awaiting_response (reading from stdin by the job) if self._be and self._be.is_alive(): return ('running', self._ui.get_output()) else: return ('done', self._ui.get_output()) def get_remote_api_version(self): return self.REMOTE_API_VERSION def whats_up(self): """ Check what is remote-service up to :returns: (state, payload) tuple. """ _logger.debug("whats_up() -> %r", self._state) payload = None if self._state == Running: payload = ( self._job_index, self._jobs_count, self._currently_running_job ) if self._state == TestsSelected and not self._currently_running_job: payload = {'last_job': self._last_job} elif self._state == Started: payload = self._available_testplans elif self._state == Interacting: payload = self._current_interaction elif self._state == Bootstrapped: payload = self._sa.get_static_todo_list() return self._state, payload def terminate(self): if self.terminate_cb: self.terminate_cb() def get_session_progress(self): """Return list of completed and not completed jobs in a dict.""" _logger.debug("get_session_progress()") return { "done": self._sa.get_dynamic_done_list(), "todo": self._sa.get_dynamic_todo_list(), } def get_master_public_key(self): # TODO: REMOTE API RAPI: Remove this API on the next RAPI bump # this key is only for RAPI compliance. It will never be used as # this master requires slave to be completely sudoless return ( b'-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMII' b'BCgKCAQEA5r0bjOA+IH5lDKkW3OYb\nDuEjf5VKgUlDSJJuyBlfLTBIXZ8j3s98' b'6AbV0zB62rAcgiFrBOzx51IzBDBmHI8V\nYYpEa+q4OP4yprYpSg6xzX6LRQapC' b'Iv9BAqN4MWrKBukGMzJyemIVEPv4BSHL5L/\nLY98Mwh4dAXxj5ZdsoVPqgeMo8' b'dxfYEOwVRJvSkseIhxRL6tvgP37c48ApUyjdUO\n3C2YgqJRx7mKKDyLOvhDVEl' b'MqkAfp6qS/8xcGBTEqn08dDQIgPl8KofpC9GXMGbK\nV9FGP+c1bpA3vMOfnpsE' b'WCju2qDoTSKJTm3VMZj88mqH7nOpbk7JI/Yz0EmtNXOM\n6QIDAQAB\n-----EN' b'D PUBLIC KEY-----') def save_password(self, password): """Store sudo password""" # TODO: REMOTE API RAPI: Remove this API on the next RAPI bump # if the slave is running it means we don't need password # so we can consider call to this function as passing return True def finish_job(self, result=None): # assert the thread completed self.session_change_lock.acquire(blocking=False) self.session_change_lock.release() if self._sa.get_job(self._currently_running_job).plugin in [ 'manual', 'user-interact-verify'] and not result: # for manually verified jobs we don't set the outcome here # it is already determined return if not result: result = self._be.wait().get_result() self._sa.use_job_result(self._currently_running_job, result) if self._state != Bootstrapping: if not self._sa.get_dynamic_todo_list(): if ( self._launcher.auto_retry and self.get_rerun_candidates('auto') ): self._state = TestsSelected else: self._state = Idle else: self._state = TestsSelected return result def get_rerun_candidates(self, session_type='manual'): return self._sa.get_rerun_candidates(session_type) def prepare_rerun_candidates(self, rerun_candidates): candidates = self._sa.prepare_rerun_candidates(rerun_candidates) self._state = TestsSelected return candidates def get_job_result(self, job_id): return self._sa.get_job_state(job_id).result def get_jobs_repr(self, job_ids, offset=0): """ Translate jobs into a {'field': 'val'} representations. :param job_ids: list of job ids to get and translate :param offset: apply an offset to the job number if for instance the job list is being requested part way through a session :returns: list of dicts representing jobs """ test_info_list = tuple() for job_no, job_id in enumerate(job_ids, start=offset + 1): job = self._sa.get_job(job_id) cat_id = self._sa.get_job_state(job.id).effective_category_id duration_txt = _('No estimated duration provided for this job') if job.estimated_duration is not None: duration_txt = '{} {}'.format(job.estimated_duration, _( 'seconds')) # the next dict is only to get test_info generating code tidier automated_desc = { True: _('this job is fully automated'), False: _('this job requires some manual interaction') } test_info = { "id": job.id, "partial_id": job.partial_id, "name": job.tr_summary(), "category_id": cat_id, "category_name": self._sa.get_category(cat_id).tr_name(), "automated": automated_desc[job.automated], "duration": duration_txt, "description": (job.tr_description() or _('No description provided for this job')), "outcome": self._sa.get_job_state(job.id).result.outcome, "user": job.user, "command": job.command, "num": job_no, "plugin": job.plugin, } test_info_list = test_info_list + ((test_info, )) return json.dumps(test_info_list) def resume_by_id(self, session_id=None): _logger.info("resume_by_id: %r", session_id) self._launcher = load_configs() resume_candidates = list(self._sa.get_resumable_sessions()) if not session_id: if not resume_candidates: print('No session to resume') return session_id = resume_candidates[0].id if session_id not in [s.id for s in resume_candidates]: print("Requested session not found") return _logger.warning("Resuming session: %r", session_id) runner_kwargs = { 'normal_user_provider': lambda: self._normal_user, 'stdin': self._pipe_to_subproc, 'extra_env': self.prepare_extra_env(), } meta = self._sa.resume_session(session_id, runner_kwargs=runner_kwargs) app_blob = json.loads(meta.app_blob.decode("UTF-8")) launcher = app_blob['launcher'] self._launcher.read_string(launcher, False) self._sa.use_alternate_configuration(self._launcher) self._normal_user = app_blob.get( 'effective_normal_user', self._launcher.normal_user) _logger.info( "normal_user after loading metadata: %r", self._normal_user) test_plan_id = app_blob['testplan_id'] self._sa.select_test_plan(test_plan_id) self._sa.bootstrap() self._last_job = meta.running_job_name result_dict = { 'outcome': IJobResult.OUTCOME_PASS, 'comments': _("Automatically passed after resuming execution"), } session_share = WellKnownDirsHelper.session_share( self._sa._manager.storage.id) result_path = os.path.join(session_share, '__result') if os.path.exists(result_path): try: with open(result_path, 'rt') as f: result_dict = json.load(f) # the only really important field in the result is # 'outcome' so let's make sure it doesn't contain # anything stupid if result_dict.get('outcome') not in [ 'pass', 'fail', 'skip']: result_dict['outcome'] = IJobResult.OUTCOME_PASS except json.JSONDecodeError as e: pass result = MemoryJobResult(result_dict) if self._last_job: try: self._sa.use_job_result(self._last_job, result, True) except KeyError: raise SystemExit(self._last_job) # some jobs have already been run, so we need to update the attempts # count for future auto-rerunning if self._launcher.auto_retry: for job_id in [ job.id for job in self.get_rerun_candidates('auto')]: job_state = self._sa.get_job_state(job_id) job_state.attempts = self._launcher.max_attempts - len( job_state.result_history) self._state = TestsSelected def finalize_session(self): self._sa.finalize_session() self._reset_sa() def transmit_input(self, text): self._pipe_from_master.write(text) self._pipe_from_master.flush() def send_signal(self, signal): if not self._currently_running_job: return target_user = self._sa.get_job(self._currently_running_job).user self._sa.send_signal(signal, target_user) @property def manager(self): return self._sa._manager @property def passwordless_sudo(self): # TODO: REMOTE API RAPI: Remove this API on the next RAPI bump # if the slave is still running it means it's very passwordless return True @property def sideloaded_providers(self): return self._sa.sideloaded_providers def exposed_cache_report(self, exporter_id, options): exporter = self._sa._manager.create_exporter(exporter_id, options) exported_stream = SpooledTemporaryFile(max_size=102400, mode='w+b') exporter.dump_from_session_manager(self._sa._manager, exported_stream) exported_stream.flush() return exported_stream