Skip to content

Commit 9134460

Browse files
authored
Merge pull request #79 from forcedotcom/jo_win_docker
fix windows docker zip/deploy bug
2 parents d88b943 + c21e3cb commit 9134460

4 files changed

Lines changed: 67 additions & 23 deletions

File tree

src/datacustomcode/cmd.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,13 @@ def _cmd_output(
8585
**kwargs: Any,
8686
) -> tuple[int, bytes, Union[bytes, None]]:
8787
_setdefault_kwargs(kwargs)
88+
kwargs.setdefault("shell", True)
89+
# On Windows, Popen with shell=True and a sequence uses list2cmdline() which
90+
# quotes the entire string, causing cmd.exe to fail. Joining to a plain string
91+
# works correctly on both Unix (/bin/sh -c "...") and Windows (cmd.exe /c ...).
92+
cmd_arg: Union[tuple[str, ...], str] = " ".join(cmd) if kwargs.get("shell") else cmd
8893
try:
89-
kwargs.setdefault("shell", True)
90-
proc = subprocess.Popen(cmd, **kwargs)
94+
proc = subprocess.Popen(cmd_arg, **kwargs)
9195
except OSError as e:
9296
returncode, stdout_b, stderr_b = _oserror_to_output(e)
9397
else:

src/datacustomcode/deploy.py

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ def create_deployment(
272272
raise
273273

274274

275-
PLATFORM_ENV_VAR = "DOCKER_DEFAULT_PLATFORM=linux/amd64"
275+
PLATFORM_ENV = {"DOCKER_DEFAULT_PLATFORM": "linux/amd64"}
276276
DOCKER_IMAGE_NAME = "datacloud-custom-code-dependency-builder"
277277
DEPENDENCIES_ARCHIVE_NAME = "native_dependencies"
278278
DEPENDENCIES_ARCHIVE_FULL_NAME = f"{DEPENDENCIES_ARCHIVE_NAME}.tar.gz"
@@ -286,19 +286,25 @@ def prepare_dependency_archive(directory: str, docker_network: str) -> None:
286286
cmd = f"docker images -q {DOCKER_IMAGE_NAME}"
287287
image_exists = cmd_output(cmd)
288288

289+
docker_env = {**os.environ, **PLATFORM_ENV}
290+
289291
if not image_exists:
290292
logger.info(f"Building docker image with docker network: {docker_network}...")
291293
cmd = docker_build_cmd(docker_network)
292-
cmd_output(cmd)
294+
cmd_output(cmd, env=docker_env)
293295

294-
with tempfile.TemporaryDirectory() as temp_dir:
296+
# ignore_cleanup_errors=True: on Windows, Docker creates files inside the
297+
# mounted volume whose permissions prevent the host from deleting them.
298+
# The archive has already been copied out, so silently skipping leftover
299+
# files is safe and avoids a fatal error on context-manager exit.
300+
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir:
295301
logger.info(
296302
f"Building dependencies archive with docker network: {docker_network}"
297303
)
298304
shutil.copy("requirements.txt", temp_dir)
299305
shutil.copy("build_native_dependencies.sh", temp_dir)
300306
cmd = docker_run_cmd(docker_network, temp_dir)
301-
cmd_output(cmd)
307+
cmd_output(cmd, env=docker_env)
302308
archives_temp_path = os.path.join(temp_dir, DEPENDENCIES_ARCHIVE_FULL_NAME)
303309
os.makedirs(os.path.dirname(DEPENDENCIES_ARCHIVE_PATH), exist_ok=True)
304310
shutil.copy(archives_temp_path, DEPENDENCIES_ARCHIVE_PATH)
@@ -307,23 +313,19 @@ def prepare_dependency_archive(directory: str, docker_network: str) -> None:
307313

308314

309315
def docker_build_cmd(network: str) -> str:
310-
cmd = (
311-
f"{PLATFORM_ENV_VAR} docker build -t {DOCKER_IMAGE_NAME} "
312-
f"--file Dockerfile.dependencies . "
313-
)
316+
cmd = f"docker build -t {DOCKER_IMAGE_NAME} --file Dockerfile.dependencies . "
314317

315318
if network != "default":
316319
cmd = cmd + f"--network {network}"
317320
logger.debug(f"Docker build command: {cmd}")
318321
return cmd
319322

320323

321-
def docker_run_cmd(network: str, temp_dir) -> str:
322-
cmd = (
323-
f"{PLATFORM_ENV_VAR} docker run --rm "
324-
f"-v {temp_dir}:/workspace "
325-
f"{DOCKER_IMAGE_NAME} "
326-
)
324+
def docker_run_cmd(network: str, temp_dir: str) -> str:
325+
# Normalise path separators: Docker expects forward slashes even on Windows,
326+
# and quoting handles paths that contain spaces.
327+
docker_path = temp_dir.replace("\\", "/")
328+
cmd = f'docker run --rm -v "{docker_path}:/workspace" {DOCKER_IMAGE_NAME} '
327329

328330
if network != "default":
329331
cmd = cmd + f"--network {network} "

tests/test_cmd.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,3 +168,40 @@ def test_cmd_output_with_custom_kwargs(self, mock_cmd_output):
168168
_, kwargs = mock_cmd_output.call_args
169169
assert kwargs["env"] == {"PATH": "/usr/bin"}
170170
assert kwargs["cwd"] == "/tmp"
171+
172+
@patch("subprocess.Popen")
173+
def test_cmd_output_shell_passes_string_not_tuple(self, mock_popen):
174+
"""When shell=True, Popen must receive a plain string, not a tuple.
175+
176+
On Windows, Popen(tuple, shell=True) calls list2cmdline() which quotes
177+
the entire string (e.g. '"docker images -q foo"'), causing cmd.exe to
178+
fail with 'not recognized as an internal or external command'. Passing
179+
a joined string avoids this on both Windows and Unix.
180+
"""
181+
process_mock = Mock()
182+
process_mock.communicate.return_value = (b"", b"")
183+
process_mock.returncode = 0
184+
mock_popen.return_value = process_mock
185+
186+
_cmd_output("docker images -q my-image")
187+
188+
args, _ = mock_popen.call_args
189+
cmd_arg = args[0]
190+
assert isinstance(cmd_arg, str), (
191+
f"Expected str but got {type(cmd_arg)}: {cmd_arg!r}. "
192+
"Passing a tuple with shell=True causes Windows quoting issues."
193+
)
194+
assert cmd_arg == "docker images -q my-image"
195+
196+
@patch("subprocess.Popen")
197+
def test_cmd_output_multi_arg_shell_joins_to_string(self, mock_popen):
198+
"""Multiple positional args are joined into one string for shell=True."""
199+
process_mock = Mock()
200+
process_mock.communicate.return_value = (b"", b"")
201+
process_mock.returncode = 0
202+
mock_popen.return_value = process_mock
203+
204+
_cmd_output("docker", "images", "-q")
205+
206+
args, _ = mock_popen.call_args
207+
assert args[0] == "docker images -q"

tests/test_deploy.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44
from unittest.mock import (
5+
ANY,
56
MagicMock,
67
mock_open,
78
patch,
@@ -49,12 +50,12 @@ class TestPrepareDependencyArchive:
4950
"docker images -q datacloud-custom-code-dependency-builder"
5051
)
5152
EXPECTED_BUILD_CMD = (
52-
"DOCKER_DEFAULT_PLATFORM=linux/amd64 docker build "
53-
"-t datacloud-custom-code-dependency-builder -f Dockerfile.dependencies . "
53+
"docker build "
54+
"-t datacloud-custom-code-dependency-builder --file Dockerfile.dependencies . "
5455
)
5556
EXPECTED_DOCKER_RUN_CMD = (
56-
"DOCKER_DEFAULT_PLATFORM=linux/amd64 docker run --rm "
57-
"-v /tmp/test_dir:/workspace "
57+
"docker run --rm "
58+
'-v "/tmp/test_dir:/workspace" '
5859
"datacloud-custom-code-dependency-builder "
5960
)
6061

@@ -106,7 +107,7 @@ def test_prepare_dependency_archive_image_exists(
106107

107108
# Verify docker run command was called
108109
mock_docker_run_cmd.assert_called_once_with("default", "/tmp/test_dir")
109-
mock_cmd_output.assert_any_call("mock run command")
110+
mock_cmd_output.assert_any_call("mock run command", env=ANY)
110111

111112
# Verify archives directory was created
112113
mock_makedirs.assert_called_once_with("payload/archives", exist_ok=True)
@@ -159,15 +160,15 @@ def test_prepare_dependency_archive_build_image(
159160

160161
# Verify docker build command was called
161162
mock_docker_build_cmd.assert_called_once_with("default")
162-
mock_cmd_output.assert_any_call("mock build command")
163+
mock_cmd_output.assert_any_call("mock build command", env=ANY)
163164

164165
# Verify files were copied to temp directory
165166
mock_copy.assert_any_call("requirements.txt", "/tmp/test_dir")
166167
mock_copy.assert_any_call("build_native_dependencies.sh", "/tmp/test_dir")
167168

168169
# Verify docker run command was called
169170
mock_docker_run_cmd.assert_called_once_with("default", "/tmp/test_dir")
170-
mock_cmd_output.assert_any_call("mock run command")
171+
mock_cmd_output.assert_any_call("mock run command", env=ANY)
171172

172173
# Verify archives directory was created
173174
mock_makedirs.assert_called_once_with("payload/archives", exist_ok=True)

0 commit comments

Comments
 (0)