Repository: avocado-framework/avocado

Issue:
utils/software_management: Add extract_from_deb and extract_from_rpm operations
### Context ### 

The QEMU acceptance tests [1] provides some facilities for extracting files from either RPM and Deb packages. Those files are usually compiled Linux kernels that are used on 'boot an VM' kind of tests.

Those facilities are implemented in two methods in the `boot_linux_console.py` test module:
```
extract_from_deb(self, deb, path)

extract_from_rpm(self, rpm, path)
```
 

### Problem ###

Maintain that kind of facility isn't really on the scope of QEMU acceptance tests project, they would be better kept in Avocado. Also it could benefit Avocado users given that those utilities are pretty generic.

### Proposal ###

1) Move `extract_from_[rpm|deb]` to Avocado's `utils/software_management.py` module. 

The `utils/software_management.py` provides the `RpmBackend` and `DpkgBackend` implementations for, respectively, `rpm` and `dpkg` commands. Those backend seems to be good places to put those methods in. Or, alternatively, there could be created classes that represent an *rpm* or *deb* file and provides operations.   
 
2) Improve `extract_from_[rpm|deb]` method

Both methods extract a single file (passed via `path` parameter) but it could be useful to allow extract more than one file at once. Or the user could not even pass `path`, in this case it simply decompress/extract the whole package.

It needs to properly raise an exception or provides other mean to notify the user that the operation failed. 

### References ###

[1] https://github.com/qemu/qemu/tree/master/tests/acceptance


Discussion:
Technically, I see no problem, and I agree that it may benefit the Avocado project. As I know little about the Avocado+QEMU package, I don't know what impacts this change may have on it, so I'm answering just for the Avocado project side.
> Technically, I see no problem, and I agree that it may benefit the Avocado project. As I know little about the Avocado+QEMU package, I don't know what impacts this change may have on it, so I'm answering just for the Avocado project side.

Avocado+QEMU already uses some Avocado utils so it will need just a little adjustment on QEMU side.

 I also know little about Avocado+QEMU. But I'm ok with that unless I'm missing something.

One suggestion is to create a more generic method that will call the proper one:

```python

def extract_from_package(self, package, path):
  if is_rpm(package):
    return self.extract_from_rpm(package, path)
  elif is_deb(package):
    return self.extract_from_deb(package, path)
  elif:
    raise Exception("Not supported")
```

Not sure if it is really useful, just my two cents here, feel free to ignore this.
> One suggestion is to create a more generic method that will call the proper one:
> 
> ```python
> def extract_from_package(self, package, path):
>   if is_rpm(package):
>     return self.extract_from_rpm(package, path)
>   elif is_deb(package):
>     return self.extract_from_deb(package, path)
>   elif:
>     raise Exception("Not supported")
> ```
Good idea!

Resolving patch:
diff --git a/avocado/utils/software_manager/backends/dpkg.py b/avocado/utils/software_manager/backends/dpkg.py
--- a/avocado/utils/software_manager/backends/dpkg.py
+++ b/avocado/utils/software_manager/backends/dpkg.py
@@ -51,6 +51,42 @@ def list_all():
                 installed_packages.append("%s-%s" % (parts[1], parts[2]))
         return installed_packages
 
