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 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
@@ -0,0 +1,6 @@
1
+ """Internal constants shared across samstack fixtures."""
2
+
3
+ # LocalStack accepts any non-empty value for AWS credentials.
4
+ # "test" is its documented default and is not configurable.
5
+ LOCALSTACK_ACCESS_KEY = "test"
6
+ LOCALSTACK_SECRET_KEY = "test"
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()