",
9 | "homepage": "https://stelvio.dev",
10 | "repository": {
11 | "type": "git",
12 | "url": "https://github.com/stelviodev/stelvio.git"
13 | },
14 | "bugs": {
15 | "url": "https://github.com/stelviodev/stelvio/issues"
16 | },
17 | "bin": {
18 | "stlv": "./dist/index.js"
19 | },
20 | "devDependencies": {
21 | "@types/node": "^20",
22 | "typescript": "^5"
23 | },
24 | "scripts": {
25 | "build": "tsc && cp README.md LICENSE dist/",
26 | "prepublishOnly": "npm run build"
27 | },
28 | "files": [
29 | "dist/**/*"
30 | ]
31 | }
--------------------------------------------------------------------------------
/tests/aws/test_permission.py:
--------------------------------------------------------------------------------
1 | from stelvio.aws.permission import AwsPermission
2 |
3 |
4 | def test_init_with_simple_values():
5 | actions = ["s3:GetObject", "s3:PutObject"]
6 | resources = ["arn:aws:s3:::my-bucket/*"]
7 |
8 | permission = AwsPermission(actions=actions, resources=resources)
9 |
10 | assert permission.actions == actions
11 | assert permission.resources == resources
12 |
13 |
14 | def test_to_provider_format():
15 | actions = ["dynamodb:GetItem", "dynamodb:PutItem"]
16 | resources = ["arn:aws:dynamodb:us-east-1:123456789012:table/my-table"]
17 |
18 | permission = AwsPermission(actions=actions, resources=resources)
19 | provider_format = permission.to_provider_format()
20 |
21 | # Check that values are passed through correctly
22 | assert provider_format.actions == actions
23 | assert provider_format.resources == resources
24 |
--------------------------------------------------------------------------------
/stelvio/aws/cloudfront/origins/base.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 |
3 | import pulumi
4 | import pulumi_aws
5 |
6 | from stelvio.aws.cloudfront.dtos import Route, RouteOriginConfig
7 | from stelvio.component import Component
8 |
9 |
10 | class ComponentCloudfrontAdapter(ABC):
11 | component_class: type[Component] | None = None
12 |
13 | def __init__(self, idx: int, route: Route) -> None:
14 | self.idx = idx
15 | self.route = route
16 |
17 | @classmethod
18 | def match(cls, stlv_component: Component) -> bool:
19 | return isinstance(stlv_component, cls.component_class)
20 |
21 | @abstractmethod
22 | def get_origin_config(self) -> RouteOriginConfig:
23 | pass
24 |
25 | @abstractmethod
26 | def get_access_policy(
27 | self, distribution: pulumi_aws.cloudfront.Distribution
28 | ) -> pulumi.Resource | None:
29 | pass
30 |
--------------------------------------------------------------------------------
/stelvio/aws/api_gateway/constants.py:
--------------------------------------------------------------------------------
1 | from enum import Enum
2 | from typing import Literal
3 |
4 | ROUTE_MAX_PARAMS = 10
5 | ROUTE_MAX_LENGTH = 8192
6 | HTTP_METHODS = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
7 | API_GATEWAY_LOGS_POLICY = (
8 | "arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs"
9 | )
10 |
11 |
12 | # These are methods supported by api gateway
13 | class HTTPMethod(Enum):
14 | GET = "GET"
15 | POST = "POST"
16 | PUT = "PUT"
17 | DELETE = "DELETE"
18 | PATCH = "PATCH"
19 | HEAD = "HEAD"
20 | OPTIONS = "OPTIONS"
21 | ANY = "ANY"
22 |
23 |
24 | HTTPMethodLiteral = Literal["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "ANY", "*"]
25 |
26 | type HTTPMethodInput = (
27 | str | HTTPMethodLiteral | HTTPMethod | list[str | HTTPMethodLiteral | HTTPMethod]
28 | )
29 |
30 | ApiEndpointType = Literal["regional", "edge"]
31 | DEFAULT_STAGE_NAME = "v1"
32 | DEFAULT_ENDPOINT_TYPE: ApiEndpointType = "regional"
33 | API_GATEWAY_ROLE_NAME = "StelvioAPIGatewayPushToCloudWatchLogsRole"
34 |
--------------------------------------------------------------------------------
/.devcontainer/setup:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -euo pipefail
4 |
5 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6 | source "$SCRIPT_DIR/setup.conf"
7 |
8 | # check if uv is installed
9 | if ! command -v uv &> /dev/null
10 | then
11 | echo "uv could not be found, please add feature 'ghcr.io/va-h/devcontainers-features/uv:1' to your devcontainer.json or ensure 'uv' is in PATH"
12 | exit 1
13 | fi
14 |
15 | # Check if the workspace folder exists
16 | if [ ! -d "$WORKSPACE_DIR" ]; then
17 | echo "Workspace folder does not exist: $WORKSPACE_DIR. Please create it."
18 | exit 1
19 | fi
20 |
21 | # Navigate to the workspace folder
22 | cd "$WORKSPACE_DIR" || exit 1
23 |
24 | # Create a virtual environment using uv
25 |
26 | # Intentionally overwrite default `UV_VENV_CLEAR` here as
27 | # exception for postCreateCommand.
28 | # Default behavior in the container should still be 0
29 | UV_VENV_CLEAR=1 uv venv
30 |
31 | # Install project dependencies using uv
32 | # (Removed redundant uv pip install --editable .)
33 |
34 | # Run a sync
35 | uv sync --dev
36 |
37 | echo " (*) Done."
38 |
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Stelvio.dev (py3.13, trixie)",
3 | "build": {
4 | "dockerfile": "./Dockerfile",
5 | "context": "..",
6 | "cacheFrom": "type=registry,ref=ghcr.io/stelviodev/stelviodevcontainer:latest"
7 | },
8 | "features": {
9 | "ghcr.io/devcontainers/features/aws-cli:1": {},
10 | "ghcr.io/devcontainer-community/devcontainer-features/bun.sh:1": {},
11 | "ghcr.io/devcontainer-community/devcontainer-features/uv:1": { "shellautocompletion": "true" },
12 | "ghcr.io/devcontainers/features/github-cli:1": {}
13 | },
14 | "customizations": {
15 | "vscode": {
16 | "extensions": [
17 | "ms-python.python"
18 | ],
19 | "settings": {
20 | "python.testing.pytestArgs": [
21 | "tests"
22 | ],
23 | "python.testing.unittestEnabled": false,
24 | "python.testing.pytestEnabled": true
25 | }
26 | }
27 | },
28 | "postCreateCommand": ".devcontainer/setup",
29 | "containerEnv": {
30 | "UV_LINK_MODE": "copy",
31 | "UV_VENV_CLEAR": "0"
32 | },
33 | "remoteEnv": {
34 | "STLV_DEBUG": "1",
35 | "PYTHONDONTWRITEBYTECODE": "1",
36 | "PYTHONDEVMODE": "1",
37 | "PYTHONPERFSUPPORT": "1",
38 | "PYTHON_COLORS": "1",
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/.github/workflows/prebuild-devcontainer.yml:
--------------------------------------------------------------------------------
1 | name: Pre-build Devcontainer
2 |
3 | on:
4 | workflow_dispatch:
5 | push:
6 | branches:
7 | - main
8 |
9 | jobs:
10 | build_devcontainer:
11 | runs-on: ubuntu-24.04
12 | permissions:
13 | contents: read
14 | packages: write
15 | steps:
16 | - name: Checkout code
17 | uses: actions/checkout@v4
18 |
19 | - name: Set up QEMU
20 | uses: docker/setup-qemu-action@v3
21 |
22 | - name: Set up Docker Buildx
23 | uses: docker/setup-buildx-action@v3
24 | with:
25 | version: v0.29.1
26 | platforms: linux/amd64,linux/arm64
27 | install: true
28 | use: true
29 |
30 | - name: Login to GitHub Container Registry
31 | uses: docker/login-action@v3
32 | with:
33 | registry: ghcr.io
34 | username: ${{ github.repository_owner }}
35 | password: ${{ secrets.GITHUB_TOKEN }}
36 |
37 | - name: Pre-build dev container image
38 | uses: devcontainers/ci@v0.3
39 | with:
40 | imageName: ghcr.io/stelviodev/stelviodevcontainer
41 | # cacheTo: type=inline # This is the default value.
42 | push: always
43 | platform: linux/amd64,linux/arm64
44 |
--------------------------------------------------------------------------------
/stelvio/dns.py:
--------------------------------------------------------------------------------
1 | from typing import Protocol
2 |
3 | from pulumi import Input, Resource
4 |
5 |
6 | class DnsProviderNotConfiguredError(AttributeError):
7 | """Raised when DNS provider is not configured in the context."""
8 |
9 |
10 | class Record:
11 | def __init__(self, pulumi_resource: Resource):
12 | self._pulumi_resource = pulumi_resource
13 |
14 | @property
15 | def pulumi_resource(self) -> Resource:
16 | return self._pulumi_resource
17 |
18 |
19 | class Dns(Protocol):
20 | def create_record(
21 | self, resource_name: str, name: str, record_type: str, value: Input[str], ttl: int = 1
22 | ) -> Record:
23 | """
24 | Create a DNS record with the given name, type, and value.
25 | """
26 | raise NotImplementedError(
27 | "No DNS provider configured. "
28 | "Please set up a DNS provider in your Stelvio app configuration."
29 | )
30 |
31 | def create_caa_record(
32 | self, resource_name: str, name: str, record_type: str, content: str, ttl: int = 1
33 | ) -> Record:
34 | """
35 | Create a CAA DNS record with the given name, type, and content.
36 | """
37 | raise NotImplementedError(
38 | "No DNS provider configured. "
39 | "Please set up a DNS provider in your Stelvio app configuration."
40 | )
41 |
--------------------------------------------------------------------------------
/tests/test_parse_template.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from stelvio.cli import _parse_template_string
4 |
5 |
6 | class TestParseTemplateString:
7 | """Tests for _parse_template_string function."""
8 |
9 | @pytest.mark.parametrize(
10 | ("template", "expected"),
11 | [
12 | ("base", ("stelviodev", "templates", "main", "base")),
13 | ("example/dir", ("stelviodev", "templates", "main", "example/dir")),
14 | ("gh:owner/repo@branch/subdirectory", ("owner", "repo", "branch", "subdirectory")),
15 | ("gh:owner/repo", ("owner", "repo", "main", None)),
16 | ("gh:owner/repo@branch", ("owner", "repo", "branch", None)),
17 | ("gh:owner/repo/subdirectory", ("owner", "repo", "main", "subdirectory")),
18 | ("gh:owner/repo@main/sub/directory", ("owner", "repo", "main", "sub/directory")),
19 | ("gh:owner/repo/sub/directory", ("owner", "repo", "main", "sub/directory")),
20 | ],
21 | )
22 | def test_valid_templates(self, template, expected):
23 | owner, repo, branch, subdirectory = _parse_template_string(template)
24 | assert (owner, repo, branch, subdirectory) == expected
25 |
26 | @pytest.mark.parametrize("template", ["gh:owner", "gh:"])
27 | def test_invalid_templates(self, template):
28 | with pytest.raises(ValueError, match="Invalid template format"):
29 | _parse_template_string(template)
30 |
--------------------------------------------------------------------------------
/stelvio/home.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from typing import Protocol
3 |
4 |
5 | class Home(Protocol):
6 | """Storage interface - params and files. Dumb I/O, no domain logic."""
7 |
8 | # Params (SSM in AWS, KV in Cloudflare, etc.)
9 | def read_param(self, name: str) -> str | None: ...
10 | def write_param(
11 | self, name: str, value: str, description: str = "", *, secure: bool = False
12 | ) -> None: ...
13 |
14 | # Storage init (S3 bucket in AWS, directory locally, etc.)
15 | def init_storage(self, name: str | None = None) -> str:
16 | """
17 | Initialize storage for file operations.
18 |
19 | If name is None: generate provider-specific name, create storage, return name.
20 | If name provided: use that storage (assumes exists), return name.
21 | """
22 | ...
23 |
24 | # Files (S3 in AWS, filesystem locally, etc.)
25 | def read_file(self, key: str, local_path: Path) -> bool:
26 | """Download file to local_path. Returns True if file existed."""
27 | ...
28 |
29 | def write_file(self, key: str, local_path: Path) -> None:
30 | """Upload file from local_path."""
31 | ...
32 |
33 | def delete_file(self, key: str) -> None:
34 | """Delete file."""
35 | ...
36 |
37 | def file_exists(self, key: str) -> bool:
38 | """Check if file exists."""
39 | ...
40 |
41 | def delete_prefix(self, prefix: str) -> None:
42 | """Delete all files with given prefix."""
43 | ...
44 |
--------------------------------------------------------------------------------
/stelvio/aws/dns.py:
--------------------------------------------------------------------------------
1 | import pulumi_aws
2 | from pulumi import Input, Output
3 |
4 | from stelvio import dns
5 |
6 |
7 | class Route53PulumiResourceAdapter(dns.Record):
8 | @property
9 | def name(self) -> Output[str]:
10 | return self.pulumi_resource.name
11 |
12 | @property
13 | def type(self) -> Output[str]:
14 | return self.pulumi_resource.type
15 |
16 | @property
17 | def value(self) -> Output[str]:
18 | return self.pulumi_resource.content
19 |
20 |
21 | class Route53Dns(dns.Dns):
22 | def __init__(self, zone_id: str):
23 | self.zone_id = zone_id
24 |
25 | def create_caa_record(
26 | self, resource_name: str, name: str, record_type: str, content: str, ttl: int = 1
27 | ) -> dns.Record:
28 | validation_record = pulumi_aws.route53.Record(
29 | resource_name,
30 | zone_id=self.zone_id,
31 | name=name,
32 | type=record_type,
33 | records=[content],
34 | ttl=ttl,
35 | )
36 | return Route53PulumiResourceAdapter(validation_record)
37 |
38 | def create_record(
39 | self, resource_name: str, name: str, record_type: str, value: Input[str], ttl: int = 1
40 | ) -> dns.Record:
41 | record = pulumi_aws.route53.Record(
42 | resource_name,
43 | zone_id=self.zone_id,
44 | name=name,
45 | type=record_type,
46 | records=[value],
47 | ttl=ttl,
48 | )
49 | return Route53PulumiResourceAdapter(record)
50 |
--------------------------------------------------------------------------------
/stelvio/cloudflare/dns.py:
--------------------------------------------------------------------------------
1 | import pulumi_cloudflare
2 | from pulumi import Input, Output
3 |
4 | from stelvio import dns
5 |
6 |
7 | class CloudflarePulumiResourceAdapter(dns.Record):
8 | @property
9 | def name(self) -> Output[str]:
10 | return self.pulumi_resource.name
11 |
12 | @property
13 | def type(self) -> Output[str]:
14 | return self.pulumi_resource.type
15 |
16 | @property
17 | def value(self) -> Output[str]:
18 | return self.pulumi_resource.content
19 |
20 |
21 | class CloudflareDns(dns.Dns):
22 | def __init__(self, zone_id: str):
23 | self.zone_id = zone_id
24 |
25 | def create_caa_record(
26 | self, resource_name: str, name: str, record_type: str, content: str, ttl: int = 1
27 | ) -> dns.Record:
28 | validation_record = pulumi_cloudflare.Record(
29 | resource_name,
30 | zone_id=self.zone_id,
31 | name=name,
32 | type=record_type,
33 | content=content,
34 | ttl=ttl,
35 | )
36 | return CloudflarePulumiResourceAdapter(validation_record)
37 |
38 | def create_record(
39 | self, resource_name: str, name: str, record_type: str, value: Input[str], ttl: int = 1
40 | ) -> dns.Record:
41 | record = pulumi_cloudflare.Record(
42 | resource_name,
43 | zone_id=self.zone_id,
44 | name=name,
45 | type=record_type,
46 | content=value,
47 | ttl=ttl,
48 | )
49 | return CloudflarePulumiResourceAdapter(record)
50 |
--------------------------------------------------------------------------------
/stelvio/project.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from functools import cache
3 | from pathlib import Path
4 |
5 | logger = logging.getLogger(__name__)
6 |
7 |
8 | @cache
9 | def get_project_root() -> Path:
10 | """Find and cache the project root by looking for stlv_app.py.
11 | Raises ValueError if not found.
12 | """
13 | start_path = Path.cwd().resolve()
14 |
15 | current = start_path
16 | while current != current.parent:
17 | if (current / "stlv_app.py").exists():
18 | return current
19 | current = current.parent
20 |
21 | raise ValueError("Could not find project root: no stlv_app.py found in parent directories")
22 |
23 |
24 | def get_dot_stelvio_dir() -> Path:
25 | return get_project_root() / ".stelvio"
26 |
27 |
28 | def _read_metadata_file(filename: str) -> str | None:
29 | file_path = get_dot_stelvio_dir() / filename
30 | if file_path.exists() and file_path.is_file():
31 | return file_path.read_text().strip()
32 | return None
33 |
34 |
35 | def _write_metadata_file(filename: str, content: str) -> None:
36 | stelvio_dir = get_dot_stelvio_dir()
37 | stelvio_dir.mkdir(exist_ok=True, parents=True)
38 |
39 | file_path = stelvio_dir / filename
40 | try:
41 | file_path.write_text(content)
42 | logger.debug("Saved %s: %s", filename, content)
43 | except Exception:
44 | logger.exception("Failed to write .stelvio/%s", filename)
45 |
46 |
47 | def get_user_env() -> str | None:
48 | return _read_metadata_file("userenv")
49 |
50 |
51 | def save_user_env(env: str) -> None:
52 | _write_metadata_file("userenv", env)
53 |
--------------------------------------------------------------------------------
/pkg/npm/bun.lock:
--------------------------------------------------------------------------------
1 | {
2 | "lockfileVersion": 1,
3 | "workspaces": {
4 | "": {
5 | "name": "stlv-npm",
6 | "devDependencies": {
7 | "@types/bun": "latest",
8 | },
9 | "peerDependencies": {
10 | "typescript": "^5",
11 | },
12 | },
13 | },
14 | "packages": {
15 | "@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="],
16 |
17 | "@types/node": ["@types/node@24.9.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="],
18 |
19 | "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
20 |
21 | "bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="],
22 |
23 | "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
24 |
25 | "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
26 |
27 | "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/tests/aws/function/test_iam.py:
--------------------------------------------------------------------------------
1 | """Test that IAM functions use safe_name."""
2 |
3 | from unittest.mock import ANY, patch
4 |
5 | from pulumi_aws.iam import GetPolicyDocumentStatementArgs
6 |
7 | from stelvio import context
8 | from stelvio.aws.function.iam import _create_function_policy, _create_lambda_role
9 |
10 |
11 | @patch("stelvio.aws.function.iam.get_policy_document")
12 | @patch("stelvio.aws.function.iam.Policy")
13 | @patch("stelvio.aws.function.iam.safe_name", return_value="safe-policy-name")
14 | def test_policy_uses_safe_name(mock_safe_name, mock_policy, mock_get_policy_document):
15 | # Act
16 | statements = [GetPolicyDocumentStatementArgs(actions=["s3:GetObject"], resources=["arn"])]
17 | _create_function_policy("function-name", statements)
18 |
19 | # Assert - verify safe_name was called with correct parameters
20 | mock_safe_name.assert_called_once_with(context().prefix(), "function-name", 128, "-p")
21 |
22 | # Assert - verify Policy was created with safe_name return value
23 | mock_policy.assert_called_once_with("safe-policy-name", path="/", policy=ANY)
24 |
25 |
26 | @patch("stelvio.aws.function.iam.get_policy_document")
27 | @patch("stelvio.aws.function.iam.Role")
28 | @patch("stelvio.aws.function.iam.safe_name", return_value="safe-role-name")
29 | def test_role_uses_safe_name(mock_safe_name, mock_role, mock_get_policy_document):
30 | # Act
31 | _create_lambda_role("function-name")
32 |
33 | # Assert - verify safe_name was called with correct parameters
34 | mock_safe_name.assert_called_once_with(context().prefix(), "function-name", 64, "-r")
35 |
36 | # Assert - verify Role was created with safe_name return value
37 | mock_role.assert_called_once_with("safe-role-name", assume_role_policy=ANY)
38 |
--------------------------------------------------------------------------------
/stelvio/aws/cloudfront/origins/registry.py:
--------------------------------------------------------------------------------
1 | import importlib
2 | import pkgutil
3 | from typing import ClassVar
4 |
5 | from stelvio.aws.cloudfront.origins.base import ComponentCloudfrontAdapter
6 | from stelvio.component import Component
7 |
8 |
9 | class CloudfrontAdapterRegistry:
10 | _adapters: ClassVar[list[type[ComponentCloudfrontAdapter]]] = []
11 | _initialized = False
12 |
13 | @classmethod
14 | def add_adapter(cls, adapter_cls: type[ComponentCloudfrontAdapter]) -> None:
15 | cls._adapters.append(adapter_cls)
16 |
17 | @classmethod
18 | def all_adapters(cls) -> list[type[ComponentCloudfrontAdapter]]:
19 | return cls._adapters
20 |
21 | @classmethod
22 | def _ensure_adapters_loaded(cls) -> None:
23 | """Lazy load all adapter modules to avoid circular imports."""
24 | if cls._initialized:
25 | return
26 |
27 | # Import here to avoid circular import during module loading
28 | import stelvio.aws.cloudfront.origins.components
29 |
30 | # Find all modules in stelvio.aws.cloudfront.origins.components, register their adapters
31 | for _, module_name, _ in pkgutil.iter_modules(
32 | stelvio.aws.cloudfront.origins.components.__path__
33 | ):
34 | importlib.import_module(f"stelvio.aws.cloudfront.origins.components.{module_name}")
35 |
36 | cls._initialized = True
37 |
38 | @classmethod
39 | def get_adapter_for_component(cls, component: Component) -> type[ComponentCloudfrontAdapter]:
40 | cls._ensure_adapters_loaded()
41 | for adapter_cls in cls.all_adapters():
42 | if adapter_cls.match(component):
43 | return adapter_cls
44 | raise ValueError(f"No adapter found for component: {component}")
45 |
--------------------------------------------------------------------------------
/pkg/npm/index.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import { execSync } from "child_process";
4 |
5 | const ensureUv = () => {
6 | // check if 'uv' is a valid executable in the PATH
7 | // then run 'uv --version' to verify it's working
8 | // if not found, throw an error
9 | try {
10 | const version = execSync("uv --version", { encoding: "utf-8" });
11 | console.log(`uv is installed. Version: ${version}`);
12 | } catch (error) {
13 | throw new Error("uv executable not found in PATH. Please install uv.");
14 | }
15 | };
16 |
17 | const ensureUvx = () => {
18 | // check if 'uvx' is a valid executable in the PATH
19 | // then run 'uvx --version' to verify it's working
20 | // if not found, throw an error
21 | try {
22 | const version = execSync("uvx --version", { encoding: "utf-8" });
23 | console.log(`uvx is installed. Version: ${version}`);
24 | } catch (error) {
25 | throw new Error("uvx executable not found in PATH. Please install uvx.");
26 | }
27 | };
28 |
29 | const ensureStelvio = () => {
30 | // check if 'stelvio' is a valid executable in the PATH
31 | // then run 'stelvio --version' to verify it's working
32 | // if not found, throw an error
33 | try {
34 | execSync("uvx --from stelvio stlv --help", { encoding: "utf-8" });
35 | } catch (error) {
36 | throw new Error(
37 | "stelvio executable not found in PATH. Please install stelvio.",
38 | );
39 | }
40 | };
41 |
42 | const runStelvioCommand = (args: string[]) => {
43 | try {
44 | const command = `uvx --from stelvio stlv ${args.join(" ")}`;
45 | const output = execSync(command, { encoding: "utf-8" });
46 | console.log(output);
47 | } catch (error) {
48 | console.error("Error running stelvio command:", error);
49 | }
50 | };
51 |
52 | const main = () => {
53 | ensureUv();
54 | ensureUvx();
55 | ensureStelvio();
56 | const args = process.argv.slice(2);
57 | runStelvioCommand(args);
58 | };
59 | main();
60 |
--------------------------------------------------------------------------------
/stelvio/aws/cloudfront/js.py:
--------------------------------------------------------------------------------
1 | def default_404_function_js() -> str:
2 | return """
3 | function handler(event) {
4 | return {
5 | statusCode: 404,
6 | statusDescription: 'Not Found',
7 | headers: {
8 | 'content-type': { value: 'text/html' }
9 | },
10 | body: '404 Not Found'
11 | '404 Not Found
The requested resource was not found.
'
12 | ''
13 | };
14 | }
15 | """.strip()
16 |
17 |
18 | def strip_path_pattern_function_js(path_pattern: str) -> str:
19 | path_len = len(path_pattern)
20 | return f"""
21 | function handler(event) {{
22 | var request = event.request;
23 | var uri = request.uri;
24 | if (uri === '{path_pattern}') {{
25 | request.uri = '/';
26 | }} else if (uri.substr(0, {path_len + 1}) === '{path_pattern}/') {{
27 | request.uri = uri.substr({path_len});
28 | }}
29 | return request;
30 | }}
31 | """.strip()
32 |
33 |
34 | def set_custom_host_header(host: str) -> str:
35 | """Generate Lambda@Edge code to set a custom Host header for URL origins.
36 |
37 | This is necessary when proxying to external URLs that use virtual host routing.
38 | """
39 | # https://serverfault.com/a/888776
40 | return """
41 | 'use strict';
42 |
43 | // force a specific Host header to be sent to the origin
44 |
45 | exports.handler = (event, context, callback) => {
46 | const request = event.Records[0].cf.request;
47 | request.headers.host[0].value = '{host}';
48 | return callback(null, request);
49 | };
50 | """.replace("{host}", host).strip()
51 |
--------------------------------------------------------------------------------
/docs/doc-logo.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/stelvio/aws/api_gateway/deployment.py:
--------------------------------------------------------------------------------
1 | import json
2 | from collections.abc import Sequence
3 | from hashlib import sha256
4 |
5 | import pulumi
6 | from pulumi import Input, ResourceOptions
7 | from pulumi_aws.apigateway import Deployment, Resource, RestApi
8 |
9 | from stelvio import context
10 | from stelvio.aws.api_gateway.config import _ApiRoute
11 | from stelvio.aws.api_gateway.routing import _get_handler_key_for_trigger
12 |
13 |
14 | def _calculate_route_config_hash(routes: list[_ApiRoute]) -> str:
15 | """Calculates a stable hash based on the API route configuration."""
16 | # Create a stable representation of the routes for hashing
17 | # Sort routes by path, then by sorted methods string to ensure consistency
18 | sorted_routes_config = sorted(
19 | [
20 | {
21 | "path": route.path,
22 | "methods": sorted(route.methods), # Sort methods for consistency
23 | "handler_key": _get_handler_key_for_trigger(route.handler),
24 | }
25 | for route in routes
26 | ],
27 | key=lambda r: (r["path"], ",".join(r["methods"])),
28 | )
29 |
30 | api_config_str = json.dumps(sorted_routes_config, sort_keys=True)
31 | return sha256(api_config_str.encode()).hexdigest()
32 |
33 |
34 | def _create_deployment(
35 | api: RestApi,
36 | api_name: str,
37 | routes: list[_ApiRoute], # Add routes parameter
38 | depends_on: Input[Sequence[Input[Resource]] | Resource] | None = None,
39 | ) -> Deployment:
40 | """Creates the API deployment, triggering redeployment based on route changes."""
41 |
42 | trigger_hash = _calculate_route_config_hash(routes)
43 | pulumi.log.debug(f"API '{api_name}' deployment trigger hash based on routes: {trigger_hash}")
44 |
45 | return Deployment(
46 | context().prefix(f"{api_name}-deployment"),
47 | rest_api=api.id,
48 | # Trigger new deployment only when API route config changes
49 | triggers={"configuration_hash": trigger_hash},
50 | # Ensure deployment happens after all resources/methods/integrations are created
51 | opts=ResourceOptions(depends_on=depends_on),
52 | )
53 |
--------------------------------------------------------------------------------
/stelvio/context.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 | from typing import ClassVar, Literal
3 |
4 | from stelvio.config import AwsConfig
5 | from stelvio.dns import Dns
6 |
7 |
8 | @dataclass(frozen=True)
9 | class AppContext:
10 | """Context information available during Stelvio app execution."""
11 |
12 | name: str
13 | env: str
14 | aws: AwsConfig
15 | home: Literal["aws"]
16 | dns: Dns | None = None
17 |
18 | def prefix(self, name: str | None = None) -> str:
19 | """Get resource name prefix or prefixed name.
20 |
21 | Args:
22 | name: Optional name to prefix. If None, returns just the prefix with trailing dash.
23 |
24 | Returns:
25 | If name is None: "{app}-{env}-"
26 | If name provided: "{app}-{env}-{name}"
27 | """
28 | base = f"{self.name.lower()}-{self.env.lower()}-"
29 | return base if name is None else f"{base}{name}"
30 |
31 |
32 | class _ContextStore:
33 | """Internal storage for the global app context."""
34 |
35 | _instance: ClassVar[AppContext | None] = None
36 |
37 | @classmethod
38 | def set(cls, context: AppContext) -> None:
39 | """Set the global context. Can only be called once."""
40 | if cls._instance is not None:
41 | raise RuntimeError("Context has already been initialized")
42 | cls._instance = context
43 |
44 | @classmethod
45 | def get(cls) -> AppContext:
46 | """Get the global context."""
47 | if cls._instance is None:
48 | raise RuntimeError(
49 | "Stelvio context not initialized. This usually means you're trying to access "
50 | "context() outside of a Stelvio deployment operation."
51 | )
52 | return cls._instance
53 |
54 | @classmethod
55 | def clear(cls) -> None:
56 | """Clear the context. Only used for testing."""
57 | cls._instance = None
58 |
59 |
60 | def context() -> AppContext:
61 | """Get the current Stelvio app context.
62 |
63 | Returns:
64 | AppContext with app name, environment, and AWS configuration.
65 |
66 | Raises:
67 | RuntimeError: If called before context is initialized.
68 | """
69 | return _ContextStore.get()
70 |
--------------------------------------------------------------------------------
/stelvio/aws/function/iam.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Sequence
2 |
3 | from pulumi_aws.iam import (
4 | GetPolicyDocumentStatementArgs,
5 | GetPolicyDocumentStatementPrincipalArgs,
6 | Policy,
7 | Role,
8 | RolePolicyAttachment,
9 | get_policy_document,
10 | )
11 |
12 | from stelvio import context
13 | from stelvio.component import safe_name
14 |
15 | from .constants import LAMBDA_BASIC_EXECUTION_ROLE
16 |
17 |
18 | def _create_function_policy(
19 | name: str, statements: Sequence[GetPolicyDocumentStatementArgs]
20 | ) -> Policy | None:
21 | """Create IAM policy for Lambda if there are any statements."""
22 | if not statements:
23 | return None
24 |
25 | policy_document = get_policy_document(statements=statements)
26 |
27 | return Policy(
28 | safe_name(context().prefix(), name, 128, "-p"), path="/", policy=policy_document.json
29 | )
30 |
31 |
32 | def _create_lambda_role(name: str) -> Role:
33 | """Create basic execution role for Lambda."""
34 | assume_role_policy = get_policy_document(
35 | statements=[
36 | GetPolicyDocumentStatementArgs(
37 | actions=["sts:AssumeRole"],
38 | principals=[
39 | GetPolicyDocumentStatementPrincipalArgs(
40 | identifiers=["lambda.amazonaws.com"], type="Service"
41 | )
42 | ],
43 | )
44 | ]
45 | )
46 |
47 | return Role(
48 | safe_name(context().prefix(), name, 64, "-r"), assume_role_policy=assume_role_policy.json
49 | )
50 |
51 |
52 | def _attach_role_policies(
53 | name: str, role: Role, function_policy: Policy | None
54 | ) -> list[RolePolicyAttachment]:
55 | """Attach required policies to Lambda role."""
56 | basic_role_attachment = RolePolicyAttachment(
57 | context().prefix(f"{name}-basic-execution-r-p-attachment"),
58 | role=role.name,
59 | policy_arn=LAMBDA_BASIC_EXECUTION_ROLE,
60 | )
61 | if function_policy:
62 | default_role_attachment = RolePolicyAttachment(
63 | context().prefix(f"{name}-default-r-p-attachment"),
64 | role=role.name,
65 | policy_arn=function_policy.arn,
66 | )
67 | return [basic_role_attachment, default_role_attachment]
68 |
69 | return [basic_role_attachment]
70 |
--------------------------------------------------------------------------------
/stelvio/aws/api_gateway/iam.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from functools import cache
3 |
4 | from pulumi import Output, ResourceOptions
5 | from pulumi_aws.apigateway import Account
6 | from pulumi_aws.iam import (
7 | GetPolicyDocumentStatementArgs,
8 | GetPolicyDocumentStatementPrincipalArgs,
9 | Role,
10 | get_policy_document,
11 | )
12 |
13 | from stelvio.aws.api_gateway.constants import API_GATEWAY_LOGS_POLICY, API_GATEWAY_ROLE_NAME
14 |
15 | logger = logging.getLogger("stelvio.aws.api_gateway")
16 |
17 |
18 | @cache
19 | def _create_api_gateway_account_and_role() -> Output[Account]:
20 | # Get existing account configuration (read-only reference)
21 | existing_account = Account.get("api-gateway-account-ref", "APIGatewayAccount")
22 |
23 | def create_managed_account() -> Account:
24 | role = _create_api_gateway_role()
25 | return Account(
26 | "api-gateway-account",
27 | cloudwatch_role_arn=role.arn,
28 | opts=ResourceOptions(retain_on_delete=True),
29 | )
30 |
31 | def handle_existing_role(existing_arn: str) -> Account:
32 | if existing_arn:
33 | if API_GATEWAY_ROLE_NAME in existing_arn: # Check if this is our Stelvio-managed role
34 | logger.info("Found Stelvio-managed role, returning managed Account")
35 | return create_managed_account()
36 | logger.info("Found user-managed role, using read-only reference: %s", existing_arn)
37 | return existing_account
38 |
39 | logger.info("No CloudWatch role found, creating Stelvio configuration")
40 | return create_managed_account()
41 |
42 | return existing_account.cloudwatch_role_arn.apply(handle_existing_role)
43 |
44 |
45 | def _create_api_gateway_role() -> Role:
46 | assume_role_policy = get_policy_document(
47 | statements=[
48 | GetPolicyDocumentStatementArgs(
49 | actions=["sts:AssumeRole"],
50 | principals=[
51 | GetPolicyDocumentStatementPrincipalArgs(
52 | identifiers=["apigateway.amazonaws.com"], type="Service"
53 | )
54 | ],
55 | )
56 | ]
57 | )
58 | return Role(
59 | API_GATEWAY_ROLE_NAME,
60 | assume_role_policy=assume_role_policy.json,
61 | managed_policy_arns=[API_GATEWAY_LOGS_POLICY],
62 | opts=ResourceOptions(retain_on_delete=True),
63 | )
64 |
--------------------------------------------------------------------------------
/docs/guides/state.md:
--------------------------------------------------------------------------------
1 | # State Management
2 |
3 | Stelvio stores your infrastructure state in S3, so multiple team members can work on the same application without sharing files or syncing manually.
4 |
5 | ## Where State Lives
6 |
7 | When you first deploy, Stelvio creates an S3 bucket named `stlv-state-{id}` in your AWS account. Each app and environment has separate files:
8 |
9 | ```
10 | stlv-state-{id}/
11 | ├── state/{app}/{env}.json # Current resource state
12 | ├── lock/{app}/{env}.json # Active lock (who's deploying)
13 | ├── update/{app}/{env}/{id}.json # Operation history & errors
14 | └── snapshot/{app}/{env}/{id}.json # Saved after successful deploys
15 | ```
16 |
17 | ## Locking
18 |
19 | Stelvio locks state during operations that modify it: `deploy`, `refresh`, `destroy`, `state rm`, `state repair`.
20 |
21 | If someone else is running one of these, you'll see:
22 |
23 | ```
24 | ✗ State is locked
25 | Environment 'staging' is locked by 'deploy' since 2025-12-15 10:30:00
26 | ```
27 |
28 | If a command was interrupted (Ctrl+C, crash, network issue), the lock may remain:
29 |
30 | ```bash
31 | stlv unlock
32 | stlv unlock staging
33 | ```
34 |
35 | !!! warning
36 | Only unlock if you're certain no deployment is actually running.
37 |
38 | ## Crash Recovery
39 |
40 | Stelvio saves state to S3 continuously during operations - not just at the end. If a deployment crashes:
41 |
42 | 1. Resources that completed are already saved
43 | 2. Run `stlv unlock` to release the lock
44 | 3. Run `stlv deploy` to continue where you left off
45 |
46 | ## Renaming
47 |
48 | Changing the app name or environment name creates new infrastructure - it doesn't rename existing resources.
49 |
50 | **To rename your app:**
51 |
52 | 1. `stlv destroy` - destroy the old app
53 | 2. Change the name in `stlv_app.py`
54 | 3. `stlv deploy` - deploy with new name
55 |
56 | **To rename an environment:**
57 |
58 | 1. `stlv destroy staging` - destroy old environment
59 | 2. `stlv deploy stage` - deploy with new name
60 |
61 | !!! warning
62 | If you rename without destroying first, you'll have two sets of resources both running in AWS.
63 |
64 | ## State Commands
65 |
66 | See [Using CLI - state](using-cli.md#state) for `stlv state list`, `stlv state rm`, and `stlv state repair`.
67 |
68 | ## What Else Gets Stored
69 |
70 | Stelvio stores encryption passphrases for state secrets in AWS Parameter Store at `/stlv/passphrase/{app}/{env}`, and bootstrap info (bucket name, version) at `/stlv/bootstrap`.
71 |
72 | During operations, Stelvio downloads state from S3 to a temporary folder `.stelvio/{id}/` in your project. This is cleaned up automatically when the command completes.
73 |
--------------------------------------------------------------------------------
/stelvio/aws/acm.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 | from typing import final
3 |
4 | import pulumi
5 | import pulumi_aws
6 |
7 | from stelvio import context
8 | from stelvio.component import Component
9 | from stelvio.dns import DnsProviderNotConfiguredError, Record
10 |
11 |
12 | @final
13 | @dataclass(frozen=True)
14 | class AcmValidatedDomainResources:
15 | certificate: pulumi_aws.acm.Certificate
16 | validation_record: Record
17 | cert_validation: pulumi_aws.acm.CertificateValidation
18 |
19 |
20 | @final
21 | class AcmValidatedDomain(Component[AcmValidatedDomainResources]):
22 | def __init__(self, name: str, domain_name: str):
23 | self.domain_name = domain_name
24 | super().__init__(name)
25 |
26 | def _create_resources(self) -> AcmValidatedDomainResources:
27 | dns = context().dns
28 | if dns is None:
29 | raise DnsProviderNotConfiguredError(
30 | "DNS provider is not configured in the context. "
31 | "Please set up a DNS provider to use custom domains."
32 | )
33 |
34 | # 1 - Issue Certificate
35 | certificate = pulumi_aws.acm.Certificate(
36 | context().prefix(f"{self.name}-certificate"),
37 | domain_name=self.domain_name,
38 | validation_method="DNS",
39 | )
40 |
41 | # 2 - Validate Certificate with DNS PROVIDER
42 | first_option = certificate.domain_validation_options.apply(lambda options: options[0])
43 | validation_record = dns.create_caa_record(
44 | resource_name=context().prefix(f"{self.name}-certificate-validation-record"),
45 | name=first_option.apply(lambda opt: opt["resource_record_name"]),
46 | record_type=first_option.apply(lambda opt: opt["resource_record_type"]),
47 | content=first_option.apply(lambda opt: opt["resource_record_value"]),
48 | ttl=1,
49 | )
50 |
51 | # 3 - Wait for validation - use the validation record's FQDN to ensure it exists
52 | cert_validation = pulumi_aws.acm.CertificateValidation(
53 | context().prefix(f"{self.name}-certificate-validation"),
54 | certificate_arn=certificate.arn,
55 | # This ensures validation_record exists
56 | validation_record_fqdns=[validation_record.name],
57 | opts=pulumi.ResourceOptions(
58 | depends_on=[certificate, validation_record.pulumi_resource]
59 | ),
60 | )
61 |
62 | return AcmValidatedDomainResources(
63 | certificate=certificate,
64 | validation_record=validation_record,
65 | cert_validation=cert_validation,
66 | )
67 |
--------------------------------------------------------------------------------
/stelvio/aws/function/packaging.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | from pulumi import Archive, Asset, AssetArchive, FileAsset, StringAsset
4 |
5 | from stelvio.project import get_project_root
6 |
7 | from .config import FunctionConfig
8 | from .constants import LAMBDA_EXCLUDED_DIRS, LAMBDA_EXCLUDED_EXTENSIONS, LAMBDA_EXCLUDED_FILES
9 | from .dependencies import _get_function_packages
10 |
11 |
12 | def _create_lambda_archive(
13 | function_config: FunctionConfig,
14 | resource_file_content: str | None,
15 | extra_assets_map: dict[str, Asset],
16 | ) -> AssetArchive:
17 | """Create an AssetArchive for Lambda function based on configuration.
18 | Handles both single file and folder-based Lambdas.
19 | """
20 |
21 | project_root = get_project_root()
22 |
23 | assets: dict[str, Asset | Archive] = extra_assets_map
24 | handler_file = str(Path(function_config.handler_file_path).with_suffix(".py"))
25 | if function_config.folder_path:
26 | # Handle folder-based Lambda
27 | full_folder_path = project_root / function_config.folder_path
28 | if not full_folder_path.exists():
29 | raise ValueError(f"Folder not found: {full_folder_path}")
30 |
31 | # Check if handler file exists in the folder
32 | if handler_file not in extra_assets_map:
33 | absolute_handler_file = full_folder_path / handler_file
34 | if not absolute_handler_file.exists():
35 | raise ValueError(f"Handler file not found in folder: {absolute_handler_file}.py")
36 |
37 | # Recursively collect all files from the folder
38 | assets |= {
39 | str(file_path.relative_to(full_folder_path)): FileAsset(file_path)
40 | for file_path in full_folder_path.rglob("*")
41 | if not (
42 | file_path.is_dir()
43 | or file_path.name in LAMBDA_EXCLUDED_FILES
44 | or file_path.parent.name in LAMBDA_EXCLUDED_DIRS
45 | or file_path.suffix in LAMBDA_EXCLUDED_EXTENSIONS
46 | )
47 | }
48 | # Handle single file Lambda
49 | elif handler_file not in extra_assets_map:
50 | absolute_handler_file = project_root / handler_file
51 | if not absolute_handler_file.exists():
52 | raise ValueError(f"Handler file not found: {absolute_handler_file}")
53 | assets[absolute_handler_file.name] = FileAsset(absolute_handler_file)
54 |
55 | if resource_file_content:
56 | assets["stlv_resources.py"] = StringAsset(resource_file_content)
57 |
58 | function_packages_archives = _get_function_packages(function_config)
59 | if function_packages_archives:
60 | assets |= function_packages_archives
61 | return AssetArchive(assets)
62 |
--------------------------------------------------------------------------------
/tests/test_utils.py:
--------------------------------------------------------------------------------
1 | import typing
2 | from dataclasses import fields
3 | from types import UnionType
4 | from typing import Union, get_args, get_origin, get_type_hints
5 |
6 | NoneType = type(None)
7 |
8 |
9 | def assert_config_dict_matches_dataclass(dataclass_type: type, typeddict_type: type) -> None:
10 | """Tests that a TypedDict matches its corresponding dataclass."""
11 | # noinspection PyTypeChecker
12 | dataclass_fields = {f.name: f.type for f in fields(dataclass_type)}
13 | typeddict_fields = get_type_hints(typeddict_type)
14 |
15 | assert set(dataclass_fields.keys()) == set(typeddict_fields.keys()), (
16 | f"{typeddict_type.__name__} and {dataclass_type.__name__} have different fields."
17 | )
18 |
19 | for field_name, dataclass_field_type in dataclass_fields.items():
20 | if field_name not in typeddict_fields:
21 | continue
22 |
23 | typeddict_field_type = typeddict_fields[field_name]
24 |
25 | normalized_dataclass_type = normalize_type(dataclass_field_type)
26 | normalized_typeddict_type = normalize_type(typeddict_field_type)
27 |
28 | assert normalized_dataclass_type == normalized_typeddict_type, (
29 | f"Type mismatch for field '{field_name}' in {dataclass_type.__name__}:\n"
30 | f" Dataclass (original): {dataclass_field_type}\n"
31 | f" TypedDict (original): {typeddict_field_type}\n"
32 | f" Dataclass (normalized): {normalized_dataclass_type}\n"
33 | f" TypedDict (normalized): {normalized_typeddict_type}\n"
34 | f" Comparison Failed: {normalized_dataclass_type} != {normalized_typeddict_type}"
35 | )
36 |
37 |
38 | def normalize_type(type_hint: type) -> type:
39 | """
40 | Normalizes a type hint by removing 'NoneType' from its Union representation,
41 | if applicable. Keeps other Union members intact.
42 |
43 | Examples:
44 | Union[str, None] -> str
45 | Union[str, list[str], None] -> Union[str, list[str]]
46 | Union[Literal["a", "b"], None] -> Literal["a", "b"]
47 | str -> str
48 | Union[str, int] -> Union[str, int]
49 | NoneType -> NoneType
50 | Union[NoneType] -> NoneType
51 | """
52 | origin = get_origin(type_hint)
53 |
54 | if origin is Union or origin is UnionType:
55 | args = get_args(type_hint)
56 |
57 | non_none_args = tuple(arg for arg in args if arg is not NoneType)
58 |
59 | if not non_none_args:
60 | return NoneType
61 | if len(non_none_args) == 1:
62 | return non_none_args[0]
63 | return typing.Union[non_none_args] # noqa: UP007
64 |
65 | return type_hint
66 |
--------------------------------------------------------------------------------
/tests/aws/dynamo_db/test_dynamo_table_config.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from stelvio.aws.dynamo_db import DynamoTableConfig, FieldType, StreamView
4 |
5 |
6 | def test_stream_config_properties():
7 | """Test stream configuration properties without Pulumi."""
8 | # Test enum value
9 | config_enum = DynamoTableConfig(
10 | fields={"id": FieldType.STRING}, partition_key="id", stream=StreamView.KEYS_ONLY
11 | )
12 | assert config_enum.stream_enabled is True
13 | assert config_enum.normalized_stream_view_type == "KEYS_ONLY"
14 |
15 | # Test string literal
16 | config_literal = DynamoTableConfig(
17 | fields={"id": FieldType.STRING}, partition_key="id", stream="new-image"
18 | )
19 | assert config_literal.stream_enabled is True
20 | assert config_literal.normalized_stream_view_type == "NEW_IMAGE"
21 |
22 | # Test no stream
23 | config_no_stream = DynamoTableConfig(fields={"id": FieldType.STRING}, partition_key="id")
24 | assert config_no_stream.stream_enabled is False
25 | assert config_no_stream.normalized_stream_view_type is None
26 |
27 | # Test all stream types
28 | stream_mappings = [
29 | ("keys-only", "KEYS_ONLY"),
30 | ("new-image", "NEW_IMAGE"),
31 | ("old-image", "OLD_IMAGE"),
32 | ("new-and-old-images", "NEW_AND_OLD_IMAGES"),
33 | ]
34 |
35 | for literal, expected_aws_value in stream_mappings:
36 | config = DynamoTableConfig(
37 | fields={"id": FieldType.STRING}, partition_key="id", stream=literal
38 | )
39 | assert config.normalized_stream_view_type == expected_aws_value
40 |
41 |
42 | def test_field_type_literals_normalized():
43 | """Test that field type literals are normalized correctly."""
44 | config = DynamoTableConfig(
45 | fields={"id": "string", "score": "number", "data": "binary"}, partition_key="id"
46 | )
47 | assert config.normalized_fields == {"id": "S", "score": "N", "data": "B"}
48 |
49 | # Test with mixed types
50 | config2 = DynamoTableConfig(
51 | fields={"id": FieldType.STRING, "score": "number", "data": "B"}, partition_key="id"
52 | )
53 | assert config2.normalized_fields == {"id": "S", "score": "N", "data": "B"}
54 |
55 |
56 | @pytest.mark.parametrize(
57 | ("config_args", "expected_error"),
58 | [
59 | (
60 | {"fields": {"id": FieldType.STRING}, "partition_key": "invalid_key"},
61 | "partition_key 'invalid_key' not in fields list",
62 | ),
63 | (
64 | {
65 | "fields": {"id": FieldType.STRING},
66 | "partition_key": "id",
67 | "sort_key": "invalid_sort",
68 | },
69 | "sort_key 'invalid_sort' not in fields list",
70 | ),
71 | ],
72 | )
73 | def test_dynamo_table_config_validation_basic(config_args, expected_error):
74 | """Test basic validation of DynamoTableConfig."""
75 | with pytest.raises(ValueError, match=expected_error):
76 | DynamoTableConfig(**config_args)
77 |
--------------------------------------------------------------------------------
/mkdocs.yml:
--------------------------------------------------------------------------------
1 | site_name: stelvio.dev
2 | theme:
3 | name: material
4 | custom_dir: docs/overrides
5 | palette:
6 | # Palette toggle for automatic mode
7 | - media: "(prefers-color-scheme)"
8 | primary: cyan
9 | accent: light blue
10 | toggle:
11 | icon: material/brightness-auto
12 | name: Switch to light mode
13 |
14 | # Palette toggle for light mode
15 | - media: "(prefers-color-scheme: light)"
16 | scheme: default
17 | primary: cyan
18 | accent: light blue
19 | toggle:
20 | icon: material/brightness-7
21 | name: Switch to dark mode
22 |
23 | # Palette toggle for dark mode
24 | - media: "(prefers-color-scheme: dark)"
25 | scheme: slate
26 | primary: teal
27 | accent: light green
28 | toggle:
29 | icon: material/brightness-4
30 | name: Switch to system preference
31 |
32 | features:
33 | - content.tabs.link
34 | - content.code.annotate
35 | - content.code.copy
36 | - content.tooltips
37 | - navigation.tabs
38 | - navigation.sections
39 | - navigation.expand
40 | - announce.dismiss
41 | - navigation.tabs
42 | - navigation.instant
43 | - navigation.instant.prefetch
44 | - navigation.instant.preview
45 | - navigation.instant.progress
46 | - navigation.path
47 | - navigation.sections
48 | - navigation.top
49 | - navigation.tracking
50 | - search.suggest
51 | - toc.follow
52 | logo: 'doc-logo.svg'
53 | favicon: 'favicon.png'
54 |
55 | repo_name: stelviodev/stelvio
56 | repo_url: https://github.com/stelviodev/stelvio
57 |
58 | nav:
59 | - Home:
60 | - Welcome to Stelvio: index.md
61 | - Changelog: changelog.md
62 | - Getting Started:
63 | - Quick Start: getting-started/quickstart.md
64 | - Guides:
65 | - Project Configuration: guides/stelvio-app.md
66 | - Using CLI: guides/using-cli.md
67 | - Environments: guides/environments.md
68 | - API Gateway: guides/api-gateway.md
69 | - Lambda Functions: guides/lambda.md
70 | - Dynamo DB: guides/dynamo-db.md
71 | - S3 Buckets: guides/s3.md
72 | - Linking: guides/linking.md
73 | - DNS and Custom Domains: guides/dns.md
74 | - Routing components on one domain: guides/cloudfront-router.md
75 | - Project Structure: guides/project-structure.md
76 | - State Management: guides/state.md
77 | - Troubleshooting: guides/troubleshooting.md
78 |
79 | markdown_extensions:
80 | - pymdownx.highlight:
81 | anchor_linenums: true
82 | line_spans: __span
83 | pygments_lang_class: true
84 | - pymdownx.inlinehilite
85 | - pymdownx.snippets
86 | - pymdownx.superfences
87 | - pymdownx.tabbed:
88 | alternate_style: true
89 | - admonition
90 | - pymdownx.details
91 | - pymdownx.critic
92 | - pymdownx.caret
93 | - pymdownx.keys
94 | - pymdownx.mark
95 | - pymdownx.tilde
96 | - abbr
97 |
98 | extra:
99 | analytics:
100 | provider: custom
--------------------------------------------------------------------------------
/tests/aws/api_gateway/test_api_config.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from stelvio.aws.api_gateway.config import ApiConfig, ApiConfigDict
4 |
5 | from ...test_utils import assert_config_dict_matches_dataclass
6 |
7 |
8 | def test_api_config_dict_has_same_fields_as_api_config():
9 | """Tests that the ApiConfigDict matches the ApiConfig dataclass."""
10 | assert_config_dict_matches_dataclass(ApiConfig, ApiConfigDict)
11 |
12 |
13 | @pytest.mark.parametrize(
14 | ("config_kwargs", "expected_error"),
15 | [
16 | ({"domain_name": ""}, "Domain name cannot be empty"),
17 | ({"domain_name": " "}, "Domain name cannot be empty"),
18 | ({"stage_name": ""}, "Stage name cannot be empty"),
19 | (
20 | {"stage_name": "invalid_chars!"},
21 | "Stage name can only contain alphanumeric characters, hyphens, and underscores",
22 | ),
23 | (
24 | {"stage_name": "with spaces"},
25 | "Stage name can only contain alphanumeric characters, hyphens, and underscores",
26 | ),
27 | (
28 | {"endpoint_type": "invalid"},
29 | "Invalid endpoint type: invalid. Only 'regional' and 'edge' are supported.",
30 | ),
31 | (
32 | {"endpoint_type": "private"},
33 | "Invalid endpoint type: private. Only 'regional' and 'edge' are supported.",
34 | ),
35 | ],
36 | )
37 | def test_api_config_validation_errors(config_kwargs, expected_error):
38 | with pytest.raises(ValueError, match=expected_error):
39 | ApiConfig(**config_kwargs)
40 |
41 |
42 | @pytest.mark.parametrize(
43 | "domain_name",
44 | [123, [], {}, True],
45 | )
46 | def test_api_config_domain_name_type_error(domain_name):
47 | with pytest.raises(TypeError, match="Domain name must be a string"):
48 | ApiConfig(domain_name=domain_name)
49 |
50 |
51 | @pytest.mark.parametrize(
52 | "stage_name",
53 | [
54 | "v1",
55 | "prod",
56 | "staging",
57 | "test-env",
58 | "api_v2",
59 | "stage-123",
60 | "a", # single character
61 | "very-long-stage-name-with-many-chars",
62 | ],
63 | )
64 | def test_api_config_valid_stage_names(stage_name):
65 | config = ApiConfig(stage_name=stage_name)
66 | assert config.stage_name == stage_name
67 |
68 |
69 | @pytest.mark.parametrize(
70 | "endpoint_type",
71 | ["regional", "edge"],
72 | )
73 | def test_api_config_valid_endpoint_types(endpoint_type):
74 | config = ApiConfig(endpoint_type=endpoint_type)
75 | assert config.endpoint_type == endpoint_type
76 |
77 |
78 | def test_api_config_all_none():
79 | config = ApiConfig()
80 | assert config.domain_name is None
81 | assert config.stage_name is None
82 | assert config.endpoint_type is None
83 |
84 |
85 | def test_api_config_valid_full_config():
86 | config = ApiConfig(domain_name="api.example.com", stage_name="prod", endpoint_type="edge")
87 | assert config.domain_name == "api.example.com"
88 | assert config.stage_name == "prod"
89 | assert config.endpoint_type == "edge"
90 |
--------------------------------------------------------------------------------
/stelvio/cli/init_command.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import textwrap
3 | import time
4 | from pathlib import Path
5 |
6 | from rich.console import Console
7 | from rich.live import Live
8 | from rich.text import Text
9 |
10 | logger = logging.getLogger(__name__)
11 |
12 | TEMPLATE_CONTENT = """\
13 | from stelvio.app import StelvioApp
14 | from stelvio.config import StelvioAppConfig, AwsConfig
15 |
16 | app = StelvioApp("{project_name}")
17 |
18 | @app.config
19 | def configuration(env: str) -> StelvioAppConfig:
20 | return StelvioAppConfig(
21 | aws=AwsConfig(
22 | # region="us-east-1", # Uncomment to override AWS CLI/env var region
23 | # profile="your-profile", # Uncomment to use specific AWS profile
24 | ),
25 | )
26 |
27 | @app.run
28 | def run() -> None:
29 | # Create your infra here
30 | pass
31 | """
32 |
33 |
34 | def stelvio_art(console: Console) -> None:
35 | art_lines_raw = [
36 | " ____ _ _ _ ",
37 | "/ ___|| |_ ___| |_ _(_) ___ ",
38 | "\\___ \\| __/ _ \\ \\ \\ / / |/ _ \\",
39 | " ___) | || __/ |\\ V /| | (_) |",
40 | "|____/ \\__\\___|_| \\_/ |_|\\___/ ",
41 | ]
42 |
43 | max_len = max([len(line) for line in art_lines_raw])
44 | normalized_art_lines = [line.ljust(max_len) for line in art_lines_raw]
45 |
46 | reveal_color = "bold turquoise2"
47 | cursor_char = "_"
48 | reveal_delay = 0.03
49 |
50 | with Live(console=console, refresh_per_second=30, transient=False) as live:
51 | for column in range(max_len + 1):
52 | frame_text = Text()
53 | for full_line in normalized_art_lines:
54 | revealed_part = full_line[:column]
55 | remaining_part_len = max_len - column
56 |
57 | frame_text.append(revealed_part, style=reveal_color)
58 |
59 | if column < max_len:
60 | frame_text.append("_", style="dim grey70")
61 | frame_text.append(" " * (remaining_part_len - (1 if cursor_char else 0)))
62 | else:
63 | frame_text.append(" " * remaining_part_len)
64 | frame_text.append("\n")
65 |
66 | live.update(frame_text)
67 | if column == max_len:
68 | break
69 | time.sleep(reveal_delay)
70 |
71 | final_art_text = Text()
72 | for full_line in normalized_art_lines:
73 | final_art_text.append(full_line + "\n", style=reveal_color)
74 | live.update(final_art_text)
75 |
76 |
77 | def get_stlv_app_path() -> tuple[Path, bool]:
78 | cwd = Path.cwd()
79 | logger.info("CWD %s", cwd)
80 | logger.info("Dir name: %s", cwd.name)
81 | stlv_app = "stlv_app.py"
82 | stlv_app_path = cwd / stlv_app
83 | return stlv_app_path, stlv_app_path.exists() and stlv_app_path.is_file()
84 |
85 |
86 | def create_stlv_app_file(stlv_app_path: Path) -> None:
87 | file_content = textwrap.dedent(TEMPLATE_CONTENT).format(project_name=Path.cwd().name)
88 | with stlv_app_path.open("w", encoding="utf-8") as f:
89 | f.write(file_content)
90 |
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | import shutil
2 | from pathlib import Path
3 | from unittest.mock import patch
4 |
5 | import pytest
6 |
7 | from stelvio.aws.function.function import FunctionAssetsRegistry, LinkPropertiesRegistry
8 | from stelvio.component import ComponentRegistry
9 | from stelvio.config import AwsConfig
10 | from stelvio.context import AppContext, _ContextStore
11 |
12 |
13 | @pytest.fixture(autouse=True)
14 | def clean_registries():
15 | LinkPropertiesRegistry._folder_links_properties_map.clear()
16 | ComponentRegistry._instances.clear()
17 | ComponentRegistry._registered_names.clear()
18 | ComponentRegistry._user_link_creators.clear()
19 | FunctionAssetsRegistry._functions_assets_map.clear()
20 |
21 |
22 | def mock_get_or_install_dependencies(path: str):
23 | with patch(path) as mock_ensure:
24 | # Simulate get_or_install_dependencies returning a valid cache path
25 | # Use a unique path per test potentially, or ensure cleanup
26 | mock_cache_path = Path("mock_cache_dir_for_fixture").resolve()
27 | mock_cache_path.mkdir(parents=True, exist_ok=True)
28 | # Add a dummy file to simulate non-empty cache after install
29 | (mock_cache_path / "dummy_installed_package").touch()
30 | mock_ensure.return_value = mock_cache_path
31 | yield mock_ensure
32 | # Clean up the dummy cache dir after test
33 | shutil.rmtree(mock_cache_path, ignore_errors=True)
34 |
35 |
36 | @pytest.fixture
37 | def mock_get_or_install_dependencies_layer():
38 | yield from mock_get_or_install_dependencies("stelvio.aws.layer.get_or_install_dependencies")
39 |
40 |
41 | @pytest.fixture
42 | def mock_get_or_install_dependencies_function():
43 | yield from mock_get_or_install_dependencies(
44 | "stelvio.aws.function.dependencies.get_or_install_dependencies"
45 | )
46 |
47 |
48 | @pytest.fixture(autouse=True)
49 | def app_context():
50 | _ContextStore.clear()
51 | _ContextStore.set(
52 | AppContext(
53 | name="test",
54 | env="test",
55 | aws=AwsConfig(profile="default", region="us-east-1"),
56 | home="aws",
57 | )
58 | )
59 |
60 |
61 | @pytest.fixture
62 | def project_cwd(monkeypatch, pytestconfig, tmp_path):
63 | """Provide a temporary Stelvio project root and chdir into it.
64 |
65 | This matches what Function-related tests need and can be reused
66 | by other components (e.g. CloudFront router) that instantiate
67 | real Functions with file-based handlers.
68 | """
69 | from stelvio.project import get_project_root
70 |
71 | # Ensure no stale cache from previous tests
72 | get_project_root.cache_clear()
73 |
74 | # Use the existing sample project as a realistic template
75 | rootpath = pytestconfig.rootpath
76 | source_project_dir = rootpath / "tests" / "aws" / "sample_test_project"
77 | temp_project_dir = tmp_path / "sample_project_copy"
78 |
79 | shutil.copytree(source_project_dir, temp_project_dir, dirs_exist_ok=True)
80 |
81 | # Remember original cwd and switch into the temp project
82 | original_cwd = Path.cwd()
83 | monkeypatch.chdir(temp_project_dir)
84 |
85 | yield temp_project_dir
86 |
87 | # Restore state after test
88 | monkeypatch.chdir(original_cwd)
89 | get_project_root.cache_clear()
90 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "stelvio"
3 | version = "0.5.0a7"
4 | description = "AWS for Python devs made simple."
5 | license = { text = "Apache-2.0" }
6 | readme = "README.md"
7 | requires-python = ">=3.12"
8 | authors = [{ name = "Michal Martinka", email = "michal@stelvio.dev" }, { name = "Bas Steins", email = "bas@stelvio.dev" }]
9 | maintainers = [{ name = "Michal", email = "michal@stelvio.dev" }, { name = "Bas Steins", email = "bas@stelvio.dev" }]
10 | keywords = ["aws", "cloudflare", "infrastructure"]
11 |
12 | classifiers = [
13 | "Development Status :: 3 - Alpha",
14 | "Intended Audience :: Developers",
15 | "License :: OSI Approved :: Apache Software License",
16 | "Programming Language :: Python :: 3",
17 | "Programming Language :: Python :: 3.12",
18 | "Programming Language :: Python :: 3.13",
19 | "Topic :: Software Development :: Libraries :: Python Modules",
20 | "Topic :: System :: Systems Administration",
21 | ]
22 |
23 | dependencies = [
24 | "pulumi (==3.187.0)",
25 | "pulumi-aws (==7.2.0)",
26 | "click",
27 | "appdirs",
28 | "requests",
29 | "rich>=14.0.0",
30 | "boto3",
31 | "pulumi-cloudflare==6.4.1",
32 | ]
33 |
34 |
35 | [dependency-groups]
36 | dev = [
37 | "pytest >=8.3.4",
38 | "pytest-cov >=6.0.0",
39 | "ruff >=0.11.0",
40 | "mkdocs-material >=9.5.49",
41 | ]
42 |
43 | [project.scripts]
44 | stlv = "stelvio.cli:cli"
45 |
46 | [project.urls]
47 | homepage = "https://stelvio.dev/"
48 | repository = "https://github.com/stelviodev/stelvio"
49 | documentation = "https://stelvio.dev/docs/"
50 | "Bug Tracker" = "https://github.com/stelviodev/stelvio/issues"
51 |
52 | [build-system]
53 | requires = ['hatchling']
54 | build-backend = 'hatchling.build'
55 |
56 | [tool.hatch.build.targets.sdist]
57 | include = [
58 | "/stelvio",
59 | "/pyproject.toml",
60 | "/README.md",
61 | "/LICENSE",
62 | ]
63 |
64 | [tool.coverage.report]
65 | omit = ["*/tests/*"]
66 |
67 | [tool.ruff]
68 | line-length = 99
69 | target-version = "py312"
70 | exclude = ['pulumi-tmpl', 'templates']
71 |
72 | # Configure linting
73 | [tool.ruff.lint]
74 | select = ["E", "F", "I", "B", "N", "UP", "C4", "A", "S", "ARG", "LOG", "G", "PIE", "T20", "PYI",
75 | "PT", "Q", "RSE", "RET", "SLF", "SIM", "SLOT", "TID", "TC", "PTH", "FLY", "C90", "PERF", "W",
76 | "PGH", "PL", "FURB", "RUF", "TRY", "INP", "ANN"
77 | # "D" - will need, defo need to work on more docs
78 | ]
79 | ignore = ['TRY003']
80 | # Allow autofix for all enabled rules (when `--fix` is passed)
81 | fixable = ["ALL"]
82 | unfixable = []
83 |
84 | [tool.ruff.lint.flake8-annotations]
85 | mypy-init-return = true
86 |
87 | # Sort imports
88 | [tool.ruff.lint.isort]
89 | known-first-party = ["stelvio"]
90 |
91 | [tool.ruff.lint.per-file-ignores]
92 | "tests/**/*.py" = [
93 | "S101", # asserts allowed in tests
94 | "T20", # asserts allowed in tests
95 | "SLF", # protected access allowed in tests
96 | "TID", # allow relative imports in tests
97 | "ARG", # Unused function args -> fixtures nevertheless are functionally relevant
98 | "D", # No need to check docstrings in tests
99 | "PLR2004",
100 | "ANN"
101 | ]
102 | "tests/aws/sample_test_project/**/*.py" = ["INP"]
103 |
104 |
--------------------------------------------------------------------------------
/docs/guides/dns.md:
--------------------------------------------------------------------------------
1 | # Working With DNS in Stelvio
2 |
3 | When you create resources with cloud providers (such as an Api Gateway), these resources needs to be accessible via HTTP.
4 | For example, by default, you get a URL like `https://.execute-api..amazonaws.com/` for your Api Gateway.
5 |
6 | In real world scenarios, you would want to use a custom domain name like `api.example.com` instead of the default one provided by the cloud provider.
7 |
8 | There is a lot of setup and configuration needed to map a custom domain name to your cloud resources: Besides the domain name itself, you need to manage DNS records, TLS certificates, and ensure that your application can respond to requests made to these custom domains.
9 |
10 | Stelvio handles all of this for you automatically with a very simple setup.
11 |
12 | ## Setting up DNS with Stelvio
13 |
14 | Stelvio supports popular DNS providers:
15 |
16 | - `stelvio.cloudflare.dns.CloudflareDns` for Cloudflare
17 | - `stelvio.aws.route53.Route53Dns` for AWS Route 53
18 | - more providers will be added in the future
19 |
20 | ??? note "Under the bonnet"
21 | All of these classes inherit from `stelvio.dns.Dns` and implement the necessary methods to create and manage DNS records. When creating a record, using the `create_record` method, a `stelvio.dns.Record` object is returned, which contains the details of the created record.
22 |
23 | ## Configuring DNS in Stelvio
24 |
25 | To configure DNS in your Stelvio application, you need to create an instance of the DNS provider class and pass it to your `StelvioApp` instance.
26 | Here's an example of how to set up Cloudflare DNS in your Stelvio application:
27 |
28 | ```python
29 | from stelvio import StelvioApp
30 | from stelvio.cloudflare.dns import CloudflareDns
31 | dns = CloudflareDns(zone_id="your-cloudflare-zone-id")
32 |
33 | app = StelvioApp(
34 | "my-app",
35 | dns=dns,
36 | )
37 | ```
38 |
39 | This example initializes a Stelvio application with Cloudflare DNS. You need to replace `"your-cloudflare-zone-id"` with your actual Cloudflare zone ID.
40 |
41 | ??? note "Managing Certificates for Domains with Stelvio"
42 |
43 | When using custom domain names, you also need to manage TLS certificates.
44 |
45 | Stelvio provides a way to manage custom domain names with TLS certificates through the `stelvio.aws.acm.AcmValidatedDomain` class for custom domain names on AWS.
46 |
47 | Here's an example of how to set up a custom domain with a TLS certificate in Stelvio:
48 |
49 | ```python
50 | from stelvio.aws.acm import AcmValidatedDomain
51 |
52 | domain = AcmValidatedDomain(
53 | domain_name="your-custom-domain.com"
54 | )
55 | ```
56 |
57 | This class will handle the creation and validation of the TLS certificate for your custom domain.
58 | You can then use this domain in your Stelvio application.
59 |
60 | **However, Stelvio usually handles this step for you (e.g. when using custom domain with API Gateway)**
61 |
62 | `AcmValidatedDomain` is a Stelvio component that automatically creates the following three Pulumi resources on AWS, and your DNS provider:
63 |
64 | - `certificate`: `pulumi_aws.acm.Certificate`
65 | - `validation_record`: `stelvio.dns.Record`
66 | - `cert_validation`: `pulumi_aws.acm.CertificateValidation`
67 |
68 |
69 | ## Use custom domains with ApiGateway
70 |
71 | See: [ApiGateway Custom Domains](/guides/api-gateway/#custom-domains)
72 |
--------------------------------------------------------------------------------
/stelvio/link.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Callable, Sequence
2 | from dataclasses import dataclass
3 | from typing import TYPE_CHECKING, Any, Optional, Protocol, final
4 |
5 | from pulumi import Input
6 |
7 | from stelvio.aws.permission import AwsPermission
8 |
9 | if TYPE_CHECKING:
10 | from stelvio.component import Component
11 |
12 |
13 | # This protocol is not strictly needed as in Link we use AwsPermission directly and later we'll
14 | # use union types e.g. AwsPermission | GcpPermission but I keep it here for reference and for
15 | # letting people know any new permission types should have to_provider_format method.
16 | @dataclass
17 | class Permission(Protocol):
18 | def to_provider_format(self) -> Any: # noqa: ANN401
19 | """Convert permission to provider-specific format."""
20 | ...
21 |
22 |
23 | type ConfigureLink = Callable[[Any], tuple[dict, list[Permission] | Permission]]
24 |
25 |
26 | # Link has permissions, and each permission has actions and resources
27 | # so permission represents part of statement
28 | @final
29 | @dataclass(frozen=True)
30 | class LinkConfig:
31 | properties: dict[str, Input[str]] | None = None
32 | permissions: Sequence[AwsPermission] | None = None
33 |
34 |
35 | @final
36 | @dataclass(frozen=True)
37 | class Link:
38 | name: str
39 | properties: dict[str, Input[str]] | None
40 | permissions: Sequence[AwsPermission] | None
41 | component: Optional["Component"] = None
42 |
43 | def link(self) -> "Link":
44 | return self
45 |
46 | def with_config(
47 | self,
48 | *,
49 | properties: dict[str, Input[str]] | None = None,
50 | permissions: list[Permission] | None = None,
51 | ) -> "Link":
52 | """Replace both properties and permissions at once."""
53 | return Link(
54 | name=self.name,
55 | properties=properties,
56 | permissions=permissions,
57 | component=self.component,
58 | )
59 |
60 | def with_properties(self, **props: Input[str]) -> "Link":
61 | """Replace all properties."""
62 | return Link(
63 | name=self.name,
64 | properties=props,
65 | permissions=self.permissions,
66 | component=self.component,
67 | )
68 |
69 | def with_permissions(self, *permissions: AwsPermission) -> "Link":
70 | """Replace all permissions."""
71 | return Link(
72 | name=self.name,
73 | properties=self.properties,
74 | permissions=list(permissions),
75 | component=self.component,
76 | )
77 |
78 | def add_properties(self, **extra_props: Input[str]) -> "Link":
79 | """Add to existing properties."""
80 | new_props = {**(self.properties or {}), **extra_props}
81 | return self.with_properties(**new_props)
82 |
83 | def add_permissions(self, *extra_permissions: AwsPermission) -> "Link":
84 | """Add to existing permissions."""
85 | current = self.permissions or []
86 | return self.with_permissions(*(current + list(extra_permissions)))
87 |
88 | def remove_properties(self, *keys: str) -> "Link":
89 | """Remove specific properties by key."""
90 | if not self.properties:
91 | return self
92 |
93 | new_props = {k: v for k, v in self.properties.items() if k not in keys}
94 | return self.with_properties(**new_props)
95 |
96 |
97 | class Linkable(Protocol):
98 | def link(self) -> Link:
99 | raise NotImplementedError
100 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to Stelvio
2 |
3 | First off, thank you for considering contributing to Stelvio! Contributions of all kinds are welcome and valued, from code improvements to documentation updates. Every contribution, no matter how small, helps make Stelvio better for everyone.
4 |
5 | ## Code of Conduct
6 |
7 | By participating in this project, you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md). It's very short and simple - basically just be nice and stay on topic.
8 |
9 | ## Contributor License Agreement
10 |
11 | Stelvio uses a Contributor License Agreement (CLA) to ensure that the project has the necessary rights to use your contributions. When you submit your first PR, our CLA Assistant bot will guide you through the signing process. This is a one-time process for all your future contributions.
12 |
13 | The CLA protects both contributors and the project by clearly defining the terms under which code is contributed.
14 |
15 | ## Getting Started
16 |
17 | The quickest way to get started with development is to:
18 |
19 | 1. Fork the repository
20 | 2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/stelvio.git`
21 | 3. Add the original repo as upstream: `git remote add upstream https://github.com/stelviodev/stelvio.git`
22 | 4. Install UV if not already installed (see [UV installation docs](https://github.com/astral-sh/uv?tab=readme-ov-file#installation))
23 | 5. Set up the development environment: `uv sync`
24 | 6. Run tests to make sure everything works: `uv run pytest`
25 | 7. Run docs locally with: `uv run mkdocs serve`
26 | 8. Format code with Ruff: `uv run ruff format`
27 | 9. Check code with Ruff: `uv run ruff check`
28 |
29 | For a more detailed guide on using Stelvio, please refer to our [Quick Start Guide](https://stelvio.dev/docs/getting-started/quickstart/).
30 |
31 | ## Contribution Process
32 |
33 | 1. Create a branch for your work: `git checkout -b feature/your-feature-name`
34 | 2. Make your changes
35 | 3. Write tests for any code you add or modify
36 | 4. Update documentation if needed
37 | 5. Ensure all tests pass: `uv run pytest`
38 | 6. Commit your changes with descriptive messages
39 | 7. Push to your fork: `git push origin feature/your-feature-name`
40 | 8. Create a Pull Request to the `main` branch of the original repository
41 |
42 | ## Pull Request Guidelines
43 |
44 | - Every code change should include appropriate tests
45 | - Update documentation for any user-facing changes
46 | - Keep PRs focused on a single change or feature
47 | - Follow the existing code style
48 | - Format your code with Ruff: `uv run ruff format`
49 | - Ensure all linting checks pass: `uv run ruff check`
50 | - Ensure all tests pass before submitting: `uv run pytest`
51 | - Provide a clear description of the changes in your PR
52 |
53 | ## Communication
54 |
55 | Have questions or suggestions? Here are the best ways to reach out:
56 |
57 | - [GitHub Issues](https://github.com/stelviodev/stelvio/issues) for bug reports and feature requests
58 | - [GitHub Discussions](https://github.com/stelviodev/stelvio/discussions) for general questions and discussions
59 | - Email: team@stelvio.dev
60 | - Twitter: [@stelviodev](https://twitter.com/stelviodev)
61 |
62 | ## Issue Reporting
63 |
64 | When reporting issues, please include:
65 |
66 | - A clear and descriptive title
67 | - A detailed description of the issue
68 | - Steps to reproduce the behavior
69 | - What you expected to happen
70 | - What actually happened
71 | - Your environment (OS, Python version, Stelvio version)
72 |
73 | ## Thank You!
74 |
75 | Your contributions to open source, no matter how small, are greatly appreciated. Even if it's just fixing a typo in the documentation, it helps make Stelvio better for everyone.
76 |
--------------------------------------------------------------------------------
/pkg/npm/README.md:
--------------------------------------------------------------------------------
1 | # Stelvio
2 |
3 | ## AWS for Python devs - made simple
4 |
5 | [**Documentation**](https://stelvio.dev/docs/getting-started/quickstart/) - [**Stelvio Manifesto**](https://stelvio.dev/manifesto/) - [**Intro article with quickstart**](https://stelvio.dev/blog/introducing-stelvio/)
6 |
7 | ## What is Stelvio?
8 |
9 | Stelvio is a Python framework that simplifies AWS cloud infrastructure management and deployment. It lets you define your cloud infrastructure using pure Python, with smart defaults that handle complex configuration automatically.
10 |
11 | With the `stlv` CLI, you can deploy AWS infrastructure in seconds without complex setup or configuration.
12 |
13 | ### Key Features
14 |
15 | - **Developer-First**: Built specifically for Python developers, not infrastructure experts
16 | - **Zero-Setup CLI**: Just run `stlv init` and start deploying - no complex configuration
17 | - **Python-Native Infrastructure**: Define your cloud resources using familiar Python code
18 | - **Environments**: Personal and shared environments with automatic resource isolation
19 | - **Smart Defaults**: Automatic configuration of IAM roles, networking, and security
20 |
21 | ### Currently Supported
22 |
23 | - [AWS Lambda & Layers](https://stelvio.dev/docs/guides/lambda/)
24 | - [Amazon DynamoDB](https://stelvio.dev/docs/guides/dynamo-db/)
25 | - [API Gateway](https://stelvio.dev/docs/guides/api-gateway/)
26 | - [Linking - automated IAM](https://stelvio.dev/docs/guides/linking/)
27 | - [S3 Buckets](https://stelvio.dev/docs/guides/s3/)
28 | - [Custom Domains](https://stelvio.dev/docs/guides/dns)
29 |
30 | Support for additional AWS services is coming. See [**Roadmap**](https://github.com/stelviodev/stelvio/wiki/Roadmap).
31 |
32 | ## Example
33 |
34 | Define AWS infrastructure in pure Python:
35 |
36 | ```python
37 | @app.run
38 | def run() -> None:
39 | # Create a DynamoDB table
40 | table = DynamoTable(
41 | name="todos",
42 | partition_key="username",
43 | sort_key="created"
44 | )
45 |
46 | # Create an API with Lambda functions
47 | api = Api("todos-api", domain_name="api.example.com")
48 | api.route("POST", "/todos", handler="functions/todos.post", links=[table])
49 | api.route("GET", "/todos/{username}", handler="functions/todos.get")
50 | ```
51 |
52 | See the [intro article](https://stelvio.dev/blog/introducing-stelvio/) for a complete working example.
53 |
54 | ## Quick Start
55 |
56 | ```bash
57 | # Create a new project
58 | uv init my-todo-api && cd my-todo-api
59 |
60 | # Install Stelvio
61 | uv add stelvio
62 |
63 | # Initialize Stelvio project
64 | uv run stlv init
65 |
66 | # Edit stlv_app.py file to define your infra
67 |
68 | # Deploy
69 | uv run stlv deploy
70 | ```
71 |
72 | Go to our [Quick Start Guide](https://stelvio.dev/docs/getting-started/quickstart/) for the full tutorial.
73 |
74 | ## Why Stelvio?
75 |
76 | Unlike generic infrastructure tools like Terraform, AWS CDK or Pulumi Stelvio is:
77 |
78 | - Built specifically for Python developers
79 | - Focused on developer productivity, not infrastructure complexity
80 | - Designed to minimize boilerplate through intelligent defaults
81 | - Maintained in pure Python without mixing application and infrastructure code
82 |
83 | For detailed explanation see [Stelvio Manifesto](https://stelvio.dev/manifesto/) blog post.
84 |
85 | ## Project Status
86 |
87 | Stelvio is currently in early but active development.
88 |
89 | ## Contributing
90 |
91 | Best way to contribute now is to play with it and report any issues.
92 |
93 | I'm also happy to gather any feedback or feature requests.
94 |
95 | Use GitHub Issues or email us directly at team@stelvio.dev
96 |
97 | If you want to contribute code you can open a PR. If you need any help I'm happy to talk.
98 |
99 | ## License
100 |
101 | This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.
--------------------------------------------------------------------------------
/stelvio/config.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass, field
2 | from typing import Literal
3 |
4 | from stelvio.dns import Dns
5 |
6 |
7 | @dataclass(frozen=True, kw_only=True)
8 | class AwsConfig:
9 | """AWS configuration for Stelvio.
10 |
11 | Both profile and region are optional overrides. When not specified, Stelvio follows
12 | the standard AWS credential and region resolution chain.
13 |
14 | ## Credentials Resolution Order
15 |
16 | Stelvio uses boto3 and Pulumi, both of which follow the AWS SDK credential chain:
17 |
18 | 1. **Environment variables**:
19 | - AWS_ACCESS_KEY_ID
20 | - AWS_SECRET_ACCESS_KEY
21 | - AWS_SESSION_TOKEN (optional, for temporary credentials)
22 |
23 | 2. **Assume role providers**:
24 | - AWS_ROLE_ARN + AWS_WEB_IDENTITY_TOKEN_FILE (for OIDC/web identity)
25 | - Configured role assumption in ~/.aws/config
26 |
27 | 3. **AWS IAM Identity Center (SSO)**:
28 | - Configured via `aws sso login` and ~/.aws/config
29 | - Uses cached SSO token for authentication
30 |
31 | 4. **Shared credentials file**:
32 | - ~/.aws/credentials (credentials stored per profile)
33 |
34 | 5. **Shared config file**:
35 | - ~/.aws/config (can also contain credentials)
36 |
37 | 6. **IAM role credentials** (when running in AWS):
38 | - ECS task role (ECS_CONTAINER_METADATA_URI)
39 | - EC2 instance profile (EC2 instance metadata service)
40 | - Lambda execution role
41 |
42 | The first method that provides valid credentials is used.
43 |
44 | ## Profile Selection
45 |
46 | When credentials are stored in files (~/.aws/credentials or ~/.aws/config):
47 |
48 | 1. Explicit `profile` parameter (this config)
49 | 2. AWS_PROFILE environment variable
50 | 3. "default" profile from ~/.aws files (if exists)
51 |
52 | ## Region Selection
53 |
54 | 1. Explicit `region` parameter (this config)
55 | 2. AWS_REGION or AWS_DEFAULT_REGION environment variable
56 | 3. Region from selected profile in ~/.aws/config
57 | 4. If none specified, AWS operations will fail (no default region)
58 |
59 | ## Examples
60 |
61 | Use environment variables (CI/CD):
62 | ```python
63 | # Set in environment: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION
64 | AwsConfig() # Uses env vars
65 | ```
66 |
67 | Use SSO (recommended for developers):
68 | ```bash
69 | aws sso login --profile my-sso-profile
70 | ```
71 | ```python
72 | AwsConfig(profile="my-sso-profile")
73 | ```
74 |
75 | Use different profiles per stage:
76 | ```python
77 | @app.config
78 | def config(stage: str) -> StelvioAppConfig:
79 | if stage == "prod":
80 | return StelvioAppConfig(aws=AwsConfig(profile="prod-profile"))
81 | return StelvioAppConfig(aws=AwsConfig()) # Personal/dev uses default
82 | ```
83 |
84 | Override region:
85 | ```python
86 | AwsConfig(region="eu-west-1") # Deploy to EU regardless of profile region
87 | ```
88 | """
89 |
90 | profile: str | None = None
91 | region: str | None = None
92 |
93 |
94 | @dataclass(frozen=True, kw_only=True)
95 | class StelvioAppConfig:
96 | """Stelvio app configuration.
97 |
98 | Attributes:
99 | aws: AWS credentials and region configuration.
100 | dns: DNS provider configuration for custom domains.
101 | environments: List of shared environment names (e.g., ["staging", "production"]).
102 | home: State storage backend. Currently only "aws" is supported.
103 | """
104 |
105 | aws: AwsConfig = field(default_factory=AwsConfig)
106 | dns: Dns | None = None
107 | environments: list[str] = field(default_factory=list)
108 | home: Literal["aws"] = "aws"
109 |
110 | def is_valid_environment(self, env: str, username: str) -> bool:
111 | return env == username or env in self.environments
112 |
--------------------------------------------------------------------------------
/stelvio/aws/cors.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 | from typing import TypedDict
3 |
4 |
5 | def _validate_cors_field(value: str | list[str], field_name: str) -> None:
6 | """Validate CORS field (origins, methods, headers) for common patterns.
7 |
8 | Rejects:
9 | - Empty strings or empty lists
10 | - Wildcard '*' in a list (must be a string)
11 | - Non-string items in lists
12 | """
13 | if isinstance(value, str):
14 | if not value:
15 | raise ValueError(f"{field_name} string cannot be empty")
16 | elif isinstance(value, list):
17 | if not value:
18 | raise ValueError(f"{field_name} list cannot be empty")
19 | if "*" in value:
20 | raise ValueError(
21 | f"Wildcard '*' must be a string, not in a list. Use {field_name}='*' instead"
22 | )
23 | for item in value:
24 | if not isinstance(item, str) or not item:
25 | raise ValueError(f"Each {field_name} value must be a non-empty string")
26 | else:
27 | raise TypeError(f"{field_name} must be a string or list of strings")
28 |
29 |
30 | class CorsConfigDict(TypedDict, total=False):
31 | allow_origins: str | list[str]
32 | allow_methods: str | list[str]
33 | allow_headers: str | list[str]
34 | allow_credentials: bool
35 | max_age: int | None
36 | expose_headers: list[str] | None
37 |
38 |
39 | @dataclass(frozen=True, kw_only=True)
40 | class CorsConfig:
41 | """CORS configuration for API Gateway and Function URLs.
42 |
43 | Note: REST API v1 only supports single origin (string). Multiple origins (list)
44 | are supported for HTTP API v2. Validation occurs at the API component level.
45 | """
46 |
47 | allow_origins: str | list[str] = "*"
48 | allow_methods: str | list[str] = "*"
49 | allow_headers: str | list[str] = "*"
50 | allow_credentials: bool = False
51 | max_age: int | None = None
52 | expose_headers: list[str] | None = None
53 |
54 | def __post_init__(self) -> None:
55 | # Validate allow_origins
56 | _validate_cors_field(self.allow_origins, "allow_origins")
57 | if self.allow_credentials and self.allow_origins == "*":
58 | raise ValueError("allow_credentials=True requires specific origins, cannot use '*'")
59 |
60 | # Validate allow_methods
61 | self._validate_methods()
62 |
63 | # Validate allow_headers
64 | _validate_cors_field(self.allow_headers, "allow_headers")
65 |
66 | # Validate max_age
67 | if self.max_age is not None and self.max_age < 0:
68 | raise ValueError("max_age must be non-negative")
69 |
70 | # Validate expose_headers
71 | if self.expose_headers is not None:
72 | if not self.expose_headers:
73 | raise ValueError("expose_headers list cannot be empty when specified")
74 | for header in self.expose_headers:
75 | if not isinstance(header, str) or not header:
76 | raise ValueError("Each expose_headers value must be a non-empty string")
77 |
78 | def _validate_methods(self) -> None:
79 | valid_methods = {"DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "*"}
80 | _validate_cors_field(self.allow_methods, "allow_methods")
81 | if isinstance(self.allow_methods, str):
82 | if self.allow_methods.upper() not in valid_methods:
83 | raise ValueError(
84 | f"Invalid HTTP method '{self.allow_methods}'. Valid: "
85 | f"{', '.join(sorted(valid_methods - {'*'}))}, or '*' for all"
86 | )
87 | elif isinstance(self.allow_methods, list):
88 | for method in self.allow_methods:
89 | if method.upper() not in valid_methods:
90 | raise ValueError(
91 | f"Invalid HTTP method '{method}'. Valid: "
92 | f"{', '.join(sorted(valid_methods - {'*'}))}, or '*' for all"
93 | )
94 |
--------------------------------------------------------------------------------
/stelvio/aws/cloudfront/origins/components/api_gateway.py:
--------------------------------------------------------------------------------
1 | import pulumi
2 | import pulumi_aws
3 |
4 | from stelvio.aws.api_gateway import Api
5 | from stelvio.aws.cloudfront.dtos import Route, RouteOriginConfig
6 | from stelvio.aws.cloudfront.js import strip_path_pattern_function_js
7 | from stelvio.aws.cloudfront.origins.base import ComponentCloudfrontAdapter
8 | from stelvio.aws.cloudfront.origins.decorators import register_adapter
9 | from stelvio.context import context
10 |
11 |
12 | @register_adapter(Api)
13 | class ApiGatewayCloudfrontAdapter(ComponentCloudfrontAdapter):
14 | def __init__(self, idx: int, route: Route) -> None:
15 | super().__init__(idx, route)
16 | self.api = route.component
17 |
18 | def get_origin_config(self) -> RouteOriginConfig:
19 | # API Gateway doesn't need Origin Access Control like S3 buckets do
20 | # API Gateway has its own access control mechanisms
21 | region = pulumi_aws.get_region().name
22 | origin_args = pulumi_aws.cloudfront.DistributionOriginArgs(
23 | origin_id=self.api.resources.rest_api.id,
24 | domain_name=self.api.resources.rest_api.id.apply(
25 | lambda api_id: f"{api_id}.execute-api.{region}.amazonaws.com"
26 | ),
27 | # API Gateway needs the stage name in the origin path
28 | origin_path=self.api.resources.stage.stage_name.apply(lambda stage: f"/{stage}"),
29 | )
30 | origin_dict = {
31 | "origin_id": origin_args.origin_id,
32 | "domain_name": origin_args.domain_name,
33 | "origin_path": origin_args.origin_path,
34 | # For API Gateway, we need to specify custom_origin_config to avoid S3 validation
35 | "custom_origin_config": {
36 | "http_port": 80,
37 | "https_port": 443,
38 | "origin_protocol_policy": "https-only",
39 | "origin_ssl_protocols": ["TLSv1.2"],
40 | },
41 | # No origin_access_control_id needed for API Gateway
42 | }
43 | path_pattern = (
44 | f"{self.route.path_pattern}/*"
45 | if not self.route.path_pattern.endswith("*")
46 | else self.route.path_pattern
47 | )
48 | function_code = strip_path_pattern_function_js(self.route.path_pattern)
49 | cf_function = pulumi_aws.cloudfront.Function(
50 | context().prefix(f"{self.api.name}-uri-rewrite-{self.idx}"),
51 | runtime="cloudfront-js-2.0",
52 | code=function_code,
53 | comment=f"Strip {self.route.path_pattern} prefix for route {self.idx}",
54 | opts=pulumi.ResourceOptions(depends_on=[self.api.resources.rest_api]),
55 | )
56 | cache_behavior = {
57 | "path_pattern": path_pattern,
58 | "allowed_methods": ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"],
59 | "cached_methods": ["GET", "HEAD"],
60 | "target_origin_id": origin_dict["origin_id"],
61 | "compress": True,
62 | "viewer_protocol_policy": "redirect-to-https",
63 | "forwarded_values": {
64 | "query_string": True, # API Gateway often uses query parameters
65 | "cookies": {"forward": "none"},
66 | },
67 | "min_ttl": 0,
68 | "default_ttl": 0, # Don't cache API responses by default
69 | "max_ttl": 0,
70 | "function_associations": [
71 | {
72 | "event_type": "viewer-request",
73 | "function_arn": cf_function.arn,
74 | }
75 | ],
76 | }
77 |
78 | return RouteOriginConfig(
79 | origin_access_controls=None, # API Gateway doesn't need OAC
80 | origins=origin_dict,
81 | ordered_cache_behaviors=cache_behavior,
82 | cloudfront_functions=cf_function,
83 | )
84 |
85 | def get_access_policy(self, distribution: pulumi_aws.cloudfront.Distribution) -> None: # noqa: ARG002
86 | # API Gateway does not require an S3 Bucket Policy equivalent
87 | return None
88 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Stelvio
2 |
3 | [](https://pypi.org/project/stelvio/)
4 | [](https://pypi.org/project/stelvio/)
5 | [](https://opensource.org/licenses/Apache-2.0)
6 |
7 | ## AWS for Python devs - made simple
8 |
9 | [**Documentation**](https://stelvio.dev/docs/getting-started/quickstart/) - [**Stelvio Manifesto**](https://stelvio.dev/manifesto/) - [**Intro article with quickstart**](https://stelvio.dev/blog/introducing-stelvio/)
10 |
11 | ## What is Stelvio?
12 |
13 | Stelvio is a Python framework that simplifies AWS cloud infrastructure management and deployment. It lets you define your cloud infrastructure using pure Python, with smart defaults that handle complex configuration automatically.
14 |
15 | With the `stlv` CLI, you can deploy AWS infrastructure in seconds without complex setup or configuration.
16 |
17 | ### Key Features
18 |
19 | - **Developer-First**: Built specifically for Python developers, not infrastructure experts
20 | - **Zero-Setup CLI**: Just run `stlv init` and start deploying - no complex configuration
21 | - **Python-Native Infrastructure**: Define your cloud resources using familiar Python code
22 | - **Environments**: Personal and shared environments with automatic resource isolation
23 | - **Smart Defaults**: Automatic configuration of IAM roles, networking, and security
24 |
25 | ### Currently Supported
26 |
27 | - [AWS Lambda & Layers](https://stelvio.dev/docs/guides/lambda/)
28 | - [Amazon DynamoDB](https://stelvio.dev/docs/guides/dynamo-db/)
29 | - [API Gateway](https://stelvio.dev/docs/guides/api-gateway/)
30 | - [Linking - automated IAM](https://stelvio.dev/docs/guides/linking/)
31 | - [S3 Buckets](https://stelvio.dev/docs/guides/s3/)
32 | - [Custom Domains](https://stelvio.dev/docs/guides/dns)
33 |
34 | Support for additional AWS services is coming. See [**Roadmap**](https://github.com/stelviodev/stelvio/wiki/Roadmap).
35 |
36 | ## Example
37 |
38 | Define AWS infrastructure in pure Python:
39 |
40 | ```python
41 | @app.run
42 | def run() -> None:
43 | # Create a DynamoDB table
44 | table = DynamoTable(
45 | name="todos",
46 | partition_key="username",
47 | sort_key="created"
48 | )
49 |
50 | # Create an API with Lambda functions
51 | api = Api("todos-api", domain_name="api.example.com")
52 | api.route("POST", "/todos", handler="functions/todos.post", links=[table])
53 | api.route("GET", "/todos/{username}", handler="functions/todos.get")
54 | ```
55 |
56 | See the [intro article](https://stelvio.dev/blog/introducing-stelvio/) for a complete working example.
57 |
58 | ## Quick Start
59 |
60 | ```bash
61 | # Create a new project
62 | uv init my-todo-api && cd my-todo-api
63 |
64 | # Install Stelvio
65 | uv add stelvio
66 |
67 | # Initialize Stelvio project
68 | uv run stlv init
69 |
70 | # Edit stlv_app.py file to define your infra
71 |
72 | # Deploy
73 | uv run stlv deploy
74 | ```
75 |
76 | Go to our [Quick Start Guide](https://stelvio.dev/docs/getting-started/quickstart/) for the full tutorial.
77 |
78 | ## Why Stelvio?
79 |
80 | Unlike generic infrastructure tools like Terraform, AWS CDK or Pulumi Stelvio is:
81 |
82 | - Built specifically for Python developers
83 | - Focused on developer productivity, not infrastructure complexity
84 | - Designed to minimize boilerplate through intelligent defaults
85 | - Maintained in pure Python without mixing application and infrastructure code
86 |
87 | For detailed explanation see [Stelvio Manifesto](https://stelvio.dev/manifesto/) blog post.
88 |
89 | ## Project Status
90 |
91 | Stelvio is currently in early but active development.
92 |
93 | ## Contributing
94 |
95 | Best way to contribute now is to play with it and report any issues.
96 |
97 | I'm also happy to gather any feedback or feature requests.
98 |
99 | Use GitHub Issues or email us directly at team@stelvio.dev
100 |
101 | If you want to contribute code you can open a PR. If you need any help I'm happy to talk.
102 |
103 | ## License
104 |
105 | This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # UV
98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99 | # This is especially recommended for binary packages to ensure reproducibility, and is more
100 | # commonly ignored for libraries.
101 | #uv.lock
102 |
103 | # poetry
104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105 | # This is especially recommended for binary packages to ensure reproducibility, and is more
106 | # commonly ignored for libraries.
107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108 | #poetry.lock
109 |
110 | # pdm
111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
112 | #pdm.lock
113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
114 | # in version control.
115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
116 | .pdm.toml
117 | .pdm-python
118 | .pdm-build/
119 |
120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
121 | __pypackages__/
122 |
123 | # Celery stuff
124 | celerybeat-schedule
125 | celerybeat.pid
126 |
127 | # SageMath parsed files
128 | *.sage.py
129 |
130 | # Environments
131 | .env
132 | .venv
133 | env/
134 | venv/
135 | ENV/
136 | env.bak/
137 | venv.bak/
138 |
139 | # Spyder project settings
140 | .spyderproject
141 | .spyproject
142 |
143 | # Rope project settings
144 | .ropeproject
145 |
146 | # mkdocs documentation
147 | /site
148 |
149 | # mypy
150 | .mypy_cache/
151 | .dmypy.json
152 | dmypy.json
153 |
154 | # Pyre type checker
155 | .pyre/
156 |
157 | # pytype static type analyzer
158 | .pytype/
159 |
160 | # Cython debug symbols
161 | cython_debug/
162 |
163 | # PyCharm
164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
166 | # and can be added to the global gitignore or merged into this file. For a more nuclear
167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
168 | #.idea/
169 |
170 | # PyPI configuration file
171 | .pypirc
172 |
--------------------------------------------------------------------------------
/stelvio/aws/home.py:
--------------------------------------------------------------------------------
1 | import hashlib
2 | from pathlib import Path
3 |
4 | import boto3
5 | from botocore.exceptions import ClientError
6 |
7 |
8 | class AwsHome:
9 | """AWS implementation of Home - S3 for files, SSM for params."""
10 |
11 | def __init__(self, profile: str | None = None, region: str | None = None) -> None:
12 | self._session = boto3.Session(profile_name=profile, region_name=region)
13 | self._ssm = self._session.client("ssm")
14 | self._s3 = self._session.client("s3")
15 | self._bucket: str | None = None
16 |
17 | def read_param(self, name: str) -> str | None:
18 | try:
19 | response = self._ssm.get_parameter(Name=name, WithDecryption=True)
20 | return response["Parameter"]["Value"]
21 | except ClientError as e:
22 | if e.response["Error"]["Code"] == "ParameterNotFound":
23 | return None
24 | raise
25 |
26 | def write_param(
27 | self, name: str, value: str, description: str = "", *, secure: bool = False
28 | ) -> None:
29 | self._ssm.put_parameter(
30 | Name=name,
31 | Value=value,
32 | Type="SecureString" if secure else "String",
33 | Description=description,
34 | Overwrite=True,
35 | )
36 |
37 | def init_storage(self, name: str | None = None) -> str:
38 | """Initialize S3 bucket. If name is None, generate and create. Returns bucket name."""
39 | if name is None:
40 | name = self._generate_bucket_name()
41 | self._create_bucket(name)
42 | self._bucket = name
43 | return name
44 |
45 | def _generate_bucket_name(self) -> str:
46 | """Generate bucket name from account ID and region."""
47 | account_id = self._session.client("sts").get_caller_identity()["Account"]
48 | region = self._session.region_name
49 | hash_input = f"{account_id}{region}".encode()
50 | hash_suffix = hashlib.sha256(hash_input).hexdigest()[:12]
51 | return f"stlv-state-{hash_suffix}"
52 |
53 | def _create_bucket(self, name: str) -> None:
54 | """Create S3 bucket with versioning enabled."""
55 | region = self._session.region_name
56 | if region == "us-east-1":
57 | self._s3.create_bucket(Bucket=name)
58 | else:
59 | self._s3.create_bucket(
60 | Bucket=name,
61 | CreateBucketConfiguration={"LocationConstraint": region},
62 | )
63 | self._s3.put_bucket_versioning(
64 | Bucket=name,
65 | VersioningConfiguration={"Status": "Enabled"},
66 | )
67 |
68 | def read_file(self, key: str, local_path: Path) -> bool:
69 | """Download file from S3. Returns True if file existed."""
70 | local_path.parent.mkdir(parents=True, exist_ok=True)
71 | try:
72 | self._s3.download_file(self._bucket, key, str(local_path))
73 | except ClientError as e:
74 | if e.response["Error"]["Code"] == "404":
75 | return False
76 | raise
77 | else:
78 | return True
79 |
80 | def write_file(self, key: str, local_path: Path) -> None:
81 | """Upload file to S3."""
82 | self._s3.upload_file(str(local_path), self._bucket, key)
83 |
84 | def delete_file(self, key: str) -> None:
85 | """Delete file from S3."""
86 | self._s3.delete_object(Bucket=self._bucket, Key=key)
87 |
88 | def file_exists(self, key: str) -> bool:
89 | """Check if file exists in S3."""
90 | try:
91 | self._s3.head_object(Bucket=self._bucket, Key=key)
92 | except ClientError as e:
93 | if e.response["Error"]["Code"] == "404":
94 | return False
95 | raise
96 | else:
97 | return True
98 |
99 | def delete_prefix(self, prefix: str) -> None:
100 | """Delete all files with given prefix."""
101 | paginator = self._s3.get_paginator("list_objects_v2")
102 | for page in paginator.paginate(Bucket=self._bucket, Prefix=prefix):
103 | for obj in page.get("Contents", []):
104 | self._s3.delete_object(Bucket=self._bucket, Key=obj["Key"])
105 |
--------------------------------------------------------------------------------
/docs/guides/environments.md:
--------------------------------------------------------------------------------
1 | # Environments
2 |
3 | Stelvio makes it easy to manage different deployment environments like
4 | development, staging, and production. Each environment gets its own isolated AWS
5 | resources with automatic naming to prevent conflicts.
6 |
7 | ## How Environments Work
8 |
9 | When you deploy with Stelvio, every AWS resource gets named with this pattern:
10 |
11 | ```
12 | {app-name}-{environment}-{resource-name}
13 | ```
14 |
15 | For example, if your app is called "my-api" and you deploy to the "staging"
16 | environment, a DynamoDB table named "users" becomes `my-api-staging-users`.
17 |
18 | ## Default Behavior
19 |
20 | If you don't specify an environment, Stelvio uses your personal environment (computer username by default):
21 |
22 | ```bash
23 | stlv deploy # Deploys to your personal environment (e.g., "john")
24 | ```
25 |
26 | This gives every developer their own isolated sandbox to work in without
27 | conflicts.
28 |
29 | ### Customizing Your Personal Environment Name
30 |
31 | Stelvio stores your personal environment name in `.stelvio/userenv`. You can customize this if needed:
32 |
33 | ```bash
34 | echo "myname" > .stelvio/userenv
35 | ```
36 |
37 | This is useful when:
38 |
39 | - Multiple developers on the team have the same computer username
40 | - You want a consistent name across different machines
41 | - Your computer username contains special characters
42 | - You want to use something other than your computer username
43 |
44 | !!! info
45 | The `.stelvio/` folder contains personal settings and caches - add it to `.gitignore`.
46 |
47 | ## Using Environments
48 |
49 | Most CLI commands accept an optional environment name as an argument:
50 |
51 | ```bash
52 | stlv deploy # Your personal environment
53 | stlv deploy staging # Staging environment
54 | stlv deploy prod # Production environment
55 | ```
56 |
57 | Without an environment argument, commands default to your personal environment. See [Using CLI](using-cli.md) for the full list of commands.
58 |
59 | ## Configuring Environments
60 |
61 | You can define which environments are valid for your project:
62 |
63 | ```python
64 | from stelvio.config import StelvioAppConfig
65 |
66 |
67 | @app.config
68 | def configuration(env: str) -> StelvioAppConfig:
69 | return StelvioAppConfig(
70 | environments=["staging", "prod"] # Valid shared environments
71 | )
72 | ```
73 |
74 | With this configuration:
75 |
76 | - **Anyone** can deploy to their personal environment (username)
77 | - **Only** "staging" and "prod" are accepted as shared environments
78 | - Stelvio will validate environment names and show an error for invalid ones
79 |
80 | ## Environment-Specific Configuration
81 |
82 | You can customize settings per environment:
83 |
84 | ```python
85 | from stelvio.config import AwsConfig
86 |
87 | @app.config
88 | def configuration(env: str) -> StelvioAppConfig:
89 | if env == "prod":
90 | return StelvioAppConfig(
91 | aws=AwsConfig(profile="production-account"),
92 | environments=["staging", "prod"]
93 | )
94 | return StelvioAppConfig(environments=["staging", "prod"])
95 | ```
96 |
97 | ## Tips
98 |
99 | - Keep environment names short: `dev`, `staging`, `prod`
100 | - Avoid special characters - stick to letters and numbers
101 | - Consider using different AWS accounts for production
102 |
103 | ## Resource Naming
104 |
105 | ### Naming Pattern
106 |
107 | All AWS resources follow the `{app}-{env}-{name}` pattern. Some resources have additional suffixes to identify their type:
108 |
109 | | Resource | Pattern |
110 | |----------|---------|
111 | | IAM Roles | `{app}-{env}-{name}-r` |
112 | | IAM Policies | `{app}-{env}-{name}-p`|
113 |
114 | ### Automatic Truncation
115 |
116 | When a name would exceed AWS limits, Stelvio automatically truncates it and adds a 7-character hash to keep it unique:
117 |
118 | ```text
119 | # This name is too long for the 64-char IAM role limit
120 | myapp-prod-process-user-authentication-requests-handler-r
121 |
122 | # Stelvio truncates and adds a hash for uniqueness
123 | myapp-prod-process-user-authentication-request-e4f2a91-r
124 | ```
125 |
126 | The hash is derived from the original name, so:
127 |
128 | - The same name always produces the same truncated result
129 | - Different long names won't collide even if they start the same way
130 | - You can still identify the resource from the readable portion
131 |
--------------------------------------------------------------------------------
/stelvio/aws/s3/s3.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 | from typing import Literal, final
3 |
4 | import pulumi
5 | import pulumi_aws
6 |
7 | from stelvio import context
8 | from stelvio.aws.permission import AwsPermission
9 | from stelvio.component import Component, ComponentRegistry, link_config_creator
10 | from stelvio.link import Link, Linkable, LinkConfig
11 |
12 |
13 | @final
14 | @dataclass(frozen=True)
15 | class S3BucketResources:
16 | bucket: pulumi_aws.s3.Bucket
17 | public_access_block: pulumi_aws.s3.BucketPublicAccessBlock
18 | bucket_policy: pulumi_aws.s3.BucketPolicy | None
19 |
20 |
21 | @final
22 | class Bucket(Component[S3BucketResources], Linkable):
23 | def __init__(
24 | self, name: str, versioning: bool = False, access: Literal["public"] | None = None
25 | ):
26 | super().__init__(name)
27 | self.versioning = versioning
28 | self.access = access
29 | self._resources = None
30 |
31 | def _create_resources(self) -> S3BucketResources:
32 | bucket = pulumi_aws.s3.Bucket(
33 | context().prefix(self.name),
34 | bucket=context().prefix(self.name),
35 | versioning={"enabled": self.versioning},
36 | )
37 |
38 | # Configure public access block
39 | if self.access == "public":
40 | # setup readonly configuration
41 | public_access_block = pulumi_aws.s3.BucketPublicAccessBlock(
42 | context().prefix(f"{self.name}-pab"),
43 | bucket=bucket.id,
44 | block_public_acls=False,
45 | block_public_policy=False,
46 | ignore_public_acls=False,
47 | restrict_public_buckets=False,
48 | )
49 | public_read_policy = pulumi_aws.iam.get_policy_document(
50 | statements=[
51 | {
52 | "effect": "Allow",
53 | "principals": [
54 | {
55 | "type": "*",
56 | "identifiers": ["*"],
57 | }
58 | ],
59 | "actions": ["s3:GetObject"],
60 | "resources": [bucket.arn.apply(lambda arn: f"{arn}/*")],
61 | }
62 | ]
63 | )
64 | bucket_policy = pulumi_aws.s3.BucketPolicy(
65 | context().prefix(f"{self.name}-policy"),
66 | bucket=bucket.id,
67 | policy=public_read_policy.json,
68 | )
69 | pulumi.export(f"s3bucket_{self.name}_policy_id", bucket_policy.id)
70 | else:
71 | public_access_block = pulumi_aws.s3.BucketPublicAccessBlock(
72 | context().prefix(f"{self.name}-pab"),
73 | bucket=bucket.id,
74 | block_public_acls=True,
75 | block_public_policy=True,
76 | ignore_public_acls=True,
77 | restrict_public_buckets=True,
78 | )
79 | bucket_policy = None
80 |
81 | pulumi.export(f"s3bucket_{self.name}_arn", bucket.arn)
82 | pulumi.export(f"s3bucket_{self.name}_name", bucket.bucket)
83 | pulumi.export(f"s3bucket_{self.name}_public_access_block_id", public_access_block.id)
84 |
85 | return S3BucketResources(bucket, public_access_block, bucket_policy)
86 |
87 | @property
88 | def arn(self) -> pulumi.Output[str]:
89 | """Get the ARN of the S3 bucket."""
90 | return self.resources.bucket.arn
91 |
92 | def link(self) -> Link:
93 | link_creator_ = ComponentRegistry.get_link_config_creator(type(self))
94 |
95 | link_config = link_creator_(self.resources.bucket)
96 | return Link(self.name, link_config.properties, link_config.permissions)
97 |
98 |
99 | @link_config_creator(Bucket)
100 | def default_bucket_link(bucket: pulumi_aws.s3.Bucket) -> LinkConfig:
101 | return LinkConfig(
102 | properties={"bucket_arn": bucket.arn, "bucket_name": bucket.bucket},
103 | permissions=[
104 | AwsPermission(
105 | actions=["s3:ListBucket"],
106 | resources=[bucket.arn],
107 | ),
108 | AwsPermission(
109 | actions=["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
110 | resources=[bucket.arn.apply(lambda arn: f"{arn}/*")],
111 | ),
112 | ],
113 | )
114 |
--------------------------------------------------------------------------------
/tests/aws/function/test_function_init.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 | import pytest
4 |
5 | from stelvio.aws.function import Function, FunctionConfig
6 |
7 |
8 | @pytest.mark.parametrize(
9 | ("name", "config", "opts", "expected_error"),
10 | [
11 | (
12 | "my_function",
13 | None,
14 | {},
15 | "Missing function handler: must provide either a complete configuration "
16 | "via 'config' parameter or at least the 'handler' option",
17 | ),
18 | (
19 | "my_function",
20 | {"handler": "functions/handler.main"},
21 | {"handler": "functions/handler.main"},
22 | "Invalid configuration: cannot combine 'config' parameter with additional "
23 | "options - provide all settings either in 'config' or as separate options",
24 | ),
25 | (
26 | "my_function",
27 | {"handler": "functions/handler.main"},
28 | {"memory": 256},
29 | "Invalid configuration: cannot combine 'config' parameter with additional "
30 | "options - provide all settings either in 'config' or as separate options",
31 | ),
32 | (
33 | "my_function",
34 | {"folder": "functions/handler"},
35 | {"memory": 256},
36 | "Invalid configuration: cannot combine 'config' parameter with additional "
37 | "options - provide all settings either in 'config' or as separate options",
38 | ),
39 | ],
40 | )
41 | def test_invalid_config(name: str, config: Any, opts: dict, expected_error: str):
42 | """Test that Function raises ValueError with invalid configurations."""
43 | with pytest.raises(ValueError, match=expected_error):
44 | Function(name, config=config, **opts)
45 |
46 |
47 | @pytest.mark.parametrize(
48 | ("name", "config", "opts", "wrong_type"),
49 | [
50 | ("my_function", 123, {}, "int"),
51 | ("my_function", "hello", {}, "str"),
52 | ("my_function", [4, 5, 6], {}, "list"),
53 | ],
54 | )
55 | def test_invalid_config_type_error(name: str, config: Any, opts: dict, wrong_type: str):
56 | """Test that Function raises ValueError with invalid configurations."""
57 | with pytest.raises(
58 | TypeError, match=f"Invalid config type: expected FunctionConfig or dict, got {wrong_type}"
59 | ):
60 | Function(name, config=config, **opts)
61 |
62 |
63 | def test_function_config_property():
64 | """Test that the config property returns the correct FunctionConfig object."""
65 | config = {"handler": "functions/handler.main", "memory": 128}
66 | function = Function("my_function", config=config)
67 | assert isinstance(function.config, FunctionConfig)
68 | assert function.config.handler == "functions/handler.main"
69 | assert function.config.memory == 128
70 |
71 |
72 | @pytest.mark.parametrize(
73 | ("name", "config", "opts"),
74 | [
75 | ("my_function", {"handler": "folder::handler.main"}, {}),
76 | ("my_function", None, {"handler": "folder::handler.main"}),
77 | ("my_function", FunctionConfig(handler="folder::handler.main"), {}),
78 | ],
79 | )
80 | def test_valid_folder_config(name: str, config: Any, opts: dict):
81 | """Test that Function initializes correctly with valid folder-based configurations."""
82 | try:
83 | Function(name, config=config, **opts)
84 | except Exception as e:
85 | pytest.fail(f"Function initialization failed with valid config: {e}")
86 |
87 |
88 | @pytest.mark.parametrize(
89 | ("name", "config", "opts", "expected_error"),
90 | [
91 | (
92 | "my_function",
93 | {"handler": "invalid.handler.format"},
94 | {},
95 | "File path part should not contain dots",
96 | ),
97 | (
98 | "my_function",
99 | {"handler": "handler."},
100 | {},
101 | "Both file path and function name must be non-empty",
102 | ),
103 | (
104 | "my_function",
105 | {"handler": ".handler"},
106 | {},
107 | "Both file path and function name must be non-empty",
108 | ),
109 | ],
110 | )
111 | def test_invalid_handler_format(name: str, config: Any, opts: dict, expected_error: str):
112 | """Test that Function raises ValueError with invalid handler formats.
113 | This is already thoroughly tested in FunctionConfig tests, there we just do simple
114 | sanity tests.
115 | """
116 | with pytest.raises(ValueError, match=expected_error):
117 | Function(name, config=config, **opts)
118 |
--------------------------------------------------------------------------------
/tests/test_context.py:
--------------------------------------------------------------------------------
1 | """Tests for context module functionality."""
2 |
3 | from hashlib import sha256
4 |
5 | import pytest
6 |
7 | from stelvio.component import safe_name
8 | from stelvio.context import _ContextStore
9 |
10 |
11 | @pytest.fixture(autouse=True)
12 | def clear_context():
13 | _ContextStore.clear()
14 | yield
15 | _ContextStore.clear()
16 |
17 |
18 | def _calculate_expected_hash(name: str) -> str:
19 | return sha256(name.encode()).hexdigest()[:7]
20 |
21 |
22 | def test_short_name_no_truncation():
23 | result = safe_name("myapp-prod-", "user-handler", 64, "-r")
24 | assert result == "myapp-prod-user-handler-r"
25 |
26 |
27 | def test_role_name_with_pulumi_suffix():
28 | """Test role name accounting for Pulumi suffix."""
29 | # Available: 64 - 11 (prefix) - 2 (suffix) - 8 (pulumi) = 43 chars for name
30 | long_name = "a" * 50 # Exceeds available space
31 | result = safe_name("myapp-prod-", long_name, 64, "-r")
32 |
33 | expected_hash = _calculate_expected_hash(long_name)
34 | # 43 - 8 (hash+dash) = 35 chars for truncated name
35 | expected = f"myapp-prod-{'a' * 35}-{expected_hash}-r"
36 |
37 | assert result == expected
38 | assert len(result) == 64 - 8 # Leaves space for Pulumi suffix
39 |
40 |
41 | def test_policy_name_128_limit():
42 | long_name = "very-long-policy-name-that-should-be-truncated-appropriately"
43 | result = safe_name("myapp-prod-", long_name, 128, "-p")
44 |
45 | # Should fit without truncation: 11 + 63 + 2 + 8 = 84 < 128
46 | assert result == f"myapp-prod-{long_name}-p"
47 |
48 |
49 | def test_truncation_deterministic():
50 | long_name = "very-long-name-that-will-definitely-be-truncated"
51 | result1 = safe_name("test-", long_name, 30, "-r")
52 | result2 = safe_name("test-", long_name, 30, "-r")
53 | assert result1 == result2
54 |
55 |
56 | def test_error_insufficient_space():
57 | """Test error when there's insufficient space for any name."""
58 | with pytest.raises(ValueError, match="Cannot create safe name"):
59 | safe_name("very-long-prefix-", "name", 10, "-suffix")
60 |
61 |
62 | def test_error_insufficient_space_for_hash():
63 | """Test error when there's insufficient space for hash."""
64 | with pytest.raises(ValueError, match="Cannot create safe name"):
65 | safe_name("long-prefix-", "name", 20, "-r") # 12 + 2 + 8 = 22 > 20
66 |
67 |
68 | def test_empty_name_raises_error():
69 | """Test that empty names are rejected."""
70 | with pytest.raises(ValueError, match="Name cannot be empty or whitespace-only"):
71 | safe_name("prefix-", "", 20, "-r")
72 |
73 | with pytest.raises(ValueError, match="Name cannot be empty or whitespace-only"):
74 | safe_name("prefix-", " ", 20, "-r") # Whitespace-only
75 |
76 |
77 | def test_no_suffix():
78 | result = safe_name("app-", "test", 20)
79 | assert result == "app-test"
80 |
81 |
82 | @pytest.mark.parametrize(
83 | ("max_length", "suffix", "pulumi_suffix", "expected_available"),
84 | [
85 | (64, "-r", 8, 43), # Role: 64 - 11 - 2 - 8 = 43
86 | (128, "-p", 8, 107), # Policy: 128 - 11 - 2 - 8 = 107
87 | (140, "", 0, 129), # Layer no suffix, no Pulumi: 140 - 11 - 0 - 0 = 129
88 | ],
89 | )
90 | def test_space_calculations(max_length, suffix, pulumi_suffix, expected_available):
91 | """Test that space calculations are correct for different limits."""
92 | prefix = "myapp-prod-" # 11 chars
93 |
94 | # Test with name that exactly fits available space
95 | name = "a" * expected_available
96 | result = safe_name(prefix, name, max_length, suffix, pulumi_suffix)
97 |
98 | expected_length = len(prefix) + len(name) + len(suffix)
99 | assert len(result) == expected_length
100 |
101 |
102 | def test_exactly_at_truncation_boundary():
103 | """Test names that are exactly at the truncation boundary."""
104 | # Available space: 30 - 5 (prefix) - 2 (suffix) - 8 (pulumi) = 15 chars
105 | # Name with exactly 15 chars should not truncate
106 | result = safe_name("test-", "a" * 15, 30, "-r")
107 | assert result == f"test-{'a' * 15}-r"
108 |
109 | # Name with 16 chars should truncate (15 available, need 8 for hash, so 7 chars + hash)
110 | result = safe_name("test-", "a" * 16, 30, "-r")
111 | expected_hash = _calculate_expected_hash("a" * 16)
112 | assert result == f"test-{'a' * 7}-{expected_hash}-r"
113 |
114 |
115 | def test_very_long_truncation():
116 | """Test with extremely long names."""
117 | very_long_name = "a" * 1000 # Very long name
118 | result = safe_name("short-", very_long_name, 30, "-r")
119 |
120 | # Should be truncated to fit
121 | assert len(result) == 30 - 8 # Leave space for Pulumi
122 | expected_hash = _calculate_expected_hash(very_long_name)
123 | assert result.endswith(f"-{expected_hash}-r")
124 |
--------------------------------------------------------------------------------
/stelvio/aws/cloudfront/origins/components/s3.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | import pulumi
4 | import pulumi_aws
5 |
6 | from stelvio.aws.cloudfront.dtos import Route, RouteOriginConfig
7 | from stelvio.aws.cloudfront.js import strip_path_pattern_function_js
8 | from stelvio.aws.cloudfront.origins.base import ComponentCloudfrontAdapter
9 | from stelvio.aws.cloudfront.origins.decorators import register_adapter
10 | from stelvio.aws.s3.s3 import Bucket
11 | from stelvio.context import context
12 |
13 |
14 | @register_adapter(Bucket)
15 | class S3BucketCloudfrontAdapter(ComponentCloudfrontAdapter):
16 | def __init__(self, idx: int, route: Route) -> None:
17 | super().__init__(idx, route)
18 | self.bucket = route.component
19 |
20 | def get_origin_config(self) -> RouteOriginConfig:
21 | oac = pulumi_aws.cloudfront.OriginAccessControl(
22 | context().prefix(f"{self.bucket.name}-oac-{self.idx}"),
23 | description=f"Origin Access Control for {self.bucket.name} route {self.idx}",
24 | origin_access_control_origin_type="s3",
25 | signing_behavior="always",
26 | signing_protocol="sigv4",
27 | opts=pulumi.ResourceOptions(depends_on=[self.bucket.resources.bucket]),
28 | )
29 | origin_args = pulumi_aws.cloudfront.DistributionOriginArgs(
30 | origin_id=self.bucket.resources.bucket.arn,
31 | domain_name=self.bucket.resources.bucket.bucket_regional_domain_name,
32 | )
33 | origin_dict = {
34 | "origin_id": origin_args.origin_id,
35 | "domain_name": origin_args.domain_name,
36 | "origin_access_control_id": oac.id,
37 | }
38 | path_pattern = (
39 | f"{self.route.path_pattern}/*"
40 | if not self.route.path_pattern.endswith("*")
41 | else self.route.path_pattern
42 | )
43 | function_code = strip_path_pattern_function_js(self.route.path_pattern)
44 | cf_function = pulumi_aws.cloudfront.Function(
45 | context().prefix(f"{self.bucket.name}-uri-rewrite-{self.idx}"),
46 | runtime="cloudfront-js-2.0",
47 | code=function_code,
48 | comment=f"Strip {self.route.path_pattern} prefix for route {self.idx}",
49 | opts=pulumi.ResourceOptions(depends_on=[self.bucket.resources.bucket]),
50 | )
51 | cache_behavior = {
52 | "path_pattern": path_pattern,
53 | "allowed_methods": ["GET", "HEAD", "OPTIONS"],
54 | "cached_methods": ["GET", "HEAD"],
55 | "target_origin_id": origin_dict["origin_id"],
56 | "compress": True,
57 | "viewer_protocol_policy": "redirect-to-https",
58 | "forwarded_values": {
59 | "query_string": False,
60 | "cookies": {"forward": "none"},
61 | "headers": ["If-Modified-Since"],
62 | },
63 | "min_ttl": 0,
64 | "default_ttl": 86400, # 1 day
65 | "max_ttl": 31536000, # 1 year
66 | "function_associations": [
67 | {
68 | "event_type": "viewer-request",
69 | "function_arn": cf_function.arn,
70 | }
71 | ],
72 | }
73 | return RouteOriginConfig(
74 | origin_access_controls=oac,
75 | origins=origin_dict,
76 | ordered_cache_behaviors=cache_behavior,
77 | cloudfront_functions=cf_function,
78 | )
79 |
80 | def get_access_policy(
81 | self, distribution: pulumi_aws.cloudfront.Distribution
82 | ) -> pulumi_aws.s3.BucketPolicy:
83 | bucket = self.bucket.resources.bucket
84 | bucket_arn = bucket.arn
85 |
86 | return pulumi_aws.s3.BucketPolicy(
87 | context().prefix(f"{self.bucket.name}-bucket-policy-{self.idx}"),
88 | bucket=bucket.id,
89 | policy=pulumi.Output.all(
90 | distribution_arn=distribution.arn,
91 | bucket_arn=bucket_arn,
92 | ).apply(
93 | lambda args: json.dumps(
94 | {
95 | "Version": "2012-10-17",
96 | "Statement": [
97 | {
98 | "Sid": "AllowCloudFrontServicePrincipal",
99 | "Effect": "Allow",
100 | "Principal": {"Service": "cloudfront.amazonaws.com"},
101 | "Action": "s3:GetObject",
102 | "Resource": f"{args['bucket_arn']}/*",
103 | "Condition": {
104 | "StringEquals": {"AWS:SourceArn": args["distribution_arn"]}
105 | },
106 | }
107 | ],
108 | }
109 | )
110 | ),
111 | )
112 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | # Welcome to Stelvio
2 |
3 | Stelvio is a Python framework that makes AWS development simple for Python devs.
4 |
5 | It lets you build and deploy AWS applications using pure Python code and a simple CLI, without dealing with complex infrastructure tools.
6 |
7 | **Head over to the _[Quick Start](getting-started/quickstart.md)_ guide to get started.**
8 |
9 | !!! note "Stelvio is in Early Development"
10 | Stelvio is actively developed as a side project. While the core features are stable, expect some API changes as we improve the developer experience.
11 |
12 | Currently supports Lambda, DynamoDB, API Gateway, and more AWS services coming soon!
13 |
14 | ## Why We're Built This
15 |
16 | As a Python developers working with AWS, I got tired of:
17 |
18 | - Switching between YAML, JSON, and other config formats
19 | - Figuring out IAM roles and permissions
20 | - Managing infrastructure separately from my code
21 | - Clicking through endless AWS console screens
22 | - Writing and maintaining complex infrastructure code
23 |
24 | We wanted to focus on building applications, not fighting with infrastructure. That's
25 | why we created Stelvio.
26 |
27 | ## How It Works
28 |
29 | Here's how simple it is to create and deploy an API with Stelvio:
30 |
31 | ```py
32 | from stelvio.app import StelvioApp
33 | from stelvio.config import StelvioAppConfig
34 |
35 | app = StelvioApp("my-api")
36 |
37 | @app.config
38 | def config(env: str) -> StelvioAppConfig:
39 | return StelvioAppConfig()
40 |
41 | @app.run
42 | def run() -> None:
43 | from stelvio.aws.api_gateway import Api
44 |
45 | api = Api('my-api')
46 | api.route('GET', '/users', 'users/handler.get')
47 | api.route('POST', '/users', 'users/handler.create')
48 | ```
49 |
50 | Then deploy with one command:
51 | ```bash
52 | stlv deploy
53 | ```
54 |
55 | Stelvio takes care of everything else:
56 |
57 | - Creates Lambda functions automatically
58 | - Sets up API Gateway with routing
59 | - Handles IAM roles and permissions
60 | - Manages environment variables
61 | - Deploys everything to AWS
62 |
63 | ## What Makes It Different
64 |
65 | ### Zero-Setup CLI
66 | Get started in seconds with `stlv init`. No complex configuration, no manual tool installation, no YAML files. Just install Stelvio and start deploying.
67 |
68 | ### Just Python
69 | Write everything in Python. No new tools or languages to learn. If you know Python, you know how to use Stelvio.
70 |
71 | ### Environments Built-In
72 | Deploy to your personal environment by default, or share staging/production environments with your team. All resources are automatically isolated and named.
73 |
74 | ### Smart Defaults That Make Sense
75 | Start simple with sensible defaults. Add configuration only when you need it. Simple things stay simple, but you still have full control when you need it.
76 |
77 | ### Type Safety That Actually Helps
78 | Get IDE support and type checking for all your AWS resources. No more guessing about environment variables or resource configurations.
79 |
80 | ## Ready to Try It?
81 |
82 | Head over to the [Quick Start](getting-started/quickstart.md) guide to get started.
83 |
84 | ## What I Believe In
85 |
86 | I built Stelvio believing that:
87 |
88 | 1. Infrastructure should feel natural in your Python code
89 | 2. You shouldn't need to become an AWS expert
90 | 3. Simple things should be simple
91 | 4. Your IDE should help you catch problems early
92 | 5. Good defaults beat endless options
93 | 6. Developer experience matters as much as functionality
94 |
95 | ## Let's Talk
96 |
97 | - Found a bug or want a feature? [Open an issue](https://github.com/stelviodev/stelvio/issues)
98 | - Have questions? [Join the discussion](https://github.com/stelviodev/stelvio/discussions)
99 | - Want updates and tips? [Follow us on X](https://x.com/stelviodev)
100 |
101 | ## License
102 |
103 | Stelvio is released under the Apache 2.0 License. See the LICENSE file for details.
104 |
105 | ## Where to go from here
106 |
107 | ### Getting Started
108 |
109 | - [Quick Start](getting-started/quickstart.md) - Deploy your first app in minutes
110 | - [StelvioApp Basics](guides/stelvio-app.md) - Understanding the core concepts
111 | - [Environments](guides/environments.md) - Personal and team environments
112 |
113 | ### Guides
114 |
115 | - [Lambda Functions](guides/lambda.md) - Serverless functions with Python
116 | - [API Gateway](guides/api-gateway.md) - Build REST APIs
117 | - [DynamoDB](guides/dynamo-db.md) - NoSQL database
118 | - [S3 Buckets](guides/s3.md) - AWS S3 (Object Storage)
119 | - [Linking](guides/linking.md) - Automatic IAM permissions
120 | - [DNS](guides/dns.md) - Custom domains and TLS certificates
121 | - [Project Structure](guides/project-structure.md) - Organizing your code
122 |
123 | ### Reference
124 |
125 | - [CLI Commands](guides/using-cli.md) - All stlv commands
126 | - [State Management](guides/state.md) - How Stelvio manages state
127 | - [Troubleshooting](guides/troubleshooting.md) - Debug common issues
128 |
--------------------------------------------------------------------------------
/docs/changelog.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## 0.5.0a7 (2025-10-31)
4 |
5 | With this release, Stelvio gets:
6 |
7 | - a S3StaticWebsite component for S3 static website hosting with CloudFront CDN and optional custom domain support
8 | - support for DynamoDB streams and subscriptions.
9 | - support for Authorizers and CORS for `Api`
10 |
11 | ### Static Website Hosting with S3 and CloudFront
12 | - Added `stelvio.aws.s3.S3StaticWebsite` for managing S3 buckets for static website hosting with CloudFront CDN and optional custom domain support
13 |
14 | ### DynamoDB Streams
15 | - Added `stream` property and `subscribe` method to the `DynamoTable` component so you can easily enable streams and add lambda that listens to the changes in the table.
16 |
17 | ### Api gateway authorizers
18 | - Added `add_token_authorizer`, `add_request_authorizer` and `add_cognito_authorizer` so you can add different authorizers.
19 | - Added `default_auth` property to set default authorizers for all endpoints and methods
20 | - Added `auth` param to the `route` method to set authorizer on per route basis.
21 |
22 | ### Api gateway CORS
23 |
24 | - Added `CorsConfig` and `CorsConfigDict` classes that can be used to pass to the new `cors` param of `Api` and its config classes(`ApiConfig` and `ApiConfigDict`) to configure cors settings of your Api gateway.
25 |
26 | ## 0.4.0a6 (2025-09-05)
27 |
28 | With this release, S3 buckets, custom domains (including Cloudflare) for ApiGateway and DynamoDB Indexes are supported.
29 |
30 | ### DNS & Custom domain support
31 | - Added `stelvio.aws.route53.Route53Dns` for managing DNS records in AWS Route 53
32 | - Added `stelvio.cloudflare.dns.CloudflareDns` for managing DNS records in Cloudflare
33 | - Added `stelvio.aws.acm.AcmValidatedDomain` for managing TLS certificates for custom domains in AWS
34 | - Stelvio now automatically creates and validates TLS certificates for custom domains
35 |
36 | ### S3 Bucket Support
37 | - Added `stelvio.aws.s3.Bucket` for managing S3 buckets
38 |
39 | ### DynamoDb Indexes Support
40 | - Added support for DynamoDB local and global indexes.
41 |
42 | ### Internal improvements & Fixes
43 | - better docs
44 | - `DynamoTableConfig`
45 | - fix so now we can have same routes in different API Gateways
46 | - fix to make sure generated roles and policy names with within AWS limits
47 | - fixed flaky tests
48 | - properly handling API Gateway account and role and correctly displaying in CLI
49 |
50 | ## 0.3.0a5 (2025-07-14)
51 |
52 | ### 🎉 Major Release: Complete CLI Experience
53 |
54 | This release transforms Stelvio from a library into a complete development
55 | platform with a dedicated CLI.
56 |
57 | #### Stelvio CLI (`stlv` command)
58 |
59 | - **`stlv init`** - Initialize new projects with interactive AWS setup
60 | - **`stlv deploy`** - Deploy with real-time progress display
61 | - **`stlv diff`** - Preview changes before deploying
62 | - **`stlv destroy`** - Clean up resources safely
63 | - **`stlv refresh`** - Sync state with actual AWS resources
64 | - **`stlv version`** - Check your Stelvio version
65 |
66 | #### Automatic Pulumi Management
67 |
68 | - Zero-setup deployment - Pulumi installed automatically
69 | - No more manual Pulumi configuration or project setup
70 |
71 | #### Environments
72 |
73 | - Personal environments (defaults to your username)
74 | - Shared environments for team collaboration
75 | - Environment-specific resource naming and isolation
76 |
77 | #### Automatic Passphrase Management
78 |
79 | - Generates and stores passphrases in AWS Parameter Store
80 | - No more manual passphrase handling
81 |
82 | #### Rich Console Output 🎨
83 |
84 | - Color-coded operations (green=create, yellow=update, red=delete)
85 | - Real-time deployment progress with operation timing
86 | - Resource grouping and operation summaries
87 | - Optional `--show-unchanged` flag for detailed views
88 |
89 |
90 | #### New StelvioApp Architecture
91 |
92 | - Clean decorator-based configuration with `@app.config` and `@app.run`
93 |
94 | #### Consistent Resource Naming
95 |
96 | - All resources get `{app}-{env}-{name}` naming pattern
97 | - Prevents resource collisions across different deployments
98 |
99 | #### Enhanced API Gateway Support
100 |
101 | - Fixed multiple environment deployment issues
102 | - Handles existing CloudWatch roles correctly
103 |
104 | #### 🐛 Bug Fixes & Improvements
105 |
106 | - Better error messages and debugging information
107 | - Improved logging system
108 | - Enhanced confirmation prompts for destructive operations
109 |
110 | ## 0.2.0a4 (2025-05-14)
111 |
112 | - Lambda Function dependencies
113 | - Lambda Layers
114 | - More tests for faster future progress
115 |
116 | ## 0.1.0a2 (2025-02-14)
117 |
118 | - Maintenance release
119 | - Fixed bug when route couldn't be created if it had just default config
120 | - Added better checks so Stelvio informs you if there's route conflicts
121 | - Added tests
122 |
123 |
124 |
125 | ## 0.1.0a1 (2025-01-31)
126 |
127 | - Initial release
128 | - Very basic support for:
129 |
130 | - AWS Lambda
131 | - Dynamo DB Table
132 | - API Gateway
133 |
--------------------------------------------------------------------------------
/docs/guides/troubleshooting.md:
--------------------------------------------------------------------------------
1 | # Troubleshooting
2 |
3 | This guide helps you debug common issues and understand Stelvio's internal workings when things go wrong.
4 |
5 | ## Debugging with Verbose Output
6 |
7 | When you encounter issues, use the verbose flags to get more detailed information:
8 |
9 | ```bash
10 | # Show INFO level logs
11 | stlv deploy -v
12 |
13 | # Show DEBUG level logs (most detailed)
14 | stlv deploy -vv
15 | ```
16 |
17 | These logs display information about the locations and values that Stelvio works
18 | with, as well as the operations it performs.
19 |
20 | ## Understanding Log Files
21 |
22 | Stelvio also writes logs to files to help diagnose issues. Log locations depend on your operating system:
23 |
24 | - **macOS:** `~/Library/Logs/stelvio/`
25 | - **Linux:** `~/.local/state/stelvio/logs/`
26 | - **Windows:** `%LOCALAPPDATA%\stelvio\logs\`
27 |
28 |
29 | ## The .stelvio Directory
30 |
31 | Each Stelvio project has a `.stelvio/` directory in the project root:
32 |
33 | **`.stelvio/userenv`**
34 |
35 | - Contains your personal environment name
36 | - Defaults to your computer username
37 | - Can be customized (see [Environments guide](environments.md#customizing-your-personal-environment-name))
38 |
39 | **`lambda_dependencies/`**
40 |
41 | - Cached Lambda and Layer dependencies
42 | - Safe to delete if you suspect corruption - regenerated on next deployment
43 |
44 | **`{timestamp}-{random}/`** (temporary working directory)
45 |
46 | - Created when running commands that need state (`diff`, `deploy`, `refresh`, `destroy`, `outputs`, `state` commands)
47 | - Contains `.pulumi/stacks/{app}/{env}.json` - state downloaded from S3
48 | - Automatically deleted when command completes
49 | - If a command crashes, leftover directories can be safely deleted
50 |
51 | ## Renaming Your App or Environment
52 |
53 | See [State Management - Renaming](state.md#renaming) for how to safely rename your app or environment.
54 |
55 | ## Common Issues and Solutions
56 |
57 | ### State Lock Errors
58 |
59 | If you kill deploy/destroy operation while running or something crashes
60 | unexpectedly you might not be able to deploy.
61 |
62 | **Problem:** You get "Stack is currently being updated"
63 |
64 | **Solution:**
65 | ```bash
66 | stlv unlock
67 | stlv unlock staging
68 | ```
69 |
70 | Only use this if you're certain no other deployment is actually running.
71 |
72 | ### AWS Credential Issues
73 |
74 | **Problem:** "Unable to locate credentials" or "Invalid security token"
75 |
76 | **Solution:**
77 | Make sure your AWS credentials are setup properly.
78 |
79 | You have three options:
80 |
81 | 1. Environment variable `AWS_PROFILE` is set and profile exists:
82 | ```bash
83 | export AWS_PROFILE=YOUR_PROFILE_NAME
84 | ```
85 |
86 | 2. Environment variables `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` are set:
87 | ```bash
88 | export AWS_ACCESS_KEY_ID=""
89 | export AWS_SECRET_ACCESS_KEY=""
90 | ```
91 | 3. Set profile in `stlv_app.py`:
92 | ```python title="stlv_app.py" hl_lines="4"
93 | @app.config
94 | def configuration(env: str) -> StelvioAppConfig:
95 | return StelvioAppConfig(
96 | aws=AwsConfig(profile="your-profile"),
97 | )
98 | ```
99 |
100 | #### How to check if AWS profile exists
101 |
102 | 1. If you have [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-welcome.html)
103 | installed, run:
104 | ```bash
105 | aws configure list-profiles
106 | ```
107 | 2. Alternatively, you can check the AWS configuration files directly.
108 | Profiles are stored in `.aws/config` and `.aws/credentials` in your user directory.
109 |
110 | ??? info "Platform-specific user directory paths"
111 | - **Linux/macOS**: `~/.aws/`
112 | - **Windows**: `%USERPROFILE%\.aws\`
113 |
114 | ### Permission Denied Errors
115 |
116 | **Problem:** "Access Denied" when accessing AWS resources
117 |
118 | **Solutions:**
119 |
120 | 1. Verify IAM permissions for your AWS user/profile
121 | 2. Check you're deploying to the correct region
122 | 3. Ensure Parameter Store access is allowed for passphrases
123 |
124 | ### Deployment Failures
125 |
126 | **Problem:** Deployment fails with unclear errors
127 |
128 | **Solution:**
129 | Run with `-vv` for detailed logs
130 |
131 | ### Cache Corruption
132 |
133 | **Problem:** Strange build errors or outdated code being deployed
134 |
135 | **Solution:**
136 | ```bash
137 | rm -rf .stelvio/lambda_dependencies
138 | ```
139 |
140 | ## Getting Help
141 |
142 | If you're still stuck:
143 |
144 | 1. Run your command with `-vv` and check the full output
145 | 2. Check the log files for detailed error information
146 | 3. Search [GitHub issues](https://github.com/stelviodev/stelvio/issues)
147 | 4. Create a new issue with:
148 | - Your Stelvio version (`stlv version`)
149 | - The command you ran
150 | - The error message
151 | - Relevant logs (with sensitive data removed)
152 | 5. Get in touch with us at [@stelviodev](http://x.com/stelviodev) on X (Twitter) or [@michal_stlv](http://x.com/michal_stlv) or [@bascodes](http://x.com/bascodes)
153 |
--------------------------------------------------------------------------------
/stelvio/app.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from collections.abc import Callable
3 | from importlib import import_module
4 | from pathlib import Path
5 | from typing import ClassVar, TypeVar, final
6 |
7 | from pulumi import Resource as PulumiResource
8 |
9 | # Import cleanup functions for both functions and layers
10 | from stelvio.component import Component, ComponentRegistry
11 | from stelvio.config import StelvioAppConfig
12 | from stelvio.link import LinkConfig
13 |
14 | from .project import get_project_root
15 |
16 | T = TypeVar("T", bound=PulumiResource)
17 |
18 | logger = logging.getLogger(__name__)
19 |
20 |
21 | type StelvioConfigFn = Callable[[str], StelvioAppConfig]
22 |
23 |
24 | @final
25 | class StelvioApp:
26 | __instance: ClassVar["StelvioApp | None"] = None
27 |
28 | def __init__(
29 | self,
30 | name: str,
31 | modules: list[str] | None = None,
32 | link_configs: dict[type[Component[T]], Callable[[T], LinkConfig]] | None = None,
33 | ):
34 | if StelvioApp.__instance is not None:
35 | raise RuntimeError("StelvioApp has already been instantiated.")
36 |
37 | self._name = name
38 | self._modules = modules or []
39 | self._config_func = None
40 | self._run_func = None
41 | if link_configs:
42 | for component_type, fn in link_configs.items():
43 | self.set_user_link_for(component_type, fn)
44 | if StelvioApp.__instance:
45 | raise RuntimeError("StelvioApp instance already exists. Only one is allowed.")
46 | StelvioApp.__instance = self
47 |
48 | @classmethod
49 | def get_instance(cls) -> "StelvioApp":
50 | if cls.__instance is None:
51 | raise RuntimeError(
52 | "StelvioApp has not been instantiated. Ensure 'app = StelvioApp(...)' is called "
53 | "in your stlv_app.py."
54 | )
55 | return cls.__instance
56 |
57 | def config(self, func: StelvioConfigFn) -> StelvioConfigFn:
58 | if self._config_func:
59 | raise RuntimeError("Config function already registered.")
60 | self._config_func = func
61 | logger.debug("Config function '%s' registered for app '%s'.", func.__name__, self._name)
62 | return func
63 |
64 | def run(self, func: Callable[[], None]) -> Callable[[], None]:
65 | if self._run_func:
66 | raise RuntimeError("Run function already registered.")
67 | self._run_func = func
68 | logger.debug("Run function '%s' registered for app '%s'.", func.__name__, self._name)
69 | return func
70 |
71 | def _execute_user_config_func(self, env: str) -> StelvioAppConfig:
72 | if not self._config_func:
73 | raise RuntimeError("No @StelvioApp.config function defined.")
74 | self._app_config: StelvioAppConfig = self._config_func(env)
75 | if self._app_config is None or not isinstance(self._app_config, StelvioAppConfig):
76 | raise ValueError("@app.config function must return an instance of StelvioAppConfig.")
77 | return self._app_config
78 |
79 | def _get_pulumi_program_func(self) -> Callable[[], None]:
80 | if not self._run_func:
81 | raise RuntimeError("No @StelvioApp.run function defined.")
82 |
83 | def run() -> None:
84 | self._run_func()
85 | self.drive()
86 |
87 | return run
88 |
89 | @staticmethod
90 | def set_user_link_for(
91 | component_type: type[Component[T]], func: Callable[[T], LinkConfig]
92 | ) -> None:
93 | """Register a user-defined link creator that overrides defaults"""
94 | ComponentRegistry.register_user_link_creator(component_type, func)
95 |
96 | def drive(self) -> None:
97 | self._load_modules(self._modules, get_project_root())
98 | # Brm brm, vroooom through those infrastructure deployments
99 | # like an Alfa Romeo through those Stelvio hairpins
100 | for i in ComponentRegistry.all_instances():
101 | _ = i.resources
102 |
103 | def _load_modules(self, modules: list[str], project_root: Path) -> None:
104 | exclude_dirs = {"__pycache__", "build", "dist", "node_modules", ".egg-info"}
105 | for pattern in modules:
106 | # Direct module import
107 | if "." in pattern and not any(c in pattern for c in "/*?[]"):
108 | import_module(pattern)
109 | continue
110 |
111 | # Glob pattern
112 | files = project_root.rglob(pattern)
113 |
114 | for file in files:
115 | path = Path(file)
116 |
117 | # Skip hidden folders (any part starts with .)
118 | if any(part.startswith(".") for part in path.parts):
119 | continue
120 |
121 | if path.suffix == ".py" and not any(
122 | excluded in path.parts for excluded in exclude_dirs
123 | ):
124 | parts = list(path.with_suffix("").parts)
125 | if all(part.isidentifier() for part in parts):
126 | module_path = ".".join(parts)
127 | import_module(module_path)
128 |
--------------------------------------------------------------------------------
/stelvio/aws/function/resources_codegen.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | from .constants import NUMBER_WORDS
4 | from .naming import _envar_name
5 |
6 |
7 | def _create_stlv_resource_file(folder: Path, content: str | None) -> None:
8 | """Create resource access file with supplied content."""
9 | path = folder / "stlv_resources.py"
10 | # Delete file if no content
11 | if not content:
12 | path.unlink(missing_ok=True)
13 | return
14 | with Path.open(path, "w") as f:
15 | f.write(content)
16 |
17 |
18 | def create_stlv_resource_file_content(
19 | link_properties_map: dict[str, list[str]], include_cors: bool = False
20 | ) -> str | None:
21 | """Generate resource access file content with classes for linked resources."""
22 | # Return None if no properties to generate and no CORS
23 | if not any(link_properties_map.values()) and not include_cors:
24 | return None
25 |
26 | lines = [
27 | "import os",
28 | "from dataclasses import dataclass",
29 | "from typing import Final",
30 | "from functools import cached_property\n\n",
31 | ]
32 |
33 | # Generate CORS class if needed
34 | if include_cors:
35 | lines.extend(_create_cors_class())
36 |
37 | for link_name, properties in link_properties_map.items():
38 | if not properties:
39 | continue
40 | lines.extend(_create_link_resource_class(link_name, properties))
41 |
42 | lines.extend(["@dataclass(frozen=True)", "class LinkedResources:"])
43 |
44 | # Add cors to LinkedResources if included
45 | if include_cors:
46 | lines.append(" cors: Final[CorsResource] = CorsResource()")
47 |
48 | for link_name in link_properties_map:
49 | cls_name = _to_valid_python_class_name(link_name)
50 | lines.append(
51 | f" {_pascal_to_snake(cls_name)}: Final[{cls_name}Resource] = {cls_name}Resource()"
52 | )
53 | lines.extend(["\n", "Resources: Final = LinkedResources()"])
54 |
55 | return "\n".join(lines)
56 |
57 |
58 | def _create_cors_class() -> list[str]:
59 | """Generate CORS resource class with env vars and get_headers() helper."""
60 | return [
61 | "@dataclass(frozen=True)",
62 | "class CorsResource:",
63 | " @cached_property",
64 | " def allow_origin(self) -> str:",
65 | ' return os.environ.get("STLV_CORS_ALLOW_ORIGIN", "")',
66 | "",
67 | " @cached_property",
68 | " def expose_headers(self) -> str:",
69 | ' return os.environ.get("STLV_CORS_EXPOSE_HEADERS", "")',
70 | "",
71 | " @cached_property",
72 | " def allow_credentials(self) -> bool:",
73 | ' return os.environ.get("STLV_CORS_ALLOW_CREDENTIALS", "false") == "true"',
74 | "",
75 | " def get_headers(self) -> dict[str, str]:",
76 | ' """Returns CORS headers for API Gateway responses."""',
77 | ' headers = {"Access-Control-Allow-Origin": self.allow_origin}',
78 | " if self.expose_headers:",
79 | ' headers["Access-Control-Expose-Headers"] = self.expose_headers',
80 | " if self.allow_credentials:",
81 | ' headers["Access-Control-Allow-Credentials"] = "true"',
82 | " return headers",
83 | "",
84 | "",
85 | ]
86 |
87 |
88 | def _create_link_resource_class(link_name: str, properties: list[str]) -> list[str] | None:
89 | if not properties:
90 | return None
91 | class_name = _to_valid_python_class_name(link_name)
92 | lines = [
93 | "@dataclass(frozen=True)",
94 | f"class {class_name}Resource:",
95 | ]
96 | for prop in properties:
97 | lines.extend(
98 | [
99 | " @cached_property",
100 | f" def {prop}(self) -> str:",
101 | f' return os.getenv("{_envar_name(link_name, prop)}")\n',
102 | ]
103 | )
104 | lines.append("")
105 | return lines
106 |
107 |
108 | def _to_valid_python_class_name(aws_name: str) -> str:
109 | # Split and clean
110 | words = aws_name.replace("-", " ").replace(".", " ").replace("_", " ").split()
111 | cleaned_words = ["".join(c for c in word if c.isalnum()) for word in words]
112 | class_name = "".join(word.capitalize() for word in cleaned_words)
113 |
114 | # Convert only first digit if name starts with number
115 | if class_name and class_name[0].isdigit():
116 | first_digit = NUMBER_WORDS[class_name[0]]
117 | class_name = first_digit + class_name[1:]
118 |
119 | return class_name
120 |
121 |
122 | def _pascal_to_camel(pascal_str: str) -> str:
123 | """Convert Pascal case to camel case.
124 | Example: PascalCase -> pascalCase, XMLParser -> xmlParser
125 | """
126 | if not pascal_str:
127 | return pascal_str
128 | i = 1
129 | while i < len(pascal_str) and pascal_str[i].isupper():
130 | i += 1
131 | return pascal_str[:i].lower() + pascal_str[i:]
132 |
133 |
134 | def _pascal_to_snake(pascal_str: str) -> str:
135 | return "".join(
136 | "_" + char.lower() if char.isupper() and i > 0 else char.lower()
137 | for i, char in enumerate(pascal_str)
138 | )
139 |
--------------------------------------------------------------------------------
/stelvio/aws/function/dependencies.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from pathlib import Path
3 | from typing import Final
4 |
5 | from pulumi import FileArchive
6 |
7 | from stelvio.aws._packaging.dependencies import (
8 | PulumiAssets,
9 | RequirementsSpec,
10 | _resolve_requirements_from_list,
11 | _resolve_requirements_from_path,
12 | clean_active_dependencies_caches_file,
13 | clean_stale_dependency_caches,
14 | get_or_install_dependencies,
15 | )
16 | from stelvio.aws.function.config import FunctionConfig
17 | from stelvio.aws.function.constants import DEFAULT_ARCHITECTURE, DEFAULT_RUNTIME
18 | from stelvio.project import get_project_root
19 |
20 | # Constants specific to function dependency resolution
21 | _REQUIREMENTS_FILENAME: Final[str] = "requirements.txt"
22 | _FUNCTION_CACHE_SUBDIR: Final[str] = "functions"
23 |
24 | logger = logging.getLogger(__name__)
25 |
26 |
27 | def _get_function_packages(function_config: FunctionConfig) -> PulumiAssets | None:
28 | """
29 | Resolves, installs (via shared logic), and packages Lambda function dependencies.
30 |
31 | Args:
32 | function_config: The configuration object for the Lambda function.
33 |
34 | Returns:
35 | A dictionary mapping filenames/paths to Pulumi Assets/Archives for the
36 | dependencies, or None if no requirements are specified or found.
37 | """
38 | project_root = get_project_root()
39 | log_context = f"Function: {function_config.handler}" # Use handler for context
40 | logger.debug("[%s] Starting dependency resolution", log_context)
41 |
42 | # 1. Resolve requirements source
43 | source = _resolve_requirements_source(function_config, project_root, log_context)
44 | if source is None:
45 | logger.debug("[%s] No requirements source found or requirements disabled.", log_context)
46 | return None
47 |
48 | # 2. Determine runtime, architecture, and source context path for shared functions
49 | runtime = function_config.runtime or DEFAULT_RUNTIME
50 | architecture = function_config.architecture or DEFAULT_ARCHITECTURE
51 |
52 | # 3. Ensure Dependencies are Installed (using shared function)
53 | cache_dir = get_or_install_dependencies(
54 | requirements_source=source,
55 | runtime=runtime,
56 | architecture=architecture,
57 | project_root=project_root,
58 | cache_subdirectory=_FUNCTION_CACHE_SUBDIR,
59 | log_context=log_context,
60 | )
61 |
62 | # 4. Package dependencies from the cache directory (using shared function)
63 | return {"": FileArchive(str(cache_dir))}
64 |
65 |
66 | def _handle_requirements_none(
67 | config: FunctionConfig, project_root: Path, log_context: str
68 | ) -> RequirementsSpec | None:
69 | """Handle the case where requirements=None (default lookup)."""
70 | logger.debug(
71 | "[%s] Requirements option is None, looking for default %s",
72 | log_context,
73 | _REQUIREMENTS_FILENAME,
74 | )
75 | if config.folder_path: # Folder-based: look inside the folder
76 | base_folder_relative = Path(config.folder_path)
77 | else: # Single file lambda: relative to the handler file's directory
78 | base_folder_relative = Path(config.handler_file_path).parent
79 |
80 | # Path relative to project root
81 | source_path_relative = base_folder_relative / _REQUIREMENTS_FILENAME
82 | abs_path = project_root / source_path_relative
83 | logger.debug("[%s] Checking for default requirements file at: %s", log_context, abs_path)
84 |
85 | if abs_path.is_file():
86 | logger.info("[%s] Found default requirements file: %s", log_context, abs_path)
87 | return RequirementsSpec(content=None, path_from_root=source_path_relative)
88 | logger.debug("[%s] Default %s not found.", log_context, _REQUIREMENTS_FILENAME)
89 | return None
90 |
91 |
92 | def _resolve_requirements_source(
93 | config: FunctionConfig, project_root: Path, log_context: str
94 | ) -> RequirementsSpec | None:
95 | """
96 | Determines the source and content of requirements based on FunctionConfig.
97 |
98 | Returns:
99 | A RequirementsSource, or None if no requirements are applicable.
100 | Raises:
101 | FileNotFoundError: If an explicitly specified requirements file is not found.
102 | ValueError: If an explicitly specified path is not a file.
103 | """
104 | requirements = config.requirements
105 | logger.debug("[%s] Resolving requirements source with option: %r", log_context, requirements)
106 |
107 | if requirements is False or requirements == []:
108 | logger.info(
109 | "[%s] Requirements handling explicitly disabled or empty list provided.", log_context
110 | )
111 | return None
112 |
113 | if requirements is None:
114 | return _handle_requirements_none(config, project_root, log_context)
115 |
116 | if isinstance(requirements, str):
117 | return _resolve_requirements_from_path(requirements, project_root, log_context)
118 |
119 | if isinstance(requirements, list):
120 | return _resolve_requirements_from_list(requirements, log_context)
121 |
122 | # Should be caught by FunctionConfig validation, but raise defensively
123 | raise TypeError(
124 | f"[{log_context}] Unexpected type for requirements configuration: {type(requirements)}"
125 | )
126 |
127 |
128 | def clean_function_active_dependencies_caches_file() -> None:
129 | clean_active_dependencies_caches_file(_FUNCTION_CACHE_SUBDIR)
130 |
131 |
132 | def clean_function_stale_dependency_caches() -> None:
133 | clean_stale_dependency_caches(_FUNCTION_CACHE_SUBDIR)
134 |
--------------------------------------------------------------------------------
/tests/test_git.py:
--------------------------------------------------------------------------------
1 | """Tests for stelvio.git helper functions."""
2 |
3 | from __future__ import annotations
4 |
5 | import subprocess
6 | from types import SimpleNamespace
7 | from typing import TYPE_CHECKING
8 |
9 | import pytest
10 |
11 | from stelvio import git
12 |
13 | if TYPE_CHECKING:
14 | from pathlib import Path
15 |
16 |
17 | def test_get_git_executable_returns_path(monkeypatch, tmp_path):
18 | fake_git = tmp_path / "git"
19 | fake_git.write_text("")
20 | monkeypatch.setattr("stelvio.git.shutil.which", lambda _: fake_git.as_posix())
21 |
22 | result = git._get_git_executable()
23 |
24 | assert result == fake_git.as_posix()
25 |
26 |
27 | def test_get_git_executable_missing(monkeypatch):
28 | monkeypatch.setattr("stelvio.git.shutil.which", lambda _: None)
29 |
30 | with pytest.raises(RuntimeError, match="Git is not installed"):
31 | git._get_git_executable()
32 |
33 |
34 | @pytest.mark.parametrize(
35 | ("value", "name"),
36 | [
37 | ("invalid owner", "owner"),
38 | ("repo$", "repo"),
39 | ("bad..branch", "branch"),
40 | ],
41 | )
42 | def test_validate_github_identifier_rejects_invalid(value, name):
43 | with pytest.raises(ValueError): # noqa: PT011
44 | git._validate_github_identifier(value, name)
45 |
46 |
47 | def test_validate_github_identifier_accepts_valid_values():
48 | for value, name in [("my-org", "owner"), ("repo_name", "repo"), ("feature/add", "branch")]:
49 | git._validate_github_identifier(value, name)
50 |
51 |
52 | def test_validate_subdirectory_checks_for_traversal():
53 | with pytest.raises(ValueError, match="Subdirectory cannot contain '..'"):
54 | git._validate_subdirectory("../../secret")
55 |
56 |
57 | def test_validate_subdirectory_accepts_valid_path():
58 | git._validate_subdirectory("src/app")
59 |
60 |
61 | def test_run_git_command_executes_with_safe_arguments(tmp_path, monkeypatch):
62 | git_path = tmp_path / "git"
63 | git_path.write_text("")
64 | destination = tmp_path / "dest"
65 |
66 | calls: list[SimpleNamespace] = []
67 |
68 | def fake_run(cmd_list, check, shell, cwd, capture_output, text, timeout): # noqa: PLR0913
69 | calls.append(SimpleNamespace(cmd=cmd_list, cwd=cwd))
70 | assert check is True
71 | assert shell is False
72 | assert capture_output is True
73 | assert text is True
74 | assert timeout == 300
75 | return subprocess.CompletedProcess(cmd_list, 0)
76 |
77 | monkeypatch.setattr(subprocess, "run", fake_run)
78 |
79 | git._run_git_command(
80 | git_path.as_posix(),
81 | ["clone", "https://github.com/owner/repo.git", destination.as_posix()],
82 | )
83 |
84 | assert calls[0].cmd[0] == git_path.as_posix()
85 | assert calls[0].cmd[1] == "clone"
86 |
87 |
88 | def test_run_git_command_rejects_unsafe_command(tmp_path):
89 | git_path = tmp_path / "git"
90 | git_path.write_text("")
91 |
92 | with pytest.raises(ValueError, match="Disallowed git command"):
93 | git._run_git_command(git_path.as_posix(), ["clone;rm"])
94 |
95 |
96 | def test_run_git_command_rejects_non_string_argument(tmp_path):
97 | git_path = tmp_path / "git"
98 | git_path.write_text("")
99 |
100 | with pytest.raises(TypeError):
101 | git._run_git_command(git_path.as_posix(), ["clone", 123])
102 |
103 |
104 | def test_checkout_from_github_with_subdirectory(tmp_path, monkeypatch):
105 | destination = tmp_path / "checkout"
106 | destination.mkdir()
107 | (destination / ".git").mkdir()
108 |
109 | run_calls: list[tuple[list[str], Path | None]] = []
110 |
111 | monkeypatch.setattr("stelvio.git._get_git_executable", lambda: "git")
112 |
113 | def fake_run(git_executable, args, cwd=None):
114 | run_calls.append((args, cwd))
115 |
116 | monkeypatch.setattr("stelvio.git._run_git_command", fake_run)
117 |
118 | result = git._checkout_from_github(
119 | owner="owner",
120 | repo="repo",
121 | branch="main",
122 | subdirectory="src/app",
123 | destination=destination,
124 | )
125 |
126 | expected_clone = [
127 | "clone",
128 | "--branch",
129 | "main",
130 | "https://github.com/owner/repo.git",
131 | destination.as_posix(),
132 | "--single-branch",
133 | "--depth",
134 | "1",
135 | "--filter=blob:none",
136 | "--sparse",
137 | ]
138 |
139 | assert run_calls[0] == (expected_clone, None)
140 | assert run_calls[1] == (["sparse-checkout", "init", "--cone"], destination)
141 | assert run_calls[2] == (["sparse-checkout", "add", "src/app"], destination)
142 | assert not (destination / ".git").exists()
143 | assert result == destination / "src/app"
144 |
145 |
146 | def test_copy_from_github_copies_files(monkeypatch, tmp_path):
147 | created_files: list[Path] = []
148 |
149 | def fake_checkout(owner, repo, branch, subdirectory, destination):
150 | target = destination / (subdirectory if subdirectory else "")
151 | target.mkdir(parents=True, exist_ok=True)
152 | file_path = target / "example.txt"
153 | file_path.write_text("hello")
154 | created_files.append(file_path)
155 | return target
156 |
157 | monkeypatch.setattr("stelvio.git._checkout_from_github", fake_checkout)
158 |
159 | dest_path = tmp_path / "final"
160 | result = git.copy_from_github("owner", "repo", subdirectory="src", destination=dest_path)
161 |
162 | assert result == dest_path
163 | copied_file = dest_path / "example.txt"
164 | assert copied_file.read_text() == "hello"
165 | assert created_files[0].name == "example.txt"
166 |
--------------------------------------------------------------------------------
/docs/guides/using-cli.md:
--------------------------------------------------------------------------------
1 | # Using Stelvio CLI
2 |
3 | The Stelvio CLI (`stlv`) manages your AWS infrastructure deployments.
4 |
5 | ## Global Options
6 |
7 | - `--verbose, -v` - Show INFO level logs
8 | - `-vv` - Show DEBUG level logs
9 | - `--help` - Show command help
10 |
11 | Global options go right after `stlv`:
12 |
13 | ```bash
14 | stlv -v deploy staging
15 | stlv -vv diff
16 | ```
17 |
18 | ## Commands
19 |
20 | ### init
21 |
22 | Initializes a new Stelvio project in the current directory.
23 |
24 | ```bash
25 | stlv init
26 | ```
27 |
28 | **Options:**
29 |
30 | - `--profile YOUR_PROFILE_NAME` - AWS profile name
31 | - `--region YOUR_REGION` - AWS region (e.g., us-east-1, eu-west-1)
32 |
33 | Creates `stlv_app.py` with your project configuration. If you don't specify options, you'll be prompted for AWS profile and region.
34 |
35 | ### diff
36 |
37 | `stlv diff [env]` - Shows what changes will happen for specified environment. Defaults to personal environment if not provided.
38 |
39 | ```bash
40 | stlv diff
41 | stlv diff staging
42 | ```
43 |
44 | ### deploy
45 |
46 | `stlv deploy [env]` - Deploys your infrastructure to specified environment. Defaults to personal environment if not provided.
47 |
48 | ```bash
49 | stlv deploy
50 | stlv deploy staging
51 | ```
52 |
53 | **Options:**
54 |
55 | - `--yes, -y` - Skip confirmation prompts
56 |
57 | !!! warning
58 | Shared environments ask for confirmation unless you use `--yes`.
59 |
60 | ### refresh
61 |
62 | `stlv refresh [env]` - Updates your state to match what's actually in AWS for specified environment. Defaults to personal environment if not provided.
63 |
64 | ```bash
65 | stlv refresh
66 | stlv refresh prod
67 | ```
68 |
69 | Use this when resources were changed outside of Stelvio (e.g., someone modified a Lambda in the AWS console). Refresh updates your state to match what's actually in AWS.
70 |
71 | After refreshing, run `stlv diff` to see the difference between your code and the updated state. You can then either:
72 |
73 | - Update your code to match the changes made in AWS
74 | - Run `stlv deploy` to revert AWS back to what your code defines
75 |
76 | **What refresh does:**
77 |
78 | - Updates state for resources already tracked by Stelvio
79 | - Detects drift (differences between state and actual AWS resources)
80 |
81 | **What refresh does NOT do:**
82 |
83 | - Import resources that exist in AWS but aren't in state
84 | - Modify your code or infrastructure definition
85 | - Create, update, or delete any AWS resources
86 |
87 | ### destroy
88 |
89 | `stlv destroy [env]` - Destroys all infrastructure in specified environment. Defaults to personal environment if not provided.
90 |
91 | ```bash
92 | stlv destroy
93 | stlv destroy staging
94 | ```
95 |
96 | **Options:**
97 |
98 | - `--yes, -y` - Skip confirmation prompts
99 |
100 | !!! danger
101 | This deletes everything. Always asks for confirmation unless you use `--yes`.
102 |
103 | ### unlock
104 |
105 | `stlv unlock [env]` - Unlocks state when a previous operation was interrupted. Defaults to personal environment if not provided.
106 |
107 | ```bash
108 | stlv unlock
109 | stlv unlock staging
110 | ```
111 |
112 | Use this when:
113 |
114 | - A previous deployment was interrupted (Ctrl+C, network issue, etc.)
115 | - You see "Stack is currently being updated" errors
116 |
117 | !!! warning
118 | Only run this if you're sure no other deployment is actually running. Running `unlock` while another deployment is active can cause state corruption.
119 |
120 | ### outputs
121 |
122 | `stlv outputs [env]` - Shows stack outputs for specified environment. Defaults to personal environment if not provided.
123 |
124 | ```bash
125 | stlv outputs
126 | stlv outputs staging
127 | stlv outputs --json
128 | ```
129 |
130 | **Options:**
131 |
132 | - `--json` - Output as JSON for scripting
133 |
134 | ### state
135 |
136 | Manage infrastructure state directly. Use for recovery scenarios.
137 |
138 | #### state list
139 |
140 | `stlv state list [-e env]` - Lists all resources tracked in state. Use `-e/--env` to specify environment. Defaults to personal environment if not provided.
141 |
142 | ```bash
143 | stlv state list
144 | stlv state list -e prod
145 | ```
146 |
147 | #### state rm
148 |
149 | `stlv state rm [-e env]` - Removes a resource from state without deleting from AWS. Use `-e/--env` to specify environment. Defaults to personal environment if not provided.
150 |
151 | ```bash
152 | stlv state rm my-function
153 | stlv state rm my-function -e staging
154 | ```
155 |
156 | Use when you've manually deleted something in AWS and need to clean up state.
157 |
158 | !!! warning
159 | This only removes resource from state. The resource may still exist in AWS.
160 |
161 | #### state repair
162 |
163 | `stlv state repair [-e env]` - Repairs corrupted state by fixing orphans and broken dependencies. Use `-e/--env` to specify environment. Defaults to personal environment if not provided.
164 |
165 | ```bash
166 | stlv state repair
167 | stlv state repair -e staging
168 | ```
169 |
170 | Use after manual state edits or when Pulumi complains about missing resources.
171 |
172 | ### system
173 |
174 | Checks system requirements and installs Pulumi if needed.
175 |
176 | ```bash
177 | stlv system
178 | ```
179 |
180 | Useful in Dockerfiles to ensure the image is ready for deployments.
181 |
182 | ### version
183 |
184 | Shows versions of Stelvio and Pulumi.
185 |
186 | ```bash
187 | stlv version
188 | stlv --version
189 | ```
190 |
191 | ## Environments
192 |
193 | Most commands accept an optional environment name. Without one, commands use your personal environment (your username by default).
194 |
195 | See [Environments](environments.md) for details on personal vs shared environments and configuration options.
196 |
197 | ## Need Help?
198 |
199 | - Use `stlv COMMAND --help` for command details
200 | - Use `-v` or `-vv` flags for more detailed error information
201 |
--------------------------------------------------------------------------------
/stelvio/aws/s3/s3_static_website.py:
--------------------------------------------------------------------------------
1 | import mimetypes
2 | import re
3 | from dataclasses import dataclass
4 | from pathlib import Path
5 | from typing import final
6 |
7 | import pulumi
8 | import pulumi_aws
9 |
10 | from stelvio import context
11 | from stelvio.aws.cloudfront import CloudFrontDistribution
12 | from stelvio.aws.s3.s3 import Bucket
13 | from stelvio.component import Component, safe_name
14 |
15 |
16 | @final
17 | @dataclass(frozen=True)
18 | class S3StaticWebsiteResources:
19 | bucket: pulumi_aws.s3.Bucket
20 | files: list[pulumi_aws.s3.BucketObject]
21 | cloudfront_distribution: CloudFrontDistribution
22 |
23 |
24 | REQUEST_INDEX_HTML_FUNCTION_JS = """
25 | function handler(event) {
26 | var request = event.request;
27 | var uri = request.uri;
28 | // Check whether the URI is missing a file name.
29 | if (uri.endsWith('/')) {
30 | request.uri += 'index.html';
31 | }
32 | // Check whether the URI is missing a file extension.
33 | else if (!uri.includes('.')) {
34 | request.uri += '/index.html';
35 | }
36 | return request;
37 | }
38 | """
39 |
40 |
41 | @final
42 | class S3StaticWebsite(Component[S3StaticWebsiteResources]):
43 | def __init__(
44 | self,
45 | name: str,
46 | custom_domain: str | None = None,
47 | directory: Path | str | None = None,
48 | default_cache_ttl: int = 120,
49 | ):
50 | super().__init__(name)
51 | self.directory = Path(directory) if isinstance(directory, str) else directory
52 | self.custom_domain = custom_domain
53 | self.default_cache_ttl = default_cache_ttl
54 | self._resources = None
55 |
56 | def _create_resources(self) -> S3StaticWebsiteResources:
57 | # Validate directory exists
58 | if self.directory is not None and not self.directory.exists():
59 | raise FileNotFoundError(f"Directory does not exist: {self.directory}")
60 |
61 | bucket = Bucket(f"{self.name}-bucket")
62 | # Create CloudFront Function to handle directory index rewriting
63 | viewer_request_function = pulumi_aws.cloudfront.Function(
64 | context().prefix(f"{self.name}-viewer-request"),
65 | name=context().prefix(f"{self.name}-viewer-request-function"),
66 | runtime="cloudfront-js-1.0",
67 | comment="Rewrite requests to directories to serve index.html",
68 | code=REQUEST_INDEX_HTML_FUNCTION_JS, # TODO: (configurable?)
69 | )
70 | cloudfront_distribution = CloudFrontDistribution(
71 | name=f"{self.name}-cloudfront",
72 | bucket=bucket,
73 | custom_domain=self.custom_domain,
74 | function_associations=[
75 | {
76 | "event_type": "viewer-request",
77 | "function_arn": viewer_request_function.arn,
78 | }
79 | ],
80 | )
81 |
82 | # Upload files from directory to S3 bucket
83 | files = self._process_directory_and_upload_files(bucket, self.directory)
84 |
85 | pulumi.export(f"s3_static_website_{self.name}_bucket_name", bucket.resources.bucket.bucket)
86 | pulumi.export(f"s3_static_website_{self.name}_bucket_arn", bucket.resources.bucket.arn)
87 | pulumi.export(
88 | f"s3_static_website_{self.name}_cloudfront_distribution_name",
89 | cloudfront_distribution.name,
90 | )
91 | pulumi.export(
92 | f"s3_static_website_{self.name}_cloudfront_domain_name",
93 | cloudfront_distribution.resources.distribution.domain_name,
94 | )
95 | pulumi.export(f"s3_static_website_{self.name}_custom_domain", self.custom_domain)
96 | pulumi.export(f"s3_static_website_{self.name}_files", [file.arn for file in files])
97 |
98 | return S3StaticWebsiteResources(
99 | bucket=bucket.resources.bucket,
100 | files=files,
101 | cloudfront_distribution=cloudfront_distribution,
102 | )
103 |
104 | def _create_s3_bucket_object(
105 | self, bucket: Bucket, directory: Path, file_path: Path
106 | ) -> pulumi_aws.s3.BucketObject:
107 | key = file_path.relative_to(directory)
108 |
109 | # Convert path separators and special chars to dashes,
110 | # ensure valid Pulumi resource name
111 | safe_key = re.sub(r"[^a-zA-Z0-9]", "-", str(key))
112 | # Remove consecutive dashes and leading/trailing dashes
113 | safe_key = re.sub(r"-+", "-", safe_key).strip("-")
114 | # resource_name = f"{self.name}-{safe_key}-{file_hash[:8]}"
115 |
116 | # DO NOT INCLUDE HASH IN RESOURCE NAME
117 | # If the resource name changes, Pulumi will treat it as a new resource,
118 | # and create a new s3 object
119 | # Then, the old one is deleted by pulumi. Sounds correct, but since the
120 | # filename (key) is the same, the delete operation deletes the new object!
121 | resource_name = f"{self.name}-{safe_key}"
122 |
123 | # For binary files, use source instead of content
124 | mimetype, _ = mimetypes.guess_type(file_path.name)
125 |
126 | cache_control = f"public, max-age={self.default_cache_ttl}"
127 |
128 | return pulumi_aws.s3.BucketObject(
129 | safe_name(context().prefix(), resource_name, 128, "-p"),
130 | bucket=bucket.resources.bucket.id,
131 | key=str(key),
132 | source=pulumi.FileAsset(file_path),
133 | content_type=mimetype,
134 | cache_control=cache_control,
135 | )
136 |
137 | def _process_directory_and_upload_files(
138 | self, bucket: Bucket, directory: Path
139 | ) -> list[pulumi_aws.s3.BucketObject]:
140 | # glob all files in the directory
141 | if directory is None:
142 | return []
143 |
144 | return [
145 | self._create_s3_bucket_object(bucket, directory, file_path)
146 | for file_path in directory.rglob("*")
147 | if file_path.is_file()
148 | ]
149 |
--------------------------------------------------------------------------------
/tests/aws/api_gateway/test_cors_config.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from stelvio.aws.api_gateway.config import ApiConfig, CorsConfig, CorsConfigDict
4 |
5 | from ...test_utils import assert_config_dict_matches_dataclass
6 |
7 |
8 | def test_cors_config_dict_has_same_fields_as_cors_config():
9 | assert_config_dict_matches_dataclass(CorsConfig, CorsConfigDict)
10 |
11 |
12 | def test_cors_config_defaults():
13 | config = CorsConfig()
14 | assert config.allow_origins == "*"
15 | assert config.allow_methods == "*"
16 | assert config.allow_headers == "*"
17 | assert config.allow_credentials is False
18 | assert config.max_age is None
19 | assert config.expose_headers is None
20 |
21 |
22 | @pytest.mark.parametrize(
23 | ("field", "value", "error_pattern"),
24 | [
25 | ("allow_origins", "", "allow_origins string cannot be empty"),
26 | ("allow_methods", "", "allow_methods string cannot be empty"),
27 | ("allow_headers", "", "allow_headers string cannot be empty"),
28 | ("allow_origins", [], "allow_origins list cannot be empty"),
29 | ("allow_methods", [], "allow_methods list cannot be empty"),
30 | ("allow_headers", [], "allow_headers list cannot be empty"),
31 | ("allow_origins", ["*"], "Wildcard '\\*' must be a string"),
32 | ("allow_methods", ["*"], "Wildcard '\\*' must be a string"),
33 | ("allow_headers", ["*"], "Wildcard '\\*' must be a string"),
34 | ("allow_origins", ["https://a.com", None], "Each allow_origins value must be"),
35 | ("allow_methods", ["GET", ""], "Each allow_methods value must be"),
36 | ("allow_headers", ["Content-Type", 123], "Each allow_headers value must be"),
37 | ],
38 | )
39 | def test_cors_config_validation_errors(field, value, error_pattern):
40 | with pytest.raises(ValueError, match=error_pattern):
41 | CorsConfig(**{field: value})
42 |
43 |
44 | @pytest.mark.parametrize(
45 | ("field", "value", "error_pattern"),
46 | [
47 | ("allow_origins", 123, "allow_origins must be a string or list of strings"),
48 | ("allow_methods", None, "allow_methods must be a string or list of strings"),
49 | ("allow_headers", {}, "allow_headers must be a string or list of strings"),
50 | ],
51 | )
52 | def test_cors_config_type_errors(field, value, error_pattern):
53 | with pytest.raises(TypeError, match=error_pattern):
54 | CorsConfig(**{field: value})
55 |
56 |
57 | def test_cors_config_credentials_with_wildcard_origin_rejected():
58 | with pytest.raises(ValueError, match="allow_credentials=True requires specific origins"):
59 | CorsConfig(allow_credentials=True)
60 |
61 |
62 | def test_cors_config_invalid_http_method_rejected():
63 | with pytest.raises(ValueError, match="Invalid HTTP method"):
64 | CorsConfig(allow_methods="INVALID")
65 |
66 |
67 | @pytest.mark.parametrize(
68 | "methods",
69 | ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "*", ["GET", "POST"]],
70 | )
71 | def test_cors_config_valid_http_methods_accepted(methods):
72 | config = CorsConfig(allow_methods=methods)
73 | assert config.allow_methods == methods
74 |
75 |
76 | def test_cors_config_http_methods_case_insensitive():
77 | config = CorsConfig(allow_methods="get")
78 | assert config.allow_methods == "get"
79 |
80 |
81 | @pytest.mark.parametrize(
82 | ("expose_headers", "error_pattern"),
83 | [
84 | (["X-Custom", ""], "Each expose_headers value must be"),
85 | (["X-Custom", None], "Each expose_headers value must be"),
86 | ],
87 | )
88 | def test_cors_config_expose_headers_with_invalid_items_rejected(expose_headers, error_pattern):
89 | with pytest.raises(ValueError, match=error_pattern):
90 | CorsConfig(expose_headers=expose_headers)
91 |
92 |
93 | def test_cors_config_negative_max_age_rejected():
94 | with pytest.raises(ValueError, match="max_age must be non-negative"):
95 | CorsConfig(max_age=-1)
96 |
97 |
98 | def test_cors_config_empty_expose_headers_rejected():
99 | with pytest.raises(ValueError, match="expose_headers list cannot be empty"):
100 | CorsConfig(expose_headers=[])
101 |
102 |
103 | def test_cors_config_full_custom_configuration():
104 | config = CorsConfig(
105 | allow_origins="https://example.com",
106 | allow_methods=["GET", "POST"],
107 | allow_headers=["Content-Type", "Authorization"],
108 | allow_credentials=True,
109 | max_age=3600,
110 | expose_headers=["X-Request-Id"],
111 | )
112 | assert config.allow_origins == "https://example.com"
113 | assert config.allow_methods == ["GET", "POST"]
114 | assert config.allow_headers == ["Content-Type", "Authorization"]
115 | assert config.allow_credentials is True
116 | assert config.max_age == 3600
117 | assert config.expose_headers == ["X-Request-Id"]
118 |
119 |
120 | @pytest.mark.parametrize("cors_value", [False, None])
121 | def test_api_config_cors_disabled_normalizes_to_none(cors_value):
122 | config = ApiConfig(cors=cors_value)
123 | assert config.normalized_cors is None
124 |
125 |
126 | def test_api_config_cors_true_normalizes_to_permissive_defaults():
127 | config = ApiConfig(cors=True)
128 | cors = config.normalized_cors
129 | assert cors is not None
130 | assert cors.allow_origins == "*"
131 | assert cors.allow_methods == "*"
132 | assert cors.allow_headers == "*"
133 |
134 |
135 | def test_api_config_cors_config_instance_returned_as_is():
136 | cors_config = CorsConfig(allow_origins="https://example.com")
137 | config = ApiConfig(cors=cors_config)
138 | assert config.normalized_cors is cors_config
139 |
140 |
141 | def test_api_config_cors_dict_converted_to_cors_config():
142 | config = ApiConfig(cors={"allow_origins": "https://example.com", "allow_credentials": True})
143 | cors = config.normalized_cors
144 | assert cors is not None
145 | assert cors.allow_origins == "https://example.com"
146 | assert cors.allow_credentials is True
147 | assert cors.allow_methods == "*"
148 | assert cors.allow_headers == "*"
149 |
--------------------------------------------------------------------------------
/tests/test_component.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 |
3 | import pytest
4 |
5 | from stelvio.component import Component, ComponentRegistry, link_config_creator
6 | from stelvio.link import LinkConfig
7 |
8 |
9 | # Mock Pulumi resource for testing
10 | class MockResource:
11 | def __init__(self, name="test-resource"):
12 | self.name = name
13 | self.id = f"{name}-id"
14 |
15 |
16 | @dataclass(frozen=True)
17 | class MockComponentResources:
18 | mock_resource: MockResource
19 |
20 |
21 | # Concrete implementation of Component for testing
22 | class MockComponent(Component[MockComponentResources]):
23 | def __init__(self, name: str, resource: MockResource = None):
24 | super().__init__(name)
25 | self._mock_resource = resource or MockResource(name)
26 | # Track if _create_resource was called
27 | self.create_resources_called = False
28 |
29 | def _create_resources(self) -> MockComponentResources:
30 | self.create_resources_called = True
31 | return MockComponentResources(self._mock_resource)
32 |
33 |
34 | @pytest.fixture
35 | def clear_registry():
36 | """Clear the component registry before and after tests."""
37 | # Save old state
38 | old_instances = ComponentRegistry._instances.copy()
39 | old_default_creators = ComponentRegistry._default_link_creators.copy()
40 | old_user_creators = ComponentRegistry._user_link_creators.copy()
41 |
42 | # Clear registries
43 | ComponentRegistry._instances = {}
44 | ComponentRegistry._default_link_creators = {}
45 | ComponentRegistry._user_link_creators = {}
46 |
47 | yield
48 | # We need to do this because otherwise we get:
49 | # Task was destroyed but it is pending!
50 | # task: .
51 | # is_value_known() running at ~/Library/Caches/pypoetry/virtualenvs/
52 | # stelvio-wXLVHIoC-py3.12/lib/python3.12/site-packages/pulumi/output.py:127>
53 | # wait_for=>
54 |
55 | # Restore old state
56 | ComponentRegistry._instances = old_instances
57 | ComponentRegistry._default_link_creators = old_default_creators
58 | ComponentRegistry._user_link_creators = old_user_creators
59 |
60 |
61 | # Component base class tests
62 |
63 |
64 | def test_component_initialization(clear_registry):
65 | """Test that component is initialized and registered correctly."""
66 | component = MockComponent("test-component")
67 |
68 | # Verify name property
69 | assert component.name == "test-component"
70 |
71 | # Verify it was added to the registry
72 | assert type(component) in ComponentRegistry._instances
73 | assert component in ComponentRegistry._instances[type(component)]
74 |
75 |
76 | def test_resources_stores_created_resources(clear_registry):
77 | test_resource = MockResource("test-resource")
78 | component = MockComponent("test-component", test_resource)
79 |
80 | # First access - creates the resource
81 | resources1 = component.resources
82 | assert component.create_resources_called
83 | assert resources1.mock_resource is test_resource
84 |
85 | # Reset flag to test caching
86 | component.create_resources_called = False
87 |
88 | # Second access should use cached resource from registry
89 | resources2 = component.resources
90 | assert not component.create_resources_called # Should not call create again
91 | assert resources2.mock_resource is test_resource # Should get same resource
92 |
93 |
94 | # ComponentRegistry tests
95 |
96 |
97 | def test_add_and_get_instance(clear_registry):
98 | """Test adding and retrieving component instances."""
99 |
100 | # Create multiple components of different types
101 | class ComponentA(MockComponent):
102 | pass
103 |
104 | class ComponentB(MockComponent):
105 | pass
106 |
107 | comp_a1 = ComponentA("a1")
108 | comp_a2 = ComponentA("a2")
109 | comp_b = ComponentB("b")
110 |
111 | # Verify they're in the registry
112 | assert ComponentA in ComponentRegistry._instances
113 | assert len(ComponentRegistry._instances[ComponentA]) == 2
114 | assert comp_a1 in ComponentRegistry._instances[ComponentA]
115 | assert comp_a2 in ComponentRegistry._instances[ComponentA]
116 |
117 | assert ComponentB in ComponentRegistry._instances
118 | assert len(ComponentRegistry._instances[ComponentB]) == 1
119 | assert comp_b in ComponentRegistry._instances[ComponentB]
120 |
121 |
122 | def test_all_instances(clear_registry):
123 | """Test iterating through all component instances."""
124 |
125 | # Create components of different types
126 | class ComponentA(MockComponent):
127 | pass
128 |
129 | class ComponentB(MockComponent):
130 | pass
131 |
132 | comp_a1 = ComponentA("a1")
133 | comp_a2 = ComponentA("a2")
134 | comp_b = ComponentB("b")
135 |
136 | # Get all instances
137 | all_instances = list(ComponentRegistry.all_instances())
138 |
139 | # Verify all components are in the list
140 | assert len(all_instances) == 3
141 | assert comp_a1 in all_instances
142 | assert comp_a2 in all_instances
143 | assert comp_b in all_instances
144 |
145 |
146 | def test_link_creator_decorator(clear_registry):
147 | """Test that the decorator correctly registers and wraps the function."""
148 |
149 | # Define a test function and decorate it
150 | @link_config_creator(MockComponent)
151 | def test_creator(r):
152 | return LinkConfig(properties={"name": r.name})
153 |
154 | # Get the registered creator
155 | creator = ComponentRegistry.get_link_config_creator(MockComponent)
156 |
157 | # Create a mock resource
158 | resource = MockResource("test")
159 |
160 | # Test the registered function
161 | # noinspection PyTypeChecker
162 | config = creator(resource)
163 |
164 | # Verify it returns expected result
165 | assert isinstance(config, LinkConfig)
166 | assert config.properties == {"name": "test"}
167 |
168 | # Test that the wrapper preserves function metadata
169 | assert creator.__name__ == test_creator.__name__
170 |
--------------------------------------------------------------------------------
/tests/aws/acm/test_acm_validated_domain.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | import pulumi
4 | import pytest
5 | from pulumi.runtime import set_mocks
6 |
7 | from stelvio.aws.acm import AcmValidatedDomain
8 | from stelvio.component import ComponentRegistry
9 | from stelvio.config import AwsConfig
10 | from stelvio.context import AppContext, _ContextStore
11 | from stelvio.dns import DnsProviderNotConfiguredError, Record
12 |
13 | from ..pulumi_mocks import ACCOUNT_ID, DEFAULT_REGION, MockDns, PulumiTestMocks, tid
14 |
15 | # Test prefix - matching the pattern from other tests
16 | TP = "test-test-"
17 |
18 |
19 | class MockDnsRecord(Record):
20 | """Mock DNS record for testing"""
21 |
22 | def __init__(self, name: str, record_type: str, value: str):
23 | # Create a mock pulumi resource
24 | from unittest.mock import Mock
25 |
26 | mock_resource = Mock()
27 | mock_resource.name = name
28 | mock_resource.type = record_type
29 | mock_resource.content = value
30 | super().__init__(mock_resource)
31 |
32 | @property
33 | def name(self):
34 | return self.pulumi_resource.name
35 |
36 | @property
37 | def type(self):
38 | return self.pulumi_resource.type
39 |
40 | @property
41 | def value(self):
42 | return self.pulumi_resource.content
43 |
44 |
45 | def delete_files(directory: Path, filename: str):
46 | directory_path = directory
47 | for file_path in directory_path.rglob(filename):
48 | file_path.unlink()
49 |
50 |
51 | @pytest.fixture
52 | def mock_dns():
53 | return MockDns()
54 |
55 |
56 | @pytest.fixture(autouse=True)
57 | def project_cwd(monkeypatch, pytestconfig):
58 | rootpath = pytestconfig.rootpath
59 | test_project_dir = rootpath / "tests" / "aws" / "sample_test_project"
60 | monkeypatch.chdir(test_project_dir)
61 | yield test_project_dir
62 | delete_files(test_project_dir, "stlv_resources.py")
63 |
64 |
65 | @pytest.fixture
66 | def app_context_with_dns(mock_dns):
67 | """App context with DNS provider configured"""
68 | _ContextStore.clear()
69 | _ContextStore.set(
70 | AppContext(
71 | name="test",
72 | env="test",
73 | aws=AwsConfig(profile="default", region="us-east-1"),
74 | home="aws",
75 | dns=mock_dns,
76 | )
77 | )
78 | yield mock_dns
79 | _ContextStore.clear()
80 |
81 |
82 | @pytest.fixture
83 | def pulumi_mocks():
84 | mocks = PulumiTestMocks()
85 | set_mocks(mocks)
86 | return mocks
87 |
88 |
89 | @pytest.fixture
90 | def component_registry():
91 | ComponentRegistry._instances.clear()
92 | ComponentRegistry._registered_names.clear()
93 | yield ComponentRegistry
94 | ComponentRegistry._instances.clear()
95 | ComponentRegistry._registered_names.clear()
96 |
97 |
98 | @pulumi.runtime.test
99 | def test_acm_validated_domain_basic(pulumi_mocks, app_context_with_dns, component_registry):
100 | """Test basic ACM validated domain creation"""
101 | # Arrange
102 | domain_name = "api.example.com"
103 | acm_domain = AcmValidatedDomain("test-cert", domain_name=domain_name)
104 |
105 | # Act
106 | _ = acm_domain.resources
107 |
108 | # Assert
109 | def check_resources(_):
110 | # Check certificate was created
111 | certificates = pulumi_mocks.created_certificates(TP + "test-cert-certificate")
112 | assert len(certificates) == 1
113 | cert = certificates[0]
114 | assert cert.inputs["domainName"] == domain_name # ACM uses camelCase
115 | assert cert.inputs["validationMethod"] == "DNS"
116 |
117 | # Use a simpler approach - just check that the certificate was created
118 | acm_domain.resources.certificate.id.apply(check_resources)
119 |
120 |
121 | @pulumi.runtime.test
122 | def test_acm_validated_domain_properties(pulumi_mocks, app_context_with_dns, component_registry):
123 | """Test ACM validated domain resource properties"""
124 | # Arrange
125 | domain_name = "test.example.com"
126 | acm_domain = AcmValidatedDomain("my-cert", domain_name=domain_name)
127 |
128 | # Act
129 | _ = acm_domain.resources
130 |
131 | # Assert
132 | def check_properties(args):
133 | cert_id, cert_arn, validation_arn = args
134 |
135 | # Check certificate properties
136 | assert cert_id == tid(TP + "my-cert-certificate")
137 | expected_cert_arn = (
138 | f"arn:aws:acm:{DEFAULT_REGION}:{ACCOUNT_ID}:certificate/"
139 | f"{tid(TP + 'my-cert-certificate')}"
140 | )
141 | assert cert_arn == expected_cert_arn
142 |
143 | # Check validation properties
144 | assert validation_arn == expected_cert_arn # Validation should reference certificate ARN
145 |
146 | pulumi.Output.all(
147 | acm_domain.resources.certificate.id,
148 | acm_domain.resources.certificate.arn,
149 | acm_domain.resources.cert_validation.certificate_arn,
150 | ).apply(check_properties)
151 |
152 |
153 | def test_acm_domain_name_property(app_context_with_dns, component_registry):
154 | """Test that domain_name property is accessible"""
155 | # Arrange & Act
156 | domain_name = "test.example.com"
157 | acm_domain = AcmValidatedDomain("test-cert", domain_name=domain_name)
158 |
159 | # Assert
160 | assert acm_domain.domain_name == domain_name
161 |
162 |
163 | def test_acm_without_dns_provider(component_registry):
164 | """Test that ACM component requires DNS provider in context"""
165 | # Arrange - context without DNS provider
166 | _ContextStore.clear()
167 | _ContextStore.set(
168 | AppContext(
169 | name="test",
170 | env="test",
171 | aws=AwsConfig(profile="default", region="us-east-1"),
172 | home="aws",
173 | dns=None, # No DNS provider
174 | )
175 | )
176 |
177 | acm_domain = AcmValidatedDomain("test-cert", domain_name="api.example.com")
178 |
179 | # Act & Assert - This should fail when trying to access context().dns.create_caa_record
180 | with pytest.raises(DnsProviderNotConfiguredError):
181 | _ = acm_domain.resources
182 |
183 | _ContextStore.clear()
184 |
--------------------------------------------------------------------------------
/stelvio/component.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 | from collections.abc import Callable, Iterator
3 | from functools import wraps
4 | from hashlib import sha256
5 | from typing import Any, ClassVar
6 |
7 | from pulumi import Resource as PulumiResource
8 |
9 | from stelvio.link import LinkConfig
10 |
11 |
12 | class Component[ResourcesT](ABC):
13 | _name: str
14 | _resources: ResourcesT | None
15 |
16 | def __init__(self, name: str):
17 | self._name = name
18 | self._resources = None
19 | ComponentRegistry.add_instance(self)
20 |
21 | @property
22 | def name(self) -> str:
23 | return self._name
24 |
25 | @property
26 | def resources(self) -> ResourcesT:
27 | if not self._resources:
28 | self._resources = self._create_resources()
29 | return self._resources
30 |
31 | @abstractmethod
32 | def _create_resources(self) -> ResourcesT:
33 | """Implement actual resource creation logic"""
34 | raise NotImplementedError
35 |
36 |
37 | class ComponentRegistry:
38 | _instances: ClassVar[dict[type[Component], list[Component]]] = {}
39 | _registered_names: ClassVar[set[str]] = set()
40 |
41 | # Two-tier registry for link creators
42 | _default_link_creators: ClassVar[dict[type, Callable]] = {}
43 | _user_link_creators: ClassVar[dict[type, Callable]] = {}
44 |
45 | @classmethod
46 | def add_instance(cls, instance: Component[Any]) -> None:
47 | if instance.name in cls._registered_names:
48 | raise ValueError(
49 | f"Duplicate Stelvio component name detected: '{instance.name}'. "
50 | "Component names must be unique across all component types."
51 | )
52 | cls._registered_names.add(instance.name)
53 | if type(instance) not in cls._instances:
54 | cls._instances[type(instance)] = []
55 | cls._instances[type(instance)].append(instance)
56 |
57 | @classmethod
58 | def register_default_link_creator[T: PulumiResource](
59 | cls, component_type: type[Component[T]], creator_fn: Callable[[T], LinkConfig]
60 | ) -> None:
61 | """Register a default link creator, which will be used if no user-defined creator exists"""
62 | cls._default_link_creators[component_type] = creator_fn
63 |
64 | @classmethod
65 | def register_user_link_creator[T: PulumiResource](
66 | cls, component_type: type[Component[T]], creator_fn: Callable[[T], LinkConfig]
67 | ) -> None:
68 | """Register a user-defined link creator, which takes precedence over defaults"""
69 | cls._user_link_creators[component_type] = creator_fn
70 |
71 | @classmethod
72 | def get_link_config_creator[T: PulumiResource](
73 | cls, component_type: type[Component]
74 | ) -> Callable[[T], LinkConfig] | None:
75 | """Get the link creator for a component type, prioritizing user-defined over defaults"""
76 | # First check user-defined creators, then fall back to defaults
77 | return cls._user_link_creators.get(component_type) or cls._default_link_creators.get(
78 | component_type
79 | )
80 |
81 | @classmethod
82 | def all_instances(cls) -> Iterator[Component[Any]]:
83 | instances = cls._instances.copy()
84 | for k in instances:
85 | yield from instances[k]
86 |
87 | @classmethod
88 | def instances_of[T: Component](cls, component_type: type[T]) -> Iterator[T]:
89 | yield from cls._instances.get(component_type, [])
90 |
91 |
92 | def link_config_creator[T: PulumiResource](
93 | component_type: type[Component],
94 | ) -> Callable[[Callable[[T], LinkConfig]], Callable[[T], LinkConfig]]:
95 | """Decorator to register a default link creator for a component type"""
96 |
97 | def decorator(func: Callable[[T], LinkConfig]) -> Callable[[T], LinkConfig]:
98 | @wraps(func)
99 | def wrapper(resource: T) -> LinkConfig:
100 | return func(resource)
101 |
102 | ComponentRegistry.register_default_link_creator(component_type, func)
103 | return wrapper
104 |
105 | return decorator
106 |
107 |
108 | def safe_name(
109 | prefix: str, name: str, max_length: int, suffix: str = "", pulumi_suffix_length: int = 8
110 | ) -> str:
111 | """Create safe AWS resource name accounting for Pulumi suffix and custom suffix.
112 |
113 | Args:
114 | prefix: The app-env prefix (e.g., "myapp-prod-")
115 | name: The base name for the resource
116 | max_length: AWS service limit for the resource type
117 | suffix: Custom suffix to add (e.g., '-r', '-p')
118 | pulumi_suffix_length: Length of Pulumi's random suffix (default 8, use 0 if none)
119 |
120 | Returns:
121 | Safe name that will fit within AWS limits after Pulumi adds its suffix
122 | """
123 | # Calculate space available for the base name
124 | reserved_space = len(prefix) + len(suffix) + pulumi_suffix_length
125 | available_for_name = max_length - reserved_space
126 |
127 | if available_for_name <= 0:
128 | raise ValueError(
129 | f"Cannot create safe name: prefix '{prefix}' ({len(prefix)} chars), "
130 | f"suffix '{suffix}' ({len(suffix)} chars), and Pulumi suffix "
131 | f"({pulumi_suffix_length} chars) exceed max_length ({max_length})"
132 | )
133 |
134 | # Validate name is not empty
135 | if not name.strip():
136 | raise ValueError("Name cannot be empty or whitespace-only")
137 |
138 | # Truncate name if needed
139 | if len(name) <= available_for_name:
140 | return f"{prefix}{name}{suffix}"
141 |
142 | # Need to truncate - reserve space for 7-char hash + dash
143 | hash_with_separator = 8 # 7 chars + 1 dash
144 | if available_for_name <= hash_with_separator:
145 | raise ValueError(
146 | f"Not enough space for name truncation: available={available_for_name}, "
147 | f"need at least {hash_with_separator} chars for hash"
148 | )
149 |
150 | # Truncate from end and add hash
151 | truncate_length = available_for_name - hash_with_separator
152 | name_hash = sha256(name.encode()).hexdigest()[:7]
153 | safe_name_part = f"{name[:truncate_length]}-{name_hash}"
154 |
155 | return f"{prefix}{safe_name_part}{suffix}"
156 |
--------------------------------------------------------------------------------