class SessionStateReactionToJobResultTests(TestCase): # This test checks how a simple session with a few typical job reacts to # job results of various kinds. It checks most of the resource presentation # error conditions that I could think of. def setUp(self): # All of the tests below are using one session. The session has four # jobs, clustered into two independent groups. Job A depends on a # resource provided by job R which has no dependencies at all. Job X # depends on job Y which in turn has no dependencies at all. # # A -(resource dependency)-> R # # X -(direct dependency) -> Y self.job_A = make_job("A", requires="R.attr == 'value'") self.job_A_expr = self.job_A.get_resource_program().expression_list[0] self.job_R = make_job("R", plugin="resource") self.job_X = make_job("X", depends='Y') self.job_Y = make_job("Y") self.job_list = [self.job_A, self.job_R, self.job_X, self.job_Y] self.session = SessionState(self.job_list) def job_state(self, id): # A helper function to avoid overly long expressions return self.session.job_state_map[id] def job_inhibitor(self, id, index): # Another helper that shortens deep object nesting return self.job_state(id).readiness_inhibitor_list[index] def test_assumptions(self): # This function checks the assumptions of SessionState initial state. # The job list is what we set when constructing the session. # self.assertEqual(self.session.job_list, self.job_list) # The run_list is still empty because the desired_job_list is equally # empty. self.assertEqual(self.session.run_list, []) self.assertEqual(self.session.desired_job_list, []) # All jobs have state objects that indicate they cannot run (because # they have the UNDESIRED inhibitor set for them by default). self.assertFalse(self.job_state('A').can_start()) self.assertFalse(self.job_state('R').can_start()) self.assertFalse(self.job_state('X').can_start()) self.assertFalse(self.job_state('Y').can_start()) self.assertEqual( self.job_inhibitor('A', 0).cause, InhibitionCause.UNDESIRED) self.assertEqual( self.job_inhibitor('R', 0).cause, InhibitionCause.UNDESIRED) self.assertEqual( self.job_inhibitor('X', 0).cause, InhibitionCause.UNDESIRED) self.assertEqual( self.job_inhibitor('Y', 0).cause, InhibitionCause.UNDESIRED) def test_desire_job_A_updates_state_map(self): # This function checks what happens when the job A becomes desired via # the update_desired_job_list() call. self.session.update_desired_job_list([self.job_A]) self.assertEqual(self.session.desired_job_list, [self.job_A]) # This should topologically sort the job list, according to the # relationship created by the resource requirement. This is not really # testing the dependency solver (it has separate tests), just that this # basic property is established and that the run_list properly shows # that R must run before A can run. self.assertEqual(self.session.run_list, [self.job_R, self.job_A]) # This also recomputes job readiness state so that job R is no longer # undesired, has no other inhibitor and thus can start self.assertEqual(self.job_state('R').readiness_inhibitor_list, []) self.assertTrue(self.job_state('R').can_start()) # While the A job still cannot run it now has a different inhibitor, # one with the PENDING_RESOURCE cause. The inhibitor also properly # pinpoints the related job and related expression. self.assertNotEqual(self.job_state('A').readiness_inhibitor_list, []) self.assertEqual( self.job_inhibitor('A', 0).cause, InhibitionCause.PENDING_RESOURCE) self.assertEqual(self.job_inhibitor('A', 0).related_job, self.job_R) self.assertEqual( self.job_inhibitor('A', 0).related_expression, self.job_A_expr) self.assertFalse(self.job_state('A').can_start()) def test_resource_job_result_updates_resource_and_job_states(self): # This function checks what happens when a JobResult for job R (which # is a resource job via the resource plugin) is presented to the # session. result_R = MemoryJobResult({ 'outcome': IJobResult.OUTCOME_PASS, 'io_log': [(0, 'stdout', b"attr: value\n")], }) self.session.update_job_result(self.job_R, result_R) # The most obvious thing that can happen, is that the result is simply # stored in the associated job state object. self.assertIs(self.job_state('R').result, result_R) # Initially the _resource_map was empty. SessionState parses the io_log # of results of resource jobs and creates appropriate resource objects. self.assertIn("R", self.session._resource_map) expected = {'R': [Resource({'attr': 'value'})]} self.assertEqual(self.session._resource_map, expected) # As job results are presented to the session the readiness of other # jobs is changed. Since A depends on R via a resource expression and # the particular resource that were produced by R in this test should # allow the expression to match the readiness inhibitor from A should # have been removed. Since this test does not use # update_desired_job_list() a will still have the UNDESIRED inhibitor # but it will no longer have the PENDING_RESOURCE inhibitor, self.assertEqual( self.job_inhibitor('A', 0).cause, InhibitionCause.UNDESIRED) # Now if we put A on the desired list this should clear the UNDESIRED # inhibitor and make A runnable. self.session.update_desired_job_list([self.job_A]) self.assertTrue(self.job_state('A').can_start()) def test_normal_job_result_updates(self): # This function checks what happens when a JobResult for job A is # presented to the session. Set the outcome to a "different" value as # the initial job result was pretty much identical and the comparison # below would fail to work as the update would have been silently # ignored. result_A = MemoryJobResult({'outcome': 'different'}) self.session.update_job_result(self.job_A, result_A) # As before the result should be stored as-is self.assertIs(self.job_state('A').result, result_A) # Unlike before _resource_map should be left unchanged self.assertEqual(self.session._resource_map, {}) # One interesting observation is that readiness inhibitors are entirely # unaffected by existing test results beyond dependency and resource # relationships. While a result for job A was presented, job A is still # inhibited by the UNDESIRED inhibitor. self.assertEqual( self.job_inhibitor('A', 0).cause, InhibitionCause.UNDESIRED) @mock.patch('plainbox.impl.ctrl.logger') def test_resource_job_with_broken_output(self, mock_logger): # This function checks how SessionState parses partially broken # resource jobs. A JobResult with broken output is constructed below. # The output will describe one proper record, one broken record and # another proper record in that order. result_R = MemoryJobResult({ 'outcome': IJobResult.OUTCOME_PASS, 'io_log': [(0, 'stdout', b"attr: value-1\n"), (1, 'stdout', b"\n"), (1, 'stdout', b"I-sound-like-a-broken-record\n"), (1, 'stdout', b"\n"), (1, 'stdout', b"attr: value-2\n")], }) # Since we cannot control the output of scripts and people indeed make # mistakes a warning is issued but no exception is raised to the # caller. self.session.update_job_result(self.job_R, result_R) # The observation here is that the parser is not handling the exception # in away which would allow for recovery. Out of all the output only # the first record is created and stored properly. The third, proper # record is entirely ignored. expected = {'R': [Resource({'attr': 'value-1'})]} self.assertEqual(self.session._resource_map, expected) # Make sure the right warning was logged mock_logger.warning.assert_called_once_with( "local script %s returned invalid RFC822 data: %s", self.job_R.id, RFC822SyntaxError( None, 3, "Unexpected non-empty line: " "'I-sound-like-a-broken-record\\n'")) def test_desire_job_X_updates_state_map(self): # This function checks what happens when the job X becomes desired via # the update_desired_job_list() call. self.session.update_desired_job_list([self.job_X]) self.assertEqual(self.session.desired_job_list, [self.job_X]) # As in the similar A - R test function above this topologically sorts # all affected jobs. Here X depends on Y so Y should be before X on the # run list. self.assertEqual(self.session.run_list, [self.job_Y, self.job_X]) # As in the A - R test above this also recomputes the job readiness # state. Job Y is now runnable but job X has a PENDING_DEP inhibitor. self.assertEqual(self.job_state('Y').readiness_inhibitor_list, []) # While the A job still cannot run it now has a different inhibitor, # one with the PENDING_RESOURCE cause. The inhibitor also properly # pinpoints the related job and related expression. self.assertNotEqual(self.job_state('X').readiness_inhibitor_list, []) self.assertEqual( self.job_inhibitor('X', 0).cause, InhibitionCause.PENDING_DEP) self.assertEqual(self.job_inhibitor('X', 0).related_job, self.job_Y) self.assertFalse(self.job_state('X').can_start()) def test_desired_job_X_cannot_run_with_failed_job_Y(self): # This function checks how SessionState reacts when the desired job X # readiness state changes when presented with a failed result to job Y self.session.update_desired_job_list([self.job_X]) # When X is desired, as above, it should be inhibited with PENDING_DEP # on Y self.assertNotEqual(self.job_state('X').readiness_inhibitor_list, []) self.assertEqual( self.job_inhibitor('X', 0).cause, InhibitionCause.PENDING_DEP) self.assertEqual(self.job_inhibitor('X', 0).related_job, self.job_Y) self.assertFalse(self.job_state('X').can_start()) # When a failed Y result is presented X should switch to FAILED_DEP result_Y = MemoryJobResult({'outcome': IJobResult.OUTCOME_FAIL}) self.session.update_job_result(self.job_Y, result_Y) # Now job X should have a FAILED_DEP inhibitor instead of the # PENDING_DEP it had before. Everything else should stay as-is. self.assertNotEqual(self.job_state('X').readiness_inhibitor_list, []) self.assertEqual( self.job_inhibitor('X', 0).cause, InhibitionCause.FAILED_DEP) self.assertEqual(self.job_inhibitor('X', 0).related_job, self.job_Y) self.assertFalse(self.job_state('X').can_start()) def test_desired_job_X_can_run_with_passing_job_Y(self): # A variant of the test case above, simply Y passes this time, making X # runnable self.session.update_desired_job_list([self.job_X]) result_Y = MemoryJobResult({'outcome': IJobResult.OUTCOME_PASS}) self.session.update_job_result(self.job_Y, result_Y) # Now X is runnable self.assertEqual(self.job_state('X').readiness_inhibitor_list, []) self.assertTrue(self.job_state('X').can_start()) def test_desired_job_X_cannot_run_with_no_resource_R(self): # A variant of the two test cases above, using A-R jobs self.session.update_desired_job_list([self.job_A]) result_R = MemoryJobResult({ 'outcome': IJobResult.OUTCOME_PASS, 'io_log': [(0, 'stdout', b'attr: wrong value\n')], }) self.session.update_job_result(self.job_R, result_R) # Now A is inhibited by FAILED_RESOURCE self.assertNotEqual(self.job_state('A').readiness_inhibitor_list, []) self.assertEqual( self.job_inhibitor('A', 0).cause, InhibitionCause.FAILED_RESOURCE) self.assertEqual(self.job_inhibitor('A', 0).related_job, self.job_R) self.assertEqual( self.job_inhibitor('A', 0).related_expression, self.job_A_expr) self.assertFalse(self.job_state('A').can_start()) def test_resource_job_result_overwrites_old_resources(self): # This function checks what happens when a JobResult for job R is # presented to a session that has some resources from that job already. result_R_old = MemoryJobResult({ 'outcome': IJobResult.OUTCOME_PASS, 'io_log': [(0, 'stdout', b"attr: old value\n")] }) self.session.update_job_result(self.job_R, result_R_old) # So here the old result is stored into a new 'R' resource expected_before = {'R': [Resource({'attr': 'old value'})]} self.assertEqual(self.session._resource_map, expected_before) # Now we present the second result for the same job result_R_new = MemoryJobResult({ 'outcome': IJobResult.OUTCOME_PASS, 'io_log': [(0, 'stdout', b"attr: new value\n")] }) self.session.update_job_result(self.job_R, result_R_new) # What should happen here is that the R resource is entirely replaced # by the data from the new result. The data should not be merged or # appended in any way. expected_after = {'R': [Resource({'attr': 'new value'})]} self.assertEqual(self.session._resource_map, expected_after) def test_get_outcome_stats(self): result_A = MemoryJobResult({'outcome': IJobResult.OUTCOME_PASS}) result_R = MemoryJobResult({'outcome': IJobResult.OUTCOME_FAIL}) result_Y = MemoryJobResult({'outcome': IJobResult.OUTCOME_FAIL}) self.session.update_job_result(self.job_A, result_A) self.session.update_job_result(self.job_R, result_R) self.session.update_job_result(self.job_Y, result_Y) self.assertEqual(self.session.get_outcome_stats(), { IJobResult.OUTCOME_PASS: 1, IJobResult.OUTCOME_FAIL: 2 }) def test_get_certification_status_map(self): result_A = MemoryJobResult({'outcome': IJobResult.OUTCOME_PASS}) self.session.update_job_result(self.job_A, result_A) self.session.job_state_map[ self.job_A.id].effective_certification_status = 'foo' self.assertEqual(self.session.get_certification_status_map(), {}) self.assertEqual( self.session.get_certification_status_map( outcome_filter=(IJobResult.OUTCOME_PASS, ), certification_status_filter=('foo', )), {self.job_A.id: self.session.job_state_map[self.job_A.id]}) result_Y = MemoryJobResult({'outcome': IJobResult.OUTCOME_FAIL}) self.session.job_state_map[ self.job_Y.id].effective_certification_status = 'bar' self.assertEqual(self.session.get_certification_status_map(), {}) self.assertEqual( self.session.get_certification_status_map( outcome_filter=(IJobResult.OUTCOME_PASS, IJobResult.OUTCOME_FAIL), certification_status_filter=('foo', 'bar')), {self.job_A.id: self.session.job_state_map[self.job_A.id]}) self.session.update_job_result(self.job_Y, result_Y) self.assertEqual( self.session.get_certification_status_map( outcome_filter=(IJobResult.OUTCOME_PASS, IJobResult.OUTCOME_FAIL), certification_status_filter=('foo', 'bar')), { self.job_A.id: self.session.job_state_map[self.job_A.id], self.job_Y.id: self.session.job_state_map[self.job_Y.id] })