├── tests ├── __init__.py ├── aws │ ├── __init__.py │ ├── acm │ │ ├── __init__.py │ │ └── test_acm_validated_domain.py │ ├── s3 │ │ └── __init__.py │ ├── api_gateway │ │ ├── __init__.py │ │ ├── test_api_config.py │ │ └── test_cors_config.py │ ├── cloudfront │ │ └── __init__.py │ ├── dynamo_db │ │ ├── __init__.py │ │ └── test_dynamo_table_config.py │ ├── function │ │ ├── __init__.py │ │ ├── test_iam.py │ │ └── test_function_init.py │ ├── sample_test_project │ │ ├── stlv_app.py │ │ └── functions │ │ │ ├── users.py │ │ │ ├── orders.py │ │ │ ├── simple.py │ │ │ ├── simple2.py │ │ │ ├── folder │ │ │ ├── handler.py │ │ │ └── handler2.py │ │ │ ├── folder2 │ │ │ └── handler.py │ │ │ └── authorizers │ │ │ ├── jwt.py │ │ │ └── request.py │ ├── _packaging │ │ └── __init__.py │ └── test_permission.py ├── dns │ ├── __init__.py │ └── test_dns.py ├── test_config.py ├── test_parse_template.py ├── test_utils.py ├── conftest.py ├── test_context.py ├── test_git.py └── test_component.py ├── docs ├── getting-started │ ├── concepts.md │ └── installation.md ├── favicon.png ├── overrides │ └── partials │ │ └── integrations │ │ └── analytics │ │ └── custom.html ├── doc-logo.svg ├── guides │ ├── state.md │ ├── dns.md │ ├── environments.md │ ├── troubleshooting.md │ └── using-cli.md ├── index.md └── changelog.md ├── stelvio ├── cloudflare │ ├── __init__.py │ └── dns.py ├── aws │ ├── cloudfront │ │ ├── origins │ │ │ ├── __init__.py │ │ │ ├── components │ │ │ │ ├── __init__.py │ │ │ │ ├── api_gateway.py │ │ │ │ └── s3.py │ │ │ ├── decorators.py │ │ │ ├── base.py │ │ │ └── registry.py │ │ ├── __init__.py │ │ ├── dtos.py │ │ └── js.py │ ├── __init__.py │ ├── _packaging │ │ └── __init__.py │ ├── s3 │ │ ├── __init__.py │ │ ├── s3.py │ │ └── s3_static_website.py │ ├── types.py │ ├── api_gateway │ │ ├── __init__.py │ │ ├── constants.py │ │ ├── deployment.py │ │ └── iam.py │ ├── function │ │ ├── __init__.py │ │ ├── naming.py │ │ ├── constants.py │ │ ├── iam.py │ │ ├── packaging.py │ │ ├── resources_codegen.py │ │ └── dependencies.py │ ├── permission.py │ ├── dns.py │ ├── acm.py │ ├── cors.py │ └── home.py ├── __init__.py ├── exceptions.py ├── dns.py ├── home.py ├── project.py ├── context.py ├── cli │ └── init_command.py ├── link.py ├── config.py ├── app.py └── component.py ├── templates └── base │ ├── .python-version │ ├── .gitignore │ ├── pyproject.toml │ └── stlv_app.py ├── .devcontainer ├── Dockerfile ├── setup.conf ├── setup └── devcontainer.json ├── .vscode └── settings.json ├── pkg └── npm │ ├── tsconfig.json │ ├── package.json │ ├── bun.lock │ ├── index.ts │ └── README.md ├── CODE_OF_CONDUCT.md ├── .github ├── workflows │ ├── main.yml │ └── prebuild-devcontainer.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── mkdocs.yml ├── pyproject.toml ├── CONTRIBUTING.md ├── README.md └── .gitignore /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/aws/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dns/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dns/test_dns.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/aws/acm/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/aws/s3/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/getting-started/concepts.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /stelvio/cloudflare/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/aws/api_gateway/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/aws/cloudfront/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/aws/dynamo_db/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/aws/function/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/getting-started/installation.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /stelvio/aws/cloudfront/origins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /templates/base/.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /tests/aws/sample_test_project/stlv_app.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/aws/sample_test_project/functions/users.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /stelvio/aws/cloudfront/origins/components/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/aws/sample_test_project/functions/orders.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/aws/sample_test_project/functions/simple.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/aws/sample_test_project/functions/simple2.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/aws/sample_test_project/functions/folder/handler.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/aws/sample_test_project/functions/folder/handler2.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/aws/sample_test_project/functions/folder2/handler.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /stelvio/aws/__init__.py: -------------------------------------------------------------------------------- 1 | """AWS components for Stelvio.""" 2 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/python:3.13-trixie 2 | -------------------------------------------------------------------------------- /.devcontainer/setup.conf: -------------------------------------------------------------------------------- 1 | WORKSPACE_DIR="${WORKSPACE_FOLDER:-/workspaces/stelvio}" 2 | -------------------------------------------------------------------------------- /docs/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stelviodev/stelvio/HEAD/docs/favicon.png -------------------------------------------------------------------------------- /tests/aws/_packaging/__init__.py: -------------------------------------------------------------------------------- 1 | # This file makes this directory a Python package. 2 | -------------------------------------------------------------------------------- /stelvio/__init__.py: -------------------------------------------------------------------------------- 1 | from stelvio.context import context 2 | 3 | __all__ = ["context"] 4 | -------------------------------------------------------------------------------- /stelvio/aws/_packaging/__init__.py: -------------------------------------------------------------------------------- 1 | """Internal packaging utilities for AWS Lambda.""" 2 | -------------------------------------------------------------------------------- /tests/aws/sample_test_project/functions/authorizers/jwt.py: -------------------------------------------------------------------------------- 1 | # Empty file for testing - not executed by Pulumi mocks 2 | -------------------------------------------------------------------------------- /tests/aws/sample_test_project/functions/authorizers/request.py: -------------------------------------------------------------------------------- 1 | # Empty file for testing - not executed by Pulumi mocks 2 | -------------------------------------------------------------------------------- /templates/base/.gitignore: -------------------------------------------------------------------------------- 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | 9 | # Virtual environments 10 | .venv 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestArgs": [ 3 | "tests" 4 | ], 5 | "python.testing.unittestEnabled": false, 6 | "python.testing.pytestEnabled": true 7 | } 8 | -------------------------------------------------------------------------------- /templates/base/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "stelvio-template" 3 | version = "0.1.0" 4 | description = "Add your description here" 5 | readme = "README.md" 6 | requires-python = ">=3.13" 7 | dependencies = [ 8 | "stelvio>=0.4.0a6", 9 | ] 10 | -------------------------------------------------------------------------------- /stelvio/aws/s3/__init__.py: -------------------------------------------------------------------------------- 1 | from .s3 import Bucket, S3BucketResources 2 | from .s3_static_website import S3StaticWebsite, S3StaticWebsiteResources 3 | 4 | __all__ = [ 5 | "Bucket", 6 | "S3BucketResources", 7 | "S3StaticWebsite", 8 | "S3StaticWebsiteResources", 9 | ] 10 | -------------------------------------------------------------------------------- /docs/overrides/partials/integrations/analytics/custom.html: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /stelvio/aws/types.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | # Type alias for supported AWS Lambda instruction set architectures 4 | type AwsArchitecture = Literal["x86_64", "arm64"] 5 | 6 | # Type alias for supported AWS Lambda Python runtimes 7 | type AwsLambdaRuntime = Literal["python3.12", "python3.13"] 8 | -------------------------------------------------------------------------------- /stelvio/aws/api_gateway/__init__.py: -------------------------------------------------------------------------------- 1 | from .api import Api 2 | from .config import ApiConfig, ApiConfigDict, CorsConfig, CorsConfigDict 3 | from .constants import HTTPMethod 4 | 5 | # Only export public API for users 6 | __all__ = ["Api", "ApiConfig", "ApiConfigDict", "CorsConfig", "CorsConfigDict", "HTTPMethod"] 7 | -------------------------------------------------------------------------------- /stelvio/aws/function/__init__.py: -------------------------------------------------------------------------------- 1 | from .config import FunctionConfig, FunctionConfigDict, FunctionUrlConfig, FunctionUrlConfigDict 2 | from .function import Function, FunctionResources 3 | 4 | __all__ = [ 5 | "Function", 6 | "FunctionConfig", 7 | "FunctionConfigDict", 8 | "FunctionResources", 9 | "FunctionUrlConfig", 10 | "FunctionUrlConfigDict", 11 | ] 12 | -------------------------------------------------------------------------------- /stelvio/aws/cloudfront/__init__.py: -------------------------------------------------------------------------------- 1 | from .cloudfront import ( 2 | CloudFrontDistribution, 3 | CloudFrontDistributionResources, 4 | CloudfrontPriceClass, 5 | ) 6 | from .router import Router, RouterResources 7 | 8 | __all__ = [ 9 | "CloudFrontDistribution", 10 | "CloudFrontDistributionResources", 11 | "CloudfrontPriceClass", 12 | "Router", 13 | "RouterResources", 14 | ] 15 | -------------------------------------------------------------------------------- /pkg/npm/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "outDir": "./dist", 7 | "rootDir": "./", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true 12 | }, 13 | "include": ["index.ts"], 14 | "exclude": ["node_modules", "dist"] 15 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Be Nice, Stay On Topic 4 | 5 | This project has a simple code of conduct: 6 | 7 | 1. **Be respectful** and constructive in all interactions 8 | 2. Keep discussions on-topic and focused on the project 9 | 3. The project maintainer has final say on all project decisions and can remove disruptive participants if necessary 10 | 11 | That's it. Let's build good software together. 12 | -------------------------------------------------------------------------------- /stelvio/aws/function/naming.py: -------------------------------------------------------------------------------- 1 | from .constants import NUMBER_WORDS 2 | 3 | 4 | def _envar_name(link_name: str, prop_name: str) -> str: 5 | cleaned_link_name = "".join(c if c.isalnum() else "_" for c in link_name) 6 | 7 | if (first_char := cleaned_link_name[0]) and first_char.isdigit(): 8 | cleaned_link_name = NUMBER_WORDS[first_char] + cleaned_link_name[1:] 9 | 10 | return f"STLV_{cleaned_link_name.upper()}_{prop_name.upper()}" 11 | -------------------------------------------------------------------------------- /stelvio/aws/permission.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Sequence 2 | from dataclasses import dataclass 3 | 4 | from pulumi import Input 5 | from pulumi_aws.iam import GetPolicyDocumentStatementArgs 6 | 7 | 8 | @dataclass(frozen=True) 9 | class AwsPermission: 10 | actions: Sequence[str] 11 | resources: Sequence[Input[str]] 12 | 13 | def to_provider_format(self) -> GetPolicyDocumentStatementArgs: 14 | return GetPolicyDocumentStatementArgs(actions=self.actions, resources=self.resources) 15 | -------------------------------------------------------------------------------- /stelvio/aws/cloudfront/origins/decorators.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from stelvio.aws.cloudfront.origins.registry import CloudfrontAdapterRegistry 4 | 5 | 6 | def _register_adapter(adapter_cls: type, component_cls: type) -> None: 7 | adapter_cls.component_class = component_cls 8 | CloudfrontAdapterRegistry.add_adapter(adapter_cls) 9 | 10 | 11 | def register_adapter(component_cls: type) -> callable: 12 | def wrapper(adapter_cls: type) -> type: 13 | _register_adapter(adapter_cls, component_cls) 14 | return adapter_cls 15 | 16 | return wrapper 17 | -------------------------------------------------------------------------------- /templates/base/stlv_app.py: -------------------------------------------------------------------------------- 1 | from stelvio.app import StelvioApp 2 | from stelvio.config import AwsConfig, StelvioAppConfig 3 | 4 | app = StelvioApp("stelvio-template") 5 | 6 | 7 | @app.config 8 | def configuration(env: str) -> StelvioAppConfig: 9 | return StelvioAppConfig( 10 | aws=AwsConfig( 11 | # region="us-east-1", # Uncomment to override AWS CLI/env var region 12 | # profile="your-profile", # Uncomment to use specific AWS profile 13 | ), 14 | ) 15 | 16 | 17 | @app.run 18 | def run() -> None: 19 | # Create your infra here 20 | pass 21 | -------------------------------------------------------------------------------- /stelvio/exceptions.py: -------------------------------------------------------------------------------- 1 | class StelvioProjectError(Exception): 2 | """Raised when no Stelvio project is found in the current or parent directories.""" 3 | 4 | 5 | class StateLockedError(Exception): 6 | """Raised when trying to acquire a lock on state that's already locked.""" 7 | 8 | def __init__(self, command: str, created: str, update_id: str, env: str): 9 | self.command = command 10 | self.created = created 11 | self.update_id = update_id 12 | self.env = env 13 | super().__init__( 14 | f"Environment locked by '{command}' since {created}. " 15 | f"Run 'stlv unlock {env}' to force unlock." 16 | ) 17 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | tests-and-ruff: 7 | name: Tests & Ruff 8 | timeout-minutes: 5 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Install uv 13 | uses: astral-sh/setup-uv@v5 14 | with: 15 | version: "0.7.3" 16 | enable-cache: true 17 | cache-dependency-glob: "uv.lock" 18 | - name: Install the project 19 | run: uv sync --dev 20 | - name: Run ruff format 21 | run: uv run ruff format 22 | - name: Run ruff check 23 | run: uv run ruff check 24 | - name: Run pytest 25 | run: uv run pytest tests -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: michal-stlv 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: michal-stlv 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Screenshots or other outputs** 20 | If applicable, add screenshots or outputs to help explain your problem. 21 | 22 | **Environment (please complete the following information):** 23 | - OS 24 | - Version [e.g. 0.1.0a2] 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /stelvio/aws/function/constants.py: -------------------------------------------------------------------------------- 1 | DEFAULT_RUNTIME = "python3.12" 2 | DEFAULT_ARCHITECTURE = "x86_64" 3 | DEFAULT_MEMORY = 128 4 | DEFAULT_TIMEOUT = 60 5 | LAMBDA_EXCLUDED_FILES = ["stlv.py", ".DS_Store"] # exact file matches 6 | LAMBDA_EXCLUDED_DIRS = ["__pycache__"] 7 | LAMBDA_EXCLUDED_EXTENSIONS = [".pyc"] 8 | MAX_LAMBDA_LAYERS = 5 9 | # "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole", 10 | LAMBDA_BASIC_EXECUTION_ROLE = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" 11 | NUMBER_WORDS = { 12 | "0": "Zero", 13 | "1": "One", 14 | "2": "Two", 15 | "3": "Three", 16 | "4": "Four", 17 | "5": "Five", 18 | "6": "Six", 19 | "7": "Seven", 20 | "8": "Eight", 21 | "9": "Nine", 22 | } 23 | -------------------------------------------------------------------------------- /stelvio/aws/cloudfront/dtos.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import final 3 | 4 | import pulumi_aws 5 | 6 | from stelvio.aws.function import FunctionUrlConfig, FunctionUrlConfigDict 7 | from stelvio.component import Component 8 | 9 | 10 | @final 11 | @dataclass(frozen=True) 12 | class RouteOriginConfig: 13 | origin_access_controls: pulumi_aws.cloudfront.OriginAccessControl | None 14 | origins: dict 15 | ordered_cache_behaviors: dict | list[dict] | None 16 | cloudfront_functions: pulumi_aws.cloudfront.Function 17 | 18 | 19 | @final 20 | @dataclass(frozen=True) 21 | class Route: 22 | path_pattern: str 23 | component: Component | str 24 | function_url_config: FunctionUrlConfig | FunctionUrlConfigDict | None = None 25 | -------------------------------------------------------------------------------- /pkg/npm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stlv", 3 | "version": "0.0.3", 4 | "main": "dist/index.js", 5 | "type": "module", 6 | "license": "Apache-2.0", 7 | "description": "stelvio.dev - AWS for Python developers", 8 | "author": "Stelvio Team ", 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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 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 | [![PyPI](https://img.shields.io/pypi/v/stelvio.svg)](https://pypi.org/project/stelvio/) 4 | [![Python Version](https://img.shields.io/pypi/pyversions/stelvio.svg)](https://pypi.org/project/stelvio/) 5 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](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 | --------------------------------------------------------------------------------