diff --git a/samcli/commands/build/command.py b/samcli/commands/build/command.py index 32e92591bd..509816507d 100644 --- a/samcli/commands/build/command.py +++ b/samcli/commands/build/command.py @@ -4,10 +4,13 @@ import logging import os -from typing import Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple import click +if TYPE_CHECKING: + from samcli.commands.build.build_context import BuildContext + from samcli.cli.cli_config_file import ConfigProvider, configuration_option, save_params_option from samcli.cli.context import Context from samcli.cli.main import aws_creds_options, pass_context, print_cmdline_args @@ -30,6 +33,7 @@ template_option_without_build, terraform_project_root_path_option, use_container_build_option, + watch_exclude_option, ) from samcli.commands.build.click_container import ContainerOptions from samcli.commands.build.core.command import BuildCommand @@ -45,7 +49,10 @@ DESCRIPTION = """ Build AWS serverless function code to generate artifacts targeting - AWS Lambda execution environment.\n + AWS Lambda execution environment. + + Use --watch to automatically rebuild when source files change, + similar to 'sam sync --watch' for local development workflows.\n \b Supported Resource Types ------------------------ @@ -106,6 +113,16 @@ @click.option( "--parallel", "-p", is_flag=True, help="Enable parallel builds for AWS SAM template's functions and layers." ) +@click.option( + "--watch/--no-watch", + is_flag=True, + help="Watch local files and automatically rebuild when changes are detected. " + "When enabled, monitors your source code and template for changes and " + "triggers automatic rebuilds. Changes are debounced to avoid excessive rebuilds " + "during rapid file modifications. Build artifacts, cache directories, and " + "temporary files are automatically excluded from monitoring. " + "Press Ctrl+C to stop watch mode. Similar to 'sam sync --watch' workflow.", +) @click.option( "--mount-with", "-mw", @@ -117,6 +134,7 @@ cls=ContainerOptions, ) @mount_symlinks_option +@watch_exclude_option @build_dir_option @cache_dir_option @base_dir_option @@ -144,6 +162,8 @@ def cli( use_container: bool, cached: bool, parallel: bool, + watch: bool, + watch_exclude: Optional[Dict[str, List[str]]], manifest: Optional[str], docker_network: Optional[str], container_env_var: Optional[Tuple[str]], @@ -180,6 +200,8 @@ def cli( use_container, cached, parallel, + watch, + watch_exclude, manifest, docker_network, skip_pull_image, @@ -207,6 +229,8 @@ def do_cli( # pylint: disable=too-many-locals, too-many-statements use_container: bool, cached: bool, parallel: bool, + watch: bool, + watch_exclude: Optional[Dict[str, List[str]]], manifest_path: Optional[str], docker_network: Optional[str], skip_pull_image: bool, @@ -261,7 +285,41 @@ def do_cli( # pylint: disable=too-many-locals, too-many-statements mount_with=mount_with, mount_symlinks=mount_symlinks, ) as ctx: - ctx.run() + if watch: + watch_excludes_filter = watch_exclude or {} + execute_build_watch( + template=template, + build_context=ctx, + watch_exclude=watch_excludes_filter, + ) + else: + ctx.run() + + +def execute_build_watch( + template: str, + build_context: "BuildContext", + watch_exclude: Dict[str, List[str]], +) -> None: + """Start build watch execution + + Parameters + ---------- + template : str + Template file path + build_context : BuildContext + BuildContext for build operations + watch_exclude : Dict[str, List[str]] + Dictionary of watch exclusion patterns per resource + """ + from samcli.lib.build.watch_manager import BuildWatchManager + + build_watch_manager = BuildWatchManager( + template, + build_context, + watch_exclude, + ) + build_watch_manager.start() def _get_mode_value_from_envvar(name: str, choices: List[str]) -> Optional[str]: diff --git a/samcli/commands/build/core/command.py b/samcli/commands/build/core/command.py index 10212cedee..7426013ad1 100644 --- a/samcli/commands/build/core/command.py +++ b/samcli/commands/build/core/command.py @@ -44,6 +44,10 @@ def format_examples(ctx: Context, formatter: BuildCommandHelpTextFormatter): name=style(f"$ {ctx.command_path} && {ctx.parent.command_path} local invoke"), # type: ignore extra_row_modifiers=[ShowcaseRowModifier()], ), + RowDefinition( + name=style(f"$ {ctx.command_path} --watch"), + extra_row_modifiers=[ShowcaseRowModifier()], + ), RowDefinition( name=style(f"$ {ctx.command_path} && {ctx.parent.command_path} deploy"), # type: ignore extra_row_modifiers=[ShowcaseRowModifier()], diff --git a/samcli/commands/build/core/options.py b/samcli/commands/build/core/options.py index dfb1094361..bb085fb753 100644 --- a/samcli/commands/build/core/options.py +++ b/samcli/commands/build/core/options.py @@ -29,7 +29,15 @@ EXTENSION_OPTIONS: List[str] = ["hook_name", "skip_prepare_infra"] -BUILD_STRATEGY_OPTIONS: List[str] = ["parallel", "exclude", "manifest", "cached", "build_in_source"] +BUILD_STRATEGY_OPTIONS: List[str] = [ + "build_in_source", + "cached", + "exclude", + "manifest", + "parallel", + "watch", + "watch_exclude", +] ARTIFACT_LOCATION_OPTIONS: List[str] = [ "build_dir", diff --git a/samcli/lib/build/watch_manager.py b/samcli/lib/build/watch_manager.py new file mode 100644 index 0000000000..d7e90d0320 --- /dev/null +++ b/samcli/lib/build/watch_manager.py @@ -0,0 +1,455 @@ +""" +BuildWatchManager for Build Watch Logic +""" + +import logging +import platform +import re +import threading +import time +from pathlib import Path +from typing import TYPE_CHECKING, Dict, List, Optional + +from watchdog.events import EVENT_TYPE_MODIFIED, EVENT_TYPE_OPENED, FileSystemEvent + +from samcli.lib.providers.exceptions import MissingCodeUri, MissingLocalDefinition +from samcli.lib.providers.provider import ResourceIdentifier, Stack, get_all_resource_ids +from samcli.lib.providers.sam_stack_provider import SamLocalStackProvider +from samcli.lib.utils.code_trigger_factory import CodeTriggerFactory +from samcli.lib.utils.colors import Colored, Colors +from samcli.lib.utils.path_observer import HandlerObserver +from samcli.lib.utils.resource_trigger import OnChangeCallback +from samcli.local.lambdafn.exceptions import ResourceNotFound + +if TYPE_CHECKING: # pragma: no cover + from samcli.commands.build.build_context import BuildContext + +DEFAULT_BUILD_WAIT_TIME = 1 +LOG = logging.getLogger(__name__) + + +class BuildWatchManager: + """Manager for build watch execution logic. + This manager will observe template and its code resources. + Automatically execute builds when changes are detected. + + Follows the same patterns as WatchManager but adapted for build operations. + """ + + _stacks: Optional[List[Stack]] + _template: str + _build_context: "BuildContext" + _observer: HandlerObserver + _trigger_factory: Optional[CodeTriggerFactory] + _waiting_build: bool + _build_timer: Optional[threading.Timer] + _build_lock: threading.Lock + _color: Colored + _watch_exclude: Dict[str, List[str]] + + def __init__( + self, + template: str, + build_context: "BuildContext", + watch_exclude: Dict[str, List[str]], + ): + """Manager for build watch execution logic. + This manager will observe template and its code resources. + Automatically execute builds when changes are detected. + + Parameters + ---------- + template : str + Template file path + build_context : BuildContext + BuildContext for build operations + watch_exclude : Dict[str, List[str]] + Dictionary of watch exclusion patterns per resource + """ + self._stacks = None + self._template = template + self._build_context = build_context + + self._observer = HandlerObserver() + self._trigger_factory = None + + self._waiting_build = False + self._build_timer = None + self._build_lock = threading.Lock() + self._color = Colored() + + # Build smart exclusions based on build configuration + self._watch_exclude = self._build_smart_exclusions(build_context, watch_exclude) + + # Validate safety upfront + self._validate_watch_safety(build_context) + + def _build_smart_exclusions( + self, build_context: "BuildContext", watch_exclude: Dict[str, List[str]] + ) -> Dict[str, List[str]]: + """Build exclusions that prevent recursion based on build config""" + from samcli.lib.utils.resource_trigger import DEFAULT_WATCH_IGNORED_RESOURCES + + # Base exclusions from resource_trigger.py + base_exclusions = [*DEFAULT_WATCH_IGNORED_RESOURCES] + + # Add build-specific exclusions + if build_context.build_in_source: + base_exclusions.extend( + [ + r"^.*\.pyc$", # Python bytecode + r"^.*__pycache__.*$", # Python cache + r"^.*\.class$", # Java classes + r"^.*target/.*$", # Maven target + r"^.*build/.*$", # Gradle build + ] + ) + + # Exclude cache directory if under project + cache_exclusions = self._get_cache_exclusions(build_context) + base_exclusions.extend(cache_exclusions) + + # Exclude build directory if under project + build_exclusions = self._get_build_exclusions(build_context) + base_exclusions.extend(build_exclusions) + + # Apply base exclusions to all resources, merging with user-provided exclusions + result = {} + for resource_id, user_excludes in watch_exclude.items(): + result[resource_id] = base_exclusions + user_excludes + + # For resources not specified by user, use base exclusions + return result if result else {"*": base_exclusions} + + def _get_cache_exclusions(self, build_context: "BuildContext") -> List[str]: + """Exclude cache directory from watching""" + cache_dir = Path(build_context.cache_dir).resolve() + base_dir = Path(build_context.base_dir).resolve() + + try: + # If cache_dir is under base_dir, add it to exclusions + rel_cache_path = cache_dir.relative_to(base_dir) + return [f"^.*{re.escape(str(rel_cache_path))}.*$"] + except ValueError: + # cache_dir is outside base_dir, no need to exclude + return [] + + def _get_build_exclusions(self, build_context: "BuildContext") -> List[str]: + """Exclude build directory from watching""" + build_dir = Path(build_context.build_dir).resolve() + base_dir = Path(build_context.base_dir).resolve() + + try: + # If build_dir is under base_dir, add it to exclusions + rel_build_path = build_dir.relative_to(base_dir) + return [f"^.*{re.escape(str(rel_build_path))}.*$"] + except ValueError: + # build_dir is outside base_dir, no need to exclude + return [] + + def _validate_watch_safety(self, build_context: "BuildContext") -> None: + """Validate that watch won't cause recursion issues""" + base_dir = Path(build_context.base_dir).resolve() + cache_dir = Path(build_context.cache_dir).resolve() + build_dir = Path(build_context.build_dir).resolve() + + warnings = [] + + # Check if cache dir is under a source directory + if build_context.build_in_source: + try: + if base_dir in cache_dir.parents: + warnings.append( + f"Cache directory {cache_dir} is under project directory {base_dir}. " + "This may cause build loops." + ) + except (OSError, ValueError): + pass + + # Check if build dir is under base dir when not using default + default_build_dir = base_dir / ".aws-sam" / "build" + if build_dir != default_build_dir: + try: + if base_dir in build_dir.parents: + warnings.append( + f"Custom build directory {build_dir} is under project directory. " + "This may cause watch recursion." + ) + except (OSError, ValueError): + pass + + # Log warnings but don't fail - let users proceed with caution + for warning in warnings: + LOG.warning(self._color.yellow(f"Build watch safety warning: {warning}")) + + def queue_build(self) -> None: + """Queue up a build operation. + A simple bool flag is sufficient for immediate builds (like initial build or template changes) + """ + self._waiting_build = True + + def queue_debounced_build(self, wait_time: float = DEFAULT_BUILD_WAIT_TIME) -> None: + """Queue up a debounced build operation to handle rapid file changes. + + Parameters + ---------- + wait_time : float + Time to wait before executing build (allows for batching multiple changes) + """ + with self._build_lock: + # Cancel any pending build timer + if self._build_timer and self._build_timer.is_alive(): + self._build_timer.cancel() + + # Schedule new build after wait_time + self._build_timer = threading.Timer(wait_time, self._execute_debounced_build) + self._build_timer.start() + LOG.debug(f"Debounced build scheduled in {wait_time} seconds") + + def _execute_debounced_build(self) -> None: + """Execute the debounced build by setting the waiting flag""" + with self._build_lock: + self._waiting_build = True + LOG.debug("Debounced build timer triggered") + + def _update_stacks(self) -> None: + """ + Reloads template and its stacks. + Update all other members that also depend on the stacks. + This should be called whenever there is a change to the template. + """ + self._stacks = SamLocalStackProvider.get_stacks(self._template, use_sam_transform=False)[0] + self._trigger_factory = CodeTriggerFactory(self._stacks, Path(self._build_context.base_dir)) + + def _add_code_triggers(self) -> None: + """Create CodeResourceTrigger for all resources and add their handlers to observer""" + if not self._stacks or not self._trigger_factory: + return + resource_ids = get_all_resource_ids(self._stacks) + for resource_id in resource_ids: + try: + # Get exclusions for this specific resource, or use global exclusions + additional_excludes = self._watch_exclude.get(str(resource_id), self._watch_exclude.get("*", [])) + trigger = self._trigger_factory.create_trigger( + resource_id, self._on_code_change_wrapper(resource_id), additional_excludes + ) + except (MissingCodeUri, MissingLocalDefinition): + LOG.warning( + self._color.color_log( + msg="CodeTrigger not created as CodeUri or DefinitionUri is missing for %s.", + color=Colors.WARNING, + ), + str(resource_id), + extra=dict(markup=True), + ) + continue + except ResourceNotFound: + LOG.warning( + self._color.color_log( + msg="CodeTrigger not created as %s is not found or is with a S3 Location.", + color=Colors.WARNING, + ), + str(resource_id), + extra=dict(markup=True), + ) + continue + + if not trigger: + continue + self._observer.schedule_handlers(trigger.get_path_handlers()) + + def _add_template_triggers(self) -> None: + """Create template file watcher with polling fallback""" + from watchdog.events import FileSystemEventHandler + + template_path = Path(self._template).resolve() + LOG.info(f"Setting up template monitoring for {template_path}") + + # Create a custom event handler class + class TemplateEventHandler(FileSystemEventHandler): + def __init__(self, manager: "BuildWatchManager", template_path: Path): + self.manager = manager + self.template_path = template_path + + def on_any_event(self, event: FileSystemEvent) -> None: + if event and hasattr(event, "src_path"): + event_path = Path(event.src_path).resolve() + if event_path == self.template_path: + LOG.info( + self.manager._color.color_log( + msg=f"Template change detected: {event.event_type} on {event.src_path}", + color=Colors.PROGRESS, + ), + extra=dict(markup=True), + ) + self.manager.queue_build() + + template_handler = TemplateEventHandler(self, template_path) + + # Create PathHandler for the template directory + from samcli.lib.utils.path_observer import PathHandler + + template_path_handler = PathHandler(path=template_path.parent, event_handler=template_handler, recursive=False) + + self._observer.schedule_handlers([template_path_handler]) + LOG.info(f"Template pattern watcher registered for {template_path}") + + # Add periodic template check as fallback + self._start_template_polling() + + def _start_template_polling(self) -> None: + """Start periodic template file checking as fallback""" + template_path = Path(self._template).resolve() + + if not hasattr(self, "_template_mtime"): + try: + self._template_mtime = template_path.stat().st_mtime + except OSError: + self._template_mtime = 0 + + def check_template_periodically() -> None: + while True: + try: + current_mtime = template_path.stat().st_mtime + if current_mtime != self._template_mtime: + LOG.info( + self._color.color_log( + msg="Template modification detected via polling. Starting build...", + color=Colors.PROGRESS, + ), + extra=dict(markup=True), + ) + self._template_mtime = current_mtime + self.queue_build() + except OSError: + pass + time.sleep(2) # Check every 2 seconds + + # Start polling in a daemon thread + polling_thread = threading.Thread(target=check_template_periodically, daemon=True) + polling_thread.start() + LOG.debug("Template polling fallback started") + + def start(self) -> None: + """Start BuildWatchManager and watch for changes to the template and its code resources.""" + + # The actual execution is done in _start() + # This is a wrapper for gracefully handling Ctrl+C or other termination cases. + try: + self.queue_build() + self._start_watch() + LOG.info(self._color.color_log(msg="Build watch started.", color=Colors.SUCCESS), extra=dict(markup=True)) + self._start() + except KeyboardInterrupt: + LOG.info( + self._color.color_log(msg="Shutting down build watch...", color=Colors.PROGRESS), + extra=dict(markup=True), + ) + self._observer.stop() + # Cancel any pending build timer + with self._build_lock: + if self._build_timer and self._build_timer.is_alive(): + self._build_timer.cancel() + LOG.info(self._color.color_log(msg="Build watch stopped.", color=Colors.SUCCESS), extra=dict(markup=True)) + + def _start(self) -> None: + """Start BuildWatchManager and watch for changes to the template and its code resources.""" + first_build = True + self._observer.start() + while True: + if self._waiting_build: + self._execute_build(first_build) + first_build = False + time.sleep(1) + + def _start_watch(self) -> None: + """Update stacks and populate all triggers""" + self._observer.unschedule_all() + self._update_stacks() + self._add_template_triggers() + self._add_code_triggers() + + def _execute_build(self, first_build: bool = False) -> None: + """Logic to execute build.""" + if first_build: + LOG.info( + self._color.color_log(msg="Starting initial build.", color=Colors.PROGRESS), extra=dict(markup=True) + ) + else: + LOG.info( + self._color.color_log(msg="File changes detected. Starting build.", color=Colors.PROGRESS), + extra=dict(markup=True), + ) + + self._waiting_build = False + + try: + # CRITICAL FIX: Re-parse template BEFORE building to pick up new resources + if not first_build: + LOG.debug("Refreshing build context with latest template data") + self._build_context.set_up() + + self._build_context.run() + LOG.info(self._color.color_log(msg="Build completed.", color=Colors.SUCCESS), extra=dict(markup=True)) + except Exception as e: + LOG.error( + self._color.color_log(msg="Build failed. Watching for file changes to retry.", color=Colors.FAILURE), + exc_info=e, + extra=dict(markup=True), + ) + # Don't stop watching on build failure - let users fix the issue and retry + + # Update stacks and repopulate triggers after build + # This ensures we pick up any template changes from the build + self._start_watch() + + def _on_code_change_wrapper(self, resource_id: ResourceIdentifier) -> OnChangeCallback: + """Wrapper method that generates a callback for code changes. + + Parameters + ---------- + resource_id : ResourceIdentifier + Resource that associates to the callback + + Returns + ------- + OnChangeCallback + Callback function + """ + + def on_code_change(event: Optional[FileSystemEvent] = None) -> None: + """ + Custom event handling to create a new build if a file was modified. + + Parameters + ---------- + event: Optional[FileSystemEvent] + The event that triggered the change + """ + if event and event.event_type == EVENT_TYPE_OPENED: + # Ignore all file opened events since this event is + # added in addition to a create or modified event, + # causing an infinite loop of build executions + LOG.debug("Ignoring file system OPENED event") + return + + if ( + platform.system().lower() == "linux" + and event + and event.event_type == EVENT_TYPE_MODIFIED + and event.is_directory + ): + # Linux machines appear to emit an additional event when + # a file gets updated; a folder modified event + # If folder/file.txt gets updated, there will be two events: + # 1. file.txt modified event + # 2. folder modified event + # We want to ignore the second event + LOG.debug(f"Ignoring file system MODIFIED event for folder {event.src_path!r}") + return + + # Queue up debounced build for the detected change + LOG.debug(f"Code change detected for resource {resource_id}") + self.queue_debounced_build() + + return on_code_change diff --git a/schema/samcli.json b/schema/samcli.json index 5203d60bc7..c0d9d62bd7 100644 --- a/schema/samcli.json +++ b/schema/samcli.json @@ -248,7 +248,7 @@ "properties": { "parameters": { "title": "Parameters for the build command", - "description": "Available parameters for the build command:\n* terraform_project_root_path:\nUsed for passing the Terraform project root directory path. Current directory will be used as a default value, if this parameter is not provided.\n* hook_name:\nHook package id to extend AWS SAM CLI commands functionality. \n\nExample: `terraform` to extend AWS SAM CLI commands functionality to support terraform applications. \n\nAvailable Hook Names: ['terraform']\n* skip_prepare_infra:\nSkip preparation stage when there are no infrastructure changes. Only used in conjunction with --hook-name.\n* use_container:\nBuild functions within an AWS Lambda-like container.\n* build_in_source:\nOpts in to build project in the source folder. The following workflows support building in source: ['nodejs16.x', 'nodejs18.x', 'nodejs20.x', 'nodejs22.x', 'Makefile', 'esbuild']\n* container_env_var:\nEnvironment variables to be passed into build containers\nResource format (FuncName.VarName=Value) or Global format (VarName=Value).\n\n Example: --container-env-var Func1.VAR1=value1 --container-env-var VAR2=value2\n* container_env_var_file:\nEnvironment variables json file (e.g., env_vars.json) to be passed to containers.\n* build_image:\nContainer image URIs for building functions/layers. You can specify for all functions/layers with just the image URI (--build-image public.ecr.aws/sam/build-nodejs18.x:latest). You can specify for each individual function with (--build-image FunctionLogicalID=public.ecr.aws/sam/build-nodejs18.x:latest). A combination of the two can be used. If a function does not have build image specified or an image URI for all functions, the default SAM CLI build images will be used.\n* exclude:\nName of the resource(s) to exclude from AWS SAM CLI build.\n* parallel:\nEnable parallel builds for AWS SAM template's functions and layers.\n* mount_with:\nSpecify mount mode for building functions/layers inside container. If it is mounted with write permissions, some files in source code directory may be changed/added by the build process. By default the source code directory is read only.\n* mount_symlinks:\nSpecify if symlinks at the top level of the code should be mounted inside the container. Activating this flag could allow access to locations outside of your workspace by using a symbolic link. By default symlinks are not mounted.\n* build_dir:\nDirectory to store build artifacts.Note: This directory will be first removed before starting a build.\n* cache_dir:\nDirectory to store cached artifacts. The default cache directory is .aws-sam/cache\n* base_dir:\nResolve relative paths to function's source code with respect to this directory. Use this if SAM template and source code are not in same enclosing folder. By default, relative paths are resolved with respect to the SAM template's location.\n* manifest:\nPath to a custom dependency manifest. Example: custom-package.json\n* cached:\nEnable cached builds.Reuse build artifacts that have not changed from previous builds. \n\nAWS SAM CLI evaluates if files in your project directory have changed. \n\nNote: AWS SAM CLI does not evaluate changes made to third party modules that the project depends on.Example: Python function includes a requirements.txt file with the following entry requests=1.x and the latest request module version changes from 1.1 to 1.2, AWS SAM CLI will not pull the latest version until a non-cached build is run.\n* template_file:\nAWS SAM template file.\n* parameter_overrides:\nString that contains AWS CloudFormation parameter overrides encoded as key=value pairs.\n* skip_pull_image:\nSkip pulling down the latest Docker image for Lambda runtime.\n* docker_network:\nName or ID of an existing docker network for AWS Lambda docker containers to connect to, along with the default bridge network. If not specified, the Lambda containers will only connect to the default bridge docker network.\n* beta_features:\nEnable/Disable beta features.\n* debug:\nTurn on debug logging to print debug message generated by AWS SAM CLI and display timestamps.\n* profile:\nSelect a specific profile from your credential file to get AWS credentials.\n* region:\nSet the AWS Region of the service. (e.g. us-east-1)\n* save_params:\nSave the parameters provided via the command line to the configuration file.", + "description": "Available parameters for the build command:\n* terraform_project_root_path:\nUsed for passing the Terraform project root directory path. Current directory will be used as a default value, if this parameter is not provided.\n* hook_name:\nHook package id to extend AWS SAM CLI commands functionality. \n\nExample: `terraform` to extend AWS SAM CLI commands functionality to support terraform applications. \n\nAvailable Hook Names: ['terraform']\n* skip_prepare_infra:\nSkip preparation stage when there are no infrastructure changes. Only used in conjunction with --hook-name.\n* use_container:\nBuild functions within an AWS Lambda-like container.\n* build_in_source:\nOpts in to build project in the source folder. The following workflows support building in source: ['nodejs16.x', 'nodejs18.x', 'nodejs20.x', 'nodejs22.x', 'Makefile', 'esbuild']\n* container_env_var:\nEnvironment variables to be passed into build containers\nResource format (FuncName.VarName=Value) or Global format (VarName=Value).\n\n Example: --container-env-var Func1.VAR1=value1 --container-env-var VAR2=value2\n* container_env_var_file:\nEnvironment variables json file (e.g., env_vars.json) to be passed to containers.\n* build_image:\nContainer image URIs for building functions/layers. You can specify for all functions/layers with just the image URI (--build-image public.ecr.aws/sam/build-nodejs18.x:latest). You can specify for each individual function with (--build-image FunctionLogicalID=public.ecr.aws/sam/build-nodejs18.x:latest). A combination of the two can be used. If a function does not have build image specified or an image URI for all functions, the default SAM CLI build images will be used.\n* exclude:\nName of the resource(s) to exclude from AWS SAM CLI build.\n* parallel:\nEnable parallel builds for AWS SAM template's functions and layers.\n* watch:\nWatch local files and automatically rebuild when changes are detected. When enabled, monitors your source code and template for changes and triggers automatic rebuilds. Changes are debounced to avoid excessive rebuilds during rapid file modifications. Build artifacts, cache directories, and temporary files are automatically excluded from monitoring. Press Ctrl+C to stop watch mode. Similar to 'sam sync --watch' workflow.\n* mount_with:\nSpecify mount mode for building functions/layers inside container. If it is mounted with write permissions, some files in source code directory may be changed/added by the build process. By default the source code directory is read only.\n* mount_symlinks:\nSpecify if symlinks at the top level of the code should be mounted inside the container. Activating this flag could allow access to locations outside of your workspace by using a symbolic link. By default symlinks are not mounted.\n* watch_exclude:\nExcludes a file or folder from being observed for file changes. Files and folders that are excluded will not trigger a sync workflow. This option can be provided multiple times.\n\nExamples:\n\nHelloWorldFunction=package-lock.json\n\nChildStackA/FunctionName=database.sqlite3\n* build_dir:\nDirectory to store build artifacts.Note: This directory will be first removed before starting a build.\n* cache_dir:\nDirectory to store cached artifacts. The default cache directory is .aws-sam/cache\n* base_dir:\nResolve relative paths to function's source code with respect to this directory. Use this if SAM template and source code are not in same enclosing folder. By default, relative paths are resolved with respect to the SAM template's location.\n* manifest:\nPath to a custom dependency manifest. Example: custom-package.json\n* cached:\nEnable cached builds.Reuse build artifacts that have not changed from previous builds. \n\nAWS SAM CLI evaluates if files in your project directory have changed. \n\nNote: AWS SAM CLI does not evaluate changes made to third party modules that the project depends on.Example: Python function includes a requirements.txt file with the following entry requests=1.x and the latest request module version changes from 1.1 to 1.2, AWS SAM CLI will not pull the latest version until a non-cached build is run.\n* template_file:\nAWS SAM template file.\n* parameter_overrides:\nString that contains AWS CloudFormation parameter overrides encoded as key=value pairs.\n* skip_pull_image:\nSkip pulling down the latest Docker image for Lambda runtime.\n* docker_network:\nName or ID of an existing docker network for AWS Lambda docker containers to connect to, along with the default bridge network. If not specified, the Lambda containers will only connect to the default bridge docker network.\n* beta_features:\nEnable/Disable beta features.\n* debug:\nTurn on debug logging to print debug message generated by AWS SAM CLI and display timestamps.\n* profile:\nSelect a specific profile from your credential file to get AWS credentials.\n* region:\nSet the AWS Region of the service. (e.g. us-east-1)\n* save_params:\nSave the parameters provided via the command line to the configuration file.", "type": "object", "properties": { "terraform_project_root_path": { @@ -301,6 +301,11 @@ "type": "boolean", "description": "Enable parallel builds for AWS SAM template's functions and layers." }, + "watch": { + "title": "watch", + "type": "boolean", + "description": "Watch local files and automatically rebuild when changes are detected. When enabled, monitors your source code and template for changes and triggers automatic rebuilds. Changes are debounced to avoid excessive rebuilds during rapid file modifications. Build artifacts, cache directories, and temporary files are automatically excluded from monitoring. Press Ctrl+C to stop watch mode. Similar to 'sam sync --watch' workflow." + }, "mount_with": { "title": "mount_with", "type": "string", @@ -316,6 +321,14 @@ "type": "boolean", "description": "Specify if symlinks at the top level of the code should be mounted inside the container. Activating this flag could allow access to locations outside of your workspace by using a symbolic link. By default symlinks are not mounted." }, + "watch_exclude": { + "title": "watch_exclude", + "type": "array", + "description": "Excludes a file or folder from being observed for file changes. Files and folders that are excluded will not trigger a sync workflow. This option can be provided multiple times.\n\nExamples:\n\nHelloWorldFunction=package-lock.json\n\nChildStackA/FunctionName=database.sqlite3", + "items": { + "type": "string" + } + }, "build_dir": { "title": "build_dir", "type": "string", diff --git a/tests/functional/commands/validate/lib/models/api_with_basic_custom_domain_http.yaml b/tests/functional/commands/validate/lib/models/api_with_basic_custom_domain_http.yaml index 556aa43c3c..bfbad2f715 100644 --- a/tests/functional/commands/validate/lib/models/api_with_basic_custom_domain_http.yaml +++ b/tests/functional/commands/validate/lib/models/api_with_basic_custom_domain_http.yaml @@ -5,7 +5,7 @@ Parameters: MyDomainCert: Type: String - Default: arn:aws:acm:us-east-1:123455353535:certificate/6c911401-620d-4d41-b89e-366c238bb2f3 + Default: arn:aws:acm:us-east-1:222222222222:certificate/deadbeef-0000-aaaa-1111-bbbbbbbbbbbb Globals: HttpApi: diff --git a/tests/unit/commands/buildcmd/core/test_command.py b/tests/unit/commands/buildcmd/core/test_command.py index 431eb526a1..81ac97abba 100644 --- a/tests/unit/commands/buildcmd/core/test_command.py +++ b/tests/unit/commands/buildcmd/core/test_command.py @@ -57,6 +57,7 @@ def test_get_options_build_command_text(self, mock_get_params): ("$ sam build --use-container\x1b[0m", ""), ("$ sam build --use-container --container-env-var-file env.json\x1b[0m", ""), ("$ sam build && sam local invoke\x1b[0m", ""), + ("$ sam build --watch\x1b[0m", ""), ("$ sam build && sam deploy\x1b[0m", ""), ], } diff --git a/tests/unit/commands/buildcmd/test_command.py b/tests/unit/commands/buildcmd/test_command.py index d1f5d2a95f..115ea6c332 100644 --- a/tests/unit/commands/buildcmd/test_command.py +++ b/tests/unit/commands/buildcmd/test_command.py @@ -27,6 +27,8 @@ def test_must_succeed_build(self, os_mock, BuildContextMock, mock_build_click): "use_container", "cached", "parallel", + False, # watch + None, # watch_exclude "manifest_path", "docker_network", "skip_pull_image", @@ -34,8 +36,8 @@ def test_must_succeed_build(self, os_mock, BuildContextMock, mock_build_click): "mode", (""), "container_env_var_file", - (), - (), + (), # build_image + (), # exclude hook_name=None, build_in_source=False, mount_with=MountMode.READ, diff --git a/tests/unit/commands/samconfig/test_samconfig.py b/tests/unit/commands/samconfig/test_samconfig.py index 666a54d0e3..914b2c5f95 100644 --- a/tests/unit/commands/samconfig/test_samconfig.py +++ b/tests/unit/commands/samconfig/test_samconfig.py @@ -149,6 +149,8 @@ def test_build(self, do_cli_mock): True, False, False, + False, + {}, "requirements.txt", "mynetwork", True, @@ -209,6 +211,8 @@ def test_build_with_no_use_container(self, do_cli_mock): False, False, False, + False, + {}, "requirements.txt", "mynetwork", True, @@ -268,6 +272,8 @@ def test_build_with_no_use_container_option(self, do_cli_mock): False, False, False, + False, + {}, "requirements.txt", "mynetwork", True, @@ -328,6 +334,8 @@ def test_build_with_no_use_container_override(self, do_cli_mock): False, False, False, + False, + {}, "requirements.txt", "mynetwork", True, @@ -389,6 +397,8 @@ def test_build_with_no_cached_override(self, do_cli_mock): True, False, False, + False, + {}, "requirements.txt", "mynetwork", True, @@ -447,6 +457,8 @@ def test_build_with_container_env_vars(self, do_cli_mock): True, False, False, + False, + {}, "requirements.txt", "mynetwork", True, @@ -504,6 +516,8 @@ def test_build_with_build_images(self, do_cli_mock): True, False, False, + False, + {}, "requirements.txt", "mynetwork", True, diff --git a/tests/unit/lib/build_module/test_watch_manager.py b/tests/unit/lib/build_module/test_watch_manager.py new file mode 100644 index 0000000000..3600131f5b --- /dev/null +++ b/tests/unit/lib/build_module/test_watch_manager.py @@ -0,0 +1,354 @@ +""" +Tests for BuildWatchManager +""" + +import os +from pathlib import Path +from unittest import TestCase +from unittest.mock import MagicMock, Mock, patch + +from samcli.lib.build.watch_manager import BuildWatchManager + + +class TestBuildWatchManager(TestCase): + def setUp(self): + self.template = "template.yaml" + self.build_context = MagicMock() + self.build_context.base_dir = "/base" + self.build_context.build_dir = "/base/.aws-sam/build" + self.build_context.cache_dir = "/base/.aws-sam/cache" + self.build_context.build_in_source = False + self.watch_exclude = {} + + with patch("samcli.lib.build.watch_manager.HandlerObserver"), patch.object( + BuildWatchManager, "_validate_watch_safety" + ), patch.object(BuildWatchManager, "_build_smart_exclusions", return_value={}): + self.watch_manager = BuildWatchManager( + self.template, + self.build_context, + self.watch_exclude, + ) + + def test_initialization(self): + """Test that BuildWatchManager initializes correctly""" + self.assertEqual(self.watch_manager._template, self.template) + self.assertIsNone(self.watch_manager._stacks) + self.assertIsNone(self.watch_manager._trigger_factory) + self.assertFalse(self.watch_manager._waiting_build) + + @patch("samcli.lib.build.watch_manager.time.sleep") + @patch.object(BuildWatchManager, "_execute_build") + @patch.object(BuildWatchManager, "_start_watch") + def test_start(self, start_watch_mock, execute_build_mock, time_sleep_mock): + """Test the start method initiates build watch""" + # Make time.sleep raise KeyboardInterrupt to exit the loop + time_sleep_mock.side_effect = KeyboardInterrupt() + + # Start should catch KeyboardInterrupt gracefully + self.watch_manager.start() + + # Verify that build was queued and initial build executed + self.assertTrue(execute_build_mock.called) + start_watch_mock.assert_called_once() + + @patch("samcli.lib.build.watch_manager.time.sleep") + @patch.object(BuildWatchManager, "_execute_build") + @patch.object(BuildWatchManager, "_start_watch") + def test_start_with_keyboard_interrupt(self, start_watch_mock, execute_build_mock, time_sleep_mock): + """Test that KeyboardInterrupt is handled gracefully""" + time_sleep_mock.side_effect = KeyboardInterrupt() + + # Should not raise + self.watch_manager.start() + + # Observer should have been stopped + self.watch_manager._observer.stop.assert_called_once() + + @patch("samcli.lib.build.watch_manager.time.sleep") + @patch.object(BuildWatchManager, "_start_watch") + def test_template_change_triggers_rebuild(self, start_watch_mock, time_sleep_mock): + """Test that template changes trigger a rebuild""" + # Setup: start with no pending build + self.watch_manager._waiting_build = False + + # Action: queue a build (simulating template change) + self.watch_manager.queue_build() + + # Assert: build should be queued + self.assertTrue(self.watch_manager._waiting_build) + + @patch("samcli.lib.build.watch_manager.SamLocalStackProvider.get_stacks") + @patch("samcli.lib.build.watch_manager.get_all_resource_ids") + @patch("samcli.lib.build.watch_manager.CodeTriggerFactory") + def test_add_code_triggers(self, trigger_factory_mock, get_resource_ids_mock, get_stacks_mock): + """Test adding code triggers for resources""" + # Setup stacks + stack = MagicMock() + self.watch_manager._stacks = [stack] + + # Setup factory + factory_instance = MagicMock() + self.watch_manager._trigger_factory = factory_instance + + # Setup resource IDs + resource_id = MagicMock() + get_resource_ids_mock.return_value = [resource_id] + + # Setup trigger + trigger = MagicMock() + path_handlers = [MagicMock()] + trigger.get_path_handlers.return_value = path_handlers + factory_instance.create_trigger.return_value = trigger + + # Execute + self.watch_manager._add_code_triggers() + + # Verify + get_resource_ids_mock.assert_called_once_with([stack]) + factory_instance.create_trigger.assert_called_once() + self.watch_manager._observer.schedule_handlers.assert_called_once_with(path_handlers) + + @patch("samcli.lib.build.watch_manager.Path") + @patch.object(BuildWatchManager, "_start_template_polling") + def test_template_validation(self, polling_mock, path_mock): + """Test template trigger setup""" + template_path_mock = MagicMock() + template_path_mock.resolve.return_value = template_path_mock + template_path_mock.parent = "/parent" + path_mock.return_value = template_path_mock + + self.watch_manager._add_template_triggers() + + # Verify template polling was started + polling_mock.assert_called_once() + + def test_watch_exclude_filter(self): + """Test that watch_exclude filter is properly processed with smart exclusions""" + watch_exclude = {"Function1": ["*.pyc", "__pycache__"]} + build_context = MagicMock() + build_context.base_dir = "/base" + build_context.build_dir = "/base/.aws-sam/build" + build_context.cache_dir = "/base/.aws-sam/cache" + build_context.build_in_source = False + + with patch("samcli.lib.build.watch_manager.HandlerObserver"), patch.object( + BuildWatchManager, "_validate_watch_safety" + ): + watch_manager = BuildWatchManager( + self.template, + build_context, + watch_exclude, + ) + + # Should have Function1 with both base exclusions and user exclusions + self.assertIn("Function1", watch_manager._watch_exclude) + function_exclusions = watch_manager._watch_exclude["Function1"] + + # User-provided exclusions should be present + self.assertIn("*.pyc", function_exclusions) + self.assertIn("__pycache__", function_exclusions) + + # Base exclusions should also be present + self.assertTrue(any(".aws-sam" in excl for excl in function_exclusions)) + + @patch("samcli.lib.build.watch_manager.time.sleep") + @patch("samcli.lib.build.watch_manager.threading.Thread") + @patch("samcli.lib.build.watch_manager.Path") + def test_periodic_template_check(self, path_mock, thread_mock, sleep_mock): + """Test periodic template checking is started""" + template_path = MagicMock() + template_path.stat.return_value.st_mtime = 12345 + path_mock.return_value.resolve.return_value = template_path + + thread_instance = MagicMock() + thread_mock.return_value = thread_instance + + self.watch_manager._start_template_polling() + + # Verify thread was created and started + thread_mock.assert_called_once() + thread_instance.start.assert_called_once() + + def test_queue_build(self): + """Test that queue_build sets the waiting flag""" + self.watch_manager._waiting_build = False + self.watch_manager.queue_build() + self.assertTrue(self.watch_manager._waiting_build) + + @patch("samcli.lib.build.watch_manager.threading.Timer") + def test_queue_debounced_build(self, timer_mock): + """Test that debounced build queues with a timer""" + timer_instance = MagicMock() + timer_mock.return_value = timer_instance + + self.watch_manager.queue_debounced_build(wait_time=2.0) + + # Verify timer was created and started + timer_mock.assert_called_once() + timer_instance.start.assert_called_once() + + def test_execute_debounced_build(self): + """Test that debounced build execution sets the waiting flag""" + self.watch_manager._waiting_build = False + self.watch_manager._execute_debounced_build() + self.assertTrue(self.watch_manager._waiting_build) + + @patch.object(BuildWatchManager, "_start_watch") + def test_execute_build_success(self, start_watch_mock): + """Test successful build execution""" + self.watch_manager._waiting_build = True + self.watch_manager._build_context.run = MagicMock() + self.watch_manager._build_context.set_up = MagicMock() + + self.watch_manager._execute_build(first_build=True) + + # Verify build was executed + self.watch_manager._build_context.run.assert_called_once() + self.assertFalse(self.watch_manager._waiting_build) + start_watch_mock.assert_called_once() + + @patch.object(BuildWatchManager, "_start_watch") + def test_execute_build_failure(self, start_watch_mock): + """Test build execution handles failures gracefully""" + self.watch_manager._waiting_build = True + self.watch_manager._build_context.run = MagicMock(side_effect=Exception("Build failed")) + self.watch_manager._build_context.set_up = MagicMock() + + # Should not raise - just log error + self.watch_manager._execute_build(first_build=False) + + # Verify build was attempted and watch continues + self.watch_manager._build_context.run.assert_called_once() + self.assertFalse(self.watch_manager._waiting_build) + start_watch_mock.assert_called_once() + + @patch("samcli.lib.build.watch_manager.SamLocalStackProvider.get_stacks") + @patch("samcli.lib.build.watch_manager.CodeTriggerFactory") + def test_update_stacks(self, factory_mock, get_stacks_mock): + """Test updating stacks reloads template""" + stacks = [MagicMock()] + get_stacks_mock.return_value = (stacks,) + + factory_instance = MagicMock() + factory_mock.return_value = factory_instance + + self.watch_manager._update_stacks() + + # Verify stacks were loaded and factory was created + get_stacks_mock.assert_called_once_with(self.template, use_sam_transform=False) + self.assertEqual(self.watch_manager._stacks, stacks) + self.assertEqual(self.watch_manager._trigger_factory, factory_instance) + + def test_build_smart_exclusions_with_build_in_source(self): + """Test smart exclusions when building in source""" + build_context = MagicMock() + build_context.base_dir = "/base" + build_context.build_dir = "/base/.aws-sam/build" + build_context.cache_dir = "/base/.aws-sam/cache" + build_context.build_in_source = True + + watch_exclude = {"Function1": ["custom_pattern"]} + + with patch("samcli.lib.build.watch_manager.HandlerObserver"), patch.object( + BuildWatchManager, "_validate_watch_safety" + ): + watch_manager = BuildWatchManager( + self.template, + build_context, + watch_exclude, + ) + + # Verify Function1 has both custom and smart exclusions + function_exclusions = watch_manager._watch_exclude["Function1"] + self.assertIn("custom_pattern", function_exclusions) + # Should include build-in-source specific exclusions + self.assertTrue(any(".pyc" in str(excl) for excl in function_exclusions)) + + def test_get_cache_exclusions_under_base_dir(self): + """Test cache exclusions when cache is under base dir""" + build_context = MagicMock() + build_context.base_dir = "/base" + build_context.cache_dir = "/base/.aws-sam/cache" + build_context.build_dir = "/base/.aws-sam/build" + build_context.build_in_source = False + + with patch("samcli.lib.build.watch_manager.HandlerObserver"), patch.object( + BuildWatchManager, "_validate_watch_safety" + ): + watch_manager = BuildWatchManager( + self.template, + build_context, + {}, + ) + + exclusions = watch_manager._get_cache_exclusions(build_context) + # Should have an exclusion for the cache directory + self.assertTrue(len(exclusions) > 0) + self.assertTrue(any("cache" in excl for excl in exclusions)) + + def test_get_cache_exclusions_outside_base_dir(self): + """Test cache exclusions when cache is outside base dir""" + build_context = MagicMock() + build_context.base_dir = "/base" + build_context.cache_dir = "/other/cache" + build_context.build_dir = "/base/.aws-sam/build" + build_context.build_in_source = False + + with patch("samcli.lib.build.watch_manager.HandlerObserver"), patch.object( + BuildWatchManager, "_validate_watch_safety" + ): + watch_manager = BuildWatchManager( + self.template, + build_context, + {}, + ) + + exclusions = watch_manager._get_cache_exclusions(build_context) + # Should be empty since cache is outside base + self.assertEqual(exclusions, []) + + def test_on_code_change_wrapper_ignores_opened_events(self): + """Test that file opened events are ignored""" + resource_id = MagicMock() + callback = self.watch_manager._on_code_change_wrapper(resource_id) + + event = MagicMock() + event.event_type = "opened" + + # Call should return without queueing build + self.watch_manager._waiting_build = False + callback(event) + self.assertFalse(self.watch_manager._waiting_build) + + def test_on_code_change_wrapper_handles_code_changes(self): + """Test that code changes trigger debounced build""" + resource_id = MagicMock() + callback = self.watch_manager._on_code_change_wrapper(resource_id) + + event = MagicMock() + event.event_type = "modified" + event.is_directory = False + + with patch.object(self.watch_manager, "queue_debounced_build") as queue_mock: + callback(event) + queue_mock.assert_called_once() + + @patch("samcli.lib.build.watch_manager.LOG") + def test_validate_watch_safety_logs_warnings(self, log_mock): + """Test that watch safety validation logs appropriate warnings""" + build_context = MagicMock() + build_context.base_dir = "/base" + build_context.build_dir = "/base/custom-build" # Custom build dir under base + build_context.cache_dir = "/base/.aws-sam/cache" + build_context.build_in_source = True + + with patch("samcli.lib.build.watch_manager.HandlerObserver"): + # This should trigger warning about custom build dir + watch_manager = BuildWatchManager( + self.template, + build_context, + {}, + ) + + # Verify warning was logged + self.assertTrue(log_mock.warning.called)