Skip to content

Commit c747cc7

Browse files
committed
feat: Add Docker support for shell commands in build process
- Execute shell commands inside Docker container when build_in_docker=true - Add automatic Docker image pull if not found locally - Fix critical bug: handle query=None in BuildPlanManager.execute() - Add 4 unit tests for Docker and host execution paths - Update README with shell commands documentation and example - Add example module demonstrating Docker shell commands This allows users to run package managers and build tools that are only available in the container environment. All 11 tests passing (7 existing + 4 new).
1 parent 1c3b16a commit c747cc7

File tree

5 files changed

+380
-26
lines changed

5 files changed

+380
-26
lines changed

CHANGELOG.md

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

33
All notable changes to this project will be documented in this file.
44

5+
## [Unreleased]
6+
7+
### Features
8+
9+
* Add Docker execution support for shell commands in build process
10+
511
## [8.1.0](https://github.com/terraform-aws-modules/terraform-aws-lambda/compare/v8.0.1...v8.1.0) (2025-08-22)
612

713

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,34 @@ To override the docker entrypoint when building in docker, set `docker_entrypoin
503503

504504
The entrypoint must map to a path within your container, so you need to either build your own image that contains the entrypoint or map it to a file on the host by mounting a volume (see [Passing additional Docker options](#passing-additional-docker-options)).
505505

506+
#### Shell Commands with Docker
507+
508+
When `build_in_docker = true`, shell commands specified in the `commands` parameter are executed inside the Docker container. This allows you to run package managers or other tools that are only available in the Lambda runtime environment:
509+
510+
```hcl
511+
module "lambda_function" {
512+
source = "terraform-aws-modules/lambda/aws"
513+
514+
function_name = "my-lambda"
515+
runtime = "python3.12"
516+
build_in_docker = true
517+
docker_image = "public.ecr.aws/lambda/python:3.12"
518+
519+
source_path = [{
520+
path = "${path.module}/src"
521+
commands = [
522+
# Install system dependencies in Lambda container
523+
"microdnf install -y gcc",
524+
# Build native extensions
525+
"pip install --target=. -r requirements.txt",
526+
":zip"
527+
]
528+
}]
529+
}
530+
```
531+
532+
This is particularly useful when you need to install packages or compile code using tools that are specific to the Lambda runtime environment but may not be available on your build machine.
533+
506534
## <a name="package"></a> Deployment package - Create or use existing
507535

508536
By default, this module creates deployment package and uses it to create or update Lambda Function or Lambda Layer.

examples/build-package/main.tf

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,27 @@ module "package_src_poetry2" {
102102
artifacts_dir = "${path.root}/builds/package_src_poetry2/"
103103
}
104104

105+
# Create zip-archive with custom shell commands executed in Docker container
106+
module "package_with_docker_shell_commands" {
107+
source = "../../"
108+
109+
create_function = false
110+
111+
build_in_docker = true
112+
runtime = "python3.12"
113+
docker_image = "public.ecr.aws/lambda/python:3.12"
114+
115+
source_path = [{
116+
path = "${path.module}/../fixtures/python-app1"
117+
commands = [
118+
"echo 'Running shell commands in Docker container'",
119+
"ls -la",
120+
":zip"
121+
]
122+
}]
123+
artifacts_dir = "${path.root}/builds/package_docker_shell_commands/"
124+
}
125+
105126
# Create zip-archive of a single directory where "poetry export" & "pip install --no-deps" will also be executed (not using docker)
106127
module "package_dir_poetry_no_docker" {
107128
source = "../../"

package.py

Lines changed: 141 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -906,6 +906,38 @@ def execute(self, build_plan, zip_stream, query):
906906
sh_work_dir = None
907907
pf = None
908908

909+
# Resolve Docker image ID once for all steps
910+
docker = query.docker if query else None
911+
docker_image_tag_id = None
912+
913+
if docker:
914+
docker_image = docker.docker_image
915+
if docker_image:
916+
output = check_output(docker_image_id_command(docker_image))
917+
if output:
918+
docker_image_tag_id = output.decode().strip()
919+
log.debug(
920+
"DOCKER TAG ID: %s -> %s", docker_image, docker_image_tag_id
921+
)
922+
else:
923+
# Image not found locally, try to pull it
924+
log.info("Docker image not found locally, pulling: %s", docker_image)
925+
try:
926+
check_call(docker_pull_command(docker_image))
927+
# Get the image ID after pulling
928+
output = check_output(docker_image_id_command(docker_image))
929+
if output:
930+
docker_image_tag_id = output.decode().strip()
931+
log.debug(
932+
"DOCKER TAG ID (after pull): %s -> %s",
933+
docker_image,
934+
docker_image_tag_id,
935+
)
936+
except subprocess.CalledProcessError as e:
937+
log.warning(
938+
"Failed to pull Docker image %s: %s", docker_image, e
939+
)
940+
909941
for step in build_plan:
910942
# init step
911943
sh_work_dir = tf_work_dir
@@ -987,52 +1019,128 @@ def execute(self, build_plan, zip_stream, query):
9871019
# XXX: timestamp=0 - what actually do with it?
9881020
zs.write_dirs(rd, prefix=prefix, timestamp=0)
9891021
elif cmd == "sh":
990-
with tempfile.NamedTemporaryFile(
991-
mode="w+t", delete=True
992-
) as temp_file:
993-
script = action[1]
1022+
script = action[1]
9941023

1024+
if docker and docker_image_tag_id:
1025+
# Execute shell commands in Docker container
9951026
if log.isEnabledFor(DEBUG2):
996-
log.debug("exec shell script ...")
1027+
log.debug("exec shell script in docker...")
9971028
for line in script.splitlines():
9981029
sh_log.debug(line)
9991030

1000-
script = "\n".join(
1001-
(
1031+
# Prepare script with working directory tracking
1032+
enhanced_script = "\n".join(
1033+
[
10021034
script,
1003-
# NOTE: Execute `pwd` to determine the subprocess shell's
1004-
# working directory after having executed all other commands.
10051035
"retcode=$?",
1006-
f"pwd >{temp_file.name}",
1036+
"pwd", # Output final working directory to stdout
10071037
"exit $retcode",
1008-
)
1038+
]
1039+
)
1040+
1041+
# Add chown to fix file ownership (like pip at line 1150-1154)
1042+
chown_mask = "{}:{}".format(os.getuid(), os.getgid())
1043+
full_script = "{} && {}".format(
1044+
enhanced_script, shlex_join(["chown", "-R", chown_mask, "."])
10091045
)
10101046

1011-
p = subprocess.Popen(
1012-
script,
1047+
shell_command = [full_script]
1048+
1049+
# Execute in Docker
1050+
docker_cmd = docker_run_command(
1051+
sh_work_dir, # build_root = current working directory
1052+
shell_command,
1053+
query.runtime,
1054+
image=docker_image_tag_id,
10131055
shell=True,
1056+
ssh_agent=docker.with_ssh_agent,
1057+
docker=docker,
1058+
)
1059+
1060+
# Capture output to extract new working directory
1061+
result = subprocess.run(
1062+
docker_cmd,
10141063
stdout=subprocess.PIPE,
10151064
stderr=subprocess.PIPE,
1016-
cwd=sh_work_dir,
1065+
text=True,
1066+
check=False,
10171067
)
10181068

1019-
call_stdout, call_stderr = p.communicate()
1020-
exit_code = p.returncode
1021-
log.debug("exit_code: %s", exit_code)
1022-
if exit_code != 0:
1069+
if result.returncode != 0:
10231070
raise RuntimeError(
1024-
"Script did not run successfully, exit code {}: {} - {}".format(
1025-
exit_code,
1026-
call_stdout.decode("utf-8").strip(),
1027-
call_stderr.decode("utf-8").strip(),
1071+
"Script did not run successfully in docker, exit code {}: {} - {}".format(
1072+
result.returncode,
1073+
result.stdout.strip(),
1074+
result.stderr.strip(),
10281075
)
10291076
)
10301077

1031-
temp_file.seek(0)
1032-
# NOTE: This var `sh_work_dir` is consumed in cmd == "zip" loop
1033-
sh_work_dir = temp_file.read().strip()
1078+
# Extract final working directory from stdout
1079+
# The 'pwd' command output is in the stdout, but we need to parse it
1080+
# because there might be other output from the script
1081+
output_lines = result.stdout.strip().split("\n")
1082+
if output_lines:
1083+
# The last line should be the pwd output
1084+
final_pwd = output_lines[-1]
1085+
# Map container path back to host path
1086+
# Container path structure: /var/task = sh_work_dir (via volume mount)
1087+
if final_pwd.startswith("/var/task"):
1088+
relative_path = final_pwd[len("/var/task") :].lstrip("/")
1089+
sh_work_dir = (
1090+
os.path.join(sh_work_dir, relative_path)
1091+
if relative_path
1092+
else sh_work_dir
1093+
)
1094+
sh_work_dir = os.path.normpath(sh_work_dir)
1095+
10341096
log.debug("WORKDIR: %s", sh_work_dir)
10351097

1098+
else:
1099+
# Execute shell commands on host (existing behavior)
1100+
with tempfile.NamedTemporaryFile(
1101+
mode="w+t", delete=True
1102+
) as temp_file:
1103+
if log.isEnabledFor(DEBUG2):
1104+
log.debug("exec shell script ...")
1105+
for line in script.splitlines():
1106+
sh_log.debug(line)
1107+
1108+
script = "\n".join(
1109+
(
1110+
script,
1111+
# NOTE: Execute `pwd` to determine the subprocess shell's
1112+
# working directory after having executed all other commands.
1113+
"retcode=$?",
1114+
f"pwd >{temp_file.name}",
1115+
"exit $retcode",
1116+
)
1117+
)
1118+
1119+
p = subprocess.Popen(
1120+
script,
1121+
shell=True,
1122+
stdout=subprocess.PIPE,
1123+
stderr=subprocess.PIPE,
1124+
cwd=sh_work_dir,
1125+
)
1126+
1127+
call_stdout, call_stderr = p.communicate()
1128+
exit_code = p.returncode
1129+
log.debug("exit_code: %s", exit_code)
1130+
if exit_code != 0:
1131+
raise RuntimeError(
1132+
"Script did not run successfully, exit code {}: {} - {}".format(
1133+
exit_code,
1134+
call_stdout.decode("utf-8").strip(),
1135+
call_stderr.decode("utf-8").strip(),
1136+
)
1137+
)
1138+
1139+
temp_file.seek(0)
1140+
# NOTE: This var `sh_work_dir` is consumed in cmd == "zip" loop
1141+
sh_work_dir = temp_file.read().strip()
1142+
log.debug("WORKDIR: %s", sh_work_dir)
1143+
10361144
elif cmd == "set:workdir":
10371145
path = action[1]
10381146
sh_work_dir = os.path.normpath(os.path.join(tf_work_dir, path))
@@ -1516,6 +1624,14 @@ def docker_image_id_command(tag):
15161624
return docker_cmd
15171625

15181626

1627+
def docker_pull_command(image):
1628+
""""""
1629+
docker_cmd = ["docker", "pull", image]
1630+
cmd_log.info(shlex_join(docker_cmd))
1631+
log_handler and log_handler.flush()
1632+
return docker_cmd
1633+
1634+
15191635
def docker_build_command(tag=None, docker_file=None, build_root=False):
15201636
""""""
15211637
if not (build_root or docker_file):

0 commit comments

Comments
 (0)