Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix the hanging problem in windows if the terraform plan command failed #5909

Merged
merged 9 commits into from
Sep 14, 2023
Merged
20 changes: 16 additions & 4 deletions samcli/lib/utils/subprocess_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,19 @@
"""
import logging
import os
import platform
import sys
from concurrent.futures.thread import ThreadPoolExecutor
from subprocess import PIPE, Popen
from subprocess import PIPE, STDOUT, Popen
from time import sleep
from typing import Any, AnyStr, Callable, Dict, Optional, Union

from samcli.commands.exceptions import UserException
from samcli.lib.utils.stream_writer import StreamWriter

TERRAFORM_ERROR_PREFIX = [27, 91, 51, 49]
mndeveci marked this conversation as resolved.
Show resolved Hide resolved

IS_WINDOWS = platform.system().lower() == "windows"
LOG = logging.getLogger(__name__)


Expand Down Expand Up @@ -73,7 +77,7 @@ def invoke_subprocess_with_loading_pattern(
command_args["stdout"] = PIPE

if not command_args.get("stderr"):
command_args["stderr"] = PIPE
command_args["stderr"] = STDOUT if IS_WINDOWS else PIPE

try:
keep_printing = LOG.getEffectiveLevel() >= logging.INFO
Expand All @@ -92,10 +96,18 @@ def _print_loading_pattern():
# we read from subprocess stdout to avoid the deadlock process.wait function
# for more detail check this python bug https://bugs.python.org/issue1256
for line in process.stdout:
decoded_line = _check_and_process_bytes(line)
is_error = (
IS_WINDOWS
and len(line) >= len(TERRAFORM_ERROR_PREFIX)
and line[0:4] == bytes(TERRAFORM_ERROR_PREFIX)
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This utility module and function seems very generic, can we put terraform specific stuff behind a flag so that this will be only applied to the places where it is been called for terraform stuff?

decoded_line = _check_and_process_bytes(line, preserve_whitespace=is_error)
if LOG.getEffectiveLevel() < logging.INFO:
LOG.debug(decoded_line)
process_output += decoded_line
if not is_error:
process_output += decoded_line
else:
process_stderr += decoded_line

if process.stderr:
for line in process.stderr:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,10 @@ def setUp(self):
shutil.rmtree(Path(self.working_dir))
shutil.copytree(Path(self.terraform_application_path), Path(self.working_dir))

def run_command(self, command_list, env=None, input=None):
def run_command(self, command_list, env=None, input=None, timeout=None):
process = Popen(command_list, stdout=PIPE, stderr=PIPE, stdin=PIPE, env=env, cwd=self.working_dir)
try:
(stdout, stderr) = process.communicate(input=input)
(stdout, stderr) = process.communicate(input=input, timeout=timeout)
return stdout, stderr, process.returncode
except TimeoutExpired:
process.kill()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ def test_invoke_function(self):
(True,),
],
)
class TestBuildTerraformApplicationsSourceCodeAndModulesAreNotInRootModuleDirectory(BuildTerraformApplicationIntegBase):
class TestBuildTerraformNestedDirectories(BuildTerraformApplicationIntegBase):
terraform_application = (
Path("terraform/application_outside_root_directory")
if not IS_WINDOWS
Expand Down Expand Up @@ -429,10 +429,12 @@ def test_build_and_invoke_lambda_functions(self, function_identifier, expected_o
(not RUN_BY_CANARY and not CI_OVERRIDE),
"Skip Terraform test cases unless running in CI",
)
class TestBuildTerraformApplicationsSourceCodeAndModulesAreNotInRootModuleDirectoryGetParametersFromSamConfig(
BuildTerraformApplicationIntegBase
):
terraform_application = Path("terraform/application_outside_root_directory")
class TestBuildTerraformApplicationsNestedDirectoriesGetParametersFromSamConfig(BuildTerraformApplicationIntegBase):
terraform_application = (
Path("terraform/application_outside_root_directory")
if not IS_WINDOWS
else Path("terraform/application_outside_root_directory_windows")
)

functions = [
("aws_lambda_function.function1", "hello world 1"),
Expand Down Expand Up @@ -509,12 +511,25 @@ def test_blocked_env_variables(self, env_name, env_value):
class TestTerraformHandlesExceptionFromBinary(BuildTerraformApplicationIntegBase):
terraform_application = Path("terraform/broken_tf")

def test_subprocess_handler(self):
err_message = "Terraform encountered problems during initialisation"
@parameterized.expand([True, False])
def test_subprocess_handler(self, debug_flag):
err_message = (
"Failed to execute the subprocess. The process ['terraform', 'init', '-input=false'] returned a non-zero "
"exit code 1."
)
terraform_error_message = "Error: Unclosed configuration block"
stack_trace_error = "unexpected error was encountered while executing 'sam build'"
cmdlist = self.get_command_list(hook_name="terraform", beta_features=True)
_, stderr, return_code = self.run_command(cmdlist)
cmdlist = self.get_command_list(
hook_name="terraform",
debug=debug_flag,
)
# add time out, so if the process hangs, the testing will not hang, but the sam command will be timed out.
# terraform plan command should fail within seconds, as there is an error in syntax, but we will wait for 5 mins
# in case if terraform init takes time.
_, stderr, return_code = self.run_command(cmdlist, timeout=300)
err_string = stderr.decode("utf-8").strip()
LOG.info("sam build stderr: %s", err_string)
self.assertEqual(return_code, 1)
self.assertIn(err_message, err_string)
self.assertIn(terraform_error_message, err_string)
self.assertNotIn(stack_trace_error, err_string)
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ def test_successful_request(self):
class TestStartApiTerraformApplicationCustomPlanFile(TerraformStartApiIntegrationBase):
terraform_application = "terraform-v1-api-simple"
terraform_plan_file = "custom-plan.json"
testing_urls = ["hello"]

def setUp(self):
self.url = "http://127.0.0.1:{}".format(self.port)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
version: 0.1
default:
global:
parameters:
hook_name: terraform
build:
parameters:
terraform_project_root_path: ./..
9 changes: 6 additions & 3 deletions tests/unit/lib/utils/test_subprocess_utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from subprocess import DEVNULL, PIPE
from subprocess import DEVNULL, PIPE, STDOUT

import logging
from io import StringIO
Expand All @@ -18,6 +18,7 @@
LoadingPatternError,
_check_and_process_bytes,
)
from tests.testing_utils import IS_WINDOWS


class TestSubprocessUtils(TestCase):
Expand All @@ -36,7 +37,9 @@ def test_loader_stream_logs_when_debug_level(self, patched_Popen, patched_log, p
mock_stream_writer = Mock()
patched_check_and_process_byes.side_effect = [f"Line 1{os.linesep}", "Line 2"]
actual_output = invoke_subprocess_with_loading_pattern({"args": ["ls"]}, mock_pattern, mock_stream_writer)
patched_check_and_process_byes.assert_has_calls([call(f"Line 1{os.linesep}"), call("Line 2")])
patched_check_and_process_byes.assert_has_calls(
[call(f"Line 1{os.linesep}", preserve_whitespace=False), call("Line 2", preserve_whitespace=False)]
)
mock_pattern.assert_not_called()
self.assertEqual(actual_output, expected_output)

Expand All @@ -54,7 +57,7 @@ def test_loader_stream_uses_passed_in_stdout(self, patched_Popen, patched_log, p
patched_Popen.return_value.__enter__.return_value = mock_process
patched_log.getEffectiveLevel.return_value = logging.INFO
invoke_subprocess_with_loading_pattern({"args": ["ls"], "stdout": DEVNULL}, mock_pattern, mock_stream_writer)
patched_Popen.assert_called_once_with(args=["ls"], stdout=DEVNULL, stderr=PIPE)
patched_Popen.assert_called_once_with(args=["ls"], stdout=DEVNULL, stderr=PIPE if not IS_WINDOWS else STDOUT)

@patch("samcli.lib.utils.subprocess_utils.Popen")
def test_loader_raises_exception_non_zero_exit_code(self, patched_Popen):
Expand Down