Repository: ansible/ansible

Issue:
Add an "include" parameter to unarchive
##### ISSUE TYPE
Feature Idea

##### COMPONENT NAME
unarchive module

##### ANSIBLE VERSION
```
ansible 2.1.0.0
config file = /etc/ansible/ansible.cfg
configured module search path = Default w/o overrides
This feature idea is really more geared towards Ansible > 2.1
```

##### CONFIGURATION
export ANSIBLE_NOCOWS=1

##### OS / ENVIRONMENT


##### SUMMARY
A Java JAR file is a ZIP file with a manifest and can be extracted using unarchive.  A Java JAR file can also be executed in place.  It's pretty common to extract specific files from a Java JAR file (such as properties files) so that you can alter the execution environment.  In the case below, I simply want to extract the icon file to add a launcher for Burp Suite to the menu.
In any case, there are many other instances when it would be convenient to unarchive a limited set of files from an archive.  The "exclude" parameter provides a similar function, but wouldn't be convenient in this case as there are thousands of files and a complex directory hierarchy in this archive.  Since I'm not on 2.1 yet (Xubuntu), I don't have the exclude feature yet but used the command module to run unzip instead.  The only annoying aspect to this technique is that Ansible tells me I should be using the unarchive module.
Low priority but potentially very useful ... and thanks for hard work you all put in!

##### STEPS TO REPRODUCE
The following play extracts a single file as needed:
```
- name: extract the application's icon from the archive
  command: unzip {{ burp_installation_directory }}/{{ burp_file }} {{ burp_icon_path }}/{{ burp_icon_file }}
           chdir={{ burp_installation_directory }}
           creates={{ burp_installation_directory }}/{{ burp_icon_path }}/{{ burp_icon_file }}
  tags:
    - burp_suite
    - security
```
But also generates a warning that I'm using the wrong module for the job.
```
TASK [burp_suite : extract the application's icon from the archive] ************
changed: [localhost]
 [WARNING]: Consider using unarchive module rather than running unzip
```

##### EXPECTED RESULTS


##### ACTUAL RESULTS





Discussion:
You can get rid of the warning using

``` yaml
  command: unzip ...
  args:
    chdir: /foo/bar
    creates: /some/file
    warn: no
```

REMOVED -jctanner

@jctanner - Is there some reason the ansibot suddenly notified me about an issue that's 9 month's old?  It seemed like a broken behavior to me.  I followed the template that was in place when I created the issue but quite frankly, I don't have time to tweak issues in every project I contribute to whenever the templates change.

In any case, thanks for all the hard work that's been put into Ansible!
cc @pileofrogs
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->
@smoyer64 yes, I accidentally let the bot put the message on all issues instead of just new ones. I'm going back through old issues though and fixing the templates, so that submitters won't have to.
@jctanner No worries - at least it didn't turn into the sort of problem Microsoft had with Tay!
+1 here. It would be a nice feature!

Resolving patch:
diff --git a/lib/ansible/modules/unarchive.py b/lib/ansible/modules/unarchive.py
--- a/lib/ansible/modules/unarchive.py
+++ b/lib/ansible/modules/unarchive.py
@@ -58,9 +58,20 @@
   exclude:
     description:
       - List the directory and file entries that you would like to exclude from the unarchive action.
+      - Mutually exclusive with C(include).
     type: list
+    default: []
     elements: str
     version_added: "2.1"
+  include:
+    description:
+      - List of directory and file entries that you would like to extract from the archive. Only
+        files listed here will be extracted.
+      - Mutually exclusive with C(exclude).
+    type: list
+    default: []
+    elements: str
+    version_added: "2.11"
   keep_newer:
     description:
       - Do not replace existing files that are newer than files from the archive.
@@ -264,6 +275,7 @@ def __init__(self, src, b_dest, file_args, module):
         self.module = module
         self.excludes = module.params['exclude']
         self.includes = []
+        self.include_files = self.module.params['include']
         self.cmd_path = self.module.get_bin_path('unzip')
         self.zipinfocmd_path = self.module.get_bin_path('zipinfo')
         self._files_in_archive = []
@@ -337,14 +349,19 @@ def files_in_archive(self):
         else:
             try:
                 for member in archive.namelist():
-                    exclude_flag = False
-                    if self.excludes:
-                        for exclude in self.excludes:
-                            if fnmatch.fnmatch(member, exclude):
-                                exclude_flag = True
-                                break
-                    if not exclude_flag:
-                        self._files_in_archive.append(to_native(member))
+                    if self.include_files:
+                        for include in self.include_files:
+                            if fnmatch.fnmatch(member, include):
+                                self._files_in_archive.append(to_native(member))
+                    else:
+                        exclude_flag = False
+                        if self.excludes:
+                            for exclude in self.excludes:
+                                if not fnmatch.fnmatch(member, exclude):
+                                    exclude_flag = True
+                                    break
+                        if not exclude_flag:
+                            self._files_in_archive.append(to_native(member))
             except Exception:
                 archive.close()
                 raise UnarchiveError('Unable to list files in the archive')
