diff --git a/stelvio/aws/cloudfront/cloudfront.py b/stelvio/aws/cloudfront/cloudfront.py index a3726c2a..f77bde7b 100644 --- a/stelvio/aws/cloudfront/cloudfront.py +++ b/stelvio/aws/cloudfront/cloudfront.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib from dataclasses import dataclass from typing import TYPE_CHECKING, Literal, TypedDict, final @@ -31,9 +32,9 @@ class FunctionAssociation(TypedDict): class CloudFrontDistributionResources: distribution: pulumi_aws.cloudfront.Distribution origin_access_control: pulumi_aws.cloudfront.OriginAccessControl - acm_validated_domain: AcmValidatedDomain - record: Record - bucket_policy: pulumi_aws.s3.BucketPolicy + acm_validated_domain: AcmValidatedDomain | None + record: Record | None + bucket_policy: pulumi_aws.s3.BucketPolicy | None function_associations: list[FunctionAssociation] | None @@ -43,12 +44,14 @@ def __init__( self, name: str, bucket: Bucket, + _function_resource: pulumi.Resource | None = None, price_class: CloudfrontPriceClass = "PriceClass_100", custom_domain: str | None = None, function_associations: list[FunctionAssociation] | None = None, ): super().__init__(name) self.bucket = bucket + self._function_resource = _function_resource self.custom_domain = custom_domain self.price_class = price_class self.function_associations = function_associations or [] @@ -65,30 +68,75 @@ def _create_resources(self) -> CloudFrontDistributionResources: domain_name=self.custom_domain, ) - # Create Origin Access Control for S3 - origin_access_control = pulumi_aws.cloudfront.OriginAccessControl( - context().prefix(f"{self.name}-oac"), - description=f"Origin Access Control for {self.name}", - origin_access_control_origin_type="s3", - signing_behavior="always", - signing_protocol="sigv4", - ) + if self._function_resource: + # Create Origin Access Control for Lambda + origin_access_control = pulumi_aws.cloudfront.OriginAccessControl( + context().prefix(f"{self.name}-oac-lambda"), + description=f"Origin Access Control for {self.name}", + origin_access_control_origin_type="lambda", + signing_behavior="always", + signing_protocol="sigv4", + ) - # Create CloudFront Distribution - distribution = pulumi_aws.cloudfront.Distribution( - context().prefix(self.name), - aliases=[self.custom_domain] if self.custom_domain else None, - origins=[ + origins = [ + { + "domain_name": self._function_resource.function_url.apply( + lambda url: url.replace("https://", "").rstrip("/") + ), + "origin_id": f"{self.name}-Lambda-Origin", + "origin_access_control_id": origin_access_control.id, + "custom_origin_config": { + "http_port": 80, + "https_port": 443, + "origin_protocol_policy": "https-only", + "origin_ssl_protocols": ["TLSv1.2"], + }, + } + ] + + default_cache_behavior = { + "allowed_methods": [ + "GET", + "HEAD", + "OPTIONS", + "PUT", + "POST", + "PATCH", + "DELETE", + ], + "cached_methods": ["GET", "HEAD"], + "target_origin_id": f"{self.name}-Lambda-Origin", + "compress": True, + "viewer_protocol_policy": "redirect-to-https", + # AllViewerExceptHostHeader: + "origin_request_policy_id": "b689b0a8-53d0-40ab-baf2-68738e2966ac", + # CachingDisabled: + "cache_policy_id": "4135ea2d-6df8-44a3-9df3-4b5a84be39ad", + "function_associations": self.function_associations, + } + + default_root_object = None + custom_error_responses = [] + + else: + # Create Origin Access Control for S3 + origin_access_control = pulumi_aws.cloudfront.OriginAccessControl( + context().prefix(f"{self.name}-oac-s3"), + description=f"Origin Access Control for {self.name}", + origin_access_control_origin_type="s3", + signing_behavior="always", + signing_protocol="sigv4", + ) + + origins = [ { "domain_name": self.bucket.resources.bucket.bucket_regional_domain_name, "origin_id": f"{self.name}-S3-Origin", "origin_access_control_id": origin_access_control.id, } - ], - enabled=True, - is_ipv6_enabled=True, - default_root_object="index.html", - default_cache_behavior={ + ] + + default_cache_behavior = { "allowed_methods": ["GET", "HEAD", "OPTIONS"], # Reduced to read-only methods "cached_methods": ["GET", "HEAD"], "target_origin_id": f"{self.name}-S3-Origin", @@ -103,7 +151,33 @@ def _create_resources(self) -> CloudFrontDistributionResources: "default_ttl": 300, # Reduce default TTL to 5 minutes for faster updates "max_ttl": 3600, # Reduce max TTL to 1 hour "function_associations": self.function_associations, - }, + } + + default_root_object = "index.html" + custom_error_responses = [ + { + "error_code": 403, + "response_code": 404, + "response_page_path": "/error.html", + "error_caching_min_ttl": 0, # Don't cache 403 errors + }, + { + "error_code": 404, + "response_code": 404, + "response_page_path": "/error.html", + "error_caching_min_ttl": 300, # Cache 404s for only 5 minutes + }, + ] + + # Create CloudFront Distribution + distribution = pulumi_aws.cloudfront.Distribution( + context().prefix(self.name), + aliases=[self.custom_domain] if self.custom_domain else None, + origins=origins, + enabled=True, + is_ipv6_enabled=True, + default_root_object=default_root_object, + default_cache_behavior=default_cache_behavior, price_class=self.price_class, restrictions={ "geo_restriction": { @@ -119,52 +193,51 @@ def _create_resources(self) -> CloudFrontDistributionResources: else { "cloudfront_default_certificate": True, }, - custom_error_responses=[ - { - "error_code": 403, - "response_code": 404, - "response_page_path": "/error.html", - "error_caching_min_ttl": 0, # Don't cache 403 errors - }, - { - "error_code": 404, - "response_code": 404, - "response_page_path": "/error.html", - "error_caching_min_ttl": 300, # Cache 404s for only 5 minutes - }, - ], + custom_error_responses=custom_error_responses, ) # Update S3 bucket policy to allow CloudFront access - bucket_policy = pulumi_aws.s3.BucketPolicy( - context().prefix(f"{self.name}-bucket-policy"), - bucket=self.bucket.resources.bucket.id, - policy=pulumi.Output.all( - distribution_arn=distribution.arn, - bucket_arn=self.bucket.arn, - ).apply( - lambda args: pulumi.Output.json_dumps( - { - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "AllowCloudFrontServicePrincipal", - "Effect": "Allow", - "Principal": {"Service": "cloudfront.amazonaws.com"}, - "Action": "s3:GetObject", - "Resource": f"{args['bucket_arn']}/*", - "Condition": { - "StringEquals": {"AWS:SourceArn": args["distribution_arn"]} - }, - } - ], - } - ) - ), - opts=pulumi.ResourceOptions( - depends_on=[distribution] - ), # Ensure policy is applied after distribution - ) + bucket_policy = None + if not self._function_resource: + bucket_policy = pulumi_aws.s3.BucketPolicy( + context().prefix(f"{self.name}-bucket-policy"), + bucket=self.bucket.resources.bucket.id, + policy=pulumi.Output.all( + distribution_arn=distribution.arn, + bucket_arn=self.bucket.arn, + ).apply( + lambda args: pulumi.Output.json_dumps( + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowCloudFrontServicePrincipal", + "Effect": "Allow", + "Principal": {"Service": "cloudfront.amazonaws.com"}, + "Action": "s3:GetObject", + "Resource": f"{args['bucket_arn']}/*", + "Condition": { + "StringEquals": {"AWS:SourceArn": args["distribution_arn"]} + }, + } + ], + } + ) + ), + opts=pulumi.ResourceOptions( + depends_on=[distribution] + ), # Ensure policy is applied after distribution + ) + else: + # Allow CloudFront to invoke the Lambda function URL + pulumi_aws.lambda_.Permission( + context().prefix(f"{self.name}-cloudfront-invoke"), + action="lambda:InvokeFunctionUrl", + function=self._function_resource.name, + principal="cloudfront.amazonaws.com", + source_arn=distribution.arn, + opts=pulumi.ResourceOptions(depends_on=[distribution]), + ) record = None if self.custom_domain: @@ -183,7 +256,8 @@ def _create_resources(self) -> CloudFrontDistributionResources: if record: pulumi.export(f"cloudfront_{self.name}_record_name", record.pulumi_resource.name) - pulumi.export(f"cloudfront_{self.name}_bucket_policy", bucket_policy.id) + with contextlib.suppress(Exception): + pulumi.export(f"cloudfront_{self.name}_bucket_policy", bucket_policy.id) return CloudFrontDistributionResources( distribution, diff --git a/stelvio/aws/cloudfront/origins/components/s3_static_website.py b/stelvio/aws/cloudfront/origins/components/s3_static_website.py new file mode 100644 index 00000000..6a219372 --- /dev/null +++ b/stelvio/aws/cloudfront/origins/components/s3_static_website.py @@ -0,0 +1,243 @@ +import json + +import pulumi +import pulumi_aws + +from stelvio.aws.cloudfront.dtos import Route, RouteOriginConfig +from stelvio.aws.cloudfront.js import strip_path_pattern_function_js +from stelvio.aws.cloudfront.origins.base import ComponentCloudfrontAdapter +from stelvio.aws.cloudfront.origins.decorators import register_adapter +from stelvio.aws.s3.s3_static_website import REQUEST_INDEX_HTML_FUNCTION_JS, S3StaticWebsite +from stelvio.context import context + + +@register_adapter(S3StaticWebsite) +class S3BucketCloudfrontAdapter(ComponentCloudfrontAdapter): + def __init__(self, idx: int, route: Route) -> None: + super().__init__(idx, route) + self.bucket = None + self.function_resource = None + if route.component.resources.bucket: + self.bucket = route.component + if route.component.resources._function_resource: # noqa: SLF001 + self.function_resource = route.component.resources._function_resource # noqa: SLF001 + self.function_url_resource = route.component.resources._function_resource_url # noqa: SLF001 + + def get_origin_config(self) -> RouteOriginConfig: + if self.bucket: + oac = pulumi_aws.cloudfront.OriginAccessControl( + context().prefix(f"{self.bucket.name}-oac-{self.idx}"), + description=f"Origin Access Control for {self.bucket.name} route {self.idx}", + origin_access_control_origin_type="s3", + signing_behavior="always", + signing_protocol="sigv4", + opts=pulumi.ResourceOptions(depends_on=[self.bucket.resources.bucket]), + ) + origin_args = pulumi_aws.cloudfront.DistributionOriginArgs( + origin_id=self.bucket.resources.bucket.arn, + domain_name=self.bucket.resources.bucket.bucket_regional_domain_name, + ) + origin_dict = { + "origin_id": origin_args.origin_id, + "domain_name": origin_args.domain_name, + "origin_access_control_id": oac.id, + } + path_pattern = ( + f"{self.route.path_pattern}/*" + if not self.route.path_pattern.endswith("*") + else self.route.path_pattern + ) + # function_code = strip_path_pattern_function_js(self.route.path_pattern) + # cf_function = pulumi_aws.cloudfront.Function( + # context().prefix(f"{self.bucket.name}-uri-rewrite-{self.idx}"), + # runtime="cloudfront-js-2.0", + # code=function_code, + # comment=f"Strip {self.route.path_pattern} prefix for route {self.idx}", + # opts=pulumi.ResourceOptions(depends_on=[self.bucket.resources.bucket]), + # ) + cf_function = pulumi_aws.cloudfront.Function( + context().prefix(f"{self.bucket.name}-viewer-request-function-router-{self.idx}"), + name=context().prefix( + f"{self.bucket.name}-viewer-request-function-router-{self.idx}" + ), + runtime="cloudfront-js-1.0", + comment="Rewrite requests to directories to serve index.html", + code=REQUEST_INDEX_HTML_FUNCTION_JS, # TODO: (configurable?) + ) + cache_behavior = { + "path_pattern": path_pattern, + "allowed_methods": ["GET", "HEAD", "OPTIONS"], + "cached_methods": ["GET", "HEAD"], + "target_origin_id": origin_dict["origin_id"], + "compress": True, + "viewer_protocol_policy": "redirect-to-https", + "forwarded_values": { + "query_string": False, + "cookies": {"forward": "none"}, + "headers": ["If-Modified-Since"], + }, + "min_ttl": 0, + "default_ttl": 1, # 86400, # 1 day + "max_ttl": 1, # 31536000, # 1 year + "function_associations": [ + { + "event_type": "viewer-request", + "function_arn": cf_function.arn, + } + ], + } + return RouteOriginConfig( + origin_access_controls=oac, + origins=origin_dict, + ordered_cache_behaviors=cache_behavior, + cloudfront_functions=cf_function, + ) + + if self.function_resource: + # function_url = pulumi_aws.lambda_.FunctionUrl( + # safe_name(context().prefix(), f"{self.function_resource.name}-stub-url", 64), + # function_name=self.function_resource.name, + # authorization_type="AWS_IAM", + # ) + + + function_url = self.function_url_resource + + # Extract domain from function URL (remove https:// and trailing /) + function_domain = function_url.function_url.apply( + lambda url: url.replace("https://", "").rstrip("/") + ) + + origin_args = pulumi_aws.cloudfront.DistributionOriginArgs( + origin_id=self.function_resource.name, + domain_name=function_domain, + origin_path="", # Lambda Function URLs don't need a path prefix + ) + origin_dict = { + "origin_id": origin_args.origin_id, + "domain_name": origin_args.domain_name, + "origin_path": origin_args.origin_path, + # For Lambda Function URLs, we need to specify custom_origin_config + "custom_origin_config": { + "http_port": 80, + "https_port": 443, + "origin_protocol_policy": "https-only", + "origin_ssl_protocols": ["TLSv1.2"], + }, + } + + # # Add OAC if using IAM auth + # if oac is not None: + # origin_dict["origin_access_control_id"] = oac.id + + function_code = strip_path_pattern_function_js(self.route.path_pattern) + cf_function = pulumi_aws.cloudfront.Function( + # context().prefix(f"{self.function_resource.name}-uri-rewrite-{self.idx}"), + "test-name-function", # TODO + runtime="cloudfront-js-2.0", + code=function_code, + comment=f"Strip {self.route.path_pattern} prefix for route {self.idx}", + opts=pulumi.ResourceOptions(depends_on=[self.function_resource]), + ) + + cache_behavior_template = { + "allowed_methods": ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"], + "cached_methods": ["GET", "HEAD"], + "target_origin_id": origin_dict["origin_id"], + "compress": True, + "viewer_protocol_policy": "redirect-to-https", + "forwarded_values": { + "query_string": True, # Lambda functions often use query parameters + "cookies": {"forward": "none"}, + }, + # Don't cache Lambda responses by default + "min_ttl": 0, + "default_ttl": 0, + "max_ttl": 0, + "function_associations": [ + { + "event_type": "viewer-request", + "function_arn": cf_function.arn, + } + ], + } + + if self.route.path_pattern.endswith("*"): + cache_behavior = cache_behavior_template.copy() + cache_behavior["path_pattern"] = self.route.path_pattern + ordered_cache_behaviors = cache_behavior + else: + cb1 = cache_behavior_template.copy() + cb1["path_pattern"] = self.route.path_pattern + + cb2 = cache_behavior_template.copy() + cb2["path_pattern"] = f"{self.route.path_pattern}/*" + + ordered_cache_behaviors = [cb1, cb2] + + oac = pulumi_aws.cloudfront.OriginAccessControl( + # context().prefix(f"{self.function.name}-oac-{self.idx}"), + "test-name-oac", # TODO + description="OAC for Lambda Function ", + origin_access_control_origin_type="lambda", + signing_behavior="always", + signing_protocol="sigv4", + opts=pulumi.ResourceOptions(depends_on=[self.function_resource]), + ) + # Add OAC if using IAM auth + if oac is not None: + origin_dict["origin_access_control_id"] = oac.id + + return RouteOriginConfig( + origin_access_controls=oac, + origins=origin_dict, + ordered_cache_behaviors=ordered_cache_behaviors, + cloudfront_functions=cf_function, + ) + return None + + def get_access_policy( + self, distribution: pulumi_aws.cloudfront.Distribution + ) -> pulumi_aws.s3.BucketPolicy: + if self.bucket: + bucket = self.bucket.resources.bucket + bucket_arn = bucket.arn + + return pulumi_aws.s3.BucketPolicy( + context().prefix(f"{self.bucket.name}-bucket-policy-{self.idx}"), + bucket=bucket.id, + policy=pulumi.Output.all( + distribution_arn=distribution.arn, + bucket_arn=bucket_arn, + ).apply( + lambda args: json.dumps( + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowCloudFrontServicePrincipal", + "Effect": "Allow", + "Principal": {"Service": "cloudfront.amazonaws.com"}, + "Action": "s3:GetObject", + "Resource": f"{args['bucket_arn']}/*", + "Condition": { + "StringEquals": {"AWS:SourceArn": args["distribution_arn"]} + }, + } + ], + } + ) + ), + ) + if self.function_resource: + # Grant cloudfront.amazonaws.com permission to invoke via Function URL + return pulumi_aws.lambda_.Permission( + # context().prefix(f"{self.function.name}-cloudfront-permission-{self.idx}"), + "test-name-permission", # TODO + action="lambda:InvokeFunctionUrl", + function=self.function_resource.name, + principal="cloudfront.amazonaws.com", + source_arn=distribution.arn, + function_url_auth_type="AWS_IAM", + ) + return None diff --git a/stelvio/aws/s3/s3_static_website.py b/stelvio/aws/s3/s3_static_website.py index 6b63b3b2..baca33b4 100644 --- a/stelvio/aws/s3/s3_static_website.py +++ b/stelvio/aws/s3/s3_static_website.py @@ -1,6 +1,13 @@ +import json import mimetypes +import os import re +import subprocess +import threading +import time +import uuid from dataclasses import dataclass +from hashlib import sha256 from pathlib import Path from typing import final @@ -9,14 +16,28 @@ from stelvio import context from stelvio.aws.cloudfront import CloudFrontDistribution +from stelvio.aws.function.function import _extract_links_permissions +from stelvio.aws.function.iam import ( + _attach_role_policies, + _create_function_policy, + _create_lambda_role, +) from stelvio.aws.s3.s3 import Bucket -from stelvio.component import Component, safe_name +from stelvio.bridge.local.dtos import BridgeInvocationResult +from stelvio.bridge.local.handlers import WebsocketHandlers +from stelvio.bridge.remote.infrastructure import ( + _create_lambda_bridge_archive, + discover_or_create_appsync, +) +from stelvio.component import BridgeableComponent, Component, safe_name @final @dataclass(frozen=True) class S3StaticWebsiteResources: bucket: pulumi_aws.s3.Bucket + _function_resource: pulumi_aws.lambda_.Function | None + _function_resource_url: pulumi_aws.lambda_.FunctionUrl | None files: list[pulumi_aws.s3.BucketObject] cloudfront_distribution: CloudFrontDistribution @@ -39,26 +60,53 @@ class S3StaticWebsiteResources: @final -class S3StaticWebsite(Component[S3StaticWebsiteResources]): - def __init__( +@dataclass(frozen=True) +class StaticWebsiteBuildOptions: + directory: Path | None = None + command: str | None = None + env_vars: dict[str, str] | None = None + working_directory: Path | None = None + + +@final +@dataclass(frozen=True) +class StaticWebsiteDevOptions: + port: int | None = None + directory: Path | None = None + command: str | None = None + env_vars: dict[str, str] | None = None + working_directory: Path | None = None + + +@final +class S3StaticWebsite(Component[S3StaticWebsiteResources], BridgeableComponent): + def __init__( # noqa: PLR0913 self, name: str, custom_domain: str | None = None, - directory: Path | str | None = None, + # directory: Path | str | None = None, default_cache_ttl: int = 120, + build_options: dict | StaticWebsiteBuildOptions | None = None, + dev_options: dict | StaticWebsiteDevOptions | None = None, + create_distribution: bool = True, ): super().__init__(name) - self.directory = Path(directory) if isinstance(directory, str) else directory + # self.directory = Path(directory) if isinstance(directory, str) else directory self.custom_domain = custom_domain self.default_cache_ttl = default_cache_ttl + if isinstance(build_options, dict): + build_options = StaticWebsiteBuildOptions(**build_options) + if isinstance(dev_options, dict): + dev_options = StaticWebsiteDevOptions(**dev_options) + self.build_options = build_options + self.dev_options = dev_options + self.create_distribution = create_distribution self._resources = None + self._dev_endpoint_id = f"{self.name}-{sha256(uuid.uuid4().bytes).hexdigest()[:8]}" def _create_resources(self) -> S3StaticWebsiteResources: - # Validate directory exists - if self.directory is not None and not self.directory.exists(): - raise FileNotFoundError(f"Directory does not exist: {self.directory}") - - bucket = Bucket(f"{self.name}-bucket") + bucket_name = f"{self.name}-bucket" + bucket = Bucket(bucket_name) # Create CloudFront Function to handle directory index rewriting viewer_request_function = pulumi_aws.cloudfront.Function( context().prefix(f"{self.name}-viewer-request"), @@ -67,36 +115,107 @@ def _create_resources(self) -> S3StaticWebsiteResources: comment="Rewrite requests to directories to serve index.html", code=REQUEST_INDEX_HTML_FUNCTION_JS, # TODO: (configurable?) ) - cloudfront_distribution = CloudFrontDistribution( - name=f"{self.name}-cloudfront", - bucket=bucket, - custom_domain=self.custom_domain, - function_associations=[ - { - "event_type": "viewer-request", - "function_arn": viewer_request_function.arn, - } - ], - ) - # Upload files from directory to S3 bucket - files = self._process_directory_and_upload_files(bucket, self.directory) + if context().dev_mode: + files = [] + self.process_dev_options() + appsync_bridge = discover_or_create_appsync( + region=context().aws.region, profile=context().aws.profile + ) - pulumi.export(f"s3_static_website_{self.name}_bucket_name", bucket.resources.bucket.bucket) - pulumi.export(f"s3_static_website_{self.name}_bucket_arn", bucket.resources.bucket.arn) - pulumi.export( - f"s3_static_website_{self.name}_cloudfront_distribution_name", - cloudfront_distribution.name, - ) - pulumi.export( - f"s3_static_website_{self.name}_cloudfront_domain_name", - cloudfront_distribution.resources.distribution.domain_name, - ) + function_name = safe_name(context().prefix(), f"{self.name}-stub", 64) + lambda_role = _create_lambda_role(self.name) + iam_statements = _extract_links_permissions([]) + function_policy = _create_function_policy(self.name, iam_statements) + role_attachments = _attach_role_policies(self.name, lambda_role, function_policy) + + env_vars = {} + WebsocketHandlers.register(self) + env_vars["STLV_APPSYNC_REALTIME"] = appsync_bridge.realtime_endpoint + env_vars["STLV_APPSYNC_HTTP"] = appsync_bridge.http_endpoint + env_vars["STLV_APPSYNC_API_KEY"] = appsync_bridge.api_key + env_vars["STLV_APP_NAME"] = context().name + env_vars["STLV_STAGE"] = context().env + env_vars["STLV_FUNCTION_NAME"] = self.name + env_vars["STLV_DEV_ENDPOINT_ID"] = self._dev_endpoint_id + function_resource = pulumi_aws.lambda_.Function( + function_name, + role=lambda_role.arn, + architectures=["x86_64"], + runtime="python3.12", + code=_create_lambda_bridge_archive(), + handler="stlv_function_stub.handler", + environment={"variables": env_vars}, + memory_size=128, + timeout=60, + # layers=[layer.arn for layer in self.config.layers] + # if self.config.layers else None, + layers=None, + # Technically this is necessary only for tests as otherwise + # it's ok if role attachments are created after functions + opts=pulumi.ResourceOptions(depends_on=role_attachments), + ) + + function_url = pulumi_aws.lambda_.FunctionUrl( + safe_name(context().prefix(), f"{self.name}-stub-url", 64), + function_name=function_resource.name, + authorization_type="AWS_IAM", + ) + function_resource.function_url = function_url.function_url + + pulumi.export( + f"s3_static_website_{self.name}_stub_function_name", function_resource.name + ) + pulumi.export( + f"s3_static_website_{self.name}_stub_function_url_name", function_url.function_url + ) + + bucket = None + else: + function_url = None + files = self._process_build_options(bucket) + function_resource = None + + cloudfront_distribution = None + if self.create_distribution: + cloudfront_distribution = CloudFrontDistribution( + name=f"{self.name}-cloudfront", + bucket=bucket, + _function_resource=function_resource, + custom_domain=self.custom_domain, + function_associations=[ + { + "event_type": "viewer-request", + "function_arn": viewer_request_function.arn, + } + ] + if not context().dev_mode + else [], + ) + + # Upload files from directory to S3 bucket + # files = self._process_build_options(bucket) + if bucket: + pulumi.export( + f"s3_static_website_{self.name}_bucket_name", bucket.resources.bucket.bucket + ) + pulumi.export(f"s3_static_website_{self.name}_bucket_arn", bucket.resources.bucket.arn) + if cloudfront_distribution: + pulumi.export( + f"s3_static_website_{self.name}_cloudfront_distribution_name", + cloudfront_distribution.name, + ) + pulumi.export( + f"s3_static_website_{self.name}_cloudfront_domain_name", + cloudfront_distribution.resources.distribution.domain_name, + ) pulumi.export(f"s3_static_website_{self.name}_custom_domain", self.custom_domain) pulumi.export(f"s3_static_website_{self.name}_files", [file.arn for file in files]) return S3StaticWebsiteResources( - bucket=bucket.resources.bucket, + bucket=bucket.resources.bucket if bucket else None, + _function_resource=function_resource, + _function_resource_url=function_url, files=files, cloudfront_distribution=cloudfront_distribution, ) @@ -134,9 +253,52 @@ def _create_s3_bucket_object( cache_control=cache_control, ) - def _process_directory_and_upload_files( - self, bucket: Bucket, directory: Path + def process_dev_options(self) -> None: + if self.dev_options is None: + return + + if self.dev_options.command is not None: + + def _run_dev_command() -> None: + # Execute dev command + env = os.environ.copy() + if self.dev_options.env_vars: + env.update(self.dev_options.env_vars) + + subprocess.run( # noqa: S602 + self.dev_options.command, + shell=True, + cwd=str(self.dev_options.working_directory or Path.cwd()), + env=env, + check=False, + ) + + thread = threading.Thread(target=_run_dev_command, daemon=False) + thread.start() + + def _process_build_options( + self, + bucket: Bucket, ) -> list[pulumi_aws.s3.BucketObject]: + if self.build_options is None: + return [] + + if self.build_options.command is not None: + # Execute build command + env = os.environ.copy() + if self.build_options.env_vars: + env.update(self.build_options.env_vars) + + subprocess.run( # noqa: S602 + self.build_options.command, + shell=True, + check=True, + cwd=str(self.build_options.working_directory or Path.cwd()), + env=env, + ) + + directory = self.build_options.directory + # glob all files in the directory if directory is None: return [] @@ -146,3 +308,79 @@ def _process_directory_and_upload_files( for file_path in directory.rglob("*") if file_path.is_file() ] + + def _proxy_http(self, method: str, path: str, headers: dict, body: str) -> dict: + import requests + + url = f"http://localhost:{self.dev_options.port}{path}" + response = requests.request(method, url, headers=headers, data=body, timeout=10) + return { + "statusCode": response.status_code, + "headers": dict(response.headers), + "body": response.text, + } + + def _proxy_file(self, path: str) -> dict: + if self.dev_options.directory is None: + raise RuntimeError( + "Directory is not configured in dev options for this S3StaticWebsite." + ) + + file_path = self.dev_options.directory / path.lstrip("/") + if file_path.is_dir(): + file_path = file_path / "index.html" + if not file_path.exists() or not file_path.is_file(): + return { + "statusCode": 404, + "body": "Not Found", + } + with Path.open(file_path, encoding="utf-8") as f: + content = f.read() + return { + "statusCode": 200, + "body": content, + } + + async def _handle_bridge_event(self, event: dict) -> dict: + if self.dev_options is None: + raise RuntimeError(f"Dev options are not configured for Static Website {self.name}.") + + if not self.dev_options.port and not self.dev_options.directory: + raise RuntimeError( + f"Neither port nor directory is configured in dev options for Static Website " + f"{self.name}." + ) + + if self.dev_options.port and self.dev_options.directory: + raise RuntimeError( + f"Both port and directory are configured in dev options for Static Website " + f"{self.name}. Please configure only one." + ) + + lambda_event = event.get("event", "{}") + lambda_event = json.loads(lambda_event) if isinstance(lambda_event, str) else lambda_event + + start_time = time.perf_counter() + if self.dev_options.port: + result = self._proxy_http( + method=lambda_event["event"]["requestContext"]["http"]["method"], + path=lambda_event["event"]["requestContext"]["http"]["path"], + headers=lambda_event["event"].get("headers", {}), + body=lambda_event["event"].get("body", ""), + ) + if self.dev_options.directory: + result = self._proxy_file( + path=lambda_event["event"]["requestContext"]["http"]["path"], + ) + end_time = time.perf_counter() + run_time = end_time - start_time + + return BridgeInvocationResult( + success_result=result, + error_result=None, + process_time_local=float(run_time * 1000), + request_path=lambda_event["event"]["requestContext"]["http"]["path"], + request_method=lambda_event["event"]["requestContext"]["http"]["method"], + status_code=result["statusCode"], + handler_name=f"S3StaticWebsite:{self.name}", + )