+    @staticmethod
+    def is_valid(package_path):
+        """Verifies if a package is a valid deb file.
+
+        :param str package_path: .deb package path.
+        :returns: True if valid, otherwise false.
+        :rtype: bool
+        """
+        abs_path = os.path.abspath(os.path.expanduser((package_path)))
+        try:
+            result = process.run("ar t {}".format(abs_path))
+        except process.CmdError:
+            return False
+        return result.exit_status == 0
+
+    @staticmethod
+    def extract_from_package(package_path, dest_path=None):
+        """Extracts the package content to a specific destination path.
+
+        :param str package_path: path to the deb package.
+        :param dest_path: destination path to extract the files. Default is the
+                          current directory.
+        :returns: path of the extracted file
+        :returns: the path of the extracted files.
+        :rtype: str
+        """
+        abs_path = os.path.abspath(os.path.expanduser(package_path))
+        dest = dest_path or os.path.curdir
+
+        # 'ar' command does not creates the directory if doesn't exists
+        os.makedirs(dest, exist_ok=True)
+
+        # If something goes wrong process.run will raise a CmdError exception
+        process.run("ar -x {} --output {}".format(abs_path, dest))
+        return dest
+
     def list_files(self, package):
         """
         List files installed by package [package].
diff --git a/avocado/utils/software_manager/backends/rpm.py b/avocado/utils/software_manager/backends/rpm.py
--- a/avocado/utils/software_manager/backends/rpm.py
+++ b/avocado/utils/software_manager/backends/rpm.py
@@ -225,6 +225,43 @@ def find_rpm_packages(self, rpm_dir):
                 subpacks += self.find_rpm_packages(new_filepath)
         return subpacks
 
+    @staticmethod
+    def is_valid(package_path):
+        """Verifies if a package is a valid rpm file.
+
+        :param str package_path: .rpm package path.
+        :returns: True if valid, otherwise false.
+        :rtype: bool
+        """
+        abs_path = os.path.abspath(os.path.expanduser((package_path)))
+        try:
+            result = process.run("rpm -qip {}".format(abs_path))
+        except process.CmdError:
+            return False
+        if result.exit_status == 0:
+            return True
+        return False
+
+    @staticmethod
+    def extract_from_package(package_path, dest_path=None):
+        """Extracts the package content to a specific destination path.
+
+        :param str package_path: path to the rpm package.
+        :param dest_path: destination path to extract the files. Default it
+                          will be the current directory.
+        :returns: path of the extracted file
+        :returns: the path of the extracted files.
+        :rtype: str
+        """
+        abs_path = os.path.abspath(os.path.expanduser(package_path))
+        dest = dest_path or os.path.curdir
+
+        # If something goes wrong process.run will raise a CmdError exception
+        process.run("rpm2cpio {} | cpio -dium -D {}".format(abs_path,
+                                                            dest),
+                    shell=True)
+        return dest
+
     def perform_setup(self, packages, no_dependencies=False):
         """
         General RPM setup with automatic handling of dependencies based on
diff --git a/avocado/utils/software_manager/manager.py b/avocado/utils/software_manager/manager.py
--- a/avocado/utils/software_manager/manager.py
+++ b/avocado/utils/software_manager/manager.py
@@ -49,3 +49,24 @@ def is_capable(self):
         except NotImplementedError:
             pass
         return self.initialized
+
+    @staticmethod
+    def extract_from_package(package_path, dest_path=None):
+        """Try to extract a package content into a destination directory.
+
+        It will try to see if the package is valid against all supported
+        package managers and if any is found, then extracts its content into
+        the extract_path.
+
+        Raises NotImplementedError when a non-supported package is used.
+
+        :param str package_path: package file path.
+        :param str dest_path: destination path to extract. Default is the
+                              current directory.
+        :returns: destination path were the package it was extracted.
+        """
+        for backend in SUPPORTED_PACKAGE_MANAGERS.values():
+            if backend.is_valid(package_path):
+                return backend.extract_from_package(package_path, dest_path)
+        raise NotImplementedError('No package manager supported was found for '
+                                  'package %s.' % package_path)


Test patch:
diff --git a/selftests/functional/test_software_manager.py b/selftests/functional/test_software_manager.py
new file mode 100644
--- /dev/null
+++ b/selftests/functional/test_software_manager.py
@@ -0,0 +1,43 @@
+import os
+import unittest
+
+from avocado.utils import software_manager
+from selftests.utils import BASEDIR, TestCaseTmpDir, skipUnlessPathExists
+
+
+class SoftwareManager(TestCaseTmpDir):
+    def setUp(self):
+        super().setUp()
+        # Functional tests are not being loaded properly. To be removed soon.
+        assets_dir = os.path.join(BASEDIR, "selftests", ".data")
+        self.rpm_path = os.path.join(assets_dir, "hello.rpm")
+        self.deb_path = os.path.join(assets_dir, "hello.deb")
+
+    @skipUnlessPathExists('/usr/bin/ar')
+    @skipUnlessPathExists('/usr/bin/rpm2cpio')
+    def test_extract_from_rpm(self):
+        manager = software_manager.SoftwareManager()
+        result = manager.extract_from_package(self.rpm_path,
+                                              self.tmpdir.name)
+        self.assertEqual(self.tmpdir.name, result)
+
+    @skipUnlessPathExists('/usr/bin/ar')
+    @skipUnlessPathExists('/usr/bin/rpm2cpio')
+    def test_extract_from_deb(self):
+        manager = software_manager.SoftwareManager()
+        result = manager.extract_from_package(self.deb_path,
+                                              self.tmpdir.name)
+        self.assertEqual(self.tmpdir.name, result)
+
+    @skipUnlessPathExists('/usr/bin/ar')
+    @skipUnlessPathExists('/usr/bin/rpm2cpio')
+    def test_extract_permission(self):
+        manager = software_manager.SoftwareManager()
+        with self.assertRaises(NotImplementedError) as context:
+            manager.extract_from_package('/dev/null', self.tmpdir.name)
+        expected = 'No package manager supported was found for package '
+        self.assertIn(expected, str(context.exception))
+
+
+if __name__ == '__main__':
+    unittest.main()