Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Resolve non-subdomain host prefixes to LocalStack #12653

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

Merged
merged 6 commits into from
Jun 3, 2025
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
23 changes: 23 additions & 0 deletions localstack-core/localstack/dns/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,8 +258,31 @@ def handle(self, *args, **kwargs):
pass


# List of unique non-subdomain prefixes (e.g., data-) from endpoint.hostPrefix in the botocore specs.
# Subdomain-prefixes (e.g., api.) work properly unless DNS rebind protection blocks DNS resolution, but
# these `-` dash-prefixes require special consideration.
# IMPORTANT: Adding a new host prefix here requires deploying a public DNS entry to ensure proper DNS resolution for
# such non-dot prefixed domains (e.g., data-localhost.localstack.cloud)
# LIMITATION: As of 2025-05-26, only used prefixes are deployed to our public DNS, including `sync-` and `data-`
HOST_PREFIXES_NO_SUBDOMAIN = [
"analytics-",
"control-storage-",
"data-",
"query-",
"runtime-",
"storage-",
"streaming-",
"sync-",
"tags-",
"workflows-",
]
HOST_PREFIX_NAME_PATTERNS = [
f"{host_prefix}{LOCALHOST_HOSTNAME}" for host_prefix in HOST_PREFIXES_NO_SUBDOMAIN
]

NAME_PATTERNS_POINTING_TO_LOCALSTACK = [
f".*{LOCALHOST_HOSTNAME}",
*HOST_PREFIX_NAME_PATTERNS,
]


Expand Down
71 changes: 71 additions & 0 deletions tests/aws/services/lambda_/functions/host_prefix_operation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import json
import os
from urllib.parse import urlparse

import boto3
from botocore.config import Config

region = os.environ["AWS_REGION"]
account = boto3.client("sts").get_caller_identity()["Account"]
state_machine_arn_doesnotexist = (
f"arn:aws:states:{region}:{account}:stateMachine:doesNotExistStateMachine"
)


def do_test(test_case):
sfn_client = test_case["client"]
try:
sfn_client.start_sync_execution(
stateMachineArn=state_machine_arn_doesnotexist,
input=json.dumps({}),
name="SyncExecution",
)
return {"status": "failure"}
except sfn_client.exceptions.StateMachineDoesNotExist:
# We are testing the error case here, so we expect this exception to be raised.
# Testing the error case simplifies the test case because we don't need to set up a StepFunction.
return {"status": "success"}
except Exception as e:
return {"status": "exception", "exception": str(e)}


def handler(event, context):
# The environment variable AWS_ENDPOINT_URL is only available in LocalStack
aws_endpoint_url = os.environ.get("AWS_ENDPOINT_URL")

host_prefix_client = boto3.client(
"stepfunctions",
endpoint_url=os.environ.get("AWS_ENDPOINT_URL"),
)
localstack_adjusted_domain = None
# The localstack domain only works in LocalStack, None is ignored
if aws_endpoint_url:
port = urlparse(aws_endpoint_url).port
localstack_adjusted_domain = f"http://localhost.localstack.cloud:{port}"
host_prefix_client_localstack_domain = boto3.client(
"stepfunctions",
endpoint_url=localstack_adjusted_domain,
)
no_host_prefix_client = boto3.client(
"stepfunctions",
endpoint_url=os.environ.get("AWS_ENDPOINT_URL"),
config=Config(inject_host_prefix=False),
)

test_cases = [
{"name": "host_prefix", "client": host_prefix_client},
{"name": "host_prefix_localstack_domain", "client": host_prefix_client_localstack_domain},
# Omitting the host prefix can only work in LocalStack
{
"name": "no_host_prefix",
"client": no_host_prefix_client if aws_endpoint_url else host_prefix_client,
},
]

test_results = {}
for test_case in test_cases:
test_name = test_case["name"]
test_result = do_test(test_case)
test_results[test_name] = test_result

return test_results
31 changes: 31 additions & 0 deletions tests/aws/services/lambda_/test_lambda.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@
TEST_LAMBDA_NOTIFIER = os.path.join(THIS_FOLDER, "functions/lambda_notifier.py")
TEST_LAMBDA_CLOUDWATCH_LOGS = os.path.join(THIS_FOLDER, "functions/lambda_cloudwatch_logs.py")
TEST_LAMBDA_XRAY_TRACEID = os.path.join(THIS_FOLDER, "functions/xray_tracing_traceid.py")
TEST_LAMBDA_HOST_PREFIX_OPERATION = os.path.join(THIS_FOLDER, "functions/host_prefix_operation.py")

