Repository: opensearch-project/opensearch-build

Issue:
[Bug]: Permission denied in assemble
### Describe the bug

Cross-platform assemble for Windows running on OSX fails with permission denied.


### To reproduce

```
./build.sh manifests/1.1.0/opensearch-1.1.0.yml --snapshot --platform windows
```

```
$ ./assemble.sh builds/manifest.yml 
Installing dependencies in . ...
Installing dependencies from Pipfile.lock (9310b0)...
  🐍   ▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉ 0/0 — 00:00:00
To activate this project's virtualenv, run pipenv shell.
Alternatively, run a command inside the virtualenv with pipenv run.
Running ./src/run_assemble.py builds/manifest.yml ...
2021-10-27 16:18:52 INFO     Bundling OpenSearch (x64) on windows into /Users/dblock/source/opensearch-project/opensearch-build/dblock/dist ...
2021-10-27 16:18:59 INFO     Executing "bash /Users/dblock/source/opensearch-project/opensearch-build/dblock/scripts/components/OpenSearch/install.sh -a "/Users/dblock/source/opensearch-project/opensearch-build/dblock/builds" -o "/var/folders/lf/872sgt9n4ld5s20mdj0_5txrds0wb5/T/tmp35_vhag9/opensearch-1.1.0-SNAPSHOT"" in /var/folders/lf/872sgt9n4ld5s20mdj0_5txrds0wb5/T/tmp35_vhag9/opensearch-1.1.0-SNAPSHOT
+ getopts :h:a:o: arg
+ case $arg in
+ ARTIFACTS=/Users/dblock/source/opensearch-project/opensearch-build/dblock/builds
+ getopts :h:a:o: arg
+ case $arg in
+ OUTPUT=/var/folders/lf/872sgt9n4ld5s20mdj0_5txrds0wb5/T/tmp35_vhag9/opensearch-1.1.0-SNAPSHOT
+ getopts :h:a:o: arg
++ dirname /Users/dblock/source/opensearch-project/opensearch-build/dblock/scripts/components/OpenSearch/install.sh
+ DIR=/Users/dblock/source/opensearch-project/opensearch-build/dblock/scripts/components/OpenSearch
+ echo /Users/dblock/source/opensearch-project/opensearch-build/dblock/scripts/components/OpenSearch
/Users/dblock/source/opensearch-project/opensearch-build/dblock/scripts/components/OpenSearch
+ cd /Users/dblock/source/opensearch-project/opensearch-build/dblock/scripts/components/OpenSearch
+ cp ../../../scripts/legacy/tar/linux/opensearch-tar-install.sh /var/folders/lf/872sgt9n4ld5s20mdj0_5txrds0wb5/T/tmp35_vhag9/opensearch-1.1.0-SNAPSHOT/
2021-10-27 16:18:59 INFO     Installing job-scheduler
2021-10-27 16:18:59 INFO     Executing "/var/folders/lf/872sgt9n4ld5s20mdj0_5txrds0wb5/T/tmp35_vhag9/opensearch-1.1.0-SNAPSHOT/bin/opensearch-plugin install --batch file:/var/folders/lf/872sgt9n4ld5s20mdj0_5txrds0wb5/T/tmp35_vhag9/opensearch-job-scheduler-1.1.0.0-SNAPSHOT.zip" in /var/folders/lf/872sgt9n4ld5s20mdj0_5txrds0wb5/T/tmp35_vhag9/opensearch-1.1.0-SNAPSHOT
/bin/sh: /var/folders/lf/872sgt9n4ld5s20mdj0_5txrds0wb5/T/tmp35_vhag9/opensearch-1.1.0-SNAPSHOT/bin/opensearch-plugin: Permission denied
Traceback (most recent call last):
  File "./src/run_assemble.py", line 64, in <module>
    sys.exit(main())
  File "./src/run_assemble.py", line 51, in main
    bundle.install_plugins()
  File "/Users/dblock/source/opensearch-project/opensearch-build/dblock/src/assemble_workflow/bundle.py", line 53, in install_plugins
    self.install_plugin(plugin)
  File "/Users/dblock/source/opensearch-project/opensearch-build/dblock/src/assemble_workflow/bundle_opensearch.py", line 21, in install_plugin
    self._execute(f"{cli_path} install --batch file:{tmp_path}")
  File "/Users/dblock/source/opensearch-project/opensearch-build/dblock/src/assemble_workflow/bundle.py", line 68, in _execute
    subprocess.check_call(command, cwd=self.min_dist.archive_path, shell=True)
  File "/Users/dblock/.pyenv/versions/3.7.11/lib/python3.7/subprocess.py", line 363, in check_call
    raise CalledProcessError(retcode, cmd)
subprocess.CalledProcessError: Command '/var/folders/lf/872sgt9n4ld5s20mdj0_5txrds0wb5/T/tmp35_vhag9/opensearch-1.1.0-SNAPSHOT/bin/opensearch-plugin install --batch file:/var/folders/lf/872sgt9n4ld5s20mdj0_5txrds0wb5/T/tmp35_vhag9/opensearch-job-scheduler-1.1.0.0-SNAPSHOT.zip' returned non-zero exit status 126.
```

### Expected behavior

_No response_

### Screenshots

If applicable, add screenshots to help explain your problem.

### Host / Environment

_No response_

### Additional context

_No response_

### Relevant log output

_No response_


Discussion:

Resolving patch:
diff --git a/src/assemble_workflow/bundle.py b/src/assemble_workflow/bundle.py
--- a/src/assemble_workflow/bundle.py
+++ b/src/assemble_workflow/bundle.py
@@ -94,6 +94,9 @@ def __get_min_dist(self, build_components):
         if min_bundle is None:
             raise ValueError('Missing min "dist" in input artifacts.')
         min_dist_path = self._copy_component(min_bundle, "dist")
