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

feat: Add support for additional Docker options and pre pip/npm commands #360

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,7 @@ source_path = [
- `npm_requirements` - Controls whether to execute `npm install`. Set to `false` to disable this feature, `true` to run `npm install` with `package.json` found in `path`. Or set to another filename which you want to use instead.
- `npm_tmp_dir` - Set the base directory to make the temporary directory for npm installs. Can be useful for Docker in Docker builds.
- `prefix_in_zip` - If specified, will be used as a prefix inside zip-archive. By default, everything installs into the root of zip-archive.
- `pre_package_commands` - List of commands to run before doing a `pip install` or `npm install`. Works in conjunction with `pip_requirements` and `npm_requirements`.

### Building in Docker

Expand All @@ -470,6 +471,17 @@ Using this module you can install dependencies from private hosts. To do this, y

docker_with_ssh_agent = true

#### Passing additional Docker options

To add flexibility when building in docker, you can pass any number of additional options that you require (see [Docker run reference](https://docs.docker.com/engine/reference/run/) for available options):

```hcl
docker_additional_options = [
"-e", "MY_ENV_VAR='My environment variable value'",
"-v", "/local:/docker-vol",
]
```

## <a name="package"></a> Deployment package - Create or use existing

By default, this module creates deployment package and uses it to create or update Lambda Function or Lambda Layer.
Expand Down Expand Up @@ -710,6 +722,7 @@ No modules.
| <a name="input_description"></a> [description](#input\_description) | Description of your Lambda Function (or Layer) | `string` | `""` | no |
| <a name="input_destination_on_failure"></a> [destination\_on\_failure](#input\_destination\_on\_failure) | Amazon Resource Name (ARN) of the destination resource for failed asynchronous invocations | `string` | `null` | no |
| <a name="input_destination_on_success"></a> [destination\_on\_success](#input\_destination\_on\_success) | Amazon Resource Name (ARN) of the destination resource for successful asynchronous invocations | `string` | `null` | no |
| <a name="input_docker_additional_options"></a> [docker\_additional\_options](#input\_docker\_additional\_options) | Additional options to pass to the docker run command (e.g. to set environment variables, volumes, etc.) | `list(string)` | `[]` | no |
| <a name="input_docker_build_root"></a> [docker\_build\_root](#input\_docker\_build\_root) | Root dir where to build in Docker | `string` | `""` | no |
| <a name="input_docker_file"></a> [docker\_file](#input\_docker\_file) | Path to a Dockerfile when building in Docker | `string` | `""` | no |
| <a name="input_docker_image"></a> [docker\_image](#input\_docker\_image) | Docker image to use for the build | `string` | `""` | no |
Expand Down
2 changes: 2 additions & 0 deletions examples/build-package/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,15 @@ Note that this example may create resources which cost money. Run `terraform des
| <a name="module_package_dir"></a> [package\_dir](#module\_package\_dir) | ../../ | n/a |
| <a name="module_package_dir_pip_dir"></a> [package\_dir\_pip\_dir](#module\_package\_dir\_pip\_dir) | ../../ | n/a |
| <a name="module_package_dir_with_npm_install"></a> [package\_dir\_with\_npm\_install](#module\_package\_dir\_with\_npm\_install) | ../../ | n/a |
| <a name="module_package_dir_with_npm_install_and_additional_commands"></a> [package\_dir\_with\_npm\_install\_and\_additional\_commands](#module\_package\_dir\_with\_npm\_install\_and\_additional\_commands) | ../../ | n/a |
| <a name="module_package_dir_without_npm_install"></a> [package\_dir\_without\_npm\_install](#module\_package\_dir\_without\_npm\_install) | ../../ | n/a |
| <a name="module_package_dir_without_pip_install"></a> [package\_dir\_without\_pip\_install](#module\_package\_dir\_without\_pip\_install) | ../../ | n/a |
| <a name="module_package_file"></a> [package\_file](#module\_package\_file) | ../../ | n/a |
| <a name="module_package_file_with_pip_requirements"></a> [package\_file\_with\_pip\_requirements](#module\_package\_file\_with\_pip\_requirements) | ../../ | n/a |
| <a name="module_package_with_commands_and_patterns"></a> [package\_with\_commands\_and\_patterns](#module\_package\_with\_commands\_and\_patterns) | ../../ | n/a |
| <a name="module_package_with_docker"></a> [package\_with\_docker](#module\_package\_with\_docker) | ../../ | n/a |
| <a name="module_package_with_npm_requirements_in_docker"></a> [package\_with\_npm\_requirements\_in\_docker](#module\_package\_with\_npm\_requirements\_in\_docker) | ../../ | n/a |
| <a name="module_package_with_npm_requirements_in_docker_and_additional_commands"></a> [package\_with\_npm\_requirements\_in\_docker\_and\_additional\_commands](#module\_package\_with\_npm\_requirements\_in\_docker\_and\_additional\_commands) | ../../ | n/a |
| <a name="module_package_with_patterns"></a> [package\_with\_patterns](#module\_package\_with\_patterns) | ../../ | n/a |
| <a name="module_package_with_pip_requirements_in_docker"></a> [package\_with\_pip\_requirements\_in\_docker](#module\_package\_with\_pip\_requirements\_in\_docker) | ../../ | n/a |

Expand Down
62 changes: 62 additions & 0 deletions examples/build-package/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ module "package_file_with_pip_requirements" {
{
pip_requirements = "${path.module}/../fixtures/python3.8-app1/requirements.txt"
prefix_in_zip = "vendor"
pre_package_commands = [
"echo I will run before the pip install command",
"echo so will I",
]
}
]
}
Expand All @@ -101,10 +105,19 @@ module "package_with_pip_requirements_in_docker" {
"${path.module}/../fixtures/python3.8-app1/dir1/dir2",
{
pip_requirements = "${path.module}/../fixtures/python3.8-app1/requirements.txt"
pre_package_commands = [
"echo I will run before the pip install command in the docker container",
"echo I can read $MY_ENV_VAR and my volume:",
"ls -la /my-vol",
]
}
]

build_in_docker = true
docker_additional_options = [
"-e", "MY_ENV_VAR='My environment variable value'",
"-v", "${abspath(path.module)}:/my-vol:ro",
]
}

# Create zip-archive which contains content of directory with commands and patterns applied.
Expand Down Expand Up @@ -233,6 +246,26 @@ module "package_dir_with_npm_install" {
source_path = "${path.module}/../fixtures/nodejs14.x-app1"
}

# Create zip-archive of a single directory where "npm install" will also be executed (default for nodejs runtime)
# Also run some additional commands before the npm install
module "package_dir_with_npm_install_and_additional_commands" {
source = "../../"

create_function = false

runtime = "nodejs14.x"
source_path = [
{
path = "${path.module}/../fixtures/nodejs14.x-app1"
npm_requirements = true
pre_package_commands = [
"echo I will run before the npm install command",
"echo so will I",
]
}
]
}

# Create zip-archive of a single directory without running "npm install" (which is the default for nodejs runtime)
module "package_dir_without_npm_install" {
source = "../../"
Expand Down Expand Up @@ -261,6 +294,35 @@ module "package_with_npm_requirements_in_docker" {
hash_extra = "something-unique-to-not-conflict-with-module.package_dir_with_npm_install"
}

# Create zip-archive of a single directory where "npm install" will also be executed using docker
# Also set additional docker run options and also run some commands before the npm install
module "package_with_npm_requirements_in_docker_and_additional_commands" {
source = "../../"

create_function = false

runtime = "nodejs14.x"
source_path = [
{
path = "${path.module}/../fixtures/nodejs14.x-app1"
npm_requirements = true
pre_package_commands = [
"echo I will run before the npm install command in the docker container",
"echo I can read $MY_ENV_VAR and my volume:",
"ls -l /my-vol",
]
}
]

build_in_docker = true
docker_additional_options = [
"-e", "MY_ENV_VAR='My environment variable value'",
"-v", "${abspath(path.module)}:/my-vol:ro",
]

hash_extra = "something-unique-to-not-conflict-with-module.package_dir_with_npm_install"
}

################################
# Build package in Docker and
# use it to deploy Lambda Layer
Expand Down
92 changes: 72 additions & 20 deletions package.py
Original file line number Diff line number Diff line change
Expand Up @@ -648,7 +648,7 @@ def plan(self, source_path, query):
step = lambda *x: build_plan.append(x)
hash = source_paths.append

def pip_requirements_step(path, prefix=None, required=False, tmp_dir=None):
def pip_requirements_step(path, prefix=None, required=False, tmp_dir=None, pre_package_commands=None):
requirements = path
if os.path.isdir(path):
requirements = os.path.join(path, 'requirements.txt')
Expand All @@ -657,16 +657,20 @@ def pip_requirements_step(path, prefix=None, required=False, tmp_dir=None):
raise RuntimeError(
'File not found: {}'.format(requirements))
else:
if not shutil.which(runtime):
if not query.docker and not shutil.which(runtime):
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure about this. I think it is a pretty strong anti-pattern for a terraform plan to pass, that would fail in the apply phase. Every plan should expose issues with the pipeline, to catch issues as inputs change (e.g. the runtime was python3.8 but was updated to python3.9). If the system where I'm running the plan cannot actually build the package in the apply phase, that should be known before running the apply. That's true whether using docker or not.

However, I think we can make it flexible to the user. But instead of checking docker, check whether the user has explicitly told us to skip this check, using a new argument.

Suggested change
if not query.docker and not shutil.which(runtime):
if runtime_required and not shutil.which(runtime):

where runtime_required comes from the source_path map. E.g.

source_path = [
  {
    path             = "src"
    npm_package_json = true
    runtime_required = false
  }
]

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the review. After merging the latest changes into my branch, a lot of the build-package tests failed that were previously succeeding, this is because I don't have things like nodejs installed. The check above checks the runtime on the host machine, this is pretty pointless when you're building your package in a docker container (what would be the point in docker if you already had the correct runtime installed locally?). The change you've suggested would work, but I consider this to be a breaking change as anyone in the same situation as me would have to add that setting to get previously working scripts to succeed. If runtime_required defaulted to false (or rename to check_runtime or do_runtime_check that you have to set to true if you want to do the check), that would then not constitute a breaking change but it would almost render the runtime check useless so I'm not sure that's correct either (the runtime check is a good thing to have).

I fully agree that you don't want your plan to pass when the apply phase would fail, but neither do you want it to fail when the apply would have succeeded.

I'll await further feedback before proceeding.

Copy link
Contributor

Choose a reason for hiding this comment

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

I fully agree that you don't want your plan to pass when the apply phase would fail, but neither do you want it to fail when the apply would have succeeded.

The problem I see, is that relies on information not available to terraform at plan time. Only the user knows that the apply would succeed, because the user is proactively managing their pipeline separate from their local environment, and ensuring that the runtime is present. That feels... so very fragile. Change the runtime in the code, plan still claims success, merge, apply fails. Oh, update pipeline with new runtime, re-run job. That scenario is exactly what the test was meant to catch.

The check above checks the runtime on the host machine, this is pretty pointless when you're building your package in a docker container

Ok, that is news to me, and certainly a valid concern. I was under the impression this packaging script ran inside the docker container, where the runtime would be available, and of course the user should be specifying a container that has the correct runtime. That deserves some more investigation...

(Fwiw, I'm just another user/contributor, not a maintainer, but since I proposed the change that caused your breakage, I feel responsible for providing context around why the change was made, and trying to help find an acceptable way forward for both concerns.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

(Fwiw, I'm just another user/contributor, not a maintainer, but since I proposed the change that caused your breakage, I feel responsible for providing context around why the change was made, and trying to help find an acceptable way forward for both concerns.)

I appreciate your comments

Unless overridden, the runtime variable is used to choose the image that's used, see here, so it should be pretty safe.

I haven't contributed here before so I don't know if there's more that I need to do to get this PR approved. The change mentioned above is obviously mixed in with other changes which might not be approved, so it might be be necessary to fix this in a separate PR because as it stands, I'm not able to use the latest version of this module due to this error (and I've just seen that I'm not the only one - #361).

Copy link
Contributor

Choose a reason for hiding this comment

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

Unless overridden, the runtime variable is used to choose the image that's used, see here, so it should be pretty safe.

Ok. That does still run into problems when the user specifies image separately, and forgets to update the image when they update the runtime. But, at least it makes sense for the default case. And perhaps for now the relationship between a custom image and the runtime and plan vs apply-time failures can be covered in the README somewhere.

I do think it would be better to handle fixing the breakage in a separate PR. I can take a crack at that, or defer to you since the approach using query.docker is your idea?

Copy link
Contributor

Choose a reason for hiding this comment

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

I'll go ahead and cherry-pick your commit, and open the PR...

Copy link
Contributor

Choose a reason for hiding this comment

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

raise RuntimeError(
"Python interpreter version equal "
"to defined lambda runtime ({}) should be "
"available in system PATH".format(runtime))

step('pip', runtime, requirements, prefix, tmp_dir)
if pre_package_commands and not query.docker:
additional_commands_step(path, pre_package_commands)
pre_package_commands = None

step('pip', runtime, requirements, prefix, tmp_dir, pre_package_commands)
hash(requirements)

def npm_requirements_step(path, prefix=None, required=False, tmp_dir=None):
def npm_requirements_step(path, prefix=None, required=False, tmp_dir=None, pre_package_commands=None):
requirements = path
if os.path.isdir(path):
requirements = os.path.join(path, 'package.json')
Expand All @@ -675,15 +679,36 @@ def npm_requirements_step(path, prefix=None, required=False, tmp_dir=None):
raise RuntimeError(
'File not found: {}'.format(requirements))
else:
if not shutil.which(runtime):
if not query.docker and not shutil.which(runtime):
raise RuntimeError(
"Nodejs interpreter version equal "
"to defined lambda runtime ({}) should be "
"available in system PATH".format(runtime))

step('npm', runtime, requirements, prefix, tmp_dir)
if pre_package_commands and not query.docker:
additional_commands_step(path, pre_package_commands)
pre_package_commands = None

step('npm', runtime, requirements, prefix, tmp_dir, pre_package_commands)
hash(requirements)

def additional_commands_step(path, commands):
if not commands:
return

if isinstance(commands, str):
commands = map(str.strip, commands.splitlines())

if path:
path = os.path.normpath(path)
if os.path.isfile(path):
path = os.path.dirname(path)
else:
path = query.paths.cwd

hash(path)
step('sh', path, '\n'.join(commands))

def commands_step(path, commands):
if not commands:
return
Expand Down Expand Up @@ -759,21 +784,30 @@ def commands_step(path, commands):
prefix = claim.get('prefix_in_zip')
pip_requirements = claim.get('pip_requirements')
npm_requirements = claim.get('npm_package_json')
if not npm_requirements:
# npm_package_json is an undocumented setting!!!!
# Set correct value here rather than create breaking change
npm_requirements = claim.get('npm_requirements')
pre_package_commands = claim.get('pre_package_commands')
runtime = claim.get('runtime', query.runtime)

if pip_requirements and runtime.startswith('python'):
if isinstance(pip_requirements, bool) and path:
pip_requirements_step(path, prefix, required=True, tmp_dir=claim.get('pip_tmp_dir'))
pip_requirements_step(path, prefix, required=True, tmp_dir=claim.get('pip_tmp_dir'),
pre_package_commands=pre_package_commands)
else:
pip_requirements_step(pip_requirements, prefix,
required=True, tmp_dir=claim.get('pip_tmp_dir'))
required=True, tmp_dir=claim.get('pip_tmp_dir'),
pre_package_commands=pre_package_commands)

if npm_requirements and runtime.startswith('nodejs'):
if isinstance(npm_requirements, bool) and path:
npm_requirements_step(path, prefix, required=True, tmp_dir=claim.get('npm_tmp_dir'))
npm_requirements_step(path, prefix, required=True, tmp_dir=claim.get('npm_tmp_dir'),
pre_package_commands=pre_package_commands)
else:
npm_requirements_step(npm_requirements, prefix,
required=True, tmp_dir=claim.get('npm_tmp_dir'))
required=True, tmp_dir=claim.get('npm_tmp_dir'),
pre_package_commands=pre_package_commands)

if path:
step('zip', path, prefix)
Expand Down Expand Up @@ -819,8 +853,8 @@ def execute(self, build_plan, zip_stream, query):
else:
zs.write_file(source_path, prefix=prefix, timestamp=ts)
elif cmd == 'pip':
runtime, pip_requirements, prefix, tmp_dir = action[1:]
with install_pip_requirements(query, pip_requirements, tmp_dir) as rd:
runtime, pip_requirements, prefix, tmp_dir, pre_package_commands = action[1:]
with install_pip_requirements(query, pip_requirements, tmp_dir, pre_package_commands) as rd:
if rd:
if pf:
self._zip_write_with_filter(zs, pf, rd, prefix,
Expand All @@ -829,8 +863,8 @@ def execute(self, build_plan, zip_stream, query):
# XXX: timestamp=0 - what actually do with it?
zs.write_dirs(rd, prefix=prefix, timestamp=0)
elif cmd == 'npm':
runtime, npm_requirements, prefix, tmp_dir = action[1:]
with install_npm_requirements(query, npm_requirements, tmp_dir) as rd:
runtime, npm_requirements, prefix, tmp_dir, pre_package_commands = action[1:]
with install_npm_requirements(query, npm_requirements, tmp_dir, pre_package_commands) as rd:
if rd:
if pf:
self._zip_write_with_filter(zs, pf, rd, prefix,
Expand Down Expand Up @@ -870,7 +904,7 @@ def _zip_write_with_filter(zip_stream, path_filter, source_path, prefix,


@contextmanager
def install_pip_requirements(query, requirements_file, tmp_dir):
def install_pip_requirements(query, requirements_file, tmp_dir, pre_package_commands):
# TODO:
# 1. Emit files instead of temp_dir

Expand Down Expand Up @@ -953,7 +987,13 @@ def install_pip_requirements(query, requirements_file, tmp_dir):
working_dir, artifacts_dir, 'cache/pip'))

chown_mask = '{}:{}'.format(os.getuid(), os.getgid())
shell_command = [shlex_join(pip_command), '&&',
if pre_package_commands:
additional_commands = '{} &&'.format(' && '.join(pre_package_commands))
else:
additional_commands = ""

shell_command = [additional_commands,
shlex_join(pip_command), '&&',
shlex_join(['chown', '-R',
chown_mask, '.'])]
shell_command = [' '.join(shell_command)]
Expand All @@ -962,6 +1002,7 @@ def install_pip_requirements(query, requirements_file, tmp_dir):
image=docker_image_tag_id,
shell=True, ssh_agent=with_ssh_agent,
pip_cache_dir=pip_cache_dir,
docker_additional_options=docker.docker_additional_options,
))
else:
cmd_log.info(shlex_join(pip_command))
Expand All @@ -980,7 +1021,7 @@ def install_pip_requirements(query, requirements_file, tmp_dir):


@contextmanager
def install_npm_requirements(query, requirements_file, tmp_dir):
def install_npm_requirements(query, requirements_file, tmp_dir, pre_package_commands):
# TODO:
# 1. Emit files instead of temp_dir

Expand Down Expand Up @@ -1037,14 +1078,21 @@ def install_npm_requirements(query, requirements_file, tmp_dir):
if docker:
with_ssh_agent = docker.with_ssh_agent
chown_mask = '{}:{}'.format(os.getuid(), os.getgid())
shell_command = [shlex_join(npm_command), '&&',
if pre_package_commands:
additional_commands = '{} &&'.format(' && '.join(pre_package_commands))
else:
additional_commands = ""

shell_command = [additional_commands,
shlex_join(npm_command), '&&',
shlex_join(['chown', '-R',
chown_mask, '.'])]
shell_command = [' '.join(shell_command)]
check_call(docker_run_command(
'.', shell_command, runtime,
image=docker_image_tag_id,
shell=True, ssh_agent=with_ssh_agent
shell=True, ssh_agent=with_ssh_agent,
docker_additional_options=docker.docker_additional_options,
))
else:
cmd_log.info(shlex_join(npm_command))
Expand Down Expand Up @@ -1094,7 +1142,8 @@ def docker_build_command(tag=None, docker_file=None, build_root=False):

def docker_run_command(build_root, command, runtime,
image=None, shell=None, ssh_agent=False,
interactive=False, pip_cache_dir=None):
interactive=False, pip_cache_dir=None,
docker_additional_options=None):
""""""
if platform.system() not in ('Linux', 'Darwin'):
raise RuntimeError("Unsupported platform for docker building")
Expand All @@ -1115,6 +1164,9 @@ def docker_run_command(build_root, command, runtime,
'-v', '{}/.ssh/known_hosts:/root/.ssh/known_hosts:z'.format(home),
])

if docker_additional_options:
docker_cmd.extend(docker_additional_options)

if ssh_agent:
if platform.system() == 'Darwin':
# https://docs.docker.com/docker-for-mac/osxfs/#ssh-agent-forwarding
Expand Down
11 changes: 6 additions & 5 deletions package.tf
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ data "external" "archive_prepare" {
})

docker = var.build_in_docker ? jsonencode({
docker_pip_cache = var.docker_pip_cache
docker_build_root = var.docker_build_root
docker_file = var.docker_file
docker_image = var.docker_image
with_ssh_agent = var.docker_with_ssh_agent
docker_pip_cache = var.docker_pip_cache
docker_build_root = var.docker_build_root
docker_file = var.docker_file
docker_image = var.docker_image
with_ssh_agent = var.docker_with_ssh_agent
docker_additional_options = var.docker_additional_options
}) : null

artifacts_dir = var.artifacts_dir
Expand Down
Loading