cfn-check 0.3.2__py3-none-any.whl → 0.8.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of cfn-check might be problematic. Click here for more details.

@@ -0,0 +1,10 @@
1
+ from pydantic import BaseModel, StrictStr, Field
2
+
3
+
4
+ class Config(BaseModel):
5
+ attributes: dict[StrictStr, StrictStr] | None = None
6
+ availability_zones: list[StrictStr] | None = None
7
+ import_values: dict[StrictStr, StrictStr] | None = None
8
+ mappings: dict[StrictStr, StrictStr] | None = None
9
+ parameters: dict[StrictStr, StrictStr] | None = None
10
+ references: dict[StrictStr, StrictStr] | None = None
@@ -0,0 +1,142 @@
1
+
2
+ from async_logging import LogLevelName, Logger, LoggingConfig
3
+ from cocoa.cli import CLI, YamlFile
4
+ from ruamel.yaml.comments import CommentedMap
5
+
6
+ from cfn_check.cli.utils.files import load_templates, write_to_file
7
+ from cfn_check.cli.utils.stdout import write_to_stdout
8
+ from cfn_check.rendering import Renderer
9
+ from cfn_check.logging.models import InfoLog
10
+ from .config import Config
11
+
12
+
13
+ @CLI.command(
14
+ shortnames={
15
+ 'availability-zones': 'z'
16
+ },
17
+ display_help_on_error=False,
18
+ )
19
+ async def render(
20
+ path: str,
21
+ config: YamlFile[Config] = 'config.yml',
22
+ output_file: str | None = None,
23
+ attributes: list[str] | None = None,
24
+ availability_zones: list[str] | None = None,
25
+ import_values: list[str] | None = None,
26
+ mappings: list[str] | None = None,
27
+ parameters: list[str] | None = None,
28
+ references: list[str] | None = None,
29
+ log_level: LogLevelName = 'info',
30
+ ):
31
+ """
32
+ Render a Cloud Formation template
33
+
34
+ @param attributes A list of <key>=<value> k/v strings for !GetAtt calls to use
35
+ @param availability-zones A list of <availability_zone> strings for !GetAZs calls to use
36
+ @param config A CFN-Check yaml config file
37
+ @param import-values A list of <filepath>=<export_value> k/v strings for !ImportValue
38
+ @param mappings A list of <key>=<value> k/v string specifying which Mappings to use
39
+ @param output-file Path to output the rendered CloudFormation template to
40
+ @param parameters A list of <key>=<value> k/v string for Parameters to use
41
+ @param references A list of <key>=<value> k/v string for !Ref values to use
42
+ @param log-level The log level to use
43
+ """
44
+
45
+ config_data = config.data
46
+ if config_data is None:
47
+ config_data = Config()
48
+
49
+ logging_config = LoggingConfig()
50
+ logging_config.update(
51
+ log_level=log_level,
52
+ log_output='stderr',
53
+ )
54
+
55
+ parsed_attributes: dict[str, str] | None = None
56
+ if attributes:
57
+ parsed_attributes = dict([
58
+ attribute.split('=', maxsplit=1) for attribute in attributes if len(attribute.split('=', maxsplit=1)) > 0
59
+ ])
60
+
61
+ parsed_attributes.update(
62
+ config_data.attributes or {}
63
+ )
64
+
65
+ parsed_import_values: dict[str, tuple[str, CommentedMap]] | None = None
66
+ if import_values:
67
+ parsed_import_value_pairs = dict([
68
+ import_value.split('=', maxsplit=1) for import_value in import_values if len(import_value.split('=', maxsplit=1)) > 0
69
+ ])
70
+
71
+ parsed_import_value_pairs.update(
72
+ config_data.import_values or {}
73
+ )
74
+
75
+ parsed_import_values = {}
76
+ for import_file, import_key in parsed_import_value_pairs:
77
+ parsed_import_values[import_file] = (
78
+ import_key,
79
+ await load_templates(import_file)
80
+ )
81
+
82
+ parsed_mappings: dict[str, str] | None = None
83
+ if mappings:
84
+ parsed_mappings = dict([
85
+ mapping.split('=', maxsplit=1) for mapping in mappings if len(mapping.split('=', maxsplit=1)) > 0
86
+ ])
87
+
88
+ parsed_mappings.update(
89
+ config_data.mappings or {}
90
+ )
91
+
92
+ parsed_parameters: dict[str, str] | None = None
93
+ if parameters:
94
+ parsed_parameters = dict([
95
+ parameter.split('=', maxsplit=1) for parameter in parameters if len(parameter.split('=', maxsplit=1)) > 0
96
+ ])
97
+
98
+ parsed_parameters.update(
99
+ config_data.parameters or {}
100
+ )
101
+
102
+ parsed_references: dict[str, str] | None = None
103
+ if references:
104
+ parsed_references = dict([
105
+ reference.split('=', maxsplit=1) for reference in references if len(reference.split('=', maxsplit=1)) > 0
106
+ ])
107
+
108
+ parsed_references.update(
109
+ config_data.references or {}
110
+ )
111
+
112
+ logger = Logger()
113
+
114
+ templates = await load_templates(
115
+ path,
116
+ )
117
+
118
+ assert len(templates) == 1 , '❌ Can only render one file'
119
+
120
+ _, template = templates[0]
121
+ renderer = Renderer()
122
+ rendered = renderer.render(
123
+ template,
124
+ attributes=parsed_attributes,
125
+ availability_zones=availability_zones,
126
+ mappings=parsed_mappings,
127
+ parameters=parsed_parameters,
128
+ references=parsed_references,
129
+ )
130
+
131
+ if output_file is False:
132
+ await write_to_file(output_file, rendered)
133
+ await logger.log(InfoLog(message=f'✅ {path} template rendered'))
134
+
135
+ else:
136
+ await write_to_stdout(rendered)
137
+
138
+ if config_data:
139
+ await write_to_file(
140
+ config.value,
141
+ config_data.model_dump(),
142
+ )
cfn_check/cli/root.py CHANGED
@@ -7,6 +7,7 @@ from cocoa.ui.components.terminal import Section, SectionConfig
7
7
  from cocoa.ui.components.header import Header, HeaderConfig
