Fix docker runtime executor flags by joe4dev · Pull Request #7675 · localstack/localstack · GitHub
Skip to content
This repository was archived by the owner on Mar 23, 2026. It is now read-only.
Merged
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
18 changes: 14 additions & 4 deletions localstack/services/awslambda/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -551,8 +551,18 @@ def create_function(
)

architectures = request.get("Architectures")
if architectures and Architecture.arm64 in architectures:
raise ServiceException("ARM64 is currently not supported by this provider")
if architectures:
if len(architectures) != 1:
raise ValidationException(
f"1 validation error detected: Value '[{', '.join(architectures)}]' at 'architectures' failed to "
f"satisfy constraint: Member must have length less than or equal to 1",
)
if architectures[0] not in [Architecture.x86_64, Architecture.arm64]:
raise ValidationException(
f"1 validation error detected: Value '[{', '.join(architectures)}]' at 'architectures' failed to "
f"satisfy constraint: Member must satisfy constraint: [Member must satisfy enum value set: "
f"[x86_64, arm64], Member must not be null]",
)
Comment on lines +554 to +565

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we're nearing the point where we should start writing an abstraction to generate these messages 🤔

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

(please not in this PR though 😁 )

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

fully agree 👍

@thrau thrau Feb 15, 2023

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@dominikschubert @joe4dev FYI there's a module in ASF that uses botocore for server-side validation, which does validation of constraints that are defined in the spec automatically (the first condition for example is in the spec, the second also): https://github.com/localstack/localstack/blob/master/localstack/aws/protocol/validate.py
the two problems are: defining the conditions that aren't in the spec (easy), and generalizing message creation (hard).
the messages are generally different across services, but some services definitely use the same pattern that we could generalize (i'm assuming there's some internal AWS library for matching constraints). that should definitely go into ASF :-)

we haven't invested in this yet really because in most cases, client-side validation will already take care of anything that's in the spec. so the cases where we would really benefit from this type of validation are fairly rare.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

that should definitely go into ASF :-)

For schema-related things, I agree.

For non-schema validations I think this doesn't make much sense looking at the actual behavior and output we observe from AWS. From what I've seen so far this really is completely service-dependent and even in a single service doesn't always behave in a completely generalizable manner. You often also need service-internal state for these validations.
I'm not 100% sure that we can "cleanly" separate these two though, because I'm not sure they are cleanly separated on the side of AWS as well 😬

Some of the newer services that mostly just have CRUD API might be pretty standard though 🤔

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@thrau Thank you for the pointer. I discussed this a few weeks ago with @dominikschubert but I wasn't aware the validation patterns are in the specs 👍

The mandatory fields are easier to generalize than the optional fields. In the "CreateFunction" operation there are only 3 required_members in the input shape out of 23 members. It often requires service-specific conditionals (sometimes based on service-internal state) to enable these validations.
Hence, this might require some service-specific ASF extension because the types in the request handler (i.e., @handler) are not compatible with the validation API (or I need to understand the validation API better) 🤔

As a quick POC: I can retrieve the correct model but it doesn't raise a validation error for the enum value set (would need to investigate inputs API better):

  service = load_service("lambda")
  operation = service.operation_model("CreateFunction")
  shape = operation.input_shape.members["Architectures"].member
  invalid_architecture = "x42"
  validation_errors = ParamValidator().validate(invalid_architecture, shape)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@thrau I discussed with @dominikschubert that we plan to pick up validations in a later polishing step as there are currently more urgent things to finalize.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@thrau I finally gave boto-spec-based validation another quick try with the many Lambda runtime updates slowing us down but it didn't really work:

from localstack.aws.protocol.validate import ParamValidator
from localstack.aws.spec import load_service

service = load_service("lambda")
operation = service.operation_model("CreateFunction")
shape = operation.input_shape.members["Runtime"]
invalid_runtime = "python3.10777"
validation_errors = ParamValidator().validate(invalid_runtime, shape)

print(str(validation_errors))

