"""CFNgin config models."""
# pylint: disable=no-self-argument
from __future__ import annotations
import copy
import locale
from pathlib import Path
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
List,
Optional,
Type,
TypeVar,
Union,
cast,
)
import yaml
from pydantic import Extra, Field, Protocol, root_validator, validator
from typing_extensions import Literal
from .. import utils
from ..base import ConfigProperty
from ._package_sources import (
CfnginPackageSourcesDefinitionModel,
GitCfnginPackageSourceDefinitionModel,
LocalCfnginPackageSourceDefinitionModel,
S3CfnginPackageSourceDefinitionModel,
)
if TYPE_CHECKING:
from pydantic import BaseModel
Model = TypeVar("Model", bound=BaseModel)
__all__ = [
"CfnginConfigDefinitionModel",
"CfnginHookDefinitionModel",
"CfnginPackageSourcesDefinitionModel",
"CfnginStackDefinitionModel",
"GitCfnginPackageSourceDefinitionModel",
"LocalCfnginPackageSourceDefinitionModel",
"S3CfnginPackageSourceDefinitionModel",
]
[docs]class CfnginHookDefinitionModel(ConfigProperty):
"""Model for a CFNgin hook definition."""
args: Dict[str, Any] = Field(
default={},
title="Arguments",
description="Arguments that will be passed to the hook. (supports lookups)",
)
data_key: Optional[str] = Field(
default=None,
description="Key to use when storing the returned result of the hook.",
)
enabled: bool = Field(default=True, description="Whether the hook will be run.")
path: str = Field(..., description="Python importable path to the hook.")
required: bool = Field(
default=True,
description="Whether to continue execution if the hook results in an error.",
)
[docs] class Config(ConfigProperty.Config):
"""Model configuration."""
extra = Extra.forbid
schema_extra = {
"description": "Python classes or functions run before or after "
"deploy/destroy actions."
}
title = "CFNgin Hook Definition"
[docs]class CfnginStackDefinitionModel(ConfigProperty):
"""Model for a CFNgin stack definition."""
class_path: Optional[str] = Field(
default=None,
title="Blueprint Class Path",
description="Python importable path to a blueprint class.",
)
description: Optional[str] = Field(
default=None,
title="Stack Description",
description="A description that will be applied to the stack in CloudFormation.",
)
enabled: bool = Field(
default=True, description="Whether the stack will be deployed."
)
in_progress_behavior: Optional[Literal["wait"]] = Field(
default=None,
title="Stack In Progress Behavior",
description="The action to take when a stack's status is "
"CREATE_IN_PROGRESS or UPDATE_IN_PROGRESS when trying to update it.",
)
locked: bool = Field(
default=False, description="Whether to limit updating of the stack."
)
name: str = Field(..., title="Stack Name", description="Name of the stack.")
protected: bool = Field(
default=False,
description="Whether to force all updates to the stack to be performed interactively.",
)
required_by: List[str] = Field(
default=[], description="Array of stacks (by name) that require this stack."
)
requires: List[str] = Field(
default=[], description="Array of stacks (by name) that this stack requires."
)
stack_name: Optional[str] = Field(
default=None,
title="Explicit Stack Name",
description="Explicit name of the stack (namespace will still be prepended).",
)
stack_policy_path: Optional[Path] = Field(
default=None,
description="Path to a stack policy document that will be applied to the "
"CloudFormation stack.",
)
tags: Dict[str, Any] = Field(
default={}, description="Tags that will be applied to the CloudFormation stack."
)
template_path: Optional[Path] = Field(
default=None,
description="Path to a JSON or YAML formatted CloudFormation Template.",
)
termination_protection: bool = Field(
default=False,
description="Set the value of termination protection on the CloudFormation stack.",
)
timeout: Optional[int] = Field(
default=None,
description="The amount of time (in minutes) that can pass before the "
"Stack status becomes CREATE_FAILED.",
)
variables: Dict[str, Any] = Field(
default={},
description="Parameter values that will be passed to the "
"Blueprint/CloudFormation stack. (supports lookups)",
)
[docs] class Config(ConfigProperty.Config):
"""Model configuration options."""
extra = Extra.forbid
title = "CFNgin Stack Definition"
_resolve_path_fields = cast(
"classmethod[Callable[..., Any]]",
validator("stack_policy_path", "template_path", allow_reuse=True)(
utils.resolve_path_field
),
)
@root_validator(pre=True)
def _validate_class_and_template(cls, values: Dict[str, Any]) -> Dict[str, Any]:
"""Validate class_path and template_path are not both provided."""
if values.get("class_path") and values.get("template_path"):
raise ValueError("only one of class_path or template_path can be defined")
return values
@root_validator(pre=True)
def _validate_class_or_template(cls, values: Dict[str, Any]) -> Dict[str, Any]:
"""Ensure that either class_path or template_path is defined."""
# if the stack is disabled or locked, it is ok that these are missing
required = values.get("enabled", True) and not values.get("locked", False)
if (
not values.get("class_path")
and not values.get("template_path")
and required
):
raise ValueError("either class_path or template_path must be defined")
return values
[docs]class CfnginConfigDefinitionModel(ConfigProperty):
"""Model for a CFNgin config definition."""
cfngin_bucket: Optional[str] = Field(
default=None,
title="CFNgin Bucket",
description="Name of an AWS S3 bucket to use for caching CloudFormation templates. "
"Set as an empty string to disable caching.",
)
cfngin_bucket_region: Optional[str] = Field(
default=None,
title="CFNgin Bucket Region",
description="AWS Region where the CFNgin Bucket is located. "
"If not provided, the current region is used.",
)
cfngin_cache_dir: Optional[Path] = Field(
default=None,
title="CFNgin Cache Directory",
description="Path to a local directory that CFNgin will use for local caching.",
)
log_formats: Dict[str, str] = Field( # TODO create model
default={}, description="Customize log message formatting by log level."
)
lookups: Dict[str, str] = Field(
default={},
description="Mapping of custom lookup names to a python importable path "
"for the class that will be used to resolve the lookups.",
)
mappings: Dict[str, Dict[str, Dict[str, Any]]] = Field(
default={}, description="Mappings that will be appended to all stack templates."
)
namespace: str = Field(
...,
description="The namespace used to prefix stack names to create separation "
"within an AWS account.",
)
namespace_delimiter: str = Field(
default="-",
description="Character used to separate the namespace and stack name "
"when the namespace is prepended.",
)
package_sources: CfnginPackageSourcesDefinitionModel = Field(
default=CfnginPackageSourcesDefinitionModel(),
description=CfnginPackageSourcesDefinitionModel.Config.schema_extra[
"description"
],
)
persistent_graph_key: Optional[str] = Field(
default=None,
description="Key for an AWS S3 object used to track a graph of stacks "
"between executions.",
)
post_deploy: Union[
List[CfnginHookDefinitionModel], # final type after parsing
Dict[str, CfnginHookDefinitionModel], # recommended when writing config
] = Field(default=[], title="Post Deploy Hooks")
post_destroy: Union[
List[CfnginHookDefinitionModel], # final type after parsing
Dict[str, CfnginHookDefinitionModel], # recommended when writing config
] = Field(default=[], title="Pre Destroy Hooks")
pre_deploy: Union[
List[CfnginHookDefinitionModel], # final type after parsing
Dict[str, CfnginHookDefinitionModel], # recommended when writing config
] = Field(default=[], title="Pre Deploy Hooks")
pre_destroy: Union[
List[CfnginHookDefinitionModel], # final type after parsing
Dict[str, CfnginHookDefinitionModel], # recommended when writing config
] = Field(default=[], title="Pre Destroy Hooks")
service_role: Optional[str] = Field(
default=None,
title="Service Role ARN",
description="Specify an IAM Role for CloudFormation to use.",
)
stacks: Union[
List[CfnginStackDefinitionModel], # final type after parsing
Dict[str, CfnginStackDefinitionModel], # recommended when writing config
] = Field(
default=[],
description="Define CloudFormation stacks using a Blueprint or Template.",
)
sys_path: Optional[Path] = Field(
default=None,
title="sys.path",
description="Path to append to $PATH. This is also the root of relative paths.",
)
tags: Optional[Dict[str, str]] = Field(
default=None, # None is significant here
description="Tags to try to apply to all resources created from this configuration file.",
)
template_indent: int = Field(
default=4,
description="Number of spaces per indentation level to use when "
"rendering/outputting CloudFormation templates.",
)
[docs] class Config(ConfigProperty.Config):
"""Model configuration."""
schema_extra = {"description": "Configuration file for Runway's CFNgin."}
title = "CFNgin Config File"
_resolve_path_fields = cast(
"classmethod[Callable[..., Any]]",
validator("cfngin_cache_dir", "sys_path", allow_reuse=True)(
utils.resolve_path_field
),
)
@validator("post_deploy", "post_destroy", "pre_deploy", "pre_destroy", pre=True)
def _convert_hook_definitions(
cls, v: Union[Dict[str, Any], List[Dict[str, Any]]]
) -> List[Dict[str, Any]]:
"""Convert hooks defined as a dict to a list."""
if isinstance(v, list):
return v
return list(v.values())
@validator("stacks", pre=True)
def _convert_stack_definitions(
cls, v: Union[Dict[str, Any], List[Dict[str, Any]]]
) -> List[Dict[str, Any]]:
"""Convert stacks defined as a dict to a list."""
if isinstance(v, list):
return v
result: List[Dict[str, Any]] = []
for name, stack in copy.deepcopy(v).items():
stack["name"] = name
result.append(stack)
return result
@validator("stacks")
def _validate_unique_stack_names(
cls, stacks: List[CfnginStackDefinitionModel]
) -> List[CfnginStackDefinitionModel]:
"""Validate that each stack has a unique name."""
stack_names = [stack.name for stack in stacks]
if len(set(stack_names)) != len(stack_names):
for i, name in enumerate(stack_names):
if stack_names.count(name) != 1:
raise ValueError(f"Duplicate stack {name} found at index {i}")
return stacks
[docs] @classmethod
def parse_file(
cls: Type[Model],
path: Union[str, Path],
*,
content_type: Optional[str] = None,
encoding: str = "utf8",
proto: Optional[Protocol] = None,
allow_pickle: bool = False,
) -> Model:
"""Parse a file."""
return cast(
"Model",
cls.parse_raw(
Path(path).read_text(
encoding=locale.getpreferredencoding(do_setlocale=False)
),
content_type=content_type, # type: ignore
encoding=encoding,
proto=proto, # type: ignore
allow_pickle=allow_pickle,
),
)
[docs] @classmethod
def parse_raw(
cls: Type[Model],
b: Union[bytes, str],
*,
content_type: Optional[str] = None, # pylint: disable=unused-argument
encoding: str = "utf8", # pylint: disable=unused-argument
proto: Optional[Protocol] = None, # pylint: disable=unused-argument
allow_pickle: bool = False, # pylint: disable=unused-argument
) -> Model:
"""Parse raw data."""
return cast("Model", cls.parse_obj(yaml.safe_load(b)))