8
8
  from cocoa.ui.components.terminal import Terminal, EngineConfig
9
9
 
10
+ from .render import render
10
11
  from .validate import validate
11
12
 
12
13
  async def create_header(
@@ -47,7 +48,8 @@ async def create_header(
47
48
 
48
49
 
49
50
 
50
- @CLI.root(
51
+ @CLI.root(
52
+ render,
51
53
  validate,
52
54
  global_styles=CLIStyle(
53
55
  header=create_header,
@@ -4,7 +4,7 @@ from cfn_check.validation.validator import Validator
4
4
 
5
5
  def bind(
6
6
  rule_set: Collection,
7
- validation: Validator
7
+ validation: Validator,
8
8
  ):
9
9
  validation.func = validation.func.__get__(rule_set, rule_set.__class__)
10
10
  setattr(rule_set, validation.func.__name__, validation.func)
@@ -2,22 +2,24 @@
2
2
  import asyncio
3
3
  import os
4
4
  import pathlib
5
- import yaml
6
- from cfn_check.loader.loader import (
7
- Loader,
8
- create_tag,
9
- find_templates,
10
- )
5
+ from ruamel.yaml import YAML
11
6
  from cfn_check.shared.types import YamlObject, Data
12
7
 
13
- def open_template(path: str) -> YamlObject | None:
8
+
9
+ def find_templates(path, file_pattern):
10
+ return list(pathlib.Path(path).rglob(file_pattern))
11
+
12
+ def open_template(path: str) -> tuple[str, YamlObject] | None:
14
13
 
15
14
  if os.path.exists(path) is False:
16
15
  return None
17
16
 
18
17
  try:
19
- with open(path, 'r') as f:
20
- return yaml.load(f, Loader=Loader)
18
+ with open(path, 'r') as yml:
19
+ loader = YAML(typ='rt')
20
+ loader.preserve_quotes = True
21
+ loader.indent(mapping=2, sequence=4, offset=2)
22
+ return (path, loader.load(yml))
21
23
  except Exception as e:
22
24
  raise e
23
25
 
@@ -38,6 +40,16 @@ async def convert_to_cwd(loop: asyncio.AbstractEventLoop):
38
40
  os.getcwd,
39
41
  )
40
42
 
43
+ async def convert_to_absolute(path: str, loop: asyncio.AbstractEventLoop) -> str:
44
+ abspath = pathlib.Path(path)
45
+
46
+ return str(
47
+ await loop.run_in_executor(
48
+ None,
49
+ abspath.absolute,
50
+ )
51
+ )
52
+
41
53
  async def localize_path(path: str, loop: asyncio.AbstractEventLoop):
42
54
  localized = path.replace('~/', '')
43
55
 
@@ -55,7 +67,6 @@ async def localize_path(path: str, loop: asyncio.AbstractEventLoop):
55
67
 
56
68
  async def load_templates(
57
69
  path: str,
58
- tags: list[str],
59
70
  file_pattern: str | None = None,
60
71
  ):
61
72
 
@@ -89,17 +100,7 @@ async def load_templates(
89
100
 
90
101
  assert len(template_filepaths) > 0 , '❌ No matching files found'
91
102
 
92
- for tag in tags:
93
- new_tag = await loop.run_in_executor(
94
- None,
95
- create_tag,
96
- tag,
97
- )
98
-
99
- Loader.add_constructor(f'!{tag}', new_tag)
100
-
101
-
102
- templates: list[Data] = await asyncio.gather(*[
103
+ templates: list[tuple[str, Data]] = await asyncio.gather(*[
103
104
  loop.run_in_executor(
104
105
  None,
105
106
  open_template,
@@ -114,3 +115,27 @@ async def load_templates(
114
115
  assert len(found_templates) > 0, "❌ Could not open any templates"
115
116
 
116
117
  return templates
118
+
119
+
120
+ async def write_to_file(path: str, data: YamlObject):
121
+ loop = asyncio.get_event_loop()
122
+
123
+ if path.startswith('~/'):
124
+ path = await localize_path(path, loop)
125
+
126
+ output_path = await convert_to_absolute(path, loop)
127
+
128
+ await loop.run_in_executor(
129
+ None,
130
+ _write_to_file,
131
+ output_path,
132
+ data,
133
+ )
134
+
135
+ def _write_to_file(path: str, data: YamlObject):
136
+ dumper = YAML(typ='rt')
137
+ dumper.preserve_quotes = True
138
+ dumper.width = 4096
139
+ dumper.indent(mapping=2, sequence=4, offset=2)
140
+ with open(path, 'w') as yml:
141
+ dumper.dump(data, yml)
@@ -0,0 +1,18 @@
1
+ import asyncio
2
+ import sys
3
+ from ruamel.yaml import YAML
4
+ from ruamel.yaml.comments import CommentedBase
5
+
6
+ async def write_to_stdout(data: CommentedBase):
7
+ loop = asyncio.get_event_loop()
8
+
9
+ yaml = YAML(typ=['rt'])
10
+ yaml.preserve_quotes = True
11
+ yaml.width = 4096
12
+ yaml.indent(mapping=2, sequence=4, offset=2)
13
+ await loop.run_in_executor(
14
+ None,
15
+ yaml.dump,
16
+ data,
17
+ sys.stdout,
18
+ )
cfn_check/cli/validate.py CHANGED
@@ -1,49 +1,44 @@
1
1
  import inspect
2
2
 
3
3
  from async_logging import LogLevelName, Logger, LoggingConfig
4
- from cocoa.cli import CLI, ImportType
4
+ from cocoa.cli import CLI, ImportType, YamlFile
5
5
 
6
6
  from cfn_check.cli.utils.attributes import bind
7
- from cfn_check.cli.utils.files import load_templates
7
+ from cfn_check.cli.utils.files import load_templates, write_to_file
8
8
  from cfn_check.evaluation.validate import ValidationSet
9
9
  from cfn_check.logging.models import InfoLog
10
10
  from cfn_check.collection.collection import Collection
11
11
  from cfn_check.validation.validator import Validator
12
+ from .config import Config
12
13
 
13
14
 
14
- @CLI.command()
15
+ @CLI.command(
16
+ shortnames={
17
+ 'flags': 'F'
18
+ }
19
+ )
15
20
  async def validate(
16
21
  path: str,
22
+ config: YamlFile[Config] = 'config.yml',
17
23
  file_pattern: str | None = None,
18
24
  rules: ImportType[Collection] = None,
19
- tags: list[str] = [
20
- 'Ref',
21
- 'Sub',
22
- 'Join',
23
- 'Select',
24
- 'Split',
25
- 'GetAtt',
26
- 'GetAZs',
27
- 'ImportValue',
28
- 'Equals',
29
- 'If',
30
- 'Not',
31
- 'And',
32
- 'Or',
33
- 'Condition',
34
- 'FindInMap',
35
- ],
25
+ flags: list[str] | None = None,
36
26
  log_level: LogLevelName = 'info',
37
27
  ):
38
28
  '''
39
29
  Validate Cloud Foundation
40
30
 
41
- @param rules Path to a file containing Collections
31
+ @param config A CFN-Check yaml config file
32
+ @param disabled A list of string features to disable during checks
42
33
  @param file_pattern A string pattern used to find template files
43
- @param tags List of CloudFormation intrinsic function tags
34
+ @param rules Path to a file containing Collections
44
35
  @param log_level The log level to use
45
36
  '''
46
37
 
38
+ config_data = config.data
39
+ if config_data is None:
40
+ config_data = Config()
41
+
47
42
  logging_config = LoggingConfig()
48
43
  logging_config.update(
49
44
  log_level=log_level,
@@ -52,12 +47,19 @@ async def validate(
52
47
 
53
48
  logger = Logger()
54
49
 
50
+ if flags is None:
51
+ flags = []
52
+
55
53
  templates = await load_templates(
56
54
  path,
57
- tags,
58
55
  file_pattern=file_pattern,
59
56
  )
60
57
 
58
+ for file, data in templates:
59
+ for name, rule in rules.data.items():
60
+ rules.data[name] = rule()
61
+ rules.data[name].documents[file] = data
62
+
61
63
  validation_set = ValidationSet([
62
64
  bind(
63
65
  rule,
@@ -66,12 +68,19 @@ async def validate(
66
68
  for rule in rules.data.values()
67
69
  for _, validation in inspect.getmembers(rule)
68
70
  if isinstance(validation, Validator)
69
- ])
71
+ ], flags=flags)
70
72
 
71
- if validation_error := validation_set.validate(templates):
73
+ if validation_error := validation_set.validate([
74
+ template_data for _, template_data in templates
75
+ ]):
72
76
  raise validation_error
73
77
 
74
78
  templates_evaluated = len(templates)
75
79
 
76
80
  await logger.log(InfoLog(message=f'✅ {validation_set.count} validations met for {templates_evaluated} templates'))
77
-
81
+
82
+ if config_data:
83
+ await write_to_file(
84
+ config.value,
85
+ config_data.model_dump(),
86
+ )
@@ -1,2 +1,59 @@
1
+ from typing import Callable
2
+ from pydantic import ValidationError
3
+
4
+ from cfn_check.shared.types import Data
5
+ from cfn_check.evaluation.evaluator import Evaluator
6
+
1
7
  class Collection:
2
- pass
8
+
9
+ def __init__(self):
10
+ self.documents: dict[str, Data] = {}
11
+ self._evaluator = Evaluator()
12
+
13
+ def query(
14
+ self,
15
+ query: str,
16
+ document: str | None = None,
17
+ filters: list[Callable[[Data], Data]] | None = None
18
+ ) -> list[Data] | None:
19
+
20
+ if document and (
21
+ document_data := self.documents.get(document)
22
+ ):
23
+ return self._evaluator.match(
24
+ document_data,
25
+ query,
26
+ )
27
+
28
+ results: list[tuple[str, Data]] = []
29
+
30
+ for document_data in self.documents.values():
31
+ result = self._evaluator.match(
32
+ document_data,
33
+ query,
34
+ )
35
+
36
+ results.extend(result)
37
+
38
+ filtered: list[Data] = []
39
+ if filters:
40
+ try:
41
+ for _, found in results:
42
+ for filter in filters:
43
+ found = filter(found)
44
+
45
+ if found is None:
46
+ return
47
+
48
+ if found:
49
+ filtered.append(found)
50
+
51
+ return filtered
52
+
53
+ except ValidationError:
54
+ pass
55
+
56
+ return [
57
+ found for _, found in results
58
+ ]
59
+
@@ -1,5 +1,6 @@
1
1
  from collections import deque
2
- from typing import Deque
2
+ from typing import Deque, Any
3
+ from ruamel.yaml.comments import CommentedMap
3
4
 
4
5
  from cfn_check.shared.types import (
5
6
  Data,
@@ -7,19 +8,46 @@ from cfn_check.shared.types import (
7
8
  YamlObject,
8
9
  )
9
10
 
11
+ from cfn_check.rendering import Renderer
10
12
  from .parsing import QueryParser
11
13
 
12
14
  class Evaluator:
13
15
 
14
- def __init__(self):
16
+ def __init__(
17
+ self,
18
+ flags: list[str] | None = None
19
+ ):
20
+ if flags is None:
21
+ flags = []
22
+
23
+ self.flags = flags
15
24
  self._query_parser = QueryParser()
25
+ self._renderer = Renderer()
16
26
 
17
27
  def match(
18
28
  self,
19
29
  resources: YamlObject,
20
30
  path: str,
31
+ attributes: dict[str, Any] | None = None,
32
+ availability_zones: list[str] | None = None,
33
+ import_values: dict[str, tuple[str, CommentedMap]] | None = None,
34
+ mappings: dict[str, str] | None = None,
35
+ parameters: dict[str, Any] | None = None,
36
+ references: dict[str, str] | None = None,
21
37
  ):
22
38
  items: Items = deque()
39
+
40
+ if 'no-render' not in self.flags:
41
+ resources = self._renderer.render(
42
+ resources,
43
+ attributes=attributes,
44
+ availability_zones=availability_zones,
45
+ import_values=import_values,
46
+ mappings=mappings,
47
+ parameters=parameters,
48
+ references=references,
49
+ )
50
+
23
51
  items.append(resources)
24
52
 
25
53
  segments = path.split("::")[::-1]
@@ -47,7 +75,7 @@ class Evaluator:
47
75
 
48
76
  composite_keys = updated_keys
49
77
 
50
- assert len(composite_keys) == len(items), f'❌ {len(items)} returned for {len(composite_keys)} keys. Are you sure you used a range ([*]) selector?'
78
+ assert len(composite_keys) == len(items), f'❌ {len(items)} matches returned for {len(composite_keys)} keys. Are you sure you used a range ([*]) selector?'
51
79
 
52
80
  results: list[tuple[str, Data]] = []
53
81
  for idx, item in enumerate(list(items)):
@@ -258,7 +258,10 @@ class Token:
258
258
  return None, None
259
259
 
260
260
  if isinstance(node, dict):
261
- return ['*'], node.values()
261
+ return (
262
+ ['*' for _ in node],
263
+ node.values()
264
+ )
262
265
 
263
266
  elif isinstance(node, list):
264
267
  return (
@@ -1,6 +1,13 @@
1
+ from typing import Any
2
+
1
3
  from pydantic import ValidationError
4
+ from ruamel.yaml.comments import TaggedScalar, CommentedMap, CommentedSeq
2
5
 
3
6
  from cfn_check.validation.validator import Validator
7
+ from cfn_check.shared.types import (
8
+ YamlObject,
9
+ )
10
+
4
11
  from .errors import assemble_validation_error
5
12
  from .evaluator import Evaluator
6
13
 
@@ -9,10 +16,31 @@ class ValidationSet:
9
16
  def __init__(
10
17
  self,
11
18
  validators: list[Validator],
19
+ flags: list[str] | None = None,
20
+ attributes: dict[str, Any] | None = None,
21
+ availability_zones: list[str] | None = None,
22
+ import_values: dict[str, tuple[str, CommentedMap]] | None = None,
23
+ mappings: dict[str, str] | None = None,
24
+ parameters: dict[str, Any] | None = None,
25
+ references: dict[str, str] | None = None,
12
26
  ):
13
- self._evaluator = Evaluator()
27
+
28
+ if flags is None:
29
+ flags = []
30
+
31
+ self._evaluator = Evaluator(flags=flags)
14
32
  self._validators = validators
15
33
 
34
+ self._attributes: dict[str, str] | None = attributes
35
+ self._availability_zones: list[str] | None = availability_zones
36
+ self._mappings: dict[str, str] | None = mappings
37
+ self._import_values: dict[
38
+ str,
39
+ CommentedMap | CommentedSeq | TaggedScalar | YamlObject,
40
+ ] | None = import_values
41
+ self._parameters: dict[str, str] | None = parameters
42
+ self._references: dict[str, str] | None = references
43
+
16
44
  @property
17
45
  def count(self):
18
46
  return len(self._validators)
@@ -44,7 +72,10 @@ class ValidationSet:
44
72
  validator: Validator,
45
73
  template: str,
46
74
  ):
47
- found = self._evaluator.match(template, validator.query)
75
+ found = self._evaluator.match(
76
+ template,
77
+ validator.query,
78
+ )
48
79
 
49
80
  assert len(found) > 0, f"❌ No results matching results for query {validator.query}"
50
81
 
@@ -0,0 +1 @@
1
+ from .renderer import Renderer as Renderer
@@ -0,0 +1,66 @@
1
+ class IPv4CIDRSolver:
2
+
3
+ def __init__(
4
+ self,
5
+ host: str,
6
+ desired: int,
7
+ bits: int,
8
+ ):
9
+ self.host = host
10
+ self.subnets_desired = desired
11
+ self.subnet_bits = bits
12
+
13
+ host_ip, mask = self.host.split('/', maxsplit=1)
14
+
15
+ self.host_ip = host_ip
16
+ self._host_mask_string = f'/{mask}'
17
+ self.host_mask = int(mask)
18
+
19
+ self.subnet_mask = 32 - bits
20
+
21
+ self._host_octets = [
22
+ int(octet) for octet in self.host.strip(self._host_mask_string).split('.')
23
+ ]
24
+
25
+ def provision_subnets(self):
26
+ subnet_requested_ips = 2**self.subnet_bits
27
+ host_available_ips = 2**(32 - self.host_mask)
28
+
29
+ total_ips_requested = subnet_requested_ips * self.subnets_desired
30
+ if host_available_ips < total_ips_requested:
31
+ return []
32
+
33
+ return [
34
+ self._provision_subnet(
35
+ subnet_requested_ips,
36
+ idx
37
+ ) for idx in range(self.subnets_desired)
38
+ ]
39
+
40
+
41
+ def _provision_subnet(
42
+ self,
43
+ requested_ips: int,
44
+ idx: int,
45
+ ):
46
+ increment = requested_ips
47
+ octet_idx = -1
48
+ if requested_ips > 255:
49
+ increment /= 256
50
+ octet_idx -= 1
51
+
52
+ increment *= idx
53
+
54
+ subnet = list(self._host_octets)
55
+
56
+ subnet[octet_idx] += increment
57
+
58
+ subnet_base_ip = '.'.join([
59
+ str(octet) for octet in subnet
60
+ ])
61
+
62
+ return f'{subnet_base_ip}/{self.subnet_mask}'
63
+
64
+
65
+
66
+