Nevertheless, I had to abort the idea because the snapshots revealed that the ordering was inconsistent (2 different orders within the Lambda API) and could not be reconstructed using boto-spec-based validation.

I mitigated the problem by re-organizing everything around Lambda runtimes in #9697 to facilitate future updates 🚀


if env_vars := request.get("Environment", {}).get("Variables"):
self._verify_env_variables(env_vars)
Expand All @@ -568,7 +578,7 @@ def create_function(
package_type = request.get("PackageType", PackageType.Zip)
if package_type == PackageType.Zip and request.get("Runtime") not in IMAGE_MAPPING:
raise InvalidParameterValueException(
f"Value {request.get('Runtime')} at 'runtime' failed to satisfy constraint: Member must satisfy enum value set: [nodejs12.x, provided, nodejs16.x, nodejs14.x, ruby2.7, java11, dotnet6, go1.x, provided.al2, java8, java8.al2, dotnetcore3.1, python3.7, python3.8, python3.9] or be a valid ARN",
f"Value {request.get('Runtime')} at 'runtime' failed to satisfy constraint: Member must satisfy enum value set: [nodejs12.x, provided, nodejs16.x, nodejs14.x, ruby2.7, java11, dotnet6, go1.x, nodejs18.x, provided.al2, java8, java8.al2, dotnetcore3.1, python3.7, python3.8, python3.9] or be a valid ARN",
Type="User",
)
state = lambda_stores[context.account_id][context.region]
Expand Down Expand Up @@ -637,7 +647,7 @@ def create_function(
package_type=package_type,
reserved_concurrent_executions=0,
environment=env_vars,
architectures=request.get("Architectures") or ["x86_64"], # TODO
architectures=request.get("Architectures") or [Architecture.x86_64],
tracing_config_mode=TracingMode.PassThrough, # TODO
image=image,
image_config=image_config,
Expand Down
19 changes: 11 additions & 8 deletions localstack/testing/aws/lambda_utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
import os
import platform
from typing import Literal

from localstack.utils.common import to_str
Expand Down Expand Up @@ -117,14 +118,16 @@ def get_events():


def is_old_provider():
return (
os.environ.get("TEST_TARGET") != "AWS_CLOUD"
and os.environ.get("PROVIDER_OVERRIDE_LAMBDA") != "asf"
)
return os.environ.get("TEST_TARGET") != "AWS_CLOUD" and os.environ.get(
"PROVIDER_OVERRIDE_LAMBDA"
) not in ["asf", "v2"]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

good catch 👍



def is_new_provider():
return (
os.environ.get("TEST_TARGET") != "AWS_CLOUD"
and os.environ.get("PROVIDER_OVERRIDE_LAMBDA") == "asf"
)
return os.environ.get("TEST_TARGET") != "AWS_CLOUD" and os.environ.get(
"PROVIDER_OVERRIDE_LAMBDA"
) in ["asf", "v2"]


def is_arm_compatible():
return platform.machine() == "arm64"
36 changes: 35 additions & 1 deletion localstack/utils/container_utils/container_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ def close(self):
raise NotImplementedError


class DockerPlatform(str):
"""Platform in the format ``os[/arch[/variant]]``"""

linux_amd64 = "linux/amd64"
linux_arm64 = "linux/arm64"


# defines the type for port mappings (source->target port range)
PortRange = Union[List, HashableList]

Expand Down Expand Up @@ -366,6 +373,7 @@ class ContainerConfiguration:
network: Optional[str] = None
dns: Optional[str] = None
workdir: Optional[str] = None
platform: Optional[str] = None


@dataclasses.dataclass
Expand All @@ -378,6 +386,8 @@ class DockerRunFlags:
extra_hosts: Optional[Dict[str, str]]
network: Optional[str]
labels: Optional[Dict[str, str]]
user: Optional[str]
platform: Optional[DockerPlatform]


class ContainerClient(metaclass=ABCMeta):
Expand Down Expand Up @@ -496,7 +506,7 @@ def copy_from_container(
pass

@abstractmethod
def pull_image(self, docker_image: str) -> None:
def pull_image(self, docker_image: str, platform: Optional[DockerPlatform] = None) -> None:
"""Pulls an image with a given name from a Docker registry"""
pass

Expand Down Expand Up @@ -687,6 +697,7 @@ def create_container_from_config(self, container_config: ContainerConfiguration)
additional_flags=container_config.additional_flags,
workdir=container_config.workdir,
privileged=container_config.privileged,
platform=container_config.platform,
)

@abstractmethod
Expand Down Expand Up @@ -714,6 +725,7 @@ def create_container(
workdir: Optional[str] = None,
privileged: Optional[bool] = None,
labels: Optional[Dict[str, str]] = None,
platform: Optional[DockerPlatform] = None,
) -> str:
"""Creates a container with the given image

Expand Down Expand Up @@ -888,13 +900,17 @@ def parse_additional_flags(
ports: PortMappings = None,
mounts: List[SimpleVolumeBind] = None,
network: Optional[str] = None,
user: Optional[str] = None,
platform: Optional[DockerPlatform] = None,
) -> DockerRunFlags:
"""Parses environment, volume and port flags passed as string
:param additional_flags: String which contains the flag definitions
:param env_vars: Dict with env vars. Will be modified in place.
:param ports: PortMapping object. Will be modified in place.
:param mounts: List of mount tuples (host_path, container_path). Will be modified in place.
:param network: Existing network name (optional). Warning will be printed if network is overwritten in flags.
:param user: User to run first process. Warning will be printed if user is overwritten in flags.
:param platform: Platform to execute container. Warning will be printed if platform is overwritten in flags.
:return: A DockerRunFlags object containing the env_vars, ports, mount, extra_hosts, network, and labels.
The result will return new objects if respective parameters were None and additional flags contained
a flag for that object, the same which are passed otherwise.
Expand All @@ -917,6 +933,10 @@ def parse_additional_flags(
cur_state = "set-network"
elif flag == "--label":
cur_state = "add-label"
elif flag in ["-u", "--user"]:
cur_state = "user"
elif flag == "--platform":
cur_state = "platform"
else:
raise NotImplementedError(
f"Flag {flag} is currently not supported by this Docker client."
Expand Down Expand Up @@ -982,6 +1002,18 @@ def parse_additional_flags(
labels[key] = value
else:
LOG.warning("Invalid --label specified, unable to parse: '%s'", flag)
elif cur_state == "user":
if user:
LOG.warning(
f"Overwriting Docker container user {user} with new value {flag}"
)
user = flag
elif cur_state == "platform":
if platform:
LOG.warning(
f"Overwriting Docker container platform {platform} with new value {flag}"
)
platform = flag

cur_state = None

Expand All @@ -992,6 +1024,8 @@ def parse_additional_flags(
extra_hosts=extra_hosts,
network=network,
labels=labels,
user=user,
platform=platform,
)

@staticmethod
Expand Down
8 changes: 7 additions & 1 deletion localstack/utils/container_utils/docker_cmd_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
ContainerClient,
ContainerException,
DockerContainerStatus,
DockerPlatform,
NoSuchContainer,
NoSuchImage,
NoSuchNetwork,
Expand Down Expand Up @@ -263,9 +264,11 @@ def copy_from_container(
"Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr
) from e

def pull_image(self, docker_image: str) -> None:
def pull_image(self, docker_image: str, platform: Optional[DockerPlatform] = None) -> None:
cmd = self._docker_cmd()
cmd += ["pull", docker_image]
if platform:
cmd += ["--platform", platform]
LOG.debug("Pulling image with cmd: %s", cmd)
try:
run(cmd)
Expand Down Expand Up @@ -616,6 +619,7 @@ def _build_run_create_cmd(
workdir: Optional[str] = None,
privileged: Optional[bool] = None,
labels: Optional[Dict[str, str]] = None,
platform: Optional[DockerPlatform] = None,
) -> Tuple[List[str], str]:
env_file = None
cmd = self._docker_cmd() + [action]
Expand Down Expand Up @@ -663,6 +667,8 @@ def _build_run_create_cmd(
if labels:
for key, value in labels.items():
cmd += ["--label", f"{key}={value}"]
if platform:
cmd += ["--platform", platform]

if additional_flags:
cmd += shlex.split(additional_flags)
Expand Down
13 changes: 9 additions & 4 deletions localstack/utils/container_utils/docker_sdk_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
ContainerClient,
ContainerException,
DockerContainerStatus,
DockerPlatform,
NoSuchContainer,
NoSuchImage,
NoSuchNetwork,
Expand Down Expand Up @@ -213,11 +214,11 @@ def copy_from_container(
except APIError as e:
raise ContainerException() from e

def pull_image(self, docker_image: str) -> None:
def pull_image(self, docker_image: str, platform: Optional[DockerPlatform] = None) -> None:
LOG.debug("Pulling Docker image: %s", docker_image)
# some path in the docker image string indicates a custom repository
try:
self.client().images.pull(docker_image)
self.client().images.pull(docker_image, platform=platform)
except ImageNotFound:
raise NoSuchImage(docker_image)
except APIError as e:
Expand Down Expand Up @@ -507,19 +508,22 @@ def create_container(
workdir: Optional[str] = None,
privileged: Optional[bool] = None,
labels: Optional[Dict[str, str]] = None,
platform: Optional[DockerPlatform] = None,
) -> str:
LOG.debug("Creating container with attributes: %s", locals())
extra_hosts = None
if additional_flags:
parsed_flags = Util.parse_additional_flags(
additional_flags, env_vars, ports, mount_volumes, network
additional_flags, env_vars, ports, mount_volumes, network, user, platform
)
env_vars = parsed_flags.env_vars
ports = parsed_flags.ports
mount_volumes = parsed_flags.mounts
extra_hosts = parsed_flags.extra_hosts
network = parsed_flags.network
labels = parsed_flags.labels
user = parsed_flags.user
platform = parsed_flags.platform

try:
kwargs = {}
Expand Down Expand Up @@ -558,13 +562,14 @@ def create_container():
network=network,
volumes=mounts,
extra_hosts=extra_hosts,
platform=platform,
**kwargs,
)

try:
container = create_container()
except ImageNotFound:
self.pull_image(image_name)
self.pull_image(image_name, platform)
container = create_container()
return container.id
except ImageNotFound:
Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ runtime =
cryptography
dnslib>=0.9.10
dnspython>=1.16.0
docker==5.0.0
docker==6.0.1
flask==2.1.3
flask-cors>=3.0.3,<3.1.0
flask_swagger==0.2.12
Expand Down
3 changes: 3 additions & 0 deletions tests/bootstrap/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,11 @@ def test_container_starts_non_root(self, runner, monkeypatch, container_client):
output = container_client.exec_in_container(config.MAIN_CONTAINER_NAME, ["ps", "-u", user])
assert "supervisord" in to_str(output[0])

@pytest.mark.skip(reason="skip temporarily until compatible localstack image is on DockerHub")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

re-enable as soon as the new Docker image is on DockerHub.
Bumping the docker Python dependency requires a synchronized update of LS, ext, and the Docker image on DockerHub. The current CLI tests use the latest image from DockerHub.

The update of docker adds a new platform flag and running this test against an old localstack image yields the error TypeError: run() got an unexpected keyword argument 'platform' (not visible in CI). Locally building a new LS Docker image via make docker-build fixes the issue.

def test_start_cli_within_container(self, runner, container_client):
output = container_client.run_container(
# CAVEAT: Updates to the Docker image are not immediately reflected when using the latest image from
# DockerHub in the CI. Re-build the Docker image locally through `make docker-build` for local testing.
"localstack/localstack",
remove=True,
entrypoint="",
Expand Down
14 changes: 13 additions & 1 deletion tests/integration/awslambda/functions/lambda_introspect.py
Loading