@@ -357,6 +374,8 @@ def is_unarchived(self):
         cmd = [self.zipinfocmd_path, '-T', '-s', self.src]
         if self.excludes:
             cmd.extend(['-x', ] + self.excludes)
+        if self.include_files:
+            cmd.extend(self.include_files)
         rc, out, err = self.module.run_command(cmd)
 
         old_out = out
@@ -665,6 +684,8 @@ def unarchive(self):
         # cmd.extend(map(shell_escape, self.includes))
         if self.excludes:
             cmd.extend(['-x'] + self.excludes)
+        if self.include_files:
+            cmd.extend(self.include_files)
         cmd.extend(['-d', self.b_dest])
         rc, out, err = self.module.run_command(cmd)
         return dict(cmd=cmd, rc=rc, out=out, err=err)
@@ -690,6 +711,7 @@ def __init__(self, src, b_dest, file_args, module):
         if self.module.check_mode:
             self.module.exit_json(skipped=True, msg="remote module (%s) does not support check mode when using gtar" % self.module._name)
         self.excludes = [path.rstrip('/') for path in self.module.params['exclude']]
+        self.include_files = self.module.params['include']
         # Prefer gtar (GNU tar) as it supports the compression options -z, -j and -J
         self.cmd_path = self.module.get_bin_path('gtar', None)
         if not self.cmd_path:
@@ -726,8 +748,10 @@ def files_in_archive(self):
         if self.excludes:
             cmd.extend(['--exclude=' + f for f in self.excludes])
         cmd.extend(['-f', self.src])
-        rc, out, err = self.module.run_command(cmd, cwd=self.b_dest, environ_update=dict(LANG='C', LC_ALL='C', LC_MESSAGES='C'))
+        if self.include_files:
+            cmd.extend(self.include_files)
 
+        rc, out, err = self.module.run_command(cmd, cwd=self.b_dest, environ_update=dict(LANG='C', LC_ALL='C', LC_MESSAGES='C'))
         if rc != 0:
             raise UnarchiveError('Unable to list files in the archive')
 
@@ -769,6 +793,8 @@ def is_unarchived(self):
         if self.excludes:
             cmd.extend(['--exclude=' + f for f in self.excludes])
         cmd.extend(['-f', self.src])
+        if self.include_files:
+            cmd.extend(self.include_files)
         rc, out, err = self.module.run_command(cmd, cwd=self.b_dest, environ_update=dict(LANG='C', LC_ALL='C', LC_MESSAGES='C'))
 
         # Check whether the differences are in something that we're
@@ -820,6 +846,8 @@ def unarchive(self):
         if self.excludes:
             cmd.extend(['--exclude=' + f for f in self.excludes])
         cmd.extend(['-f', self.src])
+        if self.include_files:
+            cmd.extend(self.include_files)
         rc, out, err = self.module.run_command(cmd, cwd=self.b_dest, environ_update=dict(LANG='C', LC_ALL='C', LC_MESSAGES='C'))
         return dict(cmd=cmd, rc=rc, out=out, err=err)
 
@@ -887,12 +915,14 @@ def main():
             list_files=dict(type='bool', default=False),
             keep_newer=dict(type='bool', default=False),
             exclude=dict(type='list', elements='str', default=[]),
+            include=dict(type='list', elements='str', default=[]),
             extra_opts=dict(type='list', elements='str', default=[]),
             validate_certs=dict(type='bool', default=True),
         ),
         add_file_common_args=True,
         # check-mode only works for zip files, we cover that later
         supports_check_mode=True,
+        mutually_exclusive=[('include', 'exclude')],
     )
 
     src = module.params['src']


Test patch:
diff --git a/test/integration/targets/unarchive/tasks/main.yml b/test/integration/targets/unarchive/tasks/main.yml
--- a/test/integration/targets/unarchive/tasks/main.yml
+++ b/test/integration/targets/unarchive/tasks/main.yml
@@ -6,6 +6,7 @@
 - import_tasks: test_tar_gz_keep_newer.yml
 - import_tasks: test_zip.yml
 - import_tasks: test_exclude.yml
+- import_tasks: test_include.yml
 - import_tasks: test_parent_not_writeable.yml
 - import_tasks: test_mode.yml
 - import_tasks: test_quotable_characters.yml
