Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Resolve #1252] Supporting resolvers in Hook and Resolver arguments, with new !substitute, !join, !split, and !select resolvers! #1313

Merged
merged 43 commits into from
Mar 24, 2023
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
cb5a477
creating Sub resolver
jfalkenstein Feb 25, 2023
382938f
creating nestable resolvers
jfalkenstein Feb 25, 2023
caa6d3f
testing substitute, join, and split
jfalkenstein Feb 25, 2023
f34631e
supporting resolving to nothing
jfalkenstein Feb 25, 2023
24f3333
adding docstrings and extra test
jfalkenstein Feb 25, 2023
5376462
merging nestable resolver with resolver base class
jfalkenstein Feb 25, 2023
8ba2d02
updating base resolver class
jfalkenstein Feb 26, 2023
1b56f5e
creating select resolver
jfalkenstein Feb 26, 2023
9712bfa
supporting resolvers as hook arguments
jfalkenstein Feb 26, 2023
617c37a
testing helper
jfalkenstein Feb 26, 2023
b7f4059
updating docstrings
jfalkenstein Feb 26, 2023
8ccb8bd
documenting new resolvers
jfalkenstein Feb 26, 2023
5d14ec0
adding some context to stack config docs
jfalkenstein Feb 26, 2023
a98a955
fixing docs
jfalkenstein Feb 26, 2023
c739663
docs tweak
jfalkenstein Feb 26, 2023
ee70e4e
adding docstrings
jfalkenstein Feb 26, 2023
868448b
testing for resolvers in resolvers in resolvers
jfalkenstein Feb 26, 2023
1fc438d
fixing setup
jfalkenstein Feb 26, 2023
80cb201
making an example use sceptre_user_data
jfalkenstein Mar 4, 2023
01130d4
making example a little more explicit
jfalkenstein Mar 4, 2023
ac07a92
small docs tweak
jfalkenstein Mar 4, 2023
13cf87d
improving docstring
jfalkenstein Mar 4, 2023
1502a81
improving docstring
jfalkenstein Mar 4, 2023
2c42905
improving docstring
jfalkenstein Mar 4, 2023
aa1a258
handling resolver situations
jfalkenstein Mar 4, 2023
62bcb29
documenting __repr__
jfalkenstein Mar 4, 2023
51c7079
testing placeholders with nested resolvers
jfalkenstein Mar 4, 2023
6f84e48
Merge remote-tracking branch 'sceptre/master' into 1252-sub-resolver
jfalkenstein Mar 4, 2023
c5f9c72
Apply suggestions from code review
jfalkenstein Mar 6, 2023
9fe80bc
renaming Substitute to Sub
jfalkenstein Mar 11, 2023
f75e3c3
Merge branch '1252-sub-resolver' of github.com:jfalkenstein/sceptre i…
jfalkenstein Mar 11, 2023
20ca362
fixing grammer in docs
jfalkenstein Mar 12, 2023
d7eb15f
clarifying docs
jfalkenstein Mar 12, 2023
5ad9748
making the underline shorter
jfalkenstein Mar 12, 2023
5888204
more robust joining
jfalkenstein Mar 12, 2023
d57bb3d
fixing test name
jfalkenstein Mar 12, 2023
d8cd879
adding better select tests
jfalkenstein Mar 12, 2023
9080e70
adding proper error handling around join
jfalkenstein Mar 19, 2023
26d960e
proper error handling around select
jfalkenstein Mar 19, 2023
6babfb8
testing sub
jfalkenstein Mar 19, 2023
b3d50ff
adding test on invalid argument error raising
jfalkenstein Mar 19, 2023
e1665ad
documenting resolvers in a hook argument
jfalkenstein Mar 19, 2023
fab6544
fixing typo in test name
jfalkenstein Mar 24, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 103 additions & 6 deletions docs/_source/docs/resolvers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ Sceptre implements resolvers, which can be used to resolve a value of a
CloudFormation ``parameter`` or ``sceptre_user_data`` value at runtime. This is
most commonly used to chain the outputs of one Stack to the inputs of another.

You can use resolvers with any resolvable property on a StackConfig, as well as in the arguments
of hooks and other resolvers.

If required, users can create their own resolvers, as described in the section
on `Custom Resolvers`_.

Expand Down Expand Up @@ -52,6 +55,27 @@ file_contents

**deprecated**: Consider using the `file`_ resolver instead.

join
~~~~

