Skip to content
Open
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
7 changes: 7 additions & 0 deletions samcli/commands/local/cli_common/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,13 @@ def invoke_common_options(f):
help="Specify the location basedir where the lambda layers used by the template will be downloaded to.",
default=get_default_layer_cache_dir(),
),
click.option(
"--filesystem",
type=click.Path(exists=True, file_okay=False, dir_okay=True, resolve_path=True),
help="Local directory to mount as EFS filesystem for Lambda functions. "
"Maps to the LocalMountPath specified in the function's FileSystemConfigs. "
"If no FileSystemConfigs is specified, defaults to /mnt/efs",
),
]
+ docker_click_options()
+ [
Expand Down
3 changes: 3 additions & 0 deletions samcli/commands/local/invoke/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ def cli(
docker_network,
log_file,
layer_cache_basedir,
filesystem,
skip_pull_image,
force_image_build,
shutdown,
Expand Down Expand Up @@ -138,6 +139,7 @@ def cli(
docker_network,
log_file,
layer_cache_basedir,
filesystem,
skip_pull_image,
force_image_build,
shutdown,
Expand Down Expand Up @@ -168,6 +170,7 @@ def do_cli( # pylint: disable=R0914
docker_network,
log_file,
layer_cache_basedir,
filesystem,
skip_pull_image,
force_image_build,
shutdown,
Expand Down
1 change: 1 addition & 0 deletions samcli/commands/local/invoke/core/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"add_host",
"invoke_image",
"runtime",
"filesystem",
"mount_symlinks",
"no_memory_limit",
]
Expand Down
4 changes: 4 additions & 0 deletions samcli/commands/local/start_api/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ def cli(
docker_network,
log_file,
layer_cache_basedir,
filesystem,
skip_pull_image,
force_image_build,
parameter_overrides,
Expand Down Expand Up @@ -164,6 +165,7 @@ def cli(
docker_network,
log_file,
layer_cache_basedir,
filesystem,
skip_pull_image,
force_image_build,
parameter_overrides,
Expand Down Expand Up @@ -197,6 +199,7 @@ def do_cli( # pylint: disable=R0914
docker_network,
log_file,
layer_cache_basedir,
filesystem,
skip_pull_image,
force_image_build,
parameter_overrides,
Expand Down Expand Up @@ -256,6 +259,7 @@ def do_cli( # pylint: disable=R0914
container_host_interface=container_host_interface,
invoke_images=processed_invoke_images,
add_host=add_host,
filesystem_dir=filesystem,
no_mem_limit=no_mem_limit,
) as invoke_context:
ssl_context = (ssl_cert_file, ssl_key_file) if ssl_cert_file else None
Expand Down
1 change: 1 addition & 0 deletions samcli/commands/local/start_api/core/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"docker_volume_basedir",
"skip_pull_image",
"docker_network",
"filesystem",
"force_image_build",
"no_memory_limit",
"warm_containers",
Expand Down
4 changes: 4 additions & 0 deletions samcli/commands/local/start_lambda/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ def cli(
docker_network,
log_file,
layer_cache_basedir,
filesystem,
skip_pull_image,
force_image_build,
parameter_overrides,
Expand Down Expand Up @@ -122,6 +123,7 @@ def cli(
docker_network,
log_file,
layer_cache_basedir,
filesystem,
skip_pull_image,
force_image_build,
parameter_overrides,
Expand Down Expand Up @@ -151,6 +153,7 @@ def do_cli( # pylint: disable=R0914
docker_network,
log_file,
layer_cache_basedir,
filesystem,
skip_pull_image,
force_image_build,
parameter_overrides,
Expand Down Expand Up @@ -208,6 +211,7 @@ def do_cli( # pylint: disable=R0914
container_host_interface=container_host_interface,
add_host=add_host,
invoke_images=processed_invoke_images,
filesystem_dir=filesystem,
no_mem_limit=no_mem_limit,
) as invoke_context:
service = LocalLambdaService(lambda_invoke_context=invoke_context, port=port, host=host)
Expand Down
1 change: 1 addition & 0 deletions samcli/commands/local/start_lambda/core/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"docker_volume_basedir",
"skip_pull_image",
"docker_network",
"filesystem",
"force_image_build",
"shutdown",
"container_host",
Expand Down
21 changes: 18 additions & 3 deletions schema/samcli.json

Large diffs are not rendered by default.

94 changes: 94 additions & 0 deletions tests/integration/local/start_api/test_filesystem_mount.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""
Integration tests for --filesystem flag with start-api command
Tests that the --filesystem flag correctly mounts EFS directories and allows Lambda functions
to interact with the mounted filesystem.
"""

import os
import shutil
import tempfile
from pathlib import Path

import pytest
import requests

from tests.integration.local.start_api.start_api_integ_base import StartApiIntegBaseClass


class TestFilesystemMountWithStartApi(StartApiIntegBaseClass):
"""
Test that --filesystem flag correctly mounts EFS directories for start-api
"""

template_path = "/testdata/start_api/filesystem/template.yaml"

def setUp(self):
self.url = f"http://127.0.0.1:{self.port}"

# Create a temporary filesystem directory with test files
self.test_fs_dir = tempfile.mkdtemp()
self.test_file = Path(self.test_fs_dir) / "test.txt"
self.test_file.write_text("Hello from EFS!")

def tearDown(self):
# Clean up test filesystem
if os.path.exists(self.test_fs_dir):
shutil.rmtree(self.test_fs_dir)
super().tearDown()

@property
def command_list(self):
command_list = super().command_list
# Add filesystem flag pointing to our test directory
command_list.extend(["--filesystem", self.test_fs_dir])
return command_list

@pytest.mark.flaky(reruns=3)
@pytest.mark.timeout(timeout=600, method="thread")
def test_lambda_can_read_from_mounted_filesystem(self):
"""
Test that Lambda function can read files from mounted EFS directory
"""
response = requests.get(f"{self.url}/read-file", params={"filename": "test.txt"}, timeout=300)

self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data.get("content"), "Hello from EFS!")
self.assertEqual(data.get("success"), True)
self.assertEqual(data.get("filename"), "test.txt")

@pytest.mark.flaky(reruns=3)
@pytest.mark.timeout(timeout=600, method="thread")
def test_lambda_can_write_to_mounted_filesystem(self):
"""
Test that Lambda function can write files to mounted EFS directory
and verify changes persist on host filesystem
"""
write_content = "Written from Lambda!"
response = requests.post(
f"{self.url}/write-file", json={"filename": "written.txt", "content": write_content}, timeout=300
)

self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data.get("success"), True)
self.assertEqual(data.get("filename"), "written.txt")

# Verify file actually exists on host filesystem
written_file = Path(self.test_fs_dir) / "written.txt"
self.assertTrue(written_file.exists(), "File should exist on host filesystem after Lambda write")
self.assertEqual(written_file.read_text(), write_content, "File content should match what Lambda wrote")

@pytest.mark.flaky(reruns=3)
@pytest.mark.timeout(timeout=600, method="thread")
def test_lambda_can_list_mounted_filesystem(self):
"""
Test that Lambda function can list directory contents of mounted EFS
"""
response = requests.get(f"{self.url}/list-files", timeout=300)

self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data.get("success"), True)
self.assertIn("test.txt", data.get("files", []), "test.txt should be visible to Lambda in mounted filesystem")
self.assertEqual(data.get("mount_path"), "/mnt/efs", "Mount path should be /mnt/efs")
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import json
import os

EFS_MOUNT = "/mnt/efs"


def handler(event, context):
"""
Lambda handler for testing EFS filesystem mounting
"""
path = event.get("path", "")

if path == "/read-file":
# Read a file from mounted EFS
filename = event.get("queryStringParameters", {}).get("filename", "")
try:
filepath = os.path.join(EFS_MOUNT, filename)
with open(filepath, "r") as f:
content = f.read()
return {
"statusCode": 200,
"body": json.dumps({"success": True, "content": content, "filename": filename}),
}
except Exception as e:
return {"statusCode": 500, "body": json.dumps({"success": False, "error": str(e)})}

elif path == "/write-file":
# Write a file to mounted EFS
try:
body = json.loads(event.get("body", "{}"))
filename = body.get("filename", "")
content = body.get("content", "")

filepath = os.path.join(EFS_MOUNT, filename)
with open(filepath, "w") as f:
f.write(content)

return {"statusCode": 200, "body": json.dumps({"success": True, "filename": filename})}
except Exception as e:
return {"statusCode": 500, "body": json.dumps({"success": False, "error": str(e)})}

elif path == "/list-files":
# List files in mounted EFS
try:
files = os.listdir(EFS_MOUNT)
return {"statusCode": 200, "body": json.dumps({"success": True, "files": files, "mount_path": EFS_MOUNT})}
except Exception as e:
return {"statusCode": 500, "body": json.dumps({"success": False, "error": str(e)})}

return {"statusCode": 404, "body": json.dumps({"error": "Not found"})}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31

Resources:
FileSystemFunction:
Type: AWS::Serverless::Function
Properties:
Handler: main.handler
Runtime: python3.11
CodeUri: code/
Timeout: 30
FileSystemConfigs:
- Arn: "arn:aws:elasticfilesystem:us-east-1:123456789012:access-point/fsap-test"
LocalMountPath: "/mnt/efs"
Events:
ReadFile:
Type: Api
Properties:
Path: /read-file
Method: GET
WriteFile:
Type: Api
Properties:
Path: /write-file
Method: POST
ListFiles:
Type: Api
Properties:
Path: /list-files
Method: GET
Original file line number Diff line number Diff line change
Expand Up @@ -1401,7 +1401,12 @@ def test_must_pass_custom_region(self, add_account_id_to_global_mock, get_stacks


class TestInvokeContext_add_account_id_to_global(TestCase):
def test_must_work_with_no_token(self):
@patch("samcli.commands.local.cli_common.invoke_context.get_boto_client_provider_with_config")
def test_must_work_with_no_token(self, get_boto_client_provider_with_config_mock):
# Mock to return no credentials
get_boto_client_provider_with_config_mock.return_value.return_value.get_caller_identity.side_effect = Exception(
"No credentials"
)
invoke_context = InvokeContext("template_file")
invoke_context._add_account_id_to_global()
self.assertIsNone(invoke_context._global_parameter_overrides)
Expand Down
6 changes: 4 additions & 2 deletions tests/unit/commands/local/invoke/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def setUp(self):
self.overide_runtime = None
self.mount_symlinks = False
self.no_mem_limit = False
self.filesystem = None

self.ctx_mock = Mock()
self.ctx_mock.region = self.region_name
Expand All @@ -73,9 +74,10 @@ def call_cli(self):
docker_volume_basedir=self.docker_volume_basedir,
docker_network=self.docker_network,
log_file=self.log_file,
layer_cache_basedir=self.layer_cache_basedir,
filesystem=self.filesystem,
skip_pull_image=self.skip_pull_image,
parameter_overrides=self.parameter_overrides,
layer_cache_basedir=self.layer_cache_basedir,
force_image_build=self.force_image_build,
shutdown=self.shutdown,
container_host=self.container_host,
Expand Down Expand Up @@ -161,9 +163,9 @@ def test_cli_must_invoke_with_no_event(self, get_event_mock, InvokeContextMock):
parameter_overrides=self.parameter_overrides,
layer_cache_basedir=self.layer_cache_basedir,
force_image_build=self.force_image_build,
shutdown=self.shutdown,
aws_region=self.region_name,
aws_profile=self.profile,
shutdown=self.shutdown,
container_host=self.container_host,
container_host_interface=self.container_host_interface,
add_host=self.add_host,
Expand Down
Loading