Repository: Sceptre/sceptre

Issue:
Input parameters should be validated
### Subject of the issue
For historical reasons, Sceptre has never validated input parameters that are passed in to CloudFormation stack parameters. Blocks that contain bools, ints etc give rise to confusing failures. For example:

```yaml
parameters:
  someBool: true
```

Would lead to an error appearing like:
```
"Parameter validation failed:\nInvalid type for parameter Parameters[0].ParameterValue, value: 1, type: <class 'bool'>, valid types: <class 'str'>"  
```

In more complicated examples it is often quite unclear what `Parameter[0]` means.

A feature requested here to add a layer of input data validation at the time of Stack instantiation after discussion with @jfalkenstein  in Slack.

### Your environment
* version of sceptre (sceptre --version) latest
* version of python (python --version) 3.10
* which OS/distro


Discussion:

Patch:
diff --git a/sceptre/stack.py b/sceptre/stack.py
index 8a63309..fa5639a 100644
--- a/sceptre/stack.py
+++ b/sceptre/stack.py
@@ -9,7 +9,7 @@ This module implements a Stack class, which stores a Stack's data.
 
 import logging
 
-from typing import List, Any, Optional
+from typing import List, Dict, Union, Any, Optional
 from deprecation import deprecated
 
 from sceptre import __version__
@@ -26,6 +26,7 @@ from sceptre.resolvers import (
     ResolvableValueProperty,
     RecursiveResolve,
     PlaceholderType,
+    Resolver,
 )
 from sceptre.template import Template
 
@@ -262,7 +263,7 @@ class Stack:
         )
 
         self.s3_details = s3_details
-        self.parameters = parameters or {}
+        self.parameters = self._ensure_parameters(parameters or {})
         self.sceptre_user_data = sceptre_user_data or {}
         self.notifications = notifications or []
 
@@ -275,6 +276,30 @@ class Stack:
             )
         return value
 
+    def _ensure_parameters(
+        self, parameters: Dict[str, Any]
+    ) -> Dict[str, Union[str, List[Union[str, Resolver]], Resolver]]:
+        """Ensure CloudFormation parameters are of valid types"""
+
+        def is_valid(value: Any) -> bool:
+            return (
+                isinstance(value, str)
+                or (
+                    isinstance(value, list)
+                    and all(
+                        isinstance(item, str) or isinstance(item, Resolver)
+                        for item in value
+                    )
+                )
+                or isinstance(value, Resolver)
+            )
+
+        if not all(is_valid(value) for value in parameters.values()):
+            raise InvalidConfigFileError(
+                f"{self.name}: Values for parameters must be strings, lists or resolvers, got {parameters}"
+            )
+        return parameters
+
     def __repr__(self):
         return (
             "sceptre.stack.Stack("


Test patch:
diff --git a/tests/test_stack.py b/tests/test_stack.py
index 7e843e4..6f67e08 100644
--- a/tests/test_stack.py
+++ b/tests/test_stack.py
@@ -40,6 +40,11 @@ def stack_factory(**kwargs):
     return Stack(**call_kwargs)
 
 
+class FakeResolver(Resolver):
+    def resolve(self):
+        return "Fake"
+
+
 class TestStack(object):
     def setup_method(self, test_method):
         self.stack = Stack(
@@ -183,6 +188,46 @@ class TestStack(object):
                 obsolete="true",
             )
 
+    @pytest.mark.parametrize(
+        "parameters",
+        [
+            {"someNum": 1},
+            {"someBool": True},
+            {"aBadList": [1, 2, 3]},
+            {"aDict": {"foo": "bar"}},
+        ],
+    )
+    def test_init__invalid_parameters_raise_invalid_config_file_error(self, parameters):
+        with pytest.raises(InvalidConfigFileError):
+            Stack(
+                name="stack_name",
+                project_code="project_code",
+                template_handler_config={"type": "file"},
+                region="region",
+                parameters=parameters,
+            )
+
+    @pytest.mark.parametrize(
+        "parameters",
+        [
+            {"someNum": "1"},
+            {"someBool": "true"},
+            {"aList": ["aString", FakeResolver()]},
+            {"aResolver": FakeResolver()},
+        ],
+    )
+    def test_init__valid_parameters_do_not_raise_invalid_config_file_error(
+        self, parameters
+    ):
+        stack = Stack(
+            name="stack_name",
+            project_code="project_code",
+            template_handler_config={"type": "file"},
+            region="region",
+            parameters=parameters,
+        )
+        assert isinstance(stack, Stack)
+
     def test_stack_repr(self):
         assert (
             self.stack.__repr__() == "sceptre.stack.Stack("
@@ -248,14 +293,10 @@ class TestStack(object):
     def test_configuration_manager__sceptre_role_returns_value__returns_connection_manager_with_that_role(
         self,
     ):
-        class FakeResolver(Resolver):
-            def resolve(self):
-                return "role"
-
         self.stack.sceptre_role = FakeResolver()
 
         connection_manager = self.stack.connection_manager
-        assert connection_manager.sceptre_role == "role"
+        assert connection_manager.sceptre_role == "Fake"
 
     @fail_if_not_removed
     def test_iam_role__is_removed_on_removal_version(self):