def create_file( self, *, destination: pathlib.Path, content: io.BytesIO, file_mode: str, group: str = "root", user: str = "root", ) -> None: """Create file with content and file mode. Multipass transfers data as "ubuntu" user, forcing us to first copy a file to a temporary location before moving to a (possibly) root-owned location and with appropriate permissions. :param destination: Path to file. :param content: Contents of file. :param file_mode: File mode string (e.g. '0644'). :param group: File group owner/id. :param user: File user owner/id. """ try: tmp_file_path = self._multipass.exec( instance_name=self.name, command=["mktemp"], runner=subprocess.run, capture_output=True, check=True, text=True, ).stdout.strip() self._multipass.transfer_source_io( source=content, destination=f"{self.name}:{tmp_file_path}", ) self.execute_run( ["sudo", "chown", f"{user}:{group}", tmp_file_path], capture_output=True, check=True, ) self.execute_run( ["sudo", "chmod", file_mode, tmp_file_path], capture_output=True, check=True, ) self.execute_run( ["sudo", "mv", tmp_file_path, destination.as_posix()], capture_output=True, check=True, ) except subprocess.CalledProcessError as error: raise MultipassError( brief= f"Failed to create file {destination.as_posix()!r} in {self.name!r} VM.", details=errors.details_from_called_process_error(error), ) from error
def launch( self, *, instance_name: str, image: str, cpus: str = None, mem: str = None, disk: str = None, ) -> None: """Launch multipass VM. :param instance_name: The name the launched instance_name will have. :param image: Name of image to create the instance with. :param cpus: Amount of virtual CPUs to assign to the launched instance_name. :param mem: Amount of RAM to assign to the launched instance_name. :param disk: Amount of disk space the instance_name will see. :raises MultipassError: on error. """ command = ["launch", image, "--name", instance_name] if cpus is not None: command.extend(["--cpus", cpus]) if mem is not None: command.extend(["--mem", mem]) if disk is not None: command.extend(["--disk", disk]) try: self._run(command, capture_output=True, check=True) except subprocess.CalledProcessError as error: raise MultipassError( brief=f"Failed to launch VM {instance_name!r}.", details=errors.details_from_called_process_error(error), ) from error
def test_details_from_called_process_error(): error = subprocess.CalledProcessError( -1, ["test-command", "flags", "quote$me"], "test stdout", "test stderr" ) details = errors.details_from_called_process_error(error) assert details == textwrap.dedent( """\ * Command that failed: "test-command flags 'quote$me'" * Command exit code: -1 * Command output: 'test stdout' * Command standard error output: 'test stderr'""" )
def start(self, *, instance_name: str) -> None: """Start VM instance. :param instance_name: the name of the instance to start. :raises MultipassError: on error. """ command = ["start", instance_name] try: self._run(command, check=True) except subprocess.CalledProcessError as error: raise MultipassError( brief=f"Failed to start VM {instance_name!r}.", details=errors.details_from_called_process_error(error), ) from error
def test_create_file_error(mock_multipass, instance): error = subprocess.CalledProcessError(-1, ["mktemp"], "test stdout", "test stderr") mock_multipass.exec.side_effect = error with pytest.raises(MultipassError) as exc_info: instance.create_file( destination=pathlib.Path("/etc/test.conf"), content=io.BytesIO(b"foo"), file_mode="0644", ) assert exc_info.value == MultipassError( brief="Failed to create file '/etc/test.conf' in 'test-instance' VM.", details=errors.details_from_called_process_error(error), )
def umount(self, *, mount: str) -> None: """Unmount target in VM. :param mount: Mount point in <name>[:<path>] format, where <name> are instance names, and optional <path> are mount points. If omitted, all mounts will be removed from the name instance. :raises MultipassError: On error. """ command = ["umount", mount] try: self._run(command, capture_output=True, check=True) except subprocess.CalledProcessError as error: raise MultipassError( brief=f"Failed to unmount {mount!r}.", details=errors.details_from_called_process_error(error), ) from error
def info(self, *, instance_name: str) -> Dict[str, Any]: """Get information/state for instance. :returns: Parsed json data from info command. :raises MultipassError: On error. """ command = ["info", instance_name, "--format", "json"] try: proc = self._run(command, capture_output=True, check=True, text=True) except subprocess.CalledProcessError as error: raise MultipassError( brief=f"Failed to query info for VM {instance_name!r}.", details=errors.details_from_called_process_error(error), ) from error return json.loads(proc.stdout)
def transfer(self, *, source: str, destination: str) -> None: """Transfer to destination path with source IO. :param source: The source path, prefixed with <name:> for a path inside the instance. :param destination: The destination path, prefixed with <name:> for a path inside the instance. :raises MultipassError: On error. """ command = ["transfer", source, destination] try: self._run(command, capture_output=True, check=True) except subprocess.CalledProcessError as error: raise MultipassError( brief=f"Failed to transfer {source!r} to {destination!r}.", details=errors.details_from_called_process_error(error), ) from error
def list(self) -> List[str]: """List names of VMs. :returns: Data from stdout if instance exists, else None. :raises MultipassError: On error. """ command = ["list", "--format", "json"] try: proc = self._run(command, capture_output=True, check=True, text=True) except subprocess.CalledProcessError as error: raise MultipassError( brief="Failed to query list of VMs.", details=errors.details_from_called_process_error(error), ) from error data_list = json.loads(proc.stdout).get("list", list()) return [instance["name"] for instance in data_list]
def delete(self, *, instance_name: str, purge=True) -> None: """Passthrough for running multipass delete. :param instance_name: The name of the instance_name to delete. :param purge: Flag to purge the instance_name's image after deleting. :raises MultipassError: on error. """ command = ["delete", instance_name] if purge: command.append("--purge") try: self._run(command, capture_output=True, check=True) except subprocess.CalledProcessError as error: raise MultipassError( brief=f"Failed to delete VM {instance_name!r}.", details=errors.details_from_called_process_error(error), ) from error
def stop(self, *, instance_name: str, delay_mins: int = 0) -> None: """Stop VM instance. :param instance_name: the name of the instance_name to stop. :param delay_mins: Delay shutdown for specified number of minutes. :raises MultipassError: on error. """ command = ["stop", instance_name] if delay_mins != 0: command.extend(["--time", str(delay_mins)]) try: self._run(command, capture_output=True, check=True) except subprocess.CalledProcessError as error: raise MultipassError( brief=f"Failed to stop VM {instance_name!r}.", details=errors.details_from_called_process_error(error), ) from error
def mount( self, *, source: pathlib.Path, target: str, uid_map: Dict[str, str] = None, gid_map: Dict[str, str] = None, ) -> None: """Mount host source path to target. :param source: Path of local directory to mount. :param target: Target mount points, in <name>[:<path>] format, where <name> is an instance name, and optional <path> is the mount point. If omitted, the mount point will be the same as the source's absolute path. :param uid_map: A mapping of user IDs for use in the mount of the form <host-id> -> <instance-name-id>. File and folder ownership will be mapped from <host> to <instance-name> inside the instance_name. :param gid_map: A mapping of group IDs for use in the mount of the form <host-id> -> <instance-name-id>. File and folder ownership will be mapped from <host> to <instance-name> inside the instance_name. """ command = ["mount", str(source), target] if uid_map is not None: for host_id, instance_id in uid_map.items(): command.extend(["--uid-map", f"{host_id}:{instance_id}"]) if gid_map is not None: for host_id, instance_id in gid_map.items(): command.extend(["--gid-map", f"{host_id}:{instance_id}"]) try: self._run(command, capture_output=True, check=True) except subprocess.CalledProcessError as error: raise MultipassError( brief=f"Failed to mount {str(source)!r} to {target!r}.", details=errors.details_from_called_process_error(error), ) from error