samstack 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- samstack/__init__.py +18 -0
- samstack/_constants.py +6 -0
- samstack/_errors.py +38 -0
- samstack/_process.py +124 -0
- samstack/fixtures/__init__.py +0 -0
- samstack/fixtures/_sam_container.py +133 -0
- samstack/fixtures/localstack.py +92 -0
- samstack/fixtures/resources.py +376 -0
- samstack/fixtures/sam_api.py +47 -0
- samstack/fixtures/sam_build.py +95 -0
- samstack/fixtures/sam_lambda.py +74 -0
- samstack/plugin.py +94 -0
- samstack/py.typed +0 -0
- samstack/resources/__init__.py +6 -0
- samstack/resources/dynamodb.py +77 -0
- samstack/resources/s3.py +51 -0
- samstack/resources/sns.py +56 -0
- samstack/resources/sqs.py +55 -0
- samstack/settings.py +85 -0
- samstack-0.1.0.dist-info/METADATA +456 -0
- samstack-0.1.0.dist-info/RECORD +24 -0
- samstack-0.1.0.dist-info/WHEEL +4 -0
- samstack-0.1.0.dist-info/entry_points.txt +2 -0
- samstack-0.1.0.dist-info/licenses/LICENSE +21 -0
samstack/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from samstack._errors import (
|
|
2
|
+
DockerNetworkError,
|
|
3
|
+
LocalStackStartupError,
|
|
4
|
+
SamBuildError,
|
|
5
|
+
SamStackError,
|
|
6
|
+
SamStartupError,
|
|
7
|
+
)
|
|
8
|
+
from samstack.settings import SamStackSettings, load_settings
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"DockerNetworkError",
|
|
12
|
+
"LocalStackStartupError",
|
|
13
|
+
"SamBuildError",
|
|
14
|
+
"SamStackError",
|
|
15
|
+
"SamStartupError",
|
|
16
|
+
"SamStackSettings",
|
|
17
|
+
"load_settings",
|
|
18
|
+
]
|
samstack/_constants.py
ADDED
samstack/_errors.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
class SamStackError(Exception):
|
|
2
|
+
"""Base exception for all samstack errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SamBuildError(SamStackError):
|
|
6
|
+
"""sam build container exited with non-zero status."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, logs: str) -> None:
|
|
9
|
+
self.logs = logs
|
|
10
|
+
super().__init__(f"sam build failed.\n\nLogs:\n{logs}")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SamStartupError(SamStackError):
|
|
14
|
+
"""SAM process did not bind port within timeout."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, port: int, log_tail: str) -> None:
|
|
17
|
+
self.port = port
|
|
18
|
+
self.log_tail = log_tail
|
|
19
|
+
super().__init__(
|
|
20
|
+
f"SAM did not start on port {port} within timeout.\n\nLog tail:\n{log_tail}"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LocalStackStartupError(SamStackError):
|
|
25
|
+
"""LocalStack container did not become healthy."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, log_tail: str) -> None:
|
|
28
|
+
self.log_tail = log_tail
|
|
29
|
+
super().__init__(f"LocalStack did not become healthy.\n\nLog tail:\n{log_tail}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DockerNetworkError(SamStackError):
|
|
33
|
+
"""Failed to create or attach shared Docker network."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, name: str, reason: str) -> None:
|
|
36
|
+
self.name = name
|
|
37
|
+
self.reason = reason
|
|
38
|
+
super().__init__(f"Docker network '{name}' error: {reason}")
|
samstack/_process.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import socket
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from samstack._errors import SamStartupError
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from docker.models.containers import Container
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def tail_log_file(path: Path, lines: int = 50) -> str:
|
|
18
|
+
"""Return the last *lines* lines of a log file, or '' if missing."""
|
|
19
|
+
if not path.exists():
|
|
20
|
+
return ""
|
|
21
|
+
content = path.read_text(errors="replace")
|
|
22
|
+
return "\n".join(content.splitlines()[-lines:])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def wait_for_port(
|
|
26
|
+
host: str,
|
|
27
|
+
port: int,
|
|
28
|
+
log_path: Path,
|
|
29
|
+
timeout: float = 120.0,
|
|
30
|
+
interval: float = 0.5,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Block until *port* accepts TCP connections or raise SamStartupError."""
|
|
33
|
+
deadline = time.monotonic() + timeout
|
|
34
|
+
while time.monotonic() < deadline:
|
|
35
|
+
try:
|
|
36
|
+
socket.create_connection((host, port), timeout=1.0).close()
|
|
37
|
+
return
|
|
38
|
+
except OSError:
|
|
39
|
+
time.sleep(interval)
|
|
40
|
+
raise SamStartupError(port=port, log_tail=tail_log_file(log_path))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def wait_for_http(
|
|
44
|
+
host: str,
|
|
45
|
+
port: int,
|
|
46
|
+
log_path: Path,
|
|
47
|
+
path: str = "/",
|
|
48
|
+
timeout: float = 120.0,
|
|
49
|
+
interval: float = 1.0,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Block until an HTTP GET returns any response (any status code).
|
|
52
|
+
|
|
53
|
+
Unlike wait_for_port (TCP probe), this confirms the HTTP server is
|
|
54
|
+
fully initialised and handling requests — not just that the port forwarder
|
|
55
|
+
is listening. Raises SamStartupError on timeout.
|
|
56
|
+
"""
|
|
57
|
+
url = f"http://{host}:{port}{path}"
|
|
58
|
+
deadline = time.monotonic() + timeout
|
|
59
|
+
while time.monotonic() < deadline:
|
|
60
|
+
try:
|
|
61
|
+
urllib.request.urlopen(url, timeout=2.0) # noqa: S310
|
|
62
|
+
return
|
|
63
|
+
except urllib.error.HTTPError:
|
|
64
|
+
# Any HTTP error (4xx/5xx) means the server is up
|
|
65
|
+
return
|
|
66
|
+
except (urllib.error.URLError, OSError):
|
|
67
|
+
time.sleep(interval)
|
|
68
|
+
raise SamStartupError(port=port, log_tail=tail_log_file(log_path))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def stream_logs_to_file(container: Container, log_path: Path) -> threading.Thread:
|
|
72
|
+
"""Stream Docker container stdout/stderr to *log_path* in a daemon thread.
|
|
73
|
+
|
|
74
|
+
*container* is a Docker SDK container object (docker.models.containers.Container).
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def _stream() -> None:
|
|
78
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
try:
|
|
80
|
+
with log_path.open("a") as f:
|
|
81
|
+
for chunk in container.logs(stream=True, follow=True):
|
|
82
|
+
f.write(chunk.decode(errors="replace"))
|
|
83
|
+
f.flush()
|
|
84
|
+
except Exception as exc:
|
|
85
|
+
try:
|
|
86
|
+
with log_path.open("a") as f:
|
|
87
|
+
f.write(f"\n[samstack] log streaming failed: {exc}\n")
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
t = threading.Thread(target=_stream, daemon=True)
|
|
92
|
+
t.start()
|
|
93
|
+
return t
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def run_one_shot_container(
|
|
97
|
+
image: str,
|
|
98
|
+
command: str | list[str],
|
|
99
|
+
volumes: dict[str, dict[str, str]],
|
|
100
|
+
working_dir: str = "/var/task",
|
|
101
|
+
network: str | None = None,
|
|
102
|
+
environment: dict[str, str] | None = None,
|
|
103
|
+
) -> tuple[str, int]:
|
|
104
|
+
"""Run a container to completion. Returns (logs, exit_code)."""
|
|
105
|
+
import docker as docker_sdk
|
|
106
|
+
|
|
107
|
+
client = docker_sdk.from_env()
|
|
108
|
+
kwargs: dict[str, Any] = {"network": network} if network else {}
|
|
109
|
+
if environment:
|
|
110
|
+
kwargs["environment"] = environment
|
|
111
|
+
container = client.containers.run(
|
|
112
|
+
image=image,
|
|
113
|
+
command=command,
|
|
114
|
+
volumes=volumes,
|
|
115
|
+
working_dir=working_dir,
|
|
116
|
+
detach=True,
|
|
117
|
+
**kwargs,
|
|
118
|
+
)
|
|
119
|
+
try:
|
|
120
|
+
result = container.wait()
|
|
121
|
+
logs = container.logs().decode(errors="replace")
|
|
122
|
+
return logs, result["StatusCode"]
|
|
123
|
+
finally:
|
|
124
|
+
container.remove(force=True)
|
|
File without changes
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from testcontainers.core.container import DockerContainer
|
|
10
|
+
|
|
11
|
+
from samstack._process import stream_logs_to_file, wait_for_http, wait_for_port
|
|
12
|
+
from samstack.settings import SamStackSettings
|
|
13
|
+
|
|
14
|
+
DOCKER_SOCKET = "/var/run/docker.sock"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _is_ci() -> bool:
|
|
18
|
+
"""Return True when running inside a CI environment (GitHub Actions, GitLab CI, etc.)."""
|
|
19
|
+
return bool(os.environ.get("CI"))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _extra_hosts() -> dict[str, str]:
|
|
23
|
+
"""On Linux (no Docker Desktop), map host.docker.internal to the host gateway."""
|
|
24
|
+
if platform.system() == "Darwin":
|
|
25
|
+
return {}
|
|
26
|
+
return {"host.docker.internal": "host-gateway"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_sam_args(
|
|
30
|
+
port: int,
|
|
31
|
+
env_vars_host_path: str,
|
|
32
|
+
docker_network: str,
|
|
33
|
+
warm_containers: str,
|
|
34
|
+
settings_extra_args: list[str],
|
|
35
|
+
fixture_extra_args: list[str],
|
|
36
|
+
) -> list[str]:
|
|
37
|
+
"""Return the CLI arg list shared by start-api and start-lambda."""
|
|
38
|
+
skip_pull: list[str] = [] if _is_ci() else ["--skip-pull-image"]
|
|
39
|
+
return [
|
|
40
|
+
*skip_pull,
|
|
41
|
+
"--warm-containers",
|
|
42
|
+
warm_containers,
|
|
43
|
+
"--host",
|
|
44
|
+
"0.0.0.0",
|
|
45
|
+
"--port",
|
|
46
|
+
str(port),
|
|
47
|
+
"--env-vars",
|
|
48
|
+
env_vars_host_path,
|
|
49
|
+
"--docker-network",
|
|
50
|
+
docker_network,
|
|
51
|
+
"--container-host",
|
|
52
|
+
"host.docker.internal",
|
|
53
|
+
"--container-host-interface",
|
|
54
|
+
"0.0.0.0",
|
|
55
|
+
*settings_extra_args,
|
|
56
|
+
*fixture_extra_args,
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@contextmanager
|
|
61
|
+
def _run_sam_service(
|
|
62
|
+
settings: SamStackSettings,
|
|
63
|
+
docker_network: str,
|
|
64
|
+
subcommand: Literal["start-api", "start-lambda"],
|
|
65
|
+
port: int,
|
|
66
|
+
warm_containers: str,
|
|
67
|
+
settings_extra_args: list[str],
|
|
68
|
+
fixture_extra_args: list[str],
|
|
69
|
+
log_filename: str,
|
|
70
|
+
wait_mode: Literal["http", "port"],
|
|
71
|
+
) -> Iterator[str]:
|
|
72
|
+
"""Start a `sam local <subcommand>` container and yield its endpoint URL."""
|
|
73
|
+
log_dir = settings.project_root / settings.log_dir
|
|
74
|
+
log_path = log_dir / log_filename
|
|
75
|
+
host_path = str(settings.project_root)
|
|
76
|
+
env_vars_host_path = str(log_dir / "env_vars.json")
|
|
77
|
+
|
|
78
|
+
args = build_sam_args(
|
|
79
|
+
port=port,
|
|
80
|
+
env_vars_host_path=env_vars_host_path,
|
|
81
|
+
docker_network=docker_network,
|
|
82
|
+
warm_containers=warm_containers,
|
|
83
|
+
settings_extra_args=settings_extra_args,
|
|
84
|
+
fixture_extra_args=fixture_extra_args,
|
|
85
|
+
)
|
|
86
|
+
command = ["sam", "local", subcommand, "--template", settings.template, *args]
|
|
87
|
+
|
|
88
|
+
container = create_sam_container(
|
|
89
|
+
settings=settings,
|
|
90
|
+
docker_network=docker_network,
|
|
91
|
+
host_path=host_path,
|
|
92
|
+
port=port,
|
|
93
|
+
command=command,
|
|
94
|
+
)
|
|
95
|
+
container.start()
|
|
96
|
+
inner = container.get_wrapped_container()
|
|
97
|
+
assert inner is not None, "SAM container failed to start"
|
|
98
|
+
stream_logs_to_file(inner, log_path)
|
|
99
|
+
|
|
100
|
+
host_port = int(container.get_exposed_port(port))
|
|
101
|
+
if wait_mode == "http":
|
|
102
|
+
wait_for_http("127.0.0.1", host_port, log_path=log_path, timeout=120.0)
|
|
103
|
+
else:
|
|
104
|
+
wait_for_port("127.0.0.1", host_port, log_path=log_path, timeout=120.0)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
yield f"http://127.0.0.1:{host_port}"
|
|
108
|
+
finally:
|
|
109
|
+
container.stop()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def create_sam_container(
|
|
113
|
+
settings: SamStackSettings,
|
|
114
|
+
docker_network: str,
|
|
115
|
+
host_path: str,
|
|
116
|
+
port: int,
|
|
117
|
+
command: list[str],
|
|
118
|
+
) -> DockerContainer:
|
|
119
|
+
"""Build a DockerContainer for `sam local start-*` with all standard mounts and env."""
|
|
120
|
+
return (
|
|
121
|
+
DockerContainer(settings.sam_image)
|
|
122
|
+
.with_kwargs(
|
|
123
|
+
network=docker_network,
|
|
124
|
+
extra_hosts=_extra_hosts(),
|
|
125
|
+
working_dir=host_path,
|
|
126
|
+
)
|
|
127
|
+
.with_volume_mapping(host_path, host_path, "rw")
|
|
128
|
+
.with_volume_mapping(DOCKER_SOCKET, DOCKER_SOCKET, "rw")
|
|
129
|
+
.with_exposed_ports(port)
|
|
130
|
+
.with_env("SAM_CLI_CONTAINER_CONNECTION_TIMEOUT", "60")
|
|
131
|
+
.with_env("DOCKER_DEFAULT_PLATFORM", settings.docker_platform)
|
|
132
|
+
.with_command(command)
|
|
133
|
+
)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
import docker as docker_sdk
|
|
8
|
+
import pytest
|
|
9
|
+
from testcontainers.localstack import LocalStackContainer
|
|
10
|
+
|
|
11
|
+
from samstack._errors import DockerNetworkError
|
|
12
|
+
from samstack.fixtures._sam_container import DOCKER_SOCKET
|
|
13
|
+
from samstack.settings import SamStackSettings
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture(scope="session")
|
|
17
|
+
def docker_network_name() -> str:
|
|
18
|
+
"""Return the name for the shared Docker bridge network.
|
|
19
|
+
|
|
20
|
+
Override this fixture to use a fixed or externally-managed network name.
|
|
21
|
+
"""
|
|
22
|
+
return f"samstack-{uuid4().hex[:8]}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture(scope="session")
|
|
26
|
+
def docker_network(docker_network_name: str) -> Iterator[str]:
|
|
27
|
+
"""Create a Docker bridge network shared by LocalStack and SAM containers."""
|
|
28
|
+
name = docker_network_name
|
|
29
|
+
client = docker_sdk.from_env()
|
|
30
|
+
try:
|
|
31
|
+
network = client.networks.create(name, driver="bridge")
|
|
32
|
+
except Exception as exc:
|
|
33
|
+
raise DockerNetworkError(name=name, reason=str(exc)) from exc
|
|
34
|
+
try:
|
|
35
|
+
yield name
|
|
36
|
+
finally:
|
|
37
|
+
try:
|
|
38
|
+
network.reload()
|
|
39
|
+
for container in network.containers:
|
|
40
|
+
try:
|
|
41
|
+
container.stop(timeout=5)
|
|
42
|
+
container.remove(force=True)
|
|
43
|
+
except Exception:
|
|
44
|
+
network.disconnect(container, force=True)
|
|
45
|
+
network.remove()
|
|
46
|
+
except Exception as exc:
|
|
47
|
+
warnings.warn(
|
|
48
|
+
f"samstack: failed to clean up Docker network '{name}': {exc}",
|
|
49
|
+
stacklevel=1,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.fixture(scope="session")
|
|
54
|
+
def localstack_container(
|
|
55
|
+
samstack_settings: SamStackSettings,
|
|
56
|
+
docker_network: str,
|
|
57
|
+
) -> Iterator[LocalStackContainer]:
|
|
58
|
+
"""Start LocalStack and connect it to the shared Docker network."""
|
|
59
|
+
container = LocalStackContainer(image=samstack_settings.localstack_image)
|
|
60
|
+
container.with_volume_mapping(DOCKER_SOCKET, DOCKER_SOCKET, "rw")
|
|
61
|
+
container.start()
|
|
62
|
+
|
|
63
|
+
client = docker_sdk.from_env()
|
|
64
|
+
try:
|
|
65
|
+
network = client.networks.get(docker_network)
|
|
66
|
+
inner = container.get_wrapped_container()
|
|
67
|
+
assert inner is not None, "LocalStack container failed to start"
|
|
68
|
+
network.connect(inner.id, aliases=["localstack"])
|
|
69
|
+
except Exception as exc:
|
|
70
|
+
container.stop()
|
|
71
|
+
raise DockerNetworkError(name=docker_network, reason=str(exc)) from exc
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
yield container
|
|
75
|
+
finally:
|
|
76
|
+
try:
|
|
77
|
+
network = client.networks.get(docker_network)
|
|
78
|
+
inner = container.get_wrapped_container()
|
|
79
|
+
if inner is not None:
|
|
80
|
+
network.disconnect(inner.id, force=True)
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
warnings.warn(
|
|
83
|
+
f"samstack: failed to disconnect LocalStack from network '{docker_network}': {exc}",
|
|
84
|
+
stacklevel=1,
|
|
85
|
+
)
|
|
86
|
+
container.stop()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.fixture(scope="session")
|
|
90
|
+
def localstack_endpoint(localstack_container: LocalStackContainer) -> str:
|
|
91
|
+
"""Return the host-accessible LocalStack URL for use in boto3 clients."""
|
|
92
|
+
return localstack_container.get_url()
|