diff --git a/test/integration/targets/unarchive/tasks/prepare_tests.yml b/test/integration/targets/unarchive/tasks/prepare_tests.yml
--- a/test/integration/targets/unarchive/tasks/prepare_tests.yml
+++ b/test/integration/targets/unarchive/tasks/prepare_tests.yml
@@ -89,4 +89,4 @@
     mode: preserve
 
 - name: prep a tar.gz file with directory
-  shell: tar czvf test-unarchive-dir.tar.gz unarchive-dir  chdir={{remote_tmp_dir}}
+  shell: tar czvf test-unarchive-dir.tar.gz unarchive-dir chdir={{remote_tmp_dir}}
diff --git a/test/integration/targets/unarchive/tasks/test_exclude.yml b/test/integration/targets/unarchive/tasks/test_exclude.yml
--- a/test/integration/targets/unarchive/tasks/test_exclude.yml
+++ b/test/integration/targets/unarchive/tasks/test_exclude.yml
@@ -37,12 +37,3 @@
   file:
     path: '{{remote_tmp_dir}}/test-unarchive-zip'
     state: absent
-
-- name: remove our test files for the archive
-  file:
-    path: '{{remote_tmp_dir}}/{{item}}'
-    state: absent
-  with_items:
-    - foo-unarchive.txt
-    - foo-unarchive-777.txt
-    - FOO-UNAR.TXT
diff --git a/test/integration/targets/unarchive/tasks/test_include.yml b/test/integration/targets/unarchive/tasks/test_include.yml
new file mode 100644
--- /dev/null
+++ b/test/integration/targets/unarchive/tasks/test_include.yml
@@ -0,0 +1,83 @@
+- name: Create a tar file with multiple files
+  shell: tar cvf test-unarchive-multi.tar foo-unarchive-777.txt foo-unarchive.txt
+  args:
+    chdir: "{{ remote_tmp_dir }}"
+
+- name: Create include test directories
+  file:
+    state: directory
+    path: "{{ remote_tmp_dir }}/{{ item }}"
+  loop:
+    - include-zip
+    - include-tar
+
+- name: Unpack zip file include one file
+  unarchive:
+    src: "{{ remote_tmp_dir }}/test-unarchive.zip"
+    dest: "{{ remote_tmp_dir }}/include-zip"
+    include:
+      - FOO-UNAR.TXT
+
+- name: Verify that single file was unarchived
+  find:
+    paths: "{{ remote_tmp_dir }}/include-zip"
+  register: unarchive_dir02
+
+# The map filter was added in Jinja2 2.7, which is newer than the version on RHEL/CentOS 6,
+# so we skip this validation on those hosts
+- name: Verify that zip extraction included only one file
+  assert:
+    that:
+      - file_names == ['FOO-UNAR.TXT']
+  vars:
+    file_names: "{{ unarchive_dir02.files | map(attribute='path') | map('basename') }}"
+  when:
+    - "ansible_facts.os_family == 'RedHat'"
+    - ansible_facts.distribution_major_version is version('7', '>=')
+
+- name: Unpack tar file include one file
+  unarchive:
+    src: "{{ remote_tmp_dir }}/test-unarchive-multi.tar"
+    dest: "{{ remote_tmp_dir }}/include-tar"
+    include:
+      - foo-unarchive-777.txt
+
+- name: verify that single file was unarchived from tar
+  find:
+    paths: "{{ remote_tmp_dir }}/include-tar"
+  register: unarchive_dir03
+
+- name: Verify that tar extraction included only one file
+  assert:
+    that:
+      - file_names == ['foo-unarchive-777.txt']
+  vars:
+    file_names: "{{ unarchive_dir03.files | map(attribute='path') | map('basename') }}"
+  when:
+    - "ansible_facts.os_family == 'RedHat'"
+    - ansible_facts.distribution_major_version is version('7', '>=')
+
+- name: Check mutually exclusive parameters
+  unarchive:
+    src: "{{ remote_tmp_dir }}/test-unarchive-multi.tar"
+    dest: "{{ remote_tmp_dir }}/include-tar"
+    include:
+      - foo-unarchive-777.txt
+    exclude:
+      - foo
+  ignore_errors: yes
+  register: unarchive_mutually_exclusive_check
+
+- name: Check mutually exclusive parameters
+  assert:
+    that:
+      - unarchive_mutually_exclusive_check is failed
+      - "'mutually exclusive' in unarchive_mutually_exclusive_check.msg"
+
+- name: "Remove include feature tests directory"
+  file:
+    state: absent
+    path: "{{ remote_tmp_dir }}/{{ item }}"
+  loop:
+    - 'include-zip'
+    - 'include-tar'