diff --git a/src/cfnlint/config.py b/src/cfnlint/config.py index 91781ce309..6b93b3e406 100644 --- a/src/cfnlint/config.py +++ b/src/cfnlint/config.py @@ -12,12 +12,14 @@ import logging import os import sys +from copy import deepcopy from pathlib import Path from typing import Any, Dict, Sequence, TypedDict from typing_extensions import Unpack import cfnlint.decode.cfn_yaml +from cfnlint.context.parameters import ParameterSet from cfnlint.helpers import REGIONS, format_json_string from cfnlint.jsonschema import StandardValidator from cfnlint.version import __version__ @@ -681,7 +683,8 @@ class ManualArgs(TypedDict, total=False): non_zero_exit_code: str output_file: str regions: list - parameters: list[dict[str, Any]] + parameters: list[ParameterSet] + templates: list[str] # pylint: disable=too-many-public-methods @@ -689,7 +692,6 @@ class ConfigMixIn(TemplateArgs, CliArgs, ConfigFileArgs): def __init__(self, cli_args: list[str] | None = None, **kwargs: Unpack[ManualArgs]): self._manual_args = kwargs or ManualArgs() - self._templates_to_process = False CliArgs.__init__(self, cli_args) # configure debug as soon as we can TemplateArgs.__init__(self, {}) @@ -716,12 +718,17 @@ def __repr__(self): "merge_configs": self.merge_configs, "non_zero_exit_code": self.non_zero_exit_code, "override_spec": self.override_spec, - "regions": self.regions, "parameters": self.parameters, + "regions": self.regions, "templates": self.templates, } ) + def __eq__(self, value): + if not isinstance(value, ConfigMixIn): + return False + return str(self) == str(value) + def _get_argument_value(self, arg_name, is_template, is_config_file): cli_value = getattr(self.cli_args, arg_name) template_value = self.template_args.get(arg_name) @@ -816,7 +823,9 @@ def templates(self): file_args = self._get_argument_value("templates", False, True) cli_args = self._get_argument_value("templates", False, False) - if cli_alt_args: + if "templates" in self._manual_args: + filenames = self._manual_args["templates"] + elif cli_alt_args: filenames = cli_alt_args elif file_args: filenames = file_args @@ -826,10 +835,6 @@ def templates(self): # No filenames found, could be piped in or be using the api. return None - # If we're still haven't returned, we've got templates to lint. - # Build up list of templates to lint. - self.templates_to_process = True - if isinstance(filenames, str): filenames = [filenames] @@ -880,12 +885,21 @@ def append_rules(self): ) @property - def parameters(self): - return self._get_argument_value("parameters", True, True) + def parameters(self) -> list[ParameterSet]: + parameter_sets = self._get_argument_value("parameters", True, True) + results: list[ParameterSet] = [] + for parameter_set in parameter_sets: + if isinstance(parameter_set, ParameterSet): + results.append(parameter_set) + else: + results.append( + ParameterSet( + source=None, + parameters=parameter_set, + ) + ) - @parameters.setter - def parameters(self, parameters: list[dict[str, Any]]): - self._manual_args["parameters"] = parameters + return results @property def override_spec(self): @@ -952,10 +966,8 @@ def non_zero_exit_code(self): def force(self): return self._get_argument_value("force", False, False) - @property - def templates_to_process(self): - return self._templates_to_process + def evolve(self, **kwargs: Unpack[ManualArgs]) -> "ConfigMixIn": - @templates_to_process.setter - def templates_to_process(self, value: bool): - self._templates_to_process = value + config = deepcopy(self) + config._manual_args.update(kwargs) + return config diff --git a/src/cfnlint/context/__init__.py b/src/cfnlint/context/__init__.py index 0d4e0154ef..ed0e1e027a 100644 --- a/src/cfnlint/context/__init__.py +++ b/src/cfnlint/context/__init__.py @@ -1,3 +1,8 @@ -__all__ = ["Context", "create_context_for_template"] +__all__ = ["Context", "create_context_for_template", "ParameterSet"] -from cfnlint.context.context import Context, Path, create_context_for_template +from cfnlint.context.context import ( + Context, + ParameterSet, + Path, + create_context_for_template, +) diff --git a/src/cfnlint/context/context.py b/src/cfnlint/context/context.py index 2c5a8ed092..9a11cdc172 100644 --- a/src/cfnlint/context/context.py +++ b/src/cfnlint/context/context.py @@ -9,10 +9,11 @@ from collections import deque from dataclasses import InitVar, dataclass, field, fields from functools import lru_cache -from typing import Any, Deque, Iterator, Sequence, Set, Tuple +from typing import TYPE_CHECKING, Any, Deque, Iterator, Set, Tuple from cfnlint.context._mappings import Mappings from cfnlint.context.conditions._conditions import Conditions +from cfnlint.context.parameters import ParameterSet from cfnlint.helpers import ( BOOLEAN_STRINGS_TRUE, FUNCTIONS, @@ -22,6 +23,9 @@ ) from cfnlint.schema import PROVIDER_SCHEMA_MANAGER, AttributeDict +if TYPE_CHECKING: + from cfnlint.template import Template + _PSEUDOPARAMS_NON_REGION = ["AWS::AccountId", "AWS::NoValue", "AWS::StackName"] @@ -132,12 +136,12 @@ class Context: """ # what regions we are processing - regions: Sequence[str] = field( + regions: list[str] = field( init=True, default_factory=lambda: list([REGION_PRIMARY]) ) # supported functions at this point in the template - functions: Sequence[str] = field(init=True, default_factory=list) + functions: list[str] = field(init=True, default_factory=list) path: Path = field(init=True, default_factory=Path) @@ -153,7 +157,7 @@ class Context: init=True, default_factory=lambda: set(PSEUDOPARAMS) ) - # Combiniation of storing any resolved ref + # Combination of storing any resolved ref # and adds in any Refs available from things like Fn::Sub ref_values: dict[str, Any] = field(init=True, default_factory=dict) @@ -163,6 +167,9 @@ class Context: is_resolved_value: bool = field(init=True, default=False) resolve_pseudo_parameters: bool = field(init=True, default=True) + # Deployment parameters + parameter_sets: list[ParameterSet] | None = field(init=True, default_factory=list) + def evolve(self, **kwargs) -> "Context": """ Create a new context without merging together attributes @@ -441,7 +448,9 @@ def _init_transforms(transforms: Any) -> Transforms: return Transforms([]) -def create_context_for_template(cfn): +def create_context_for_template( + cfn: Template, +) -> "Context": parameters = {} try: parameters = _init_parameters(cfn.template.get("Parameters", {})) @@ -476,5 +485,6 @@ def create_context_for_template(cfn): regions=cfn.regions, path=Path(), functions=["Fn::Transform"], - ref_values=cfn.parameters or {}, + ref_values={}, + parameter_sets=[], ) diff --git a/src/cfnlint/context/parameters.py b/src/cfnlint/context/parameters.py new file mode 100644 index 0000000000..916b84e1ba --- /dev/null +++ b/src/cfnlint/context/parameters.py @@ -0,0 +1,16 @@ +""" +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class ParameterSet: + + source: str | None = field(default=None) + parameters: dict[str, Any] = field(default_factory=dict) diff --git a/src/cfnlint/core.py b/src/cfnlint/core.py index 3c5db7abb7..fae5fc379c 100644 --- a/src/cfnlint/core.py +++ b/src/cfnlint/core.py @@ -12,12 +12,9 @@ from cfnlint.exceptions import UnexpectedRuleException from cfnlint.match import Match from cfnlint.rules import RulesCollection - -from cfnlint.runner.exceptions import UnexpectedRuleException from cfnlint.runner.template.runner import _run_template - def get_rules( append_rules: list[str], ignore_rules: list[str], diff --git a/src/cfnlint/decode/__init__.py b/src/cfnlint/decode/__init__.py index fd51552922..fda9f7ac6a 100644 --- a/src/cfnlint/decode/__init__.py +++ b/src/cfnlint/decode/__init__.py @@ -3,6 +3,15 @@ SPDX-License-Identifier: MIT-0 """ +__all__ = [ + "create_match_file_error", + "create_match_json_parser_error", + "create_match_yaml_parser_error", + "decode", + "decode_str", + "convert_dict", +] + from cfnlint.decode.decode import ( create_match_file_error, create_match_json_parser_error, diff --git a/src/cfnlint/decode/decode.py b/src/cfnlint/decode/decode.py index ef8ebe8ee0..eadc400628 100644 --- a/src/cfnlint/decode/decode.py +++ b/src/cfnlint/decode/decode.py @@ -37,7 +37,7 @@ def _decode( ) -> Decode: """Decode payload using yaml_f and json_f, using filename for log output.""" template = None - matches = [] + matches: Matches = [] try: template = yaml_f(payload) except IOError as e: @@ -145,7 +145,10 @@ def _decode( message="Template needs to be an object.", ) ] - return (template, matches) + + if matches: + return None, matches + return template, matches def create_match_yaml_parser_error(parser_error, filename): diff --git a/src/cfnlint/exceptions.py b/src/cfnlint/exceptions.py index 993106ab01..f4629dac38 100644 --- a/src/cfnlint/exceptions.py +++ b/src/cfnlint/exceptions.py @@ -1,3 +1,11 @@ +""" +Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" + +from __future__ import annotations + + class CfnLintExitException(Exception): """ An exception that is raised to indicate that the CloudFormation linter should exit. @@ -10,11 +18,11 @@ class CfnLintExitException(Exception): exit_code (int): The exit code to be used when the linter exits. Methods: - __init__(self, exit_code: int) -> None: + __init__(self, msg: str | None=None, exit_code: int=1) -> None: Initialize a new CfnLintExitException instance with the specified exit code. """ - def __init__(self, msg=None, exit_code=1): + def __init__(self, msg: str | None = None, exit_code: int = 1): """ Initialize a new CfnLintExitException instance with the specified exit code. @@ -49,9 +57,17 @@ class UnexpectedRuleException(CfnLintExitException): class DuplicateRuleError(CfnLintExitException): """ - The data associated with a particular path could not be loaded. - :ivar data_path: The data path that the user attempted to load. + An exception that is raised when an unexpected error occurs while loading rules. + + This exception is raised when the CloudFormation linter encounters a rule with a + duplicate ID. """ def __init__(self, rule_id: str): + """ + Initialize a new CfnLintExitException instance with the specified exit code. + + Args: + rule_id (str): The rule ID that a duplicate was found for. + """ super().__init__(f"Rule already included: {rule_id}") diff --git a/src/cfnlint/helpers.py b/src/cfnlint/helpers.py index 298f51cc85..e60d0ee0b6 100644 --- a/src/cfnlint/helpers.py +++ b/src/cfnlint/helpers.py @@ -7,6 +7,7 @@ from __future__ import annotations +import dataclasses import datetime import fnmatch import gzip @@ -627,6 +628,8 @@ def converter(o): # pylint: disable=R1710 """Help convert date/time into strings""" if isinstance(o, datetime.datetime): return o.__str__() # pylint: disable=unnecessary-dunder-call + elif dataclasses.is_dataclass(o): + return dataclasses.asdict(o) return json.dumps( json_string, indent=1, sort_keys=True, separators=(",", ": "), default=converter diff --git a/src/cfnlint/jsonschema/validators.py b/src/cfnlint/jsonschema/validators.py index c9450b1695..76f5bbcf3e 100644 --- a/src/cfnlint/jsonschema/validators.py +++ b/src/cfnlint/jsonschema/validators.py @@ -25,7 +25,7 @@ from typing import Any, Callable from cfnlint.conditions import UnknownSatisfisfaction -from cfnlint.context import Context, create_context_for_template +from cfnlint.context import Context from cfnlint.helpers import is_function from cfnlint.jsonschema import _keywords, _keywords_cfn, _resolvers_cfn from cfnlint.jsonschema._filter import FunctionFilter @@ -92,7 +92,7 @@ class Validator: def __post_init__(self): if self.context is None: - self.context = create_context_for_template(self.cfn) + self.context = self.cfn.context.evolve() if self.resolver is None: self.resolver = RefResolver.from_schema( schema=self.schema, diff --git a/src/cfnlint/match.py b/src/cfnlint/match.py index 1f28e58f4d..8dc204b62f 100644 --- a/src/cfnlint/match.py +++ b/src/cfnlint/match.py @@ -118,6 +118,8 @@ def create( if linenumberend is None: linenumberend = linenumber + filename = getattr(rulematch_obj, "filename", filename) + return cls( linenumber=linenumber, columnnumber=columnnumber, diff --git a/src/cfnlint/rules/deployment_files/Parameters.py b/src/cfnlint/rules/deployment_files/Parameters.py index 596c52a9f1..229c6a5330 100644 --- a/src/cfnlint/rules/deployment_files/Parameters.py +++ b/src/cfnlint/rules/deployment_files/Parameters.py @@ -79,18 +79,23 @@ def _build_schema(self, instance: Any) -> dict[str, Any]: return schema def validate(self, validator: Validator, _: Any, instance: Any, schema: Any): - if validator.cfn.parameters is None: + if validator.context.parameter_sets is None: return - cfn_validator = self.extend_validator( - validator=validator, - schema=self._build_schema(instance), - context=validator.context, - ).evolve( - context=validator.context.evolve(strict_types=False), - function_filter=validator.function_filter.evolve( - add_cfn_lint_keyword=False, - ), - ) - - yield from super()._iter_errors(cfn_validator, validator.cfn.parameters) + for parameter_set in validator.context.parameter_sets: + + cfn_validator = self.extend_validator( + validator=validator, + schema=self._build_schema(instance), + context=validator.context, + ).evolve( + context=validator.context.evolve(strict_types=False), + function_filter=validator.function_filter.evolve( + add_cfn_lint_keyword=False, + ), + ) + + for err in super()._iter_errors(cfn_validator, parameter_set.parameters): + if parameter_set.source: + err.extra_args["filename"] = parameter_set.source + yield err diff --git a/src/cfnlint/runner/__init__.py b/src/cfnlint/runner/__init__.py index ea1a996f4c..82ee8a6cba 100644 --- a/src/cfnlint/runner/__init__.py +++ b/src/cfnlint/runner/__init__.py @@ -8,17 +8,17 @@ "Runner", "run_template_by_data", "run_template_by_file_path", - "run_deployment_file", + "expand_deployment_files", "CfnLintExitException", "InvalidRegionException", "UnexpectedRuleException", ] -from cfnlint.runner.cli import Runner, main -from cfnlint.runner.deployment_file import run_deployment_files -from cfnlint.runner.exceptions import ( +from cfnlint.exceptions import ( CfnLintExitException, InvalidRegionException, UnexpectedRuleException, ) +from cfnlint.runner.cli import Runner, main +from cfnlint.runner.deployment_file import expand_deployment_files from cfnlint.runner.template import run_template_by_data, run_template_by_file_path diff --git a/src/cfnlint/runner/cli.py b/src/cfnlint/runner/cli.py index 65bd1332b5..b52afc827a 100644 --- a/src/cfnlint/runner/cli.py +++ b/src/cfnlint/runner/cli.py @@ -8,17 +8,20 @@ import logging import os import sys -from typing import Any, Iterator, Sequence +from typing import Any, Iterator import cfnlint.formatters import cfnlint.maintenance from cfnlint.config import ConfigMixIn, configure_logging from cfnlint.exceptions import CfnLintExitException, UnexpectedRuleException from cfnlint.rules import Match, Rules -from cfnlint.rules.errors import ConfigError, ParseError -from cfnlint.runner.deployment_file.runner import run_deployment_files -from cfnlint.runner.exceptions import CfnLintExitException, UnexpectedRuleException -from cfnlint.runner.template import run_template_by_data, run_template_by_file_path +from cfnlint.rules.errors import ConfigError +from cfnlint.runner.deployment_file.runner import expand_deployment_files +from cfnlint.runner.template import ( + run_template_by_data, + run_template_by_file_paths, + run_template_by_pipe, +) from cfnlint.schema import PROVIDER_SCHEMA_MANAGER LOGGER = logging.getLogger(__name__) @@ -129,41 +132,6 @@ def _get_rules(self) -> None: f"Tried to append rules but got an error: {str(e)}", 1 ) from e - def _validate_filenames(self, filenames: Sequence[str | None]) -> Iterator[Match]: - """ - Validate the specified filenames and yield any matches found. - - This function processes each filename in the provided sequence, decoding the - template and validating it against the configured rules. Any matches found - are yielded as an iterator. - - Args: - filenames (Sequence[str | None]): The sequence of filenames to be validated. - - Yields: - Match: The matches found during the validation process. - - Raises: - None: This function does not raise any exceptions. - """ - ignore_bad_template: bool = False - if self.config.ignore_bad_template: - ignore_bad_template = True - else: - # There is no collection at this point so we need to handle this - # check directly - if not ParseError().is_enabled( - include_experimental=False, - ignore_rules=self.config.ignore_checks, - include_rules=self.config.include_checks, - mandatory_rules=self.config.mandatory_checks, - ): - ignore_bad_template = True - for filename in filenames: - yield from run_template_by_file_path( - filename, self.config, self.rules, ignore_bad_template - ) - def validate_template(self, template: dict[str, Any]) -> Iterator[Match]: """ Validate a single CloudFormation template and yield any matches found. @@ -251,15 +219,24 @@ def run(self) -> Iterator[Match]: None: This function does not raise any exceptions. """ - if (not sys.stdin.isatty()) and (not self.config.templates_to_process): - yield from self._validate_filenames([None]) + if ( + not sys.stdin.isatty() + and not self.config.templates + and not self.config.deployment_files + ): + yield from run_template_by_pipe(self.config, self.rules) return - if self.config.templates: - yield from self._validate_filenames(self.config.templates) + if self.config.deployment_files: + for template_config, matches in expand_deployment_files(self.config): + if not template_config: + yield from matches + continue + yield from run_template_by_file_paths(template_config, self.rules) + return - yield from run_deployment_files(self.config, self.rules) + yield from run_template_by_file_paths(self.config, self.rules) def cli(self) -> None: """ @@ -301,14 +278,14 @@ def cli(self) -> None: print(self.rules) sys.exit(0) - if not self.config.templates_to_process and not self.config.deployment_files: + if not self.config.templates and not self.config.deployment_files: if sys.stdin.isatty(): self.config.parser.print_help() sys.exit(1) if self.config.templates and self.config.deployment_files: self.config.parser.print_help() - sys.exit(32) + sys.exit(1) try: self._cli_output(list(self.run())) diff --git a/src/cfnlint/runner/deployment_file/__init__.py b/src/cfnlint/runner/deployment_file/__init__.py index e9d82a7326..d5fc4ec8ca 100644 --- a/src/cfnlint/runner/deployment_file/__init__.py +++ b/src/cfnlint/runner/deployment_file/__init__.py @@ -3,6 +3,6 @@ SPDX-License-Identifier: MIT-0 """ -__all__ = ["run_deployment_files"] +__all__ = ["expand_deployment_files"] -from cfnlint.runner.deployment_file.runner import run_deployment_files +from cfnlint.runner.deployment_file.runner import expand_deployment_files diff --git a/src/cfnlint/runner/deployment_file/deployment.py b/src/cfnlint/runner/deployment_file/deployment.py index ce67716b4f..b2a45d4a16 100644 --- a/src/cfnlint/runner/deployment_file/deployment.py +++ b/src/cfnlint/runner/deployment_file/deployment.py @@ -10,7 +10,7 @@ @dataclass(frozen=True) -class Deployment: +class DeploymentFileData: template_file_path: str = field() parameters: dict[str, Any] = field(default_factory=dict) diff --git a/src/cfnlint/runner/deployment_file/deployment_types/git_sync.py b/src/cfnlint/runner/deployment_file/deployment_types/git_sync.py index 6af58ebd92..b2e5fa6fb2 100644 --- a/src/cfnlint/runner/deployment_file/deployment_types/git_sync.py +++ b/src/cfnlint/runner/deployment_file/deployment_types/git_sync.py @@ -11,12 +11,12 @@ from cfnlint._typing import RuleMatches from cfnlint.helpers import load_resource from cfnlint.rules.deployment_files.Configuration import Configuration -from cfnlint.runner.deployment_file.deployment import Deployment +from cfnlint.runner.deployment_file.deployment import DeploymentFileData def create_deployment_from_git_sync( data: dict[str, Any] -) -> tuple[Deployment | None, RuleMatches | None]: +) -> tuple[DeploymentFileData | None, RuleMatches | None]: schema = load_resource(cfnlint.data.schemas.other.deployment_files, "git_sync.json") matches = Configuration().validate_deployment_file(data, schema) @@ -27,7 +27,7 @@ def create_deployment_from_git_sync( parameters: dict[str, Any] = data.get("parameters", {}) tags: dict[str, Any] = data.get("tags", {}) return ( - Deployment( + DeploymentFileData( template_file_path=template_file_path, parameters=parameters, tags=tags ), None, diff --git a/src/cfnlint/runner/deployment_file/runner.py b/src/cfnlint/runner/deployment_file/runner.py index a65101c56b..3eb008d46c 100644 --- a/src/cfnlint/runner/deployment_file/runner.py +++ b/src/cfnlint/runner/deployment_file/runner.py @@ -6,44 +6,28 @@ from __future__ import annotations import logging -from copy import deepcopy from pathlib import Path from typing import Iterator import cfnlint.runner.deployment_file.deployment_types from cfnlint.config import ConfigMixIn +from cfnlint.context.parameters import ParameterSet from cfnlint.decode import decode -from cfnlint.rules import Match, RuleMatch, Rules +from cfnlint.rules import Match, RuleMatch from cfnlint.rules.deployment_files.Configuration import Configuration -from cfnlint.runner.template import run_template_by_file_path +from cfnlint.runner.deployment_file.deployment import DeploymentFileData LOGGER = logging.getLogger(__name__) -def run_deployment_file( - filename: str, config: ConfigMixIn, rules: Rules -) -> Iterator[Match]: - """ - Run a single deployment file specified in the configuration. - - Args: - filename (str): The filename of the deployment file to be run. - config (ConfigMixIn): The configuration object containing - settings for the deployment file scan. - - Yields: - - """ +def _parse_deployment_file( + filename: str, +) -> tuple[DeploymentFileData | None, list[Match]]: data, matches = decode(filename) - if matches: - yield from iter(matches) - return - - ignore_bad_template: bool = False - if config.ignore_bad_template: - ignore_bad_template = True + if data is None: + return None, matches all_matches: list[RuleMatch] = [] for plugin in cfnlint.runner.deployment_file.deployment_types.__all__: @@ -53,48 +37,30 @@ def run_deployment_file( if deployment_matches: all_matches.extend(deployment_matches) continue - try: - template_path = ( - (Path(filename).parent / deployment_data.template_file_path) - .resolve() - .relative_to(Path.cwd()) - ) - except ValueError: - LOGGER.debug( - ( - f"Template file path {deployment_data.template_file_path!r} " - "is not relative to the current working directory" - ) - ) - template_path = Path(filename).parent / deployment_data.template_file_path - template_config = deepcopy(config) - template_config.parameters = [deployment_data.parameters] - - yield from run_template_by_file_path( - filename=template_path, - config=template_config, - rules=rules, - ignore_bad_template=ignore_bad_template, - ) - return + + return deployment_data, [] for match in all_matches: LOGGER.debug( f"While tring to process deployment file got error: {match.message}" ) - yield Match( - linenumber=1, - columnnumber=1, - linenumberend=1, - columnnumberend=1, - filename=filename, - message=f"Deployment file {filename!r} is not supported", - rule=Configuration(), - ) + return None, [ + Match( + linenumber=1, + columnnumber=1, + linenumberend=1, + columnnumberend=1, + filename=filename, + message=f"Deployment file {filename!r} is not supported", + rule=Configuration(), + ) + ] -def run_deployment_files(config: ConfigMixIn, rules: Rules) -> Iterator[Match]: +def expand_deployment_files( + config: ConfigMixIn, +) -> Iterator[tuple[ConfigMixIn | None, list[Match]]]: """ Run the deployment files specified in the configuration. @@ -106,5 +72,46 @@ def run_deployment_files(config: ConfigMixIn, rules: Rules) -> Iterator[Match]: """ + deployments: dict[str, list[ParameterSet]] = {} for deployment_file in config.deployment_files: - yield from run_deployment_file(deployment_file, deepcopy(config), rules) + deployment_data, matches = _parse_deployment_file(deployment_file) + if matches: + yield None, matches + continue + + if not deployment_data: + continue + + try: + template_path = ( + (Path(deployment_file).parent / deployment_data.template_file_path) + .resolve() + .relative_to(Path.cwd()) + ) + except ValueError: + LOGGER.debug( + ( + f"Template file path {deployment_data.template_file_path!r} " + "is not relative to the current working directory" + ) + ) + template_path = ( + Path(deployment_file).parent / deployment_data.template_file_path + ) + + if str(template_path) in deployments: + deployments[str(template_path)].append( + ParameterSet(deployment_file, deployment_data.parameters) + ) + else: + deployments[str(template_path)] = [ + ParameterSet(deployment_file, deployment_data.parameters) + ] + + for path, parameter_sets in deployments.items(): + template_config = config.evolve( + templates=[path], + parameters=parameter_sets, + deployment_files=[], + ) + yield template_config, [] diff --git a/src/cfnlint/runner/exceptions.py b/src/cfnlint/runner/exceptions.py deleted file mode 100644 index f99d1d7e34..0000000000 --- a/src/cfnlint/runner/exceptions.py +++ /dev/null @@ -1,55 +0,0 @@ -""" -Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -SPDX-License-Identifier: MIT-0 -""" - -from __future__ import annotations - - -class CfnLintExitException(Exception): - """ - An exception that is raised to indicate that the CloudFormation linter should exit. - - This exception is used to signal that the linter should exit - with a specific exit code, typically indicating the severity - of the issues found in the CloudFormation template. - - Attributes: - exit_code (int): The exit code to be used when the linter exits. - - Methods: - __init__(self, exit_code: int) -> None: - Initialize a new CfnLintExitException instance with the specified exit code. - """ - - def __init__(self, msg=None, exit_code=1): - """ - Initialize a new CfnLintExitException instance with the specified exit code. - - Args: - exit_code (int): The exit code to be used when the linter exits. - """ - if msg is None: - msg = f"process failed with exit code {exit_code}" - super().__init__(msg) - self.exit_code = exit_code - - -class InvalidRegionException(CfnLintExitException): - """ - An exception that is raised when an invalid AWS region is encountered. - - This exception is raised when the CloudFormation linter encounters a resource - or parameter that references an AWS region that is not valid or supported. - """ - - -class UnexpectedRuleException(CfnLintExitException): - """ - An exception that is raised when an unexpected error occurs while loading rules. - - This exception is raised when the CloudFormation linter encounters an error - while attempting to load custom rules or rules from a specified directory or - module. This could be due to a variety of reasons, such as a missing file, - a syntax error in the rule code, or an issue with the rule implementation. - """ diff --git a/src/cfnlint/runner/template/__init__.py b/src/cfnlint/runner/template/__init__.py index 3ec5ca82fa..0144158fb7 100644 --- a/src/cfnlint/runner/template/__init__.py +++ b/src/cfnlint/runner/template/__init__.py @@ -3,9 +3,16 @@ SPDX-License-Identifier: MIT-0 """ -__all__ = ["run_template_by_file_path", "run_template_by_data"] +__all__ = [ + "run_template_by_file_path", + "run_template_by_data", + "run_template_by_pipe", + "run_template_by_file_paths", +] from cfnlint.runner.template.runner import ( run_template_by_data, run_template_by_file_path, + run_template_by_file_paths, + run_template_by_pipe, ) diff --git a/src/cfnlint/runner/template/runner.py b/src/cfnlint/runner/template/runner.py index 1155bb886b..9c570c0999 100644 --- a/src/cfnlint/runner/template/runner.py +++ b/src/cfnlint/runner/template/runner.py @@ -10,10 +10,10 @@ from cfnlint.config import ConfigMixIn from cfnlint.decode import decode +from cfnlint.exceptions import InvalidRegionException from cfnlint.helpers import REGIONS from cfnlint.rules import Match, Rules -from cfnlint.rules.errors import TransformError -from cfnlint.runner.exceptions import InvalidRegionException +from cfnlint.rules.errors import ParseError, TransformError from cfnlint.template.template import Template LOGGER = logging.getLogger(__name__) @@ -107,15 +107,8 @@ def _run_template( ) -> Iterator[Match]: config.set_template_args(template) - if config.parameters: - matches: list[Match] = [] - for parameters in config.parameters: - cfn = Template(filename, template, config.regions, parameters) - matches.extend(list(_run_template_per_config(cfn, config, rules))) - yield from _dedup(iter(matches)) - else: - cfn = Template(filename, template, config.regions) - yield from _dedup(_run_template_per_config(cfn, config, rules)) + cfn = Template(filename, template, config.regions, config.parameters) + yield from _dedup(_run_template_per_config(cfn, config, rules)) def run_template_by_file_path( @@ -157,3 +150,57 @@ def run_template_by_data( """ yield from _run_template(None, template, config, rules) + + +def run_template_by_pipe(config: ConfigMixIn, rules: Rules) -> Iterator[Match]: + """ + Runs a set of rules against a CloudFormation template. + + Attributes: + config (ConfigMixIn): The configuration object containing + settings for the template scan. + cfn (Template): The CloudFormation template object. + rules (Rules): The set of rules to be applied to the template. + """ + + (template, matches) = decode(None) # type: ignore + if matches: + yield from iter(matches) + return + yield from run_template_by_data(template, config, rules) # type: ignore + + +def run_template_by_file_paths(config: ConfigMixIn, rules: Rules) -> Iterator[Match]: + """ + Validate the specified filenames and yield any matches found. + + This function processes each filename in the provided sequence, decoding the + template and validating it against the configured rules. Any matches found + are yielded as an iterator. + + Args: + filenames (Sequence[str | None]): The sequence of filenames to be validated. + + Yields: + Match: The matches found during the validation process. + + Raises: + None: This function does not raise any exceptions. + """ + ignore_bad_template: bool = False + if config.ignore_bad_template: + ignore_bad_template = True + else: + # There is no collection at this point so we need to handle this + # check directly + if not ParseError().is_enabled( + include_experimental=False, + ignore_rules=config.ignore_checks, + include_rules=config.include_checks, + mandatory_rules=config.mandatory_checks, + ): + ignore_bad_template = True + for filename in config.templates: + yield from run_template_by_file_path( + filename, config, rules, ignore_bad_template + ) diff --git a/src/cfnlint/template/template.py b/src/cfnlint/template/template.py index ba659813a0..f4766833e3 100644 --- a/src/cfnlint/template/template.py +++ b/src/cfnlint/template/template.py @@ -15,7 +15,7 @@ import cfnlint.conditions import cfnlint.helpers from cfnlint._typing import CheckValueFn, Path -from cfnlint.context import create_context_for_template +from cfnlint.context import ParameterSet, create_context_for_template from cfnlint.decode.node import dict_node, list_node from cfnlint.graph import Graph from cfnlint.match import Match @@ -51,7 +51,7 @@ def __init__( filename: str | None, template: dict[str, Any], regions: list[str] | None = None, - parameters: dict[str, Any] | None = None, + parameter_sets: list[ParameterSet] | None = None, ): """Initialize a Template instance. @@ -60,13 +60,9 @@ def __init__( template (dict[str, Any]): The dictionary representing the CloudFormation template. regions (list[str] | None): A list of AWS regions associated with the template. """ - if regions is None: - self.regions = [cfnlint.helpers.REGION_PRIMARY] - else: - self.regions = regions - self.parameters = ( - parameters # None represents no parameters are provided at all - ) + + self.regions = regions or [cfnlint.helpers.REGION_PRIMARY] + self.filename = filename self.template = template self.transform_pre: dict[str, Any] = {} @@ -94,6 +90,8 @@ def __init__( LOGGER.info("Encountered unknown error while building graph: %s", err) self.context = create_context_for_template(self) + if parameter_sets: + self.context = self.context.evolve(parameter_sets=parameter_sets) self.search_deep_keys = functools.lru_cache()(self.search_deep_keys) # type: ignore def __deepcopy__(self, memo): diff --git a/test/fixtures/deployment_files/dev.yaml b/test/fixtures/deployment_files/dev.yaml new file mode 100644 index 0000000000..792a7f1c69 --- /dev/null +++ b/test/fixtures/deployment_files/dev.yaml @@ -0,0 +1,5 @@ +template-file-path: ../templates/integration/deployment-file-template.yaml +parameters: + Environment: Dev +tags: + Environment: Dev diff --git a/test/fixtures/deployment_files/prod.yaml b/test/fixtures/deployment_files/prod.yaml new file mode 100644 index 0000000000..546eb113d5 --- /dev/null +++ b/test/fixtures/deployment_files/prod.yaml @@ -0,0 +1,6 @@ +template-file-path: ../templates/integration/deployment-file-template.yaml +parameters: + AvailabilityZone: us-east-1a + ImageId: ami-12345678 +tags: + Environment: Prod diff --git a/test/fixtures/templates/integration/deployment-file-template.yaml b/test/fixtures/templates/integration/deployment-file-template.yaml new file mode 100644 index 0000000000..8b0aa76876 --- /dev/null +++ b/test/fixtures/templates/integration/deployment-file-template.yaml @@ -0,0 +1,25 @@ +Parameters: + AvailabilityZone: + Type: String + Default: us-east-1a + Description: The availability zone in which to deploy + ImageId: + Type: AWS::EC2::Image::Id + Description: The AMI to use for the instance +Resources: + Vpc: + Type: AWS::EC2::VPC + Properties: + CidrBlock: "10.0.0.0/16" + Subnet1: + Type: AWS::EC2::Subnet + Properties: + AvailabilityZone: !Ref AvailabilityZone + CidrBlock: "10.0.0.0/16" + VpcId: !Ref Vpc + MyInstance: + Type: AWS::EC2::Instance + Properties: + ImageId: !Ref ImageId + InstanceType: t2.micro + SubnetId: !GetAtt Subnet1.SubnetId diff --git a/test/integration/test_deployment_files.py b/test/integration/test_deployment_files.py new file mode 100644 index 0000000000..4e75d08a67 --- /dev/null +++ b/test/integration/test_deployment_files.py @@ -0,0 +1,70 @@ +""" +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" + +import pytest + +from cfnlint.config import ConfigMixIn +from cfnlint.rules import Match +from cfnlint.rules.deployment_files.Parameters import Parameters +from cfnlint.runner import Runner + + +@pytest.mark.parametrize( + ("name,deployment_files,expected"), + [ + ( + "Correctly configured deployment file", + ["test/fixtures/deployment_files/prod.yaml"], + [], + ), + ( + "Incorrectly configured deployment file", + ["test/fixtures/deployment_files/dev.yaml"], + [ + Match( + message="'ImageId' is a required property", + rule=Parameters(), + filename="test/fixtures/deployment_files/dev.yaml", + linenumber=1, + linenumberend=1, + columnnumber=1, + columnnumberend=11, + ), + Match( + message=( + "Additional properties are not allowed " + "('Environment' was unexpected)" + ), + rule=Parameters(), + filename="test/fixtures/deployment_files/dev.yaml", + linenumber=3, + linenumberend=3, + columnnumber=3, + columnnumberend=14, + ), + ], + ), + ( + "Multiple deployment files", + ["test/fixtures/deployment_files/prod.yaml"], + [], + ), + ], +) +def test_deployment_files( + name, + deployment_files, + expected, +): + + config = ConfigMixIn( + cli_args=[], + deployment_files=deployment_files, + ) + + runner = Runner(config) + results = list(runner.run()) + + assert results == expected, f"{name}: {results} != {expected}" diff --git a/test/integration/test_run_deployment_files.py b/test/integration/test_run_deployment_files.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/test/unit/module/config/test_cli_args.py b/test/unit/module/config/test_cli_args.py index 43f6b953be..b38b3ddbe5 100644 --- a/test/unit/module/config/test_cli_args.py +++ b/test/unit/module/config/test_cli_args.py @@ -126,7 +126,7 @@ def test_bad_rule_configuration(self, mock_print_help): with self.assertRaises(SystemExit) as e: cfnlint.config.CliArgs(["-x", "E3012:key;value"]) - self.assertEqual(e.exception.code, 32) + self.assertEqual(e.exception.code, 1) mock_print_help.assert_called_once() def test_exit_code_parameter(self): diff --git a/test/unit/module/config/test_config_mixin.py b/test/unit/module/config/test_config_mixin.py index 83babcd86f..ae41e3c6ec 100644 --- a/test/unit/module/config/test_config_mixin.py +++ b/test/unit/module/config/test_config_mixin.py @@ -9,7 +9,8 @@ from test.testlib.testcase import BaseTestCase from unittest.mock import patch -import cfnlint.config # pylint: disable=E0401 +import cfnlint.config +from cfnlint.context import ParameterSet from cfnlint.helpers import REGIONS LOGGER = logging.getLogger("cfnlint") @@ -306,7 +307,7 @@ def test_parameters(self, yaml_mock): config = cfnlint.config.ConfigMixIn(["--parameters", "Foo=Bar"]) # test defaults - self.assertEqual(config.parameters, [{"Foo": "Bar"}]) + self.assertEqual(getattr(config.cli_args, "parameters"), [{"Foo": "Bar"}]) @patch("cfnlint.config.ConfigFileArgs._read_config", create=True) def test_parameters_lists(self, yaml_mock): @@ -314,7 +315,7 @@ def test_parameters_lists(self, yaml_mock): config = cfnlint.config.ConfigMixIn(["--parameters", "A=1", "B=2"]) # test defaults - self.assertEqual(config.parameters, [{"A": "1", "B": "2"}]) + self.assertEqual(getattr(config.cli_args, "parameters"), [{"A": "1", "B": "2"}]) @patch("cfnlint.config.ConfigFileArgs._read_config", create=True) def test_parameters_lists_bad_value(self, yaml_mock): @@ -350,5 +351,13 @@ def test_templates_with_deployment_files(self, mock_print_help): ] ) - self.assertEqual(e.exception.code, 32) + self.assertEqual(e.exception.code, 1) mock_print_help.assert_called_once() + + def test_conversion_of_template_parameters(self): + + config = cfnlint.ConfigMixIn(["--parameters", "Foo=Bar"]) + + self.assertEqual( + config.parameters, [ParameterSet(source=None, parameters={"Foo": "Bar"})] + ) diff --git a/test/unit/module/jsonschema/test_resolvers_cfn.py b/test/unit/module/jsonschema/test_resolvers_cfn.py index bf2ab8ea4a..1e9530227a 100644 --- a/test/unit/module/jsonschema/test_resolvers_cfn.py +++ b/test/unit/module/jsonschema/test_resolvers_cfn.py @@ -15,7 +15,6 @@ def _resolve(name, instance, expected_results, **kwargs): validator = CfnTemplateValidator().evolve(**kwargs) - resolutions = list(validator.resolve_value(instance)) assert len(resolutions) == len( diff --git a/test/unit/module/runner/deployment_file/deployment_types/test_git_sync.py b/test/unit/module/runner/deployment_file/deployment_types/test_git_sync.py index e35b18f123..f74c2c2bbc 100644 --- a/test/unit/module/runner/deployment_file/deployment_types/test_git_sync.py +++ b/test/unit/module/runner/deployment_file/deployment_types/test_git_sync.py @@ -6,7 +6,7 @@ import pytest from cfnlint.rules import RuleMatch -from cfnlint.runner.deployment_file.deployment import Deployment +from cfnlint.runner.deployment_file.deployment import DeploymentFileData from cfnlint.runner.deployment_file.deployment_types import ( create_deployment_from_git_sync, ) @@ -27,7 +27,7 @@ }, }, ( - Deployment( + DeploymentFileData( template_file_path="../a/path", parameters={ "Foo": "Bar", diff --git a/test/unit/module/runner/deployment_file/test_runner.py b/test/unit/module/runner/deployment_file/test_runner.py index 6f317c16b6..b019e22c76 100644 --- a/test/unit/module/runner/deployment_file/test_runner.py +++ b/test/unit/module/runner/deployment_file/test_runner.py @@ -3,24 +3,21 @@ SPDX-License-Identifier: MIT-0 """ -from pathlib import Path -from unittest.mock import patch +from unittest.mock import PropertyMock, patch import pytest from cfnlint.config import ConfigMixIn +from cfnlint.context import ParameterSet from cfnlint.rules import Match from cfnlint.rules.deployment_files.Configuration import Configuration -from cfnlint.runner.deployment_file import run_deployment_files +from cfnlint.runner.deployment_file import expand_deployment_files _filename = "deployment-file.yaml" @pytest.mark.parametrize( - ( - "name,deployment_files,validate_template_parameters," - "validate_template_return,expected" - ), + ("name,deployment_files,expected"), [ ( "A standard git sync file", @@ -38,40 +35,16 @@ [], ), }, - { - "filename": Path("../a/path"), - "parameters": [{"Foo": "Bar"}], - }, - [], - [], - ), - ( - "Bad template-file-path type", - { - _filename: ( - { - "template-file-path": ["../a/path"], - "parameters": { - "Foo": "Bar", - }, - "tags": { - "Key": "Value", - }, - }, - [], - ) - }, - {}, - [], [ - Match( - linenumber=1, - columnnumber=1, - linenumberend=1, - columnnumberend=1, - filename=_filename, - message=f"Deployment file {_filename!r} is not supported", - rule=Configuration(), + ( + ConfigMixIn( + [], + deployment_files=[_filename], + parameters=[ + ParameterSet(source=_filename, parameters={"Foo": "Bar"}) + ], + ), + [], ) ], ), @@ -80,7 +53,7 @@ { _filename: ( { - "template-file-path": "../a/path", + "template-file-path": ["../a/path"], "parameters": { "Foo": "Bar", }, @@ -89,34 +62,22 @@ }, }, [], - ), - }, - { - "filename": Path("../a/path"), - "parameters": [{"Foo": "Bar"}], + ) }, - iter( - [ - Match( - linenumber=1, - columnnumber=1, - linenumberend=1, - columnnumberend=1, - filename=_filename, - message=f"Deployment file {_filename!r} is not supported", - rule=Configuration(), - ) - ] - ), [ - Match( - linenumber=1, - columnnumber=1, - linenumberend=1, - columnnumberend=1, - filename=_filename, - message=f"Deployment file {_filename!r} is not supported", - rule=Configuration(), + ( + None, + [ + Match( + linenumber=1, + columnnumber=1, + linenumberend=1, + columnnumberend=1, + filename=_filename, + message=f"Deployment file {_filename!r} is not supported", + rule=Configuration(), + ) + ], ) ], ), @@ -138,18 +99,21 @@ ], ), }, - {}, - None, [ - Match( - linenumber=1, - columnnumber=1, - linenumberend=1, - columnnumberend=1, - filename=_filename, - message=f"Deployment file {_filename!r} is not supported", - rule=Configuration(), - ) + ( + None, + [ + Match( + linenumber=1, + columnnumber=1, + linenumberend=1, + columnnumberend=1, + filename=_filename, + message=f"Deployment file {_filename!r} is not supported", + rule=Configuration(), + ), + ], + ), ], ), ], @@ -157,32 +121,30 @@ def test_runner( name, deployment_files, - validate_template_parameters, - validate_template_return, expected, ): decode_results = [v for _, v in deployment_files.items()] - deployment_files = {k for k, _ in deployment_files.items()} - with patch("cfnlint.runner.deployment_file.runner.decode") as mock_decode: - mock_decode.side_effect = decode_results + deployment_files = [k for k, _ in deployment_files.items()] + with patch( + "cfnlint.config.ConfigMixIn.templates", new_callable=PropertyMock + ) as mock_templates: + mock_templates.return_value = [] with patch( - "cfnlint.runner.deployment_file.runner.run_template_by_file_path", - return_value=validate_template_return, - ) as mock_run_template_by_file_path: - config = ConfigMixIn([], deployment_files=deployment_files) - deployment = list(run_deployment_files(config, None)) + "cfnlint.config.ConfigMixIn.deployment_files", new_callable=PropertyMock + ) as mock_deployment_files: + with patch("cfnlint.runner.deployment_file.runner.decode") as mock_decode: + mock_deployment_files.return_value = deployment_files + mock_decode.side_effect = decode_results + config = ConfigMixIn([], deployment_files=deployment_files) + + deployments = list(expand_deployment_files(config)) - for deployment_file in deployment_files: - mock_decode.assert_called_with(deployment_file) - if validate_template_parameters: - mock_run_template_by_file_path.assert_called_once() - config = mock_run_template_by_file_path.call_args_list - assert config[0].kwargs.get( - "config" - ).parameters == validate_template_parameters.get("parameters") - assert config[0].kwargs.get( - "filename" - ) == validate_template_parameters.get("filename") + for deployment_file in deployment_files: + mock_decode.assert_called_with(deployment_file) - assert deployment == expected, f"{name}: {deployment} != {expected}" + if len(expected) > 0: + print(deployments[0][0], expected[0][0]) + print(deployments[0][0] == expected[0][0]) + assert deployments[0][0] == expected[0][0] + assert deployments == expected, f"{name}: {deployments} != {expected}" diff --git a/test/unit/module/runner/template/test_run_template_by_data.py b/test/unit/module/runner/template/test_run_template_by_data.py index 8c2c8664bb..d0bdf69242 100644 --- a/test/unit/module/runner/template/test_run_template_by_data.py +++ b/test/unit/module/runner/template/test_run_template_by_data.py @@ -8,6 +8,7 @@ import pytest from cfnlint.config import ConfigMixIn +from cfnlint.context import ParameterSet from cfnlint.rules import RulesCollection from cfnlint.runner.template import run_template_by_data @@ -21,26 +22,39 @@ [ iter([]), ], - [None], + [], ), ( "One set of parameters", - ConfigMixIn(parameters=[{"Foo": "Bar"}]), + ConfigMixIn( + parameters=[ParameterSet(source=None, parameters={"Foo": "Bar"})] + ), [ iter([]), ], - [{"Foo": "Bar"}], + [ParameterSet(source=None, parameters={"Foo": "Bar"})], ), ( "Multiple parameters", - ConfigMixIn(parameters=[{"A": "B"}, {"C": "D"}]), + ConfigMixIn( + parameters=[ + ParameterSet(source=None, parameters={"A": "B"}), + ParameterSet(source=None, parameters={"C": "D"}), + ] + ), [ iter([]), iter([]), ], [ - {"A": "B"}, - {"C": "D"}, + ParameterSet( + source=None, + parameters={"A": "B"}, + ), + ParameterSet( + source=None, + parameters={"C": "D"}, + ), ], ), ], @@ -57,9 +71,9 @@ def test_runner( ) as mock_run: list(run_template_by_data({}, config, RulesCollection())) - calls = mock_run.call_args_list - for index, call in enumerate(calls): - assert call.kwargs["cfn"].parameters == expected_parameters[index], ( - f"{name}: {call.kwargs['cfn'].parameters} " - f"!= {expected_parameters[index]}" - ) + assert mock_run.call_count == 1 + call = mock_run.call_args + assert call.kwargs["cfn"].context.parameter_sets == expected_parameters, ( + f"{name}: {call.kwargs['cfn'].context.parameter_sets} " + f"!= {expected_parameters}" + ) diff --git a/test/unit/module/runner/test_cli.py b/test/unit/module/runner/test_cli.py index 7451f27bc9..f8635780ed 100644 --- a/test/unit/module/runner/test_cli.py +++ b/test/unit/module/runner/test_cli.py @@ -4,10 +4,12 @@ """ import logging +from io import StringIO from test.testlib.testcase import BaseTestCase from unittest.mock import patch from cfnlint import ConfigMixIn +from cfnlint.helpers import format_json_string from cfnlint.runner import Runner LOGGER = logging.getLogger("cfnlint") @@ -45,6 +47,18 @@ def test_update_specs(self, mock_maintenance): self.assertEqual(e.exception.code, 0) mock_maintenance.assert_called_once() + @patch("cfnlint.maintenance.patch_resource_specs") + def test_patch_specs(self, mock_maintenance): + config = ConfigMixIn(["--patch-specs"]) + + runner = Runner(config) + + with self.assertRaises(SystemExit) as e: + runner.cli() + + self.assertEqual(e.exception.code, 0) + mock_maintenance.assert_called_once() + @patch("cfnlint.maintenance.update_iam_policies") def test_update_iam_policies(self, mock_maintenance): config = ConfigMixIn(["--update-iam-policies"]) @@ -116,5 +130,45 @@ def test_templates_with_deployment_files(self, mock_print_help): with self.assertRaises(SystemExit) as e: runner.cli() - self.assertEqual(e.exception.code, 32) + self.assertEqual(e.exception.code, 1) mock_print_help.assert_called_once() + + @patch("fileinput.input") + @patch("sys.stdin.isatty") + def test_templates_with_stdin(self, mock_isatty, mock_fileinput): + template = { + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + } + } + } + + mock_fileinput.return_value = StringIO(format_json_string(template)) + mock_isatty.return_value = False + + config = ConfigMixIn() + + runner = Runner(config) + + with self.assertRaises(SystemExit) as e: + runner.cli() + + self.assertEqual(e.exception.code, 0) + + @patch("fileinput.input") + @patch("sys.stdin.isatty") + def test_templates_with_stdin_with_bad_syntax(self, mock_isatty, mock_fileinput): + template = "{" + + mock_fileinput.return_value = StringIO(template) + mock_isatty.return_value = False + + config = ConfigMixIn() + + runner = Runner(config) + + with self.assertRaises(SystemExit) as e: + runner.cli() + + self.assertEqual(e.exception.code, 2) diff --git a/test/unit/module/runner/test_rule_configuration.py b/test/unit/module/runner/test_rule_configuration.py index 54c5b11cac..444ad341e1 100644 --- a/test/unit/module/runner/test_rule_configuration.py +++ b/test/unit/module/runner/test_rule_configuration.py @@ -10,7 +10,6 @@ from cfnlint.runner import Runner, UnexpectedRuleException - class TestGetRules(BaseTestCase): """Test Run Checks""" diff --git a/test/unit/rules/conftest.py b/test/unit/rules/conftest.py index 3598af672d..910421b381 100644 --- a/test/unit/rules/conftest.py +++ b/test/unit/rules/conftest.py @@ -31,7 +31,7 @@ def regions(): def parameters(request): if hasattr(request, "param"): return request.param - return None + return [] @pytest.fixture @@ -75,11 +75,12 @@ def strict_types(strict_types=True): @pytest.fixture -def context(cfn, path, functions, strict_types): +def context(cfn, path, functions, strict_types, parameters): return create_context_for_template(cfn).evolve( path=path, functions=functions, strict_types=strict_types, + parameter_sets=parameters, ) diff --git a/test/unit/rules/deployment_files/test_parameters.py b/test/unit/rules/deployment_files/test_parameters.py index 27b2677bed..f00883907e 100644 --- a/test/unit/rules/deployment_files/test_parameters.py +++ b/test/unit/rules/deployment_files/test_parameters.py @@ -7,6 +7,7 @@ import pytest +from cfnlint.context import ParameterSet from cfnlint.jsonschema import ValidationError from cfnlint.rules.deployment_files.Parameters import Parameters @@ -29,9 +30,14 @@ def rule(): ( "Parameter provided with no default", {"Foo": {"Type": "String"}}, - { - "Foo": "Bar", - }, + [ + ParameterSet( + source=None, + parameters={ + "Foo": "Bar", + }, + ) + ], [], ), ( @@ -43,7 +49,7 @@ def rule(): ( "Empty with no default should have an error", {"Foo": {"Type": "String"}}, - {}, + [ParameterSet(source=None, parameters={})], [ ValidationError( "'Foo' is a required property", @@ -57,7 +63,7 @@ def rule(): ( "Failure on bad enum", {"Foo": {"Type": "String", "AllowedValues": ["A", "B", "C"]}}, - {"Foo": "D"}, + [ParameterSet(source=None, parameters={"Foo": "D"})], [ ValidationError( "'D' is not one of ['A', 'B', 'C']", @@ -71,7 +77,7 @@ def rule(): ( "Failure on bad pattern", {"Foo": {"Type": "String", "Pattern": "^Bar$"}}, - {"Foo": "D"}, + [ParameterSet(source=None, parameters={"Foo": "D"})], [ ValidationError( "'D' does not match '^Bar$'", @@ -85,13 +91,13 @@ def rule(): ( "Okay with a list", {"Foo": {"Type": "CommaDelimitedList"}}, - {"Foo": ["D"]}, + [ParameterSet(source=None, parameters={"Foo": ["D"]})], [], ), ( "Not okay with a list and a bad pattern", {"Foo": {"Type": "CommaDelimitedList", "Pattern": "^Bar$"}}, - {"Foo": ["Bar", "D"]}, + [ParameterSet(source=None, parameters={"Foo": ["Bar", "D"]})], [ ValidationError( "'D' does not match '^Bar$'", @@ -105,7 +111,7 @@ def rule(): ( "Not okay with a list and an enum", {"Foo": {"Type": "CommaDelimitedList", "AllowedValues": ["Bar"]}}, - {"Foo": ["Bar", "D"]}, + [ParameterSet(source=None, parameters={"Foo": ["Bar", "D"]})], [ ValidationError( "'D' is not one of ['Bar']", @@ -117,21 +123,21 @@ def rule(): ], ), ( - "Issues when a bad properties type", + "No issues when a bad property type", [{"Foo": {"Type": "CommaDelimitedList", "AllowedValues": ["Bar"]}}], - {"Foo": "Bar"}, + [ParameterSet(source=None, parameters={"Foo": "Bar"})], [], ), ( - "Issues when a bad property type", + "No issues when a bad property type", {"Foo": [{"Type": "CommaDelimitedList", "AllowedValues": ["Bar"]}]}, - {"Foo": "Bar"}, + [ParameterSet(source=None, parameters={"Foo": "Bar"})], [], ), ( - "Issues when a bad type", + "No issues when a bad type", {"Foo": {"Type": ["String"], "AllowedValues": ["Bar"]}}, - {"Foo": "Foo"}, + [ParameterSet(source=None, parameters={"Foo": "Foo"})], [], ), ],