PYTHON_TEST_RUNTIMES = RUNTIMES_AGGREGATED["python"]
NODE_TEST_RUNTIMES = RUNTIMES_AGGREGATED["nodejs"]
Expand Down Expand Up @@ -830,6 +831,36 @@ def test_lambda_init_environment(
result = aws_client.lambda_.invoke(FunctionName=func_name, Payload=json.dumps({"pid": 1}))
snapshot.match("lambda-init-inspection", result)

@markers.aws.validated
@pytest.mark.skipif(
not config.use_custom_dns(),
reason="Host prefix cannot be resolved if DNS server is disabled",
)
@markers.snapshot.skip_snapshot_verify(
paths=[
# TODO: Fix hostPrefix operations failing by default within Lambda
# Idea: Support prefixed and non-prefixed operations by default and botocore should drop the prefix for
# non-supported hostnames such as IPv4 (e.g., `sync-192.168.65.254`)
"$..Payload.host_prefix.*",
],
)
def test_lambda_host_prefix_api_operation(self, create_lambda_function, aws_client, snapshot):
"""Ensure that API operations with a hostPrefix are forwarded to the LocalStack instance. Examples:
* StartSyncExecution: https://docs.aws.amazon.com/step-functions/latest/apireference/API_StartSyncExecution.html
* DiscoverInstances: https://docs.aws.amazon.com/cloud-map/latest/api/API_DiscoverInstances.html
hostPrefix background test_host_prefix_no_subdomain
StepFunction example for the hostPrefix `sync-` based on test_start_sync_execution
"""
func_name = f"test_lambda_{short_uid()}"
create_lambda_function(
func_name=func_name,
handler_file=TEST_LAMBDA_HOST_PREFIX_OPERATION,
runtime=Runtime.python3_12,
)
invoke_result = aws_client.lambda_.invoke(FunctionName=func_name)
assert "FunctionError" not in invoke_result
snapshot.match("invoke-result", invoke_result)


URL_HANDLER_CODE = """
def handler(event, ctx):
Expand Down
24 changes: 24 additions & 0 deletions tests/aws/services/lambda_/test_lambda.snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -4580,5 +4580,29 @@
}
}
}
},
"tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_lambda_host_prefix_api_operation": {
"recorded-date": "26-05-2025, 16:38:54",
"recorded-content": {
"invoke-result": {
"ExecutedVersion": "$LATEST",
"Payload": {
"host_prefix": {
"status": "success"
},
"host_prefix_localstack_domain": {
"status": "success"
},
"no_host_prefix": {
"status": "success"
}
},
"StatusCode": 200,
"ResponseMetadata": {
"HTTPHeaders": {},
"HTTPStatusCode": 200
}
}
}
}
}
3 changes: 3 additions & 0 deletions tests/aws/services/lambda_/test_lambda.validation.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@
"tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_lambda_cache_local[python]": {
"last_validated_date": "2024-04-08T16:55:59+00:00"
},
"tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_lambda_host_prefix_api_operation": {
"last_validated_date": "2025-05-26T16:38:53+00:00"
},
"tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_lambda_init_environment": {
"last_validated_date": "2024-04-08T16:56:25+00:00"
},
Expand Down
12 changes: 12 additions & 0 deletions tests/bootstrap/test_dns_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,14 +149,26 @@ def test_resolve_localstack_host(

container_ip = running_container.ip_address()

# domain
stdout, _ = dns_query_from_container(name=LOCALHOST_HOSTNAME, ip_address=container_ip)
assert container_ip in stdout.decode().splitlines()

# domain with known hostPrefix (see test_host_prefix_no_subdomain)
stdout, _ = dns_query_from_container(name=f"data-{LOCALHOST_HOSTNAME}", ip_address=container_ip)
assert container_ip in stdout.decode().splitlines()

# subdomain
stdout, _ = dns_query_from_container(name=f"foo.{LOCALHOST_HOSTNAME}", ip_address=container_ip)
assert container_ip in stdout.decode().splitlines()

# domain
stdout, _ = dns_query_from_container(name=localstack_host, ip_address=container_ip)
assert container_ip in stdout.decode().splitlines()

# domain with known hostPrefix (see test_host_prefix_no_subdomain)
stdout, _ = dns_query_from_container(name=f"data-{localstack_host}", ip_address=container_ip)
assert container_ip in stdout.decode().splitlines()

# subdomain
stdout, _ = dns_query_from_container(name=f"foo.{localstack_host}", ip_address=container_ip)
assert container_ip in stdout.decode().splitlines()
31 changes: 30 additions & 1 deletion tests/unit/test_dns_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,16 @@
import pytest

from localstack import config
from localstack.aws.spec import iterate_service_operations
from localstack.constants import LOCALHOST_HOSTNAME
from localstack.dns.models import AliasTarget, RecordType, SOARecord, TargetRecord
from localstack.dns.server import DnsServer, add_resolv_entry, get_fallback_dns_server
from localstack.dns.server import (
HOST_PREFIXES_NO_SUBDOMAIN,
NAME_PATTERNS_POINTING_TO_LOCALSTACK,
DnsServer,
add_resolv_entry,
get_fallback_dns_server,
)
from localstack.utils.net import get_free_udp_port
from localstack.utils.sync import retry

Expand Down Expand Up @@ -460,3 +468,24 @@ def test_no_resolv_conf_overwriting_on_host(self, tmp_path: Path, monkeypatch):

assert "nameserver 127.0.0.1" not in new_contents.splitlines()
assert "nameserver 127.0.0.11" in new_contents.splitlines()

def test_host_prefix_no_subdomain(
self,
):
"""This tests help to detect any potential future new host prefix domains added to the botocore specs.
If this test fails:
1) Add the new entry to `HOST_PREFIXES_NO_SUBDOMAIN` to reflect any changes
2) IMPORTANT: Add a public DNS entry for the given host prefix!
"""
unique_prefixes = set()
for service_model, operation in iterate_service_operations():
if operation.endpoint and operation.endpoint.get("hostPrefix"):
unique_prefixes.add(operation.endpoint["hostPrefix"])

non_dot_unique_prefixes = [prefix for prefix in unique_prefixes if not prefix.endswith(".")]
# Intermediary validation to easily summarize all differences
assert set(HOST_PREFIXES_NO_SUBDOMAIN) == set(non_dot_unique_prefixes)

# Real validation of NAME_PATTERNS_POINTING_TO_LOCALSTACK
for host_prefix in non_dot_unique_prefixes:
assert f"{host_prefix}{LOCALHOST_HOSTNAME}" in NAME_PATTERNS_POINTING_TO_LOCALSTACK
Loading