+        logging.info(f"Copied min bundle to {min_dist_path}.")
         min_dist = Dist.from_path(min_bundle.name, min_dist_path)
+        logging.info(f"Extracting dist into {self.tmp_dir.name}.")
         min_dist.extract(self.tmp_dir.name)
+        logging.info(f"Extracted dist into {self.tmp_dir.name}.")
         return min_dist
diff --git a/src/assemble_workflow/dist.py b/src/assemble_workflow/dist.py
--- a/src/assemble_workflow/dist.py
+++ b/src/assemble_workflow/dist.py
@@ -12,6 +12,8 @@
 import zipfile
 from abc import ABC, abstractmethod
 
+from system.zip_file import ZipFile
+
 
 class Dist(ABC):
     def __init__(self, name, path):
@@ -53,11 +55,11 @@ def from_path(cls, name, path):
 
 class DistZip(Dist):
     def __extract__(self, dest):
-        with zipfile.ZipFile(self.path, "r") as zip:
+        with ZipFile(self.path, "r") as zip:
             zip.extractall(dest)
 
     def __build__(self, name, dest):
-        with zipfile.ZipFile(name, "w", zipfile.ZIP_DEFLATED) as zip:
+        with ZipFile(name, "w", zipfile.ZIP_DEFLATED) as zip:
             rootlen = len(self.archive_path) + 1
             for base, dirs, files in os.walk(self.archive_path):
                 for file in files:
@@ -67,7 +69,7 @@ def __build__(self, name, dest):
 
 class DistTar(Dist):
     def __extract__(self, dest):
-        with tarfile.open(self.path, "r") as tar:
+        with tarfile.open(self.path, "r:gz") as tar:
             tar.extractall(dest)
 
     def __build__(self, name, dest):
diff --git a/src/system/zip_file.py b/src/system/zip_file.py
new file mode 100644
--- /dev/null
+++ b/src/system/zip_file.py
@@ -0,0 +1,24 @@
+# SPDX-License-Identifier: Apache-2.0
+#
+# The OpenSearch Contributors require contributions made to
+# this file be licensed under the Apache-2.0 license or a
+# compatible open source license.
+
+# https://stackoverflow.com/questions/39296101/python-zipfile-removes-execute-permissions-from-binaries
+
+import os
+import zipfile
+
+
+class ZipFile(zipfile.ZipFile):
+    def _extract_member(self, member, targetpath, pwd):
+        if not isinstance(member, zipfile.ZipInfo):
+            member = self.getinfo(member)
+
+        targetpath = super()._extract_member(member, targetpath, pwd)
+
+        attr = member.external_attr >> 16
+        if attr != 0:
+            os.chmod(targetpath, attr)
+
+        return targetpath


Test patch:
diff --git a/tests/tests_assemble_workflow/test_bundle_opensearch.py b/tests/tests_assemble_workflow/test_bundle_opensearch.py
--- a/tests/tests_assemble_workflow/test_bundle_opensearch.py
+++ b/tests/tests_assemble_workflow/test_bundle_opensearch.py
@@ -119,7 +119,7 @@ def test_bundle_package_zip(self):
             MagicMock(package_name="opensearch.zip"),
         )
 
-        with patch("zipfile.ZipFile") as mock_zipfile_open:
+        with patch("assemble_workflow.dist.ZipFile") as mock_zipfile_open:
             mock_zipfile_write = MagicMock()
             mock_zipfile_open.return_value.__enter__.return_value.write = mock_zipfile_write
             with patch("shutil.copyfile") as mock_copyfile:
diff --git a/tests/tests_system/data/executable.sh b/tests/tests_system/data/executable.sh
new file mode 100755
--- /dev/null
+++ b/tests/tests_system/data/executable.sh
@@ -0,0 +1,9 @@
+#!/bin/bash
+
+# SPDX-License-Identifier: Apache-2.0
+#
+# The OpenSearch Contributors require contributions made to
+# this file be licensed under the Apache-2.0 license or a
+# compatible open source license.
+
+echo This line intentionally left blank.
\ No newline at end of file
diff --git a/tests/tests_system/test_zip_file.py b/tests/tests_system/test_zip_file.py
new file mode 100644
--- /dev/null
+++ b/tests/tests_system/test_zip_file.py
@@ -0,0 +1,34 @@
+# SPDX-License-Identifier: Apache-2.0
+#
+# The OpenSearch Contributors require contributions made to
+# this file be licensed under the Apache-2.0 license or a
+# compatible open source license.
+
+import os
+import unittest
+import zipfile
+
+from system.temporary_directory import TemporaryDirectory
+from system.zip_file import ZipFile
+
+
+class TestZipFile(unittest.TestCase):
+    def setUp(self):
+        self.data_path = os.path.join(os.path.dirname(__file__), "data")
+
+    def test_extractall_preserves_permissions(self):
+        with TemporaryDirectory() as tmp:
+            temp_file = os.path.join(tmp.name, "test.zip")
+            with ZipFile(temp_file, "w", zipfile.ZIP_DEFLATED) as zip:
+                zip.write(os.path.join(self.data_path, "executable.sh"), "executable.sh")
+                zip.write(__file__, "regular.py")
+
+            with ZipFile(temp_file, "r") as zip:
+                zip.extractall(tmp.name)
+
+            executable_file = os.path.join(tmp.name, "executable.sh")
+            self.assertTrue(os.path.exists(executable_file))
+            self.assertTrue(os.access(executable_file, os.X_OK))
+
+            regular_file = os.path.join(tmp.name, "regular.py")
+            self.assertTrue(os.path.exists(regular_file))