This resolver allows you to join multiple strings together to form a single string. This is great
for combining the outputs of multiple resolvers. This resolver works just like CloudFormation's
``!Join`` intrinsic function.

The argument for this resolver should be a list with two elements: (1) A string to join the elements
on and (2) a list of items to join.

Example:

.. code-block:: yaml

parameters:
BaseUrl: !join
- ":"
- - !stack_output my/app/stack.yaml::HostName
- !stack_output my/other/stack.yaml::Port


no_value
~~~~~~~~

Expand Down Expand Up @@ -81,6 +105,49 @@ Refer to `sceptre-resolver-cmd <https://github.com/Sceptre/sceptre-resolver-cmd/

.. _stack_attr_resolver:

select
~~~~~~

This resolver allows you to select a specific index of a list of items. This is great for combining
with the ``!split`` resolver to obtain part of a string. This function works almost the same as
CloudFormation's ``!Select`` intrinsic function, **except you can use this with negative indices to
select from the end of a list**.

The argument for this resolver should be a list with two elements: (1) A numerical index and (2) a
list of items to select out of. If the index is negative, it will select from the end of the list.
For example, "-1" would select the last element and "-2" would select the second-to-last element.

Example:

.. code-block:: yaml

sceptre_user_data:
# This selects the last element after you split the connection string on "/"
DatabaseName: !select
- -1
- !split ["/", !stack_output my/database/stack.yaml::ConnectionString]

split
~~~~~

This resolver will split a value on a given delimiter string. This is great when combining with the
``!select`` resolver. This function works the same as CloudFormation's ``!Split`` intrinsic function.

Note: The return value of this resolver is a *list*, not a string. This will not work to set Stack
configurations that expect strings, but it WILL work to set Stack configurations that expect lists.

The argument for this resolver should be a list with two elements: (1) The delimiter to split on and
(2) a string to split.

Example:

.. code-block:: yaml

notifications: !split
- ";"
- !stack_output my/sns/topics.yaml::SemicolonDelimitedArns
Comment on lines +144 to +146
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you think it would make it easier for users to understand if these examples just used direct values? something like this..

   notifications: !split
     - ";"
     - "note1; note2; note3"

result: the list ["note1", "note2", "note3"] is passed into the notifications parameter

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I don't really. !sub, !join, !select, and !split are ONLY useful when you have resolvers to string together; They're clumsy if you don't. It's better to use Jinja to do these things, if you can. But if you have resolvers whose resolved values you need to manipulate/interpolate/select from, they're really the only way to do that.

As such, If we don't show how these resolvers can be used with other resolvers, we're not really showing how to use them well.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

¿Por qué no los dos? :)

   notifications: !split
     - ";"
     - "note1; note2; note3"

Resolves to:

   notifications: [ "note1", "note2", "note3" ]

As a more practical example, the following will split a semicolon-delimited list exported by another stack:

   notifications: !split
     - ";"
     - !stack_output my/sns/topics.yaml::SemicolonDelimitedArns

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dboitnot, sure, that works. But If you wanted to split a simple string, you'd just go like this:

notifications: {{ "note1; note2; note3".split(";") }}

I fear that if we show people examples of using these resolvers in cases where you don't actually need to use them (and where you really shouldn't use them), it will be more confusing. The only times you need to use these resolvers is when you need to combine/manipulate the outputs of other resolvers. I actually added documentation to that effect to make it absolutely clear.



stack_attr
~~~~~~~~~~

Expand Down Expand Up @@ -181,6 +248,32 @@ Example:
parameters:
VpcIdParameter: !stack_output_external prj-network-vpc::VpcIdOutput prod


substitute
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have super-strong feelings about this, but why "substitute" and not "sub" as in CF? I might argue in favor of "sub" for consistency with CF.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know. I guess I just don't like the abbreviation. It doesn't seem as clear as substitute. @zaro0508 , thoughts on this?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i agree with @dboitnot. Since you are making references to cloudformation intrinsic functions I prefer sub for consistency.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree "substitute" has more intrinsic clarity, but since most of our users are going to be pretty familiar with CF and sub I think we could avoid the mental dichotomy by sticking to CF's nomenclature. But I would like @zaro0508's thoughts as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've changed this to sub

~~~~~~~~~~

This resolver allows you to create a string using Python string format syntax. This functions as a
great way to combine together a number of resolver outputs into a single string. This functions very
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

very/is very

similarly to Cloudformation's ``!Sub`` intrinsic function.

The argument to this resolver should be a two-element list: (1) Is the format string, using
curly-brace templates to indicate variables, and (2) a dictionary where the keys are the format
string's variable names and the values are the variable values.

Example:

.. code-block:: yaml

parameters:
ConnectionString: !substitute
- "postgres://{username}:{password}@{hostname}:{port}/{database}"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can the sub reference vars outside of it's scope? Can you set these to build-in vars like account id/region/etc.. What about references to vars from config.yaml If possible it might help users if there was an example or two of those use cases.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

!sub needs a second argument that defines the variable values. It's not the same as CloudFormation's sub that lets you just resolve values without defining them. It's just a glorified str.format() invocation. It doesn't have a "scope" outside its own arguments. If you want to resolve something from the Stack scope, you could combine !sub and Jinja syntax together, like this:

!sub
  # Note the double-curly braces is JINJA syntax to access variables within Jinja scope
  - "postgres://{{ var.username }}:{password}@{hostname}:{port}/{{ database }}"
  - password: !ssm /my/ssm/password
     hostname: !stack_output my/database/stack.yaml::HostName
     port: !stack_output my/database/stack.yaml::Port

Notice how, if it's accessible within Jinja, you don't actually need to provide that to !sub as a variable, because Jinja interpolation will happen BEFORE the yaml is rendered. I'm going to look into clarifying this in the docs, since I doubt you'll be the first one confused about this.

- username: {{ var.username }}
password: !ssm /my/ssm/password
hostname: !stack_output my/database/stack.yaml::HostName
port: !stack_output my/database/stack.yaml::Port
database: {{var.database}}


Custom Resolvers
----------------

Expand Down Expand Up @@ -306,18 +399,22 @@ For details on calling AWS services or invoking AWS-related third party tools in

Resolver arguments
^^^^^^^^^^^^^^^^^^
Resolver arguments can be a simple string or a complex data structure.
Resolver arguments can be a simple string or a complex data structure. You can even use
other resolvers in the arguments to resolvers! (Note: Other resolvers can only be passed in
arguments when they're passed in lists and dicts.)

.. code-block:: yaml

template:
path: <...>
type: <...>
parameters:
Param1: !ssm "/dev/DbPassword"
Param2: !ssm {"name": "/dev/DbPassword"}
Param3: !ssm
name: "/dev/DbPassword"
parameters:
Param1: !ssm "/dev/DbPassword"
Param2: !ssm {"name": "/dev/DbPassword"}
Param3: !ssm
name: "/dev/DbPassword"
Param4: !ssm
name: !stack_output my/other/stack.yaml::MySsmParameterName

.. _Custom Resolvers: #custom-resolvers
.. _this is great place to start: https://docs.python.org/3/distributing/
Expand Down
10 changes: 8 additions & 2 deletions docs/_source/docs/stack_config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ and that Stack need not be added as an explicit dependency.

hooks
~~~~~
* Resolvable: No
* Resolvable: No (but you can use resolvers _in_ hook arguments!)
zaro0508 marked this conversation as resolved.
Show resolved Hide resolved
* Can be inherited from StackGroup: Yes
* Inheritance strategy: Overrides parent if set

Expand Down Expand Up @@ -556,7 +556,8 @@ order:
A common point of confusion tends to be around the distinction between **"render time"** (phase 3, when
Jinja logic is applied) and **"resolve time"** (phase 6, when resolvers are resolved). You cannot use
a resolver via Jinja during "render time", since the resolver won't exist or be ready to use yet. You can,
however, use Jinja logic to indicate *whether*, *which*, or *how* a resolver is configured.
however, use Jinja logic to indicate *whether*, *which*, or *how* a resolver is configured. You can
also use resolvers like ``!substitute`` to interpolate resolved values when Jinja isn't available.

For example, you **can** do something like this:

Expand All @@ -566,6 +567,11 @@ For example, you **can** do something like this:
{% if var.use_my_parameter %}
my_parameter: !stack_output {{ var.stack_name }}::{{ var.output_name }}
{% endif %}
# !substitute will let you combine outputs of multiple resolvers into a single string
my_combined_parameter: !substitute
- "{fist_part} - {second_part}"
- first_part: !stack_output my/stack/name.yaml::Output
- second_part: {{ var.second_part }}

Accessing resolved values in other fields
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
2 changes: 1 addition & 1 deletion sceptre/config/reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ def constructor_factory(node_class):
:rtype: func
"""

# This function signture is required by PyYAML
# This function signature is required by PyYAML
def class_constructor(loader, node):
return node_class(
loader.construct_object(self.resolve_node_tag(loader, node))
Expand Down
24 changes: 23 additions & 1 deletion sceptre/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from contextlib import contextmanager
from datetime import datetime
from os import sep
from typing import Optional, Any, List
from typing import Optional, Any, List, Tuple, Union

import dateutil.parser
import deprecation
Expand Down Expand Up @@ -68,6 +68,28 @@ def func_on_instance(key):
return attr


Container = Union[list, dict]
Key = Union[str, int]


def delete_keys_from_containers(keys_to_delete: List[Tuple[Container, Key]]):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is mostly just extracted out of the ResolvableProperty class and put here. I needed to also use this in the CustomYamlTagBase class.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm glad that this has been extracted but I'm a bit queasy about this function mutating collections it doesn't own. I got side-tracked by work but I feel this could be simplified (in the future) using list comprehensions and/or filter. But then I wasn't able to dig enough into how it's used to propose a refactor.

Copy link
Contributor Author

@jfalkenstein jfalkenstein Mar 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I posted a previous response to this and then I'm replacing it with this, because I thought you were talking about something else.

If you look at how this is used, mutation is exactly the purpose of this function. Filtered lists and dicts will not suit the purpose of what this exists for and won't work. The whole point is in-place modification of containers because of the way that resolvers operate.

"""Removes the indicated keys/indexes from their paired containers."""
list_items_to_delete = []
for container, key in keys_to_delete:
if isinstance(container, list):
# If it's a list, we want to gather up the items to remove from the list.
# We don't want to modify the list length yet, since removals will change all the other
# list indexes. Instead, we'll get the actual items at those indexes to remove later.
list_items_to_delete.append((container, container[key]))
else:
del container[key]

# Finally, now that we have all the items we want to remove the lists, we'll remove those
# items specifically from the lists.
for containing_list, item in list_items_to_delete:
containing_list.remove(item)


def normalise_path(path):
"""
Converts a path to use correct path separator.
Expand Down
39 changes: 6 additions & 33 deletions sceptre/hooks/__init__.py
Original file line number Diff line number Diff line change
@@ -1,39 +1,21 @@
import abc
import logging
from functools import wraps
from typing import TYPE_CHECKING

from sceptre.helpers import _call_func_on_values
from sceptre.logging import StackLoggerAdapter

from typing import TYPE_CHECKING, Any
from sceptre.resolvers import CustomYamlTagBase

if TYPE_CHECKING:
from sceptre.stack import Stack


class Hook(abc.ABC):
class Hook(CustomYamlTagBase, metaclass=abc.ABCMeta):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The big change here is that, since there's very little that differentiates a hook from a resolver, other than the "resolve" vs "run" public method, they now share a common base class. A lot of the functionality on Hooks has now been moved up into that base class since it was shared with Resolvers.

"""
Hook is an abstract base class that should be inherited by all hooks.

:param argument: The argument of the hook.
:param stack: The associated stack of the hook.
Hook is an abstract base class that should be subclassed by all hooks.
"""

def __init__(self, argument: Any = None, stack: "Stack" = None):
self.logger = logging.getLogger(__name__)

if stack is not None:
self.logger = StackLoggerAdapter(self.logger, stack.name)

self.argument = argument
self.stack = stack

def setup(self):
"""
setup is a method that may be overwritten by inheriting classes. Allows
hooks to run so initalisation steps when config is first read.
"""
pass # pragma: no cover
logger = logging.getLogger(__name__)

@abc.abstractmethod
def run(self):
Expand All @@ -43,15 +25,6 @@ def run(self):
"""
pass # pragma: no cover

def clone(self, stack: "Stack") -> "Hook":
"""
Produces a "fresh" copy of the Hook, with the specified stack.

:param stack: The stack to set on the cloned resolver
"""
clone = type(self)(self.argument, stack)
return clone

Comment on lines -46 to -54
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now defined on the base class


class HookProperty(object):
"""
Expand Down Expand Up @@ -84,7 +57,7 @@ def __set__(self, instance: "Stack", value):
"""

def setup(attr, key, value: Hook):
attr[key] = clone = value.clone(instance)
attr[key] = clone = value.clone_for_stack(instance)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clone_for_stack (as you'll see below) is a recursive clone operation that allows for cloning resolvers inside of the arguments.

clone.setup()

_call_func_on_values(setup, value, Hook)
Expand Down
Loading