def test_str__contains_output(self): output = b"Mot\xf6rhead" unicode_output = "Mot\ufffdrhead" error = ExternalProcessError( returncode=-1, cmd="foo-bar", output=output ) self.assertIn(unicode_output, error.__str__())
def test_upgrade_does_not_change_other_errors(self): error_type = factory.make_exception_type() error = error_type() self.expectThat(error, Not(IsInstance(ExternalProcessError))) ExternalProcessError.upgrade(error) self.expectThat(error, Not(IsInstance(ExternalProcessError))) self.expectThat(error.__class__, Is(error_type))
def test_upgrade_does_not_change_CalledProcessError_subclasses(self): error_type = factory.make_exception_type(bases=(CalledProcessError,)) error = factory.make_CalledProcessError() error.__class__ = error_type # Change the class. self.expectThat(error, Not(IsInstance(ExternalProcessError))) ExternalProcessError.upgrade(error) self.expectThat(error, Not(IsInstance(ExternalProcessError))) self.expectThat(error.__class__, Is(error_type))
def test_to_ascii_defers_to_bytes(self): # Byte strings and non-unicode strings are handed to bytes() to # undergo Python's normal coercion strategy. (For byte strings # this is actually a no-op, but it's cheaper to do this than # special-case byte strings.) self.assertEqual( str(self).encode("ascii"), ExternalProcessError._to_ascii(self))
def test_raises_error_when_omshell_crashes(self): error_message = factory.make_name("error").encode("ascii") omshell = Mock() omshell.create.side_effect = ExternalProcessError(returncode=2, cmd=("omshell", ), output=error_message) mac = factory.make_mac_address() ip = factory.make_ip_address() with FakeLogger("maas.dhcp") as logger: error = self.assertRaises( exceptions.CannotCreateHostMap, dhcp._create_host_map, omshell, mac, ip, ) # The CannotCreateHostMap exception includes a message describing the # problematic mapping. self.assertDocTestMatches( "Could not create host map for %s -> %s: ..." % (mac, ip), str(error), ) # A message is also written to the maas.dhcp logger that describes the # problematic mapping. self.assertDocTestMatches( "Could not create host map for %s -> %s: ..." % (mac, ip), logger.output, )
def test_raises_error_when_omshell_not_connected(self): error = ExternalProcessError(returncode=2, cmd=("omshell", ), output="") self.patch(ExternalProcessError, "output_as_unicode", "not connected.") omshell = Mock() omshell.create.side_effect = error mac = factory.make_mac_address() ip = factory.make_ip_address() with FakeLogger("maas.dhcp") as logger: error = self.assertRaises( exceptions.CannotCreateHostMap, dhcp._create_host_map, omshell, mac, ip, ) # The CannotCreateHostMap exception includes a message describing the # problematic mapping. self.assertDocTestMatches( "Could not create host map for %s -> %s: " "The DHCP server could not be reached." % (mac, ip), str(error), ) # A message is also written to the maas.dhcp logger that describes the # problematic mapping. self.assertDocTestMatches( "Could not create host map for %s -> %s: " "The DHCP server could not be reached." % (mac, ip), logger.output, )
def test_503_response_includes_retry_after_header(self): error = ExternalProcessError(returncode=-1, cmd="foo-bar") response = self.process_exception(error) self.assertEqual(( http.client.SERVICE_UNAVAILABLE, '%s' % middleware_module.RETRY_AFTER_SERVICE_UNAVAILABLE, ), (response.status_code, response['Retry-after']))
def test_to_ascii_encodes_to_bytes(self): # Yes, this is how you really spell "smorgasbord." Look it up. unicode_string = "Sm\xf6rg\xe5sbord" expected_byte_string = b"Sm?rg?sbord" converted_string = ExternalProcessError._to_ascii(unicode_string) self.assertIsInstance(converted_string, bytes) self.assertEqual(expected_byte_string, converted_string)
def sudo_write_file(filename, contents, mode=0o644): """Write (or overwrite) file as root. USE WITH EXTREME CARE. Runs an atomic update using non-interactive `sudo`. This will fail if it needs to prompt for a password. When running in a snap or devel mode, this function calls `atomic_write` directly. :type contents: `bytes`. """ from provisioningserver.config import is_dev_environment if not isinstance(contents, bytes): raise TypeError("Content must be bytes, got: %r" % (contents, )) if snappy.running_in_snap(): atomic_write(contents, filename, mode=mode) else: maas_write_file = get_library_script_path("maas-write-file") command = _with_dev_python(maas_write_file, filename, "%.4o" % mode) if not is_dev_environment(): command = sudo(command) proc = Popen(command, stdin=PIPE) stdout, stderr = proc.communicate(contents) if proc.returncode != 0: raise ExternalProcessError(proc.returncode, command, stderr)
def test_output_as_ascii(self): output = b"Joyeux No\xebl" ascii_output = b"Joyeux No?l" error = ExternalProcessError( returncode=-1, cmd="foo-bar", output=output ) self.assertEqual(ascii_output, error.output_as_ascii)
def nullify_lease(self, ip_address: str): """Reset an existing lease so it's no longer valid. You can't delete leases with omshell, so we're setting the expiry timestamp to the epoch instead. """ stdin = dedent( """\ server {self.server_address} port {self.server_port} key omapi_key {self.shared_key} connect new lease set ip-address = {ip_address} open set ends = 00:00:00:00 update """ ) stdin = stdin.format(self=self, ip_address=ip_address) returncode, output = self._run(stdin.encode("utf-8")) if b"can't open object: not found" in output: # Consider nonexistent leases a success. return None # Catching "invalid" is a bit like catching a bare exception # but omshell is so esoteric that this is probably quite safe. # If the update succeeded, "ends = 00:00:00:00" will most certainly # be in the output. If it's not, there's been a failure. if b"invalid" not in output and b"\nends = 00:00:00:00" in output: return None raise ExternalProcessError(returncode, self.command, output)
def test_output_as_unicode(self): output = b"Mot\xf6rhead" unicode_output = "Mot\ufffdrhead" error = ExternalProcessError( returncode=-1, cmd="foo-bar", output=output ) self.assertEqual(unicode_output, error.output_as_unicode)
def test__query_outlet_state_crashes_when_wget_exits_nonzero(self): driver = dli_module.DLIPowerDriver() call_and_check_mock = self.patch(dli_module, 'call_and_check') call_and_check_mock.side_effect = ( ExternalProcessError(1, "dli something")) self.assertRaises( PowerActionError, driver._query_outlet_state, sentinel.outlet_id, sentinel.power_user, sentinel.power_pass, sentinel.power_address)
def test__issue_fence_cdu_command_handles_power_query_off(self): driver = fence_cdu_module.FenceCDUPowerDriver() mock = self.patch(fence_cdu_module, 'call_and_check') mock.side_effect = ExternalProcessError(2, "Fence CDU error") stdout = driver._issue_fence_cdu_command( 'status', sentinel.power_address, sentinel.power_id, sentinel.power_user, sentinel.power_pass) self.assertThat(stdout, Equals("Status: OFF\n"))
def test__issue_fence_cdu_command_errors_on_exception(self): driver = fence_cdu_module.FenceCDUPowerDriver() mock = self.patch(fence_cdu_module, 'call_and_check') mock.side_effect = ExternalProcessError(1, "Fence CDU error") self.assertRaises(PowerError, driver._issue_fence_cdu_command, sentinel.command, sentinel.fence_cdu, sentinel.power_address, sentinel.power_id, sentinel.power_user, sentinel.power_pass)
def test_to_ascii_removes_non_printable_chars(self): # After conversion to a byte string, all non-printable and # non-ASCII characters are replaced with question marks. byte_string = b"*How* many roads\x01\x02\xb2\xfe" expected_byte_string = b"*How* many roads????" converted_string = ExternalProcessError._to_ascii(byte_string) self.assertIsInstance(converted_string, bytes) self.assertEqual(expected_byte_string, converted_string)
def test_to_unicode_decodes_to_unicode(self): # Byte strings are decoded as ASCII by _to_unicode(), replacing # all non-ASCII characters with U+FFFD REPLACEMENT CHARACTERs. byte_string = b"This string will be converted. \xe5\xb2\x81\xe5." expected_unicode_string = ( "This string will be converted. \ufffd\ufffd\ufffd\ufffd.") converted_string = ExternalProcessError._to_unicode(byte_string) self.assertIsInstance(converted_string, str) self.assertEqual(expected_unicode_string, converted_string)
def bind_reconfigure(): """Ask BIND to reload its configuration and *new* zone files. From rndc(8): Reload the configuration file and load new zones, but do not reload existing zone files even if they have changed. This is faster than a full reload when there is a large number of zones because it avoids the need to examine the modification times of the zones files. """ try: execute_rndc_command(("reconfig", )) except CalledProcessError as exc: maaslog.error("Reloading BIND configuration failed: %s", exc) # Log before upgrade so that the output does not go to maaslog. ExternalProcessError.upgrade(exc) raise
def test__issue_ipmitool_raises_power_action_error(self): context = make_context() moonshot_driver = MoonshotIPMIPowerDriver() call_and_check_mock = self.patch(moonshot_module, "call_and_check") call_and_check_mock.side_effect = ExternalProcessError( 1, "ipmitool something") self.assertRaises(PowerActionError, moonshot_driver._issue_ipmitool_command, "status", **context)
def test_503_response_includes_retry_after_header(self): middleware = APIErrorsMiddleware() request = factory.make_fake_request( "/api/2.0/" + factory.make_string(), 'POST') error = ExternalProcessError(returncode=-1, cmd="foo-bar") response = middleware.process_exception(request, error) self.assertEqual(( http.client.SERVICE_UNAVAILABLE, '%s' % middleware_module.RETRY_AFTER_SERVICE_UNAVAILABLE, ), (response.status_code, response['Retry-after']))
def test_reports_ExternalProcessError_as_ServiceUnavailable(self): error_text = factory.make_string() exception = ExternalProcessError(1, ["cmd"], error_text) retry_after = random.randint(0, 10) self.patch(middleware_module, 'RETRY_AFTER_SERVICE_UNAVAILABLE', retry_after) response = self.process_exception(exception) self.expectThat(response.status_code, Equals(http.client.SERVICE_UNAVAILABLE)) self.expectThat(response.content.decode(settings.DEFAULT_CHARSET), Equals(str(exception))) self.expectThat(response['Retry-After'], Equals("%s" % retry_after))
def test__power_control_seamicro15k_ipmi_raises_PowerFatalError(self): ip, username, password, server_id, _ = self.make_context() power_change = choice(['on', 'off']) seamicro_power_driver = SeaMicroPowerDriver() call_and_check_mock = self.patch(seamicro_module, 'call_and_check') call_and_check_mock.side_effect = ( ExternalProcessError(1, "ipmitool something")) self.assertRaises( PowerActionError, seamicro_power_driver._power_control_seamicro15k_ipmi, ip, username, password, server_id, power_change)
def test__bad_config(self): omapi_key = factory.make_name("omapi_key") failover_peers = make_failover_peer_config() shared_network = make_shared_network() [shared_network] = fix_shared_networks_failover( [shared_network], [failover_peers] ) host = make_host() interface = make_interface() global_dhcp_snippets = make_global_dhcp_snippets() dhcpd_error = ( "Internet Systems Consortium DHCP Server 4.3.3\n" "Copyright 2004-2015 Internet Systems Consortium.\n" "All rights reserved.\n" "For info, please visit https://www.isc.org/software/dhcp/\n" "/tmp/maas-dhcpd-z5c7hfzt line 14: semicolon expected.\n" "ignore \n" "^\n" "Configuration file errors encountered -- exiting\n" "\n" "If you think you have received this message due to a bug rather\n" "than a configuration issue please read the section on submitting" "\n" "bugs on either our web page at www.isc.org or in the README file" "\n" "before submitting a bug. These pages explain the proper\n" "process and the information we find helpful for debugging..\n" "\n" "exiting." ) self.mock_call_and_check.side_effect = ExternalProcessError( returncode=1, cmd=("dhcpd",), output=dhcpd_error ) self.assertEqual( [ { "error": "semicolon expected.", "line_num": 14, "line": "ignore ", "position": "^", } ], self.validate( omapi_key, [failover_peers], [shared_network], [host], [interface], global_dhcp_snippets, ), )
def test__converts_failure_writing_file_to_CannotConfigureDHCP(self): self.patch_sudo_delete_file() self.patch_sudo_write_file().side_effect = ( ExternalProcessError(1, "sudo something")) self.patch_restartService() failover_peers = [make_failover_peer_config()] shared_networks = fix_shared_networks_failover( [make_shared_network()], failover_peers) with ExpectedException(exceptions.CannotConfigureDHCP): yield self.configure( factory.make_name('key'), failover_peers, shared_networks, [make_host()], [make_interface()], make_global_dhcp_snippets())
def create(self, ip_address: str, mac_address: str): # The "name" is not a host name; it's an identifier used within # the DHCP server. We use the MAC address. Prior to 1.9, MAAS used # the IPs as the key but changing to using MAC addresses allows the # DHCP service to give all the NICs of a bond the same IP address. # The only caveat of this change is that the remove() method in this # class has to be able to deal with legacy host mappings (using IP as # the key) and new host mappings (using the MAC as the key). log.debug( "Creating host mapping {mac}->{ip}", mac=mac_address, ip=ip_address ) name = mac_address.replace(":", "-") stdin = dedent( """\ server {self.server_address} port {self.server_port} key omapi_key {self.shared_key} connect new host set ip-address = {ip_address} set hardware-address = {mac_address} set hardware-type = 1 set name = "{name}" create """ ) stdin = stdin.format( self=self, ip_address=ip_address, mac_address=mac_address, name=name, ) returncode, output = self._run(stdin.encode("utf-8")) # If the call to omshell doesn't result in output containing the # magic string 'hardware-type' then we can be reasonably sure # that the 'create' command failed. Unfortunately there's no # other output like "successful" to check so this is the best we # can do. if b"hardware-type" in output: # Success. pass elif b"can't open object: I/O error" in output: # Host map already existed. Treat as success. pass else: raise ExternalProcessError(returncode, self.command, output)
def remove(self, mac_address: str): # The "name" is not a host name; it's an identifier used within # the DHCP server. We use the MAC address. Prior to 1.9, MAAS using # the IPs as the key but changing to using MAC addresses allows the # DHCP service to give all the NICs of a bond the same IP address. # The only caveat of this change is that the remove() method needs # to be able to deal with legacy host mappings (using IP as # the key) and new host mappings (using the MAC as the key). # This is achieved by sending both the IP and the MAC: one of them will # be the key for the mapping (it will be the IP if the record was # created with by an old version of MAAS and the MAC otherwise). log.debug("Removing host mapping key={mac}", mac=mac_address) mac_address = mac_address.replace(":", "-") stdin = dedent( """\ server {self.server_address} port {self.server_port} key omapi_key {self.shared_key} connect new host set name = "{mac_address}" open remove """ ) stdin = stdin.format(self=self, mac_address=mac_address) returncode, output = self._run(stdin.encode("utf-8")) # If the omshell worked, the last line should reference a null # object. We need to strip blanks, newlines and '>' characters # for this to work. lines = output.strip(b"\n >").splitlines() try: last_line = lines[-1] except IndexError: last_line = "" if b"obj: <null" in last_line: # Success. pass elif b"can't open object: not found" in output: # It was already removed. Consider success. pass else: raise ExternalProcessError(returncode, self.command, output)
def test_update_targets_conf_logs_error(self): self.patch(boot_resources.service_monitor, "ensureService") mock_try_send_rack_event = self.patch(boot_resources, 'try_send_rack_event') mock_maaslog = self.patch(boot_resources.maaslog, 'warning') self.patch(boot_resources.os.path, 'exists').return_value = True self.patch(boot_resources, 'call_and_check').side_effect = (ExternalProcessError( returncode=2, cmd=('tgt-admin', ), output='error')) snapshot = factory.make_name("snapshot") boot_resources.update_targets_conf(snapshot) self.assertThat(mock_try_send_rack_event, MockCalledOnce()) self.assertThat(mock_maaslog, MockCalledOnce()) self.assertThat( boot_resources.call_and_check, MockCalledOnceWith([ 'sudo', '-n', '/usr/sbin/tgt-admin', '--conf', os.path.join(snapshot, 'maas.tgt'), '--update', 'ALL' ]))
def sudo_delete_file(filename): """Delete file as root. USE WITH EXTREME CARE. Runs an atomic update using non-interactive `sudo`. This will fail if it needs to prompt for a password. When running in a snap this function calls `atomic_write` directly. """ from provisioningserver.config import is_dev_environment if snappy.running_in_snap(): atomic_delete(filename) else: maas_delete_file = get_library_script_path("maas-delete-file") command = _with_dev_python(maas_delete_file, filename) if not is_dev_environment(): command = sudo(command) proc = Popen(command) stdout, stderr = proc.communicate() if proc.returncode != 0: raise ExternalProcessError(proc.returncode, command, stderr)
def test__bad_config(self): omapi_key = factory.make_name('omapi_key') failover_peers = make_failover_peer_config() shared_network = make_shared_network() [shared_network] = fix_shared_networks_failover( [shared_network], [failover_peers]) host = make_host() interface = make_interface() global_dhcp_snippets = make_global_dhcp_snippets() dhcpd_error = ( 'Internet Systems Consortium DHCP Server 4.3.3\n' 'Copyright 2004-2015 Internet Systems Consortium.\n' 'All rights reserved.\n' 'For info, please visit https://www.isc.org/software/dhcp/\n' '/tmp/maas-dhcpd-z5c7hfzt line 14: semicolon expected.\n' 'ignore \n' '^\n' 'Configuration file errors encountered -- exiting\n' '\n' 'If you think you have received this message due to a bug rather\n' 'than a configuration issue please read the section on submitting' '\n' 'bugs on either our web page at www.isc.org or in the README file' '\n' 'before submitting a bug. These pages explain the proper\n' 'process and the information we find helpful for debugging..\n' '\n' 'exiting.' ) self.patch(dhcp, 'call_and_check').side_effect = ExternalProcessError( returncode=1, cmd=("dhcpd",), output=dhcpd_error) self.assertEqual([{ 'error': 'semicolon expected.', 'line_num': 14, 'line': 'ignore ', 'position': '^', }], self.validate( omapi_key, [failover_peers], [shared_network], [host], [interface], global_dhcp_snippets))
def test__show_service_start_error(self): url = factory.make_simple_http_url() secret = factory.make_bytes() register_command.run(self.make_args(url=url, secret=to_hex(secret))) mock_call_and_check = self.patch(register_command, 'call_and_check') mock_call_and_check.side_effect = [ call(), call(), ExternalProcessError(1, 'systemctl start', 'mock error'), ] mock_stderr = self.patch(register_command.stderr, 'write') with ExpectedException(SystemExit): register_command.run(self.make_args(url=url, secret=to_hex(secret))) self.assertThat( mock_stderr, MockCallsMatch( call('Unable to enable and start the maas-rackd service.'), call('\n'), call('Failed with error: mock error.'), call('\n'), ))