codeterrain-terraform 0.2.0__tar.gz
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.
- codeterrain_terraform-0.2.0/.gitignore +12 -0
- codeterrain_terraform-0.2.0/PKG-INFO +39 -0
- codeterrain_terraform-0.2.0/README.md +27 -0
- codeterrain_terraform-0.2.0/pyproject.toml +26 -0
- codeterrain_terraform-0.2.0/src/codeterrain_terraform/__init__.py +470 -0
- codeterrain_terraform-0.2.0/tests/test_terraform.py +519 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: codeterrain-terraform
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Terraform for codeterrain: resources, modules, providers and references of .tf files in terrain def and refs.
|
|
5
|
+
Project-URL: Homepage, https://github.com/benchli-sports/codeterrain
|
|
6
|
+
Project-URL: Issues, https://github.com/benchli-sports/codeterrain/issues
|
|
7
|
+
Author: Mitbolas GmbH
|
|
8
|
+
License: Apache-2.0
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: codeterrain>=0.3
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# codeterrain-terraform
|
|
14
|
+
|
|
15
|
+
Terraform for [codeterrain](https://github.com/benchli-sports/codeterrain): the definitions and
|
|
16
|
+
references of `.tf` files, in `terrain def` and `terrain refs`.
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
uv tool install "<path-to-the-codeterrain-checkout>[terraform]"
|
|
20
|
+
terrain def logs # resource aws_s3_bucket.logs infra/storage.tf:12
|
|
21
|
+
terrain refs var.region # every expression that reads it, and the block it sits in
|
|
22
|
+
terrain def --kind module # every module call, with its file, source and version
|
|
23
|
+
terrain def --kind required_provider # every pinned provider and its version constraint
|
|
24
|
+
terrain refs provider.aws.east # the resources and module calls that select that provider
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Not published yet: install from a checkout of the codeterrain repository, where this pack lives under
|
|
28
|
+
`packages/`. Once published, `pip install "codeterrain[terraform]"` will do the same.
|
|
29
|
+
`terrain doctor` lists the installed packs (`language packs: terraform 0.2.0`).
|
|
30
|
+
|
|
31
|
+
Names follow Terraform's own references: `aws_s3_bucket.logs`, `data.aws_ami.base`, `module.vpc`,
|
|
32
|
+
`var.region`, `local.tags`, `output.url`, and `provider.aws` for both an entry of
|
|
33
|
+
`required_providers` (kind `required_provider`) and a `provider "aws"` block (kind `provider`;
|
|
34
|
+
`provider.aws.east` with `alias = "east"`). A module call's doc is its `source` and `version`, as
|
|
35
|
+
is a required provider's. A resource's `provider = aws.east` and a module's
|
|
36
|
+
`providers = { aws = aws.east }` are references to `provider.aws.east`. References are found in attribute values and inside string
|
|
37
|
+
and heredoc templates. The reader is a tokenizer, not an evaluator: it follows no module source,
|
|
38
|
+
expands no `count` or `for_each` and reads no `.tfvars`. A resource that selects no provider uses
|
|
39
|
+
its default one implicitly; that is not listed as a reference. `.tf.json` files are not read.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# codeterrain-terraform
|
|
2
|
+
|
|
3
|
+
Terraform for [codeterrain](https://github.com/benchli-sports/codeterrain): the definitions and
|
|
4
|
+
references of `.tf` files, in `terrain def` and `terrain refs`.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
uv tool install "<path-to-the-codeterrain-checkout>[terraform]"
|
|
8
|
+
terrain def logs # resource aws_s3_bucket.logs infra/storage.tf:12
|
|
9
|
+
terrain refs var.region # every expression that reads it, and the block it sits in
|
|
10
|
+
terrain def --kind module # every module call, with its file, source and version
|
|
11
|
+
terrain def --kind required_provider # every pinned provider and its version constraint
|
|
12
|
+
terrain refs provider.aws.east # the resources and module calls that select that provider
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Not published yet: install from a checkout of the codeterrain repository, where this pack lives under
|
|
16
|
+
`packages/`. Once published, `pip install "codeterrain[terraform]"` will do the same.
|
|
17
|
+
`terrain doctor` lists the installed packs (`language packs: terraform 0.2.0`).
|
|
18
|
+
|
|
19
|
+
Names follow Terraform's own references: `aws_s3_bucket.logs`, `data.aws_ami.base`, `module.vpc`,
|
|
20
|
+
`var.region`, `local.tags`, `output.url`, and `provider.aws` for both an entry of
|
|
21
|
+
`required_providers` (kind `required_provider`) and a `provider "aws"` block (kind `provider`;
|
|
22
|
+
`provider.aws.east` with `alias = "east"`). A module call's doc is its `source` and `version`, as
|
|
23
|
+
is a required provider's. A resource's `provider = aws.east` and a module's
|
|
24
|
+
`providers = { aws = aws.east }` are references to `provider.aws.east`. References are found in attribute values and inside string
|
|
25
|
+
and heredoc templates. The reader is a tokenizer, not an evaluator: it follows no module source,
|
|
26
|
+
expands no `count` or `for_each` and reads no `.tfvars`. A resource that selects no provider uses
|
|
27
|
+
its default one implicitly; that is not listed as a reference. `.tf.json` files are not read.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "codeterrain-terraform"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Terraform for codeterrain: resources, modules, providers and references of .tf files in terrain def and refs."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "Mitbolas GmbH" }]
|
|
13
|
+
dependencies = ["codeterrain>=0.3"]
|
|
14
|
+
|
|
15
|
+
[project.entry-points."codeterrain.languages"]
|
|
16
|
+
terraform = "codeterrain_terraform:language"
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://github.com/benchli-sports/codeterrain"
|
|
20
|
+
Issues = "https://github.com/benchli-sports/codeterrain/issues"
|
|
21
|
+
|
|
22
|
+
[tool.uv.sources]
|
|
23
|
+
codeterrain = { workspace = true }
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/codeterrain_terraform"]
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""Terraform for codeterrain: the definitions and references of ``.tf`` files.
|
|
2
|
+
|
|
3
|
+
Definitions are named the way Terraform code refers to them:
|
|
4
|
+
|
|
5
|
+
- ``resource "aws_s3_bucket" "logs"`` is ``aws_s3_bucket.logs``;
|
|
6
|
+
- ``data "aws_ami" "base"`` is ``data.aws_ami.base``;
|
|
7
|
+
- ``module "vpc"`` is ``module.vpc``, ``variable "region"`` is ``var.region``, an entry of a
|
|
8
|
+
``locals`` block is ``local.name`` and ``output "url"`` is ``output.url``;
|
|
9
|
+
- an entry of ``required_providers`` is ``provider.aws``, and ``provider "aws"`` is
|
|
10
|
+
``provider.aws`` too, or ``provider.aws.east`` with ``alias = "east"``.
|
|
11
|
+
|
|
12
|
+
A module call's doc is its ``source`` and ``version``, a required provider's the same; a
|
|
13
|
+
resource's ``provider = aws.east`` and a module's ``providers = { aws = aws.east }`` are
|
|
14
|
+
references to ``provider.aws.east``.
|
|
15
|
+
|
|
16
|
+
References are the expressions that name one of those (``var.region``, ``aws_s3_bucket.logs.arn``,
|
|
17
|
+
``module.vpc.id``), in attribute values and inside string and heredoc templates, with the
|
|
18
|
+
definition they sit in as their scope. The reader is a tokenizer, not an evaluator: it follows no
|
|
19
|
+
module source, expands no ``count`` or ``for_each`` and reads no ``.tfvars``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
import string
|
|
26
|
+
import sys
|
|
27
|
+
import textwrap
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
__version__ = version("codeterrain-terraform")
|
|
34
|
+
except PackageNotFoundError: # run from a source tree
|
|
35
|
+
__version__ = "0"
|
|
36
|
+
|
|
37
|
+
#: Labels each top-level block takes, and how its definition is named.
|
|
38
|
+
_BLOCKS = {"resource": 2, "data": 2, "module": 1, "variable": 1, "output": 1, "provider": 1}
|
|
39
|
+
_PREFIX = {"resource": "", "data": "data.", "module": "module.", "variable": "var.",
|
|
40
|
+
"output": "output.", "provider": "provider."}
|
|
41
|
+
#: Objects whose attributes are never a definition of this configuration.
|
|
42
|
+
_BUILTIN = frozenset({"each", "count", "path", "terraform", "self"})
|
|
43
|
+
#: A resource type: a provider name, ``_``, then the type (``aws_s3_bucket``, ``random_id``).
|
|
44
|
+
_RESOURCE_TYPE = re.compile(r"[a-z][a-z0-9]*_[a-z0-9_]+\Z")
|
|
45
|
+
#: A Terraform identifier: Unicode letters, digits, ``_`` and ``-``, not starting with a digit.
|
|
46
|
+
_IDENT = re.compile(r"[^\W\d][\w-]*")
|
|
47
|
+
#: Operators of more than one character, kept as one token so ``==`` is never read as ``=``.
|
|
48
|
+
_OPERATOR = re.compile(r"==|!=|<=|>=|=>|&&|\|\||\.\.\.|::")
|
|
49
|
+
_HEREDOC = re.compile(r"<<-?([^\W\d][\w-]*)[ \t]*\r?\n")
|
|
50
|
+
#: What a quoted string's single-character escapes stand for.
|
|
51
|
+
_ESCAPES = {"n": "\n", "r": "\r", "t": "\t", '"': '"', "\\": "\\"}
|
|
52
|
+
#: Deepest nesting of templates inside templates read before the file is given up.
|
|
53
|
+
MAX_NESTING = 64
|
|
54
|
+
MAX_DOC = 200
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TerraformError(ValueError):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Tok:
|
|
63
|
+
#: "ident", "str" (a literal string: its text), "punct", "mark" (a directive's ``%{`` or end)
|
|
64
|
+
kind: str
|
|
65
|
+
value: str
|
|
66
|
+
line: int
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _Lexer:
|
|
70
|
+
def __init__(self, src: str) -> None:
|
|
71
|
+
self.src = src
|
|
72
|
+
self.i = 0
|
|
73
|
+
self.line = 1
|
|
74
|
+
self.out: list[Tok] = []
|
|
75
|
+
|
|
76
|
+
def _advance(self, to: int) -> None:
|
|
77
|
+
self.line += self.src.count("\n", self.i, to)
|
|
78
|
+
self.i = to
|
|
79
|
+
|
|
80
|
+
def run(self) -> list[Tok]:
|
|
81
|
+
self._code(stop=None, depth=0)
|
|
82
|
+
return self.out
|
|
83
|
+
|
|
84
|
+
def _code(self, stop: str | None, depth: int) -> None:
|
|
85
|
+
"""Tokens up to the ``}`` that closes an interpolation (``stop``), or the end."""
|
|
86
|
+
if depth > MAX_NESTING:
|
|
87
|
+
raise TerraformError("templates nested too deeply")
|
|
88
|
+
src, braces = self.src, 0
|
|
89
|
+
while self.i < len(src):
|
|
90
|
+
c = src[self.i]
|
|
91
|
+
if c == "\n" or c in " \t\r":
|
|
92
|
+
self._advance(self.i + 1)
|
|
93
|
+
elif c == "#" or src.startswith("//", self.i):
|
|
94
|
+
end = src.find("\n", self.i)
|
|
95
|
+
self._advance(len(src) if end < 0 else end)
|
|
96
|
+
elif src.startswith("/*", self.i):
|
|
97
|
+
end = src.find("*/", self.i + 2)
|
|
98
|
+
self._advance(len(src) if end < 0 else end + 2)
|
|
99
|
+
elif c == '"':
|
|
100
|
+
self._string(depth)
|
|
101
|
+
elif src.startswith("<<", self.i) and (m := _HEREDOC.match(src, self.i)):
|
|
102
|
+
self._heredoc(m, depth)
|
|
103
|
+
elif m := _IDENT.match(src, self.i):
|
|
104
|
+
self.out.append(Tok("ident", m.group(), self.line))
|
|
105
|
+
self._advance(m.end())
|
|
106
|
+
elif m := _OPERATOR.match(src, self.i):
|
|
107
|
+
self.out.append(Tok("punct", m.group(), self.line))
|
|
108
|
+
self._advance(m.end())
|
|
109
|
+
elif c in "{[(":
|
|
110
|
+
braces += c == "{"
|
|
111
|
+
self.out.append(Tok("punct", c, self.line))
|
|
112
|
+
self._advance(self.i + 1)
|
|
113
|
+
elif c == "}" and stop is not None and braces == 0:
|
|
114
|
+
self._advance(self.i + 1)
|
|
115
|
+
return
|
|
116
|
+
else:
|
|
117
|
+
braces -= c == "}"
|
|
118
|
+
self.out.append(Tok("punct", c, self.line))
|
|
119
|
+
self._advance(self.i + 1)
|
|
120
|
+
|
|
121
|
+
def _template(self, end: int, depth: int, quoted: bool = True) -> str:
|
|
122
|
+
"""Read template text up to ``end`` (exclusive), or in a quoted string up to its closing
|
|
123
|
+
quote or the end of its line, lexing each ``${...}`` as code: the code lexer finds where
|
|
124
|
+
an interpolation ends, so its comments, strings and braces never end the template. The
|
|
125
|
+
literal text, for a block label. A backslash escapes only in a quoted string: a heredoc
|
|
126
|
+
keeps it as text."""
|
|
127
|
+
src, text = self.src, []
|
|
128
|
+
while self.i < end:
|
|
129
|
+
if quoted and src[self.i] in '"\n':
|
|
130
|
+
break
|
|
131
|
+
if src.startswith(("$${", "%%{"), self.i):
|
|
132
|
+
text.append(src[self.i + 1:self.i + 3])
|
|
133
|
+
self._advance(self.i + 3)
|
|
134
|
+
elif src.startswith(("${", "%{"), self.i):
|
|
135
|
+
directive = src[self.i] == "%"
|
|
136
|
+
if directive:
|
|
137
|
+
self.out.append(Tok("mark", "%{", self.line))
|
|
138
|
+
# a ``~`` strip marker (``%{~ endfor }``) is part of the opener, not code
|
|
139
|
+
self._advance(self.i + 2 + src.startswith("~", self.i + 2))
|
|
140
|
+
self._code(stop="}", depth=depth + 1)
|
|
141
|
+
if directive:
|
|
142
|
+
self.out.append(Tok("mark", "%}", self.line))
|
|
143
|
+
elif quoted and src[self.i] == "\\" and self.i + 1 < end:
|
|
144
|
+
text.append(self._escape())
|
|
145
|
+
else:
|
|
146
|
+
text.append(src[self.i])
|
|
147
|
+
self._advance(self.i + 1)
|
|
148
|
+
return "".join(text)
|
|
149
|
+
|
|
150
|
+
def _escape(self) -> str:
|
|
151
|
+
"""The character a quoted string's escape at ``self.i`` stands for (``\\n``, ``\\t``,
|
|
152
|
+
``\\u00e9``), moving past it; an unknown escape keeps the character after the backslash."""
|
|
153
|
+
src, i = self.src, self.i
|
|
154
|
+
kind = src[i + 1]
|
|
155
|
+
width = {"u": 4, "U": 8}.get(kind)
|
|
156
|
+
if width:
|
|
157
|
+
digits = src[i + 2:i + 2 + width]
|
|
158
|
+
if len(digits) == width and all(c in string.hexdigits for c in digits):
|
|
159
|
+
code = int(digits, 16)
|
|
160
|
+
if code <= sys.maxunicode and not 0xD800 <= code <= 0xDFFF: # no lone surrogate
|
|
161
|
+
self._advance(i + 2 + width)
|
|
162
|
+
return chr(code)
|
|
163
|
+
self._advance(i + 2)
|
|
164
|
+
return _ESCAPES.get(kind, kind)
|
|
165
|
+
|
|
166
|
+
def _string(self, depth: int) -> None:
|
|
167
|
+
line = self.line
|
|
168
|
+
self._advance(self.i + 1)
|
|
169
|
+
text = self._template(len(self.src), depth)
|
|
170
|
+
self.out.append(Tok("str", text, line))
|
|
171
|
+
if self.i < len(self.src) and self.src[self.i] == '"':
|
|
172
|
+
self._advance(self.i + 1)
|
|
173
|
+
|
|
174
|
+
def _heredoc(self, m: re.Match[str], depth: int) -> None:
|
|
175
|
+
line = self.line
|
|
176
|
+
self._advance(m.end())
|
|
177
|
+
close = re.compile(rf"^[ \t]*{re.escape(m.group(1))}[ \t]*\r?$", re.M)
|
|
178
|
+
found = close.search(self.src, self.i)
|
|
179
|
+
end = found.start() if found else len(self.src)
|
|
180
|
+
text = self._template(end, depth, quoted=False)
|
|
181
|
+
# the literal text, for a description: ``<<-`` strips the common indentation
|
|
182
|
+
self.out.append(Tok("str", textwrap.dedent(text).strip(), line))
|
|
183
|
+
if found:
|
|
184
|
+
self._advance(found.end())
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _punct(toks: list[Tok], i: int, value: str) -> bool:
|
|
188
|
+
"""Whether ``toks[i]`` is the punctuation ``value``: never a string's text that reads the same
|
|
189
|
+
(the ``.`` that ends ``"${var.domain}."``)."""
|
|
190
|
+
return 0 <= i < len(toks) and toks[i].kind == "punct" and toks[i].value == value
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _entry(toks: list[Tok], i: int) -> bool:
|
|
194
|
+
"""Whether ``toks[i]`` separates an object entry's key from its value: ``=`` or ``:``. A
|
|
195
|
+
block body's attributes take only ``=``."""
|
|
196
|
+
return _punct(toks, i, "=") or _punct(toks, i, ":")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _mark(toks: list[Tok], i: int, value: str) -> bool:
|
|
200
|
+
"""Whether ``toks[i]`` is where a template directive starts (``%{``) or ends (``%}``)."""
|
|
201
|
+
return 0 <= i < len(toks) and toks[i].kind == "mark" and toks[i].value == value
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _traversal(toks: list[Tok], i: int) -> list[str]:
|
|
205
|
+
"""The names of ``a.b.c`` starting at ``toks[i]``, up to an index or the end."""
|
|
206
|
+
parts = [toks[i].value]
|
|
207
|
+
j = i + 1
|
|
208
|
+
while j + 1 < len(toks) and _punct(toks, j, ".") and toks[j + 1].kind == "ident":
|
|
209
|
+
parts.append(toks[j + 1].value)
|
|
210
|
+
j += 2
|
|
211
|
+
return parts
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _iterators(toks: list[Tok]) -> frozenset[str]:
|
|
215
|
+
"""The iterator names of the file's ``dynamic`` blocks: the label (``dynamic "rule"`` gives
|
|
216
|
+
``rule.value``), or the ``iterator = name`` that replaces it, written directly in that
|
|
217
|
+
block's body."""
|
|
218
|
+
found = set()
|
|
219
|
+
depth = 0
|
|
220
|
+
bodies: list[int] = [] # the brace depth of each open dynamic block's body
|
|
221
|
+
for i, tok in enumerate(toks):
|
|
222
|
+
if tok.kind == "punct" and tok.value == "{":
|
|
223
|
+
depth += 1
|
|
224
|
+
elif tok.kind == "punct" and tok.value == "}":
|
|
225
|
+
depth -= 1
|
|
226
|
+
while bodies and bodies[-1] > depth:
|
|
227
|
+
bodies.pop()
|
|
228
|
+
elif i + 2 < len(toks) and tok.kind == "ident":
|
|
229
|
+
if tok.value == "dynamic" and toks[i + 1].kind == "str" and _punct(toks, i + 2, "{"):
|
|
230
|
+
found.add(toks[i + 1].value)
|
|
231
|
+
bodies.append(depth + 1)
|
|
232
|
+
elif (
|
|
233
|
+
tok.value == "iterator" and bodies and bodies[-1] == depth
|
|
234
|
+
and _punct(toks, i + 1, "=") and toks[i + 2].kind == "ident"
|
|
235
|
+
and not _punct(toks, i + 3, ".")
|
|
236
|
+
):
|
|
237
|
+
found.add(toks[i + 2].value)
|
|
238
|
+
return frozenset(found)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _bound(toks: list[Tok]) -> set[int]:
|
|
242
|
+
"""The indexes of the tokens that name a ``for`` binding rather than the configuration:
|
|
243
|
+
``local`` in ``[for local in var.items : local.name]`` reads the element. An expression's
|
|
244
|
+
names hold from its ``:`` to its closing bracket; a template directive's from the end of
|
|
245
|
+
``%{ for x in xs }`` to its ``%{ endfor }``."""
|
|
246
|
+
found: set[int] = set()
|
|
247
|
+
frames: list[set[str]] = [] # the names bound inside each open bracket
|
|
248
|
+
questions: list[int] = [] # the ``?`` of each open bracket still waiting for their ``:``
|
|
249
|
+
directives: list[set[str]] = []
|
|
250
|
+
# Each ``for`` whose collection is still being read: its names, its bracket depth (None: a
|
|
251
|
+
# directive) and the directive it opens (how many were open with it). A ``for`` nested in
|
|
252
|
+
# that collection stacks above it.
|
|
253
|
+
pending: list[tuple[set[str], int | None, int]] = []
|
|
254
|
+
open_marks = 0 # directives opened and not yet ended
|
|
255
|
+
for i, tok in enumerate(toks):
|
|
256
|
+
if tok.kind == "punct" and tok.value in "{[(":
|
|
257
|
+
frames.append(set())
|
|
258
|
+
questions.append(0)
|
|
259
|
+
elif tok.kind == "punct" and tok.value in "}])":
|
|
260
|
+
if frames:
|
|
261
|
+
closed = len(frames)
|
|
262
|
+
frames.pop()
|
|
263
|
+
questions.pop()
|
|
264
|
+
pending = [p for p in pending if p[1] is None or p[1] < closed]
|
|
265
|
+
elif tok.kind == "punct" and tok.value == "?" and questions:
|
|
266
|
+
questions[-1] += 1
|
|
267
|
+
elif tok.kind == "punct" and tok.value == ":" and questions and questions[-1]:
|
|
268
|
+
questions[-1] -= 1 # the ``:`` of a conditional
|
|
269
|
+
elif tok.kind == "punct" and tok.value == ":" and pending and pending[-1][1] == len(frames):
|
|
270
|
+
if frames:
|
|
271
|
+
frames[-1] |= pending[-1][0]
|
|
272
|
+
pending.pop()
|
|
273
|
+
elif _mark(toks, i, "%{"):
|
|
274
|
+
open_marks += 1
|
|
275
|
+
elif _mark(toks, i, "%}"):
|
|
276
|
+
# only the end of the ``for`` directive's own ``%{ ... }`` starts its binding
|
|
277
|
+
if pending and pending[-1][1] is None and pending[-1][2] == open_marks:
|
|
278
|
+
directives.append(pending.pop()[0])
|
|
279
|
+
open_marks = max(open_marks - 1, 0)
|
|
280
|
+
elif tok.kind == "ident" and tok.value == "endfor" and _mark(toks, i - 1, "%{"):
|
|
281
|
+
if directives:
|
|
282
|
+
directives.pop()
|
|
283
|
+
elif tok.kind == "ident" and tok.value == "for":
|
|
284
|
+
names, j = set(), i + 1
|
|
285
|
+
while j < len(toks) and toks[j].kind == "ident" and toks[j].value != "in":
|
|
286
|
+
names.add(toks[j].value)
|
|
287
|
+
j += 1 + _punct(toks, j + 1, ",")
|
|
288
|
+
if j < len(toks) and toks[j].kind == "ident" and toks[j].value == "in" and names:
|
|
289
|
+
if _punct(toks, i - 1, "{") or _punct(toks, i - 1, "["):
|
|
290
|
+
pending.append((names, len(frames), 0))
|
|
291
|
+
elif _mark(toks, i - 1, "%{"):
|
|
292
|
+
pending.append((names, None, open_marks))
|
|
293
|
+
elif (
|
|
294
|
+
tok.kind == "ident" and not _punct(toks, i - 1, ".")
|
|
295
|
+
and any(tok.value in names for names in (*frames, *directives))
|
|
296
|
+
):
|
|
297
|
+
found.add(i)
|
|
298
|
+
return found
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _reference(parts: list[str], iterators: frozenset[str] = frozenset()) -> str | None:
|
|
302
|
+
head = parts[0]
|
|
303
|
+
if head in iterators and parts[1] in ("key", "value"):
|
|
304
|
+
return None
|
|
305
|
+
if head in ("var", "local", "module") and len(parts) >= 2:
|
|
306
|
+
return f"{head}.{parts[1]}"
|
|
307
|
+
if head == "data" and len(parts) >= 3:
|
|
308
|
+
return f"data.{parts[1]}.{parts[2]}"
|
|
309
|
+
if head not in _BUILTIN and len(parts) >= 2 and _RESOURCE_TYPE.match(head):
|
|
310
|
+
return f"{head}.{parts[1]}"
|
|
311
|
+
return None
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def read(source: str) -> dict[str, Any]:
|
|
315
|
+
toks = _Lexer(source).run()
|
|
316
|
+
iterators = _iterators(toks)
|
|
317
|
+
bound = _bound(toks)
|
|
318
|
+
defs: list[dict[str, Any]] = []
|
|
319
|
+
refs: list[dict[str, Any]] = []
|
|
320
|
+
depth, nest, scope, kind = 0, 0, "", "" # nest: open ( and [ inside the block body
|
|
321
|
+
current: dict[str, Any] | None = None
|
|
322
|
+
#: ``source`` and ``version`` of a module or required provider, for its doc
|
|
323
|
+
settings: dict[int, dict[str, str]] = {}
|
|
324
|
+
first_ref = 0 # the first reference of the current block, renamed with a provider alias
|
|
325
|
+
required = 0 # the depth of the body of ``required_providers``, while inside it
|
|
326
|
+
wanted_provider: dict[str, Any] | None = None # the required provider being read
|
|
327
|
+
providers = 0 # the depth of the body of a module's ``providers`` map, while inside it
|
|
328
|
+
i = 0
|
|
329
|
+
while i < len(toks):
|
|
330
|
+
tok = toks[i]
|
|
331
|
+
if tok.kind == "punct":
|
|
332
|
+
if tok.value == "{":
|
|
333
|
+
depth += 1
|
|
334
|
+
elif tok.value == "}":
|
|
335
|
+
depth = max(depth - 1, 0)
|
|
336
|
+
if depth < required:
|
|
337
|
+
required = 0
|
|
338
|
+
if depth <= required:
|
|
339
|
+
wanted_provider = None
|
|
340
|
+
if depth < providers:
|
|
341
|
+
providers = 0
|
|
342
|
+
if depth == 0:
|
|
343
|
+
scope, kind, current, nest = "", "", None, 0
|
|
344
|
+
elif tok.value in "([":
|
|
345
|
+
nest += 1
|
|
346
|
+
elif tok.value in ")]":
|
|
347
|
+
nest = max(nest - 1, 0)
|
|
348
|
+
i += 1
|
|
349
|
+
continue
|
|
350
|
+
if depth == 0 and tok.kind == "ident":
|
|
351
|
+
labels = []
|
|
352
|
+
j = i + 1
|
|
353
|
+
while j < len(toks) and toks[j].kind in ("str", "ident"):
|
|
354
|
+
labels.append(toks[j].value)
|
|
355
|
+
j += 1
|
|
356
|
+
if _punct(toks, j, "{"):
|
|
357
|
+
kind = tok.value
|
|
358
|
+
wanted = _BLOCKS.get(kind)
|
|
359
|
+
if wanted is not None and len(labels) == wanted:
|
|
360
|
+
scope = _PREFIX[kind] + ".".join(labels)
|
|
361
|
+
current = {"name": scope, "kind": kind, "line": tok.line, "doc": ""}
|
|
362
|
+
defs.append(current)
|
|
363
|
+
else:
|
|
364
|
+
scope, current = "", None
|
|
365
|
+
first_ref = len(refs)
|
|
366
|
+
depth = 1
|
|
367
|
+
i = j + 1
|
|
368
|
+
continue
|
|
369
|
+
if (
|
|
370
|
+
kind == "terraform" and depth == 1 and nest == 0 and tok.kind == "ident"
|
|
371
|
+
and tok.value == "required_providers" and _punct(toks, i + 1, "{")
|
|
372
|
+
):
|
|
373
|
+
required = 2
|
|
374
|
+
i += 1
|
|
375
|
+
continue
|
|
376
|
+
# A required provider: ``aws = { source = "hashicorp/aws", version = "~> 5.0" }``, or
|
|
377
|
+
# the older ``aws = "~> 5.0"``.
|
|
378
|
+
if (
|
|
379
|
+
required and depth == required and nest == 0 and tok.kind == "ident"
|
|
380
|
+
and _punct(toks, i + 1, "=")
|
|
381
|
+
):
|
|
382
|
+
wanted_provider = {
|
|
383
|
+
"name": f"provider.{tok.value}", "kind": "required_provider", "line": tok.line,
|
|
384
|
+
"doc": "",
|
|
385
|
+
}
|
|
386
|
+
defs.append(wanted_provider)
|
|
387
|
+
if i + 2 < len(toks) and toks[i + 2].kind == "str":
|
|
388
|
+
settings[id(wanted_provider)] = {"version": toks[i + 2].value}
|
|
389
|
+
i += 3
|
|
390
|
+
else:
|
|
391
|
+
i += 2
|
|
392
|
+
continue
|
|
393
|
+
if (
|
|
394
|
+
wanted_provider is not None and depth == required + 1 and nest == 0
|
|
395
|
+
and tok.kind == "ident" and tok.value in ("source", "version")
|
|
396
|
+
and _entry(toks, i + 1) and i + 2 < len(toks) and toks[i + 2].kind == "str"
|
|
397
|
+
):
|
|
398
|
+
settings.setdefault(id(wanted_provider), {})[tok.value] = toks[i + 2].value
|
|
399
|
+
i += 3
|
|
400
|
+
continue
|
|
401
|
+
# The provider configurations of a module call: ``providers = { aws = aws.east }``.
|
|
402
|
+
if (
|
|
403
|
+
providers and depth == providers and tok.kind == "ident"
|
|
404
|
+
and _entry(toks, i - 1)
|
|
405
|
+
):
|
|
406
|
+
parts = _traversal(toks, i)
|
|
407
|
+
refs.append({"name": "provider." + ".".join(parts[:2]), "line": tok.line,
|
|
408
|
+
"scope": scope})
|
|
409
|
+
i += 2 * len(parts) - 1
|
|
410
|
+
continue
|
|
411
|
+
# An attribute: ``name =`` starting a line of the block body, outside brackets.
|
|
412
|
+
if (
|
|
413
|
+
tok.kind == "ident" and depth == 1 and nest == 0
|
|
414
|
+
and _punct(toks, i + 1, "=")
|
|
415
|
+
and (i == 0 or toks[i - 1].line < tok.line or _punct(toks, i - 1, "{"))
|
|
416
|
+
):
|
|
417
|
+
if kind == "locals":
|
|
418
|
+
scope = f"local.{tok.value}"
|
|
419
|
+
defs.append({"name": scope, "kind": "local", "line": tok.line, "doc": ""})
|
|
420
|
+
i += 2
|
|
421
|
+
continue
|
|
422
|
+
if (
|
|
423
|
+
current is not None and tok.value == "description" and i + 2 < len(toks)
|
|
424
|
+
and toks[i + 2].kind == "str"
|
|
425
|
+
):
|
|
426
|
+
current["doc"] = toks[i + 2].value[:MAX_DOC]
|
|
427
|
+
literal = toks[i + 2].value if i + 2 < len(toks) and toks[i + 2].kind == "str" else None
|
|
428
|
+
if current is not None and kind == "module" and tok.value in ("source", "version"):
|
|
429
|
+
if literal is not None:
|
|
430
|
+
settings.setdefault(id(current), {})[tok.value] = literal
|
|
431
|
+
elif current is not None and kind == "provider" and tok.value == "alias":
|
|
432
|
+
# ``provider "aws" { alias = "east" }`` is what ``provider = aws.east`` names
|
|
433
|
+
if literal is not None and _IDENT.fullmatch(literal):
|
|
434
|
+
scope = current["name"] = f"{current['name']}.{literal}"
|
|
435
|
+
for ref in refs[first_ref:]:
|
|
436
|
+
ref["scope"] = scope
|
|
437
|
+
elif kind in ("resource", "data") and tok.value == "provider" and i + 2 < len(toks):
|
|
438
|
+
if toks[i + 2].kind == "ident":
|
|
439
|
+
parts = _traversal(toks, i + 2)
|
|
440
|
+
refs.append({"name": "provider." + ".".join(parts[:2]), "line": tok.line,
|
|
441
|
+
"scope": scope})
|
|
442
|
+
i += 2 + 2 * len(parts) - 1
|
|
443
|
+
continue
|
|
444
|
+
elif kind == "module" and tok.value == "providers" and _punct(toks, i + 2, "{"):
|
|
445
|
+
providers = 2
|
|
446
|
+
if tok.kind == "ident" and not _punct(toks, i - 1, "."):
|
|
447
|
+
parts = _traversal(toks, i)
|
|
448
|
+
name = _reference(parts, iterators) if len(parts) > 1 and i not in bound else None
|
|
449
|
+
if name is not None:
|
|
450
|
+
refs.append({"name": name, "line": tok.line, "scope": scope})
|
|
451
|
+
i += max(1, 2 * len(parts) - 1)
|
|
452
|
+
continue
|
|
453
|
+
i += 1
|
|
454
|
+
for d in defs:
|
|
455
|
+
found = settings.get(id(d))
|
|
456
|
+
if found and not d["doc"]:
|
|
457
|
+
d["doc"] = ", ".join(
|
|
458
|
+
f"{key} {found[key]}" for key in ("source", "version") if key in found
|
|
459
|
+
)[:MAX_DOC]
|
|
460
|
+
return {"defs": defs, "refs": refs}
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
class _Language:
|
|
464
|
+
interface = 1
|
|
465
|
+
suffixes = (".tf",)
|
|
466
|
+
read = staticmethod(read)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
#: The entry point codeterrain loads (``codeterrain.languages``: ``terraform``).
|
|
470
|
+
language = _Language()
|
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from codeterrain_terraform import language, read
|
|
7
|
+
|
|
8
|
+
from codeterrain import languages
|
|
9
|
+
from codeterrain.cli import main
|
|
10
|
+
|
|
11
|
+
MAIN = '''\
|
|
12
|
+
# storage
|
|
13
|
+
variable "region" {
|
|
14
|
+
description = "Where everything runs"
|
|
15
|
+
default = "eu-central-1"
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
locals {
|
|
19
|
+
tags = { Team = "core", Region = var.region }
|
|
20
|
+
name = "logs-${var.region}"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
resource "aws_s3_bucket" "logs" {
|
|
24
|
+
bucket = local.name
|
|
25
|
+
tags = local.tags
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
data "aws_iam_policy_document" "read" {
|
|
29
|
+
statement {
|
|
30
|
+
resources = ["${aws_s3_bucket.logs.arn}/*"]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module "vpc" {
|
|
35
|
+
source = "./modules/vpc"
|
|
36
|
+
region = var.region
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
output "bucket" {
|
|
40
|
+
value = aws_s3_bucket.logs.id
|
|
41
|
+
}
|
|
42
|
+
'''
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _names(facts, key):
|
|
46
|
+
return [(f["name"], f["line"]) for f in facts[key]]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_definitions_are_named_the_way_terraform_refers_to_them():
|
|
50
|
+
facts = read(MAIN)
|
|
51
|
+
assert [(d["name"], d["kind"], d["line"]) for d in facts["defs"]] == [
|
|
52
|
+
("var.region", "variable", 2),
|
|
53
|
+
("local.tags", "local", 8),
|
|
54
|
+
("local.name", "local", 9),
|
|
55
|
+
("aws_s3_bucket.logs", "resource", 12),
|
|
56
|
+
("data.aws_iam_policy_document.read", "data", 17),
|
|
57
|
+
("module.vpc", "module", 23),
|
|
58
|
+
("output.bucket", "output", 28),
|
|
59
|
+
]
|
|
60
|
+
assert facts["defs"][0]["doc"] == "Where everything runs"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.mark.parametrize("opener", ["<<-", "<<"])
|
|
64
|
+
def test_a_heredoc_description_is_the_definition_doc(opener):
|
|
65
|
+
text = (
|
|
66
|
+
'variable "env" {\n'
|
|
67
|
+
f' description = {opener}EOT\n'
|
|
68
|
+
' The environment to deploy to.\n'
|
|
69
|
+
' One of dev or prod.\n'
|
|
70
|
+
' EOT\n'
|
|
71
|
+
' default = var.fallback\n'
|
|
72
|
+
'}\n'
|
|
73
|
+
)
|
|
74
|
+
facts = read(text)
|
|
75
|
+
assert facts["defs"][0]["doc"] == "The environment to deploy to.\nOne of dev or prod."
|
|
76
|
+
assert _names(facts, "refs") == [("var.fallback", 6)]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.mark.parametrize(("written", "doc"), [
|
|
80
|
+
(r"First line\nSecond line", "First line\nSecond line"),
|
|
81
|
+
(r"a\tb \"quoted\" back\\slash", 'a\tb "quoted" back\\slash'),
|
|
82
|
+
(r"café \U0001F600", "café \U0001F600"),
|
|
83
|
+
(r"short \u00e and \q", "short u00e and q"),
|
|
84
|
+
(r"surrogate \uD800", "surrogate uD800"),
|
|
85
|
+
])
|
|
86
|
+
def test_a_quoted_description_decodes_its_escapes(written, doc):
|
|
87
|
+
text = f'variable "env" {{\n description = "{written}"\n}}\n'
|
|
88
|
+
assert read(text)["defs"][0]["doc"] == doc
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_references_carry_the_definition_they_sit_in():
|
|
92
|
+
facts = read(MAIN)
|
|
93
|
+
assert [(r["name"], r["line"], r["scope"]) for r in facts["refs"]] == [
|
|
94
|
+
("var.region", 8, "local.tags"),
|
|
95
|
+
("var.region", 9, "local.name"),
|
|
96
|
+
("local.name", 13, "aws_s3_bucket.logs"),
|
|
97
|
+
("local.tags", 14, "aws_s3_bucket.logs"),
|
|
98
|
+
("aws_s3_bucket.logs", 19, "data.aws_iam_policy_document.read"),
|
|
99
|
+
("var.region", 25, "module.vpc"),
|
|
100
|
+
("aws_s3_bucket.logs", 29, "output.bucket"),
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@pytest.mark.parametrize("text", [
|
|
105
|
+
'value = "var.region is not read here"\n',
|
|
106
|
+
"# value = var.region\n",
|
|
107
|
+
"// value = var.region\n",
|
|
108
|
+
"/* value =\n var.region */\n",
|
|
109
|
+
'value = "$${var.region}"\n',
|
|
110
|
+
"value = each.value\nother = count.index\nmore = self.id\nlast = path.module\n",
|
|
111
|
+
])
|
|
112
|
+
def test_text_comments_escapes_and_builtins_are_not_references(text):
|
|
113
|
+
assert read(f'resource "null_resource" "x" {{\n{text}}}\n')["refs"] == []
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_heredoc_and_nested_templates_are_read():
|
|
117
|
+
text = '''\
|
|
118
|
+
resource "aws_instance" "web" {
|
|
119
|
+
user_data = <<-EOT
|
|
120
|
+
region=${var.region}
|
|
121
|
+
%{ if var.debug }debug=on%{ endif }
|
|
122
|
+
EOT
|
|
123
|
+
name = "${upper("${var.prefix}-web")}"
|
|
124
|
+
ami = data.aws_ami.base.id
|
|
125
|
+
subnet_id = module.vpc.subnets[0]
|
|
126
|
+
depends_on = [aws_iam_role.web]
|
|
127
|
+
}
|
|
128
|
+
'''
|
|
129
|
+
assert _names(read(text), "refs") == [
|
|
130
|
+
("var.region", 3), ("var.debug", 4), ("var.prefix", 6), ("data.aws_ami.base", 7),
|
|
131
|
+
("module.vpc", 8), ("aws_iam_role.web", 9),
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_a_dynamic_block_iterator_is_not_a_reference():
|
|
136
|
+
text = """resource "aws_lambda_alias" "live" {
|
|
137
|
+
dynamic "routing_config" {
|
|
138
|
+
for_each = var.weights
|
|
139
|
+
content {
|
|
140
|
+
weight = routing_config.value
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
dynamic "ingress_rule" {
|
|
144
|
+
for_each = var.rules
|
|
145
|
+
iterator = rule_item
|
|
146
|
+
content {
|
|
147
|
+
port = rule_item.value.port
|
|
148
|
+
key = rule_item.key
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
"""
|
|
153
|
+
assert _names(read(text), "refs") == [("var.weights", 3), ("var.rules", 9)]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_an_object_literal_inside_an_interpolation_does_not_end_the_string():
|
|
157
|
+
text = (
|
|
158
|
+
'locals { msg = "${lookup({ a = "ok" }, "a")}-suffix" }\n'
|
|
159
|
+
'output "env" { value = var.env }\n'
|
|
160
|
+
)
|
|
161
|
+
facts = read(text)
|
|
162
|
+
assert [d["name"] for d in facts["defs"]] == ["local.msg", "output.env"]
|
|
163
|
+
assert [(r["name"], r["scope"]) for r in facts["refs"]] == [("var.env", "output.env")]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_an_attribute_named_iterator_outside_a_dynamic_block_hides_nothing():
|
|
167
|
+
text = '''\
|
|
168
|
+
variable "value" {}
|
|
169
|
+
locals {
|
|
170
|
+
iterator = var.value
|
|
171
|
+
}
|
|
172
|
+
output "value" {
|
|
173
|
+
value = var.value
|
|
174
|
+
}
|
|
175
|
+
'''
|
|
176
|
+
assert _names(read(text), "refs") == [("var.value", 3), ("var.value", 6)]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_a_backslash_in_a_heredoc_does_not_escape_an_interpolation():
|
|
180
|
+
text = r'''output "env" {
|
|
181
|
+
value = <<EOT
|
|
182
|
+
C:\${var.env}
|
|
183
|
+
EOT
|
|
184
|
+
}
|
|
185
|
+
'''
|
|
186
|
+
assert _names(read(text), "refs") == [("var.env", 3)]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_a_comparison_in_an_expression_is_not_a_local():
|
|
190
|
+
text = '''\
|
|
191
|
+
variable "names" {}
|
|
192
|
+
locals {
|
|
193
|
+
selected = [for name in var.names : name if name == "prod"]
|
|
194
|
+
other = var.names != null ? (count >= 1) : {}
|
|
195
|
+
}
|
|
196
|
+
'''
|
|
197
|
+
facts = read(text)
|
|
198
|
+
assert [d["name"] for d in facts["defs"]] == ["var.names", "local.selected", "local.other"]
|
|
199
|
+
assert [(r["name"], r["scope"]) for r in facts["refs"]] == [
|
|
200
|
+
("var.names", "local.selected"), ("var.names", "local.other"),
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_a_key_inside_brackets_or_after_another_token_is_not_a_local():
|
|
205
|
+
text = '''\
|
|
206
|
+
locals {
|
|
207
|
+
tags = merge(var.base, { owner = var.owner })
|
|
208
|
+
list = [{ name = var.first }]
|
|
209
|
+
}
|
|
210
|
+
'''
|
|
211
|
+
assert [d["name"] for d in read(text)["defs"]] == ["local.tags", "local.list"]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@pytest.mark.parametrize("escaped", ["$${", "%%{"])
|
|
215
|
+
def test_an_escaped_template_opener_does_not_hide_the_closing_quote(escaped):
|
|
216
|
+
text = f'locals {{ marker = "{escaped}" }}\noutput "env" {{ value = "prod" }}\n'
|
|
217
|
+
assert [d["name"] for d in read(text)["defs"]] == ["local.marker", "output.env"]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_unicode_identifiers_keep_their_whole_names():
|
|
221
|
+
text = '''\
|
|
222
|
+
variable "région" {}
|
|
223
|
+
locals {
|
|
224
|
+
café = var.région
|
|
225
|
+
}
|
|
226
|
+
output "region" {
|
|
227
|
+
value = var.région
|
|
228
|
+
}
|
|
229
|
+
'''
|
|
230
|
+
facts = read(text)
|
|
231
|
+
assert [d["name"] for d in facts["defs"]] == ["var.région", "local.café", "output.region"]
|
|
232
|
+
assert _names(facts, "refs") == [("var.région", 3), ("var.région", 6)]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_a_for_binding_is_not_a_reference_to_the_configuration():
|
|
236
|
+
text = '''\
|
|
237
|
+
variable "items" {}
|
|
238
|
+
locals {
|
|
239
|
+
name = "global"
|
|
240
|
+
}
|
|
241
|
+
output "names" {
|
|
242
|
+
value = [for local in var.items : local.name]
|
|
243
|
+
}
|
|
244
|
+
output "pairs" {
|
|
245
|
+
value = {for var, module in local.map : var.id => module.vpc}
|
|
246
|
+
}
|
|
247
|
+
output "after" {
|
|
248
|
+
value = [local.name, var.items]
|
|
249
|
+
}
|
|
250
|
+
'''
|
|
251
|
+
assert _names(read(text), "refs") == [
|
|
252
|
+
("var.items", 6), ("local.map", 9), ("local.name", 12), ("var.items", 12),
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@pytest.mark.parametrize("value", [
|
|
257
|
+
"[for local in [for x in var.items : x] : local.name]",
|
|
258
|
+
"[for local in (var.on ? var.items : []) : local.name]",
|
|
259
|
+
"[for local in var.on ? var.items : [] : local.name]",
|
|
260
|
+
"{for local in {for k, v in var.map : k => v} : local.name => 1}",
|
|
261
|
+
])
|
|
262
|
+
def test_a_for_inside_another_for_collection_keeps_the_outer_binding(value):
|
|
263
|
+
text = f'output "names" {{\n value = {value}\n}}\n'
|
|
264
|
+
refs = [r["name"] for r in read(text)["refs"]]
|
|
265
|
+
assert "local.name" not in refs
|
|
266
|
+
assert "var.items" in refs or "var.map" in refs
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_a_template_for_directive_over_a_comprehension_binds_its_names():
|
|
270
|
+
text = '''\
|
|
271
|
+
output "list" {
|
|
272
|
+
value = <<EOT
|
|
273
|
+
%{ for local in [for x in var.items : x] }${local.name}%{ endfor }
|
|
274
|
+
EOT
|
|
275
|
+
}
|
|
276
|
+
'''
|
|
277
|
+
assert _names(read(text), "refs") == [("var.items", 3)]
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@pytest.mark.parametrize("body", ["${local.endfor}${local.name}", "${local.for}${local.name}"])
|
|
281
|
+
def test_only_a_directive_opens_or_closes_a_template_binding(body):
|
|
282
|
+
text = f'output "list" {{\n value = "%{{ for local in var.items }}{body}%{{ endfor }}"\n}}\n'
|
|
283
|
+
assert [r["name"] for r in read(text)["refs"]] == ["var.items"]
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@pytest.mark.parametrize("loop", [
|
|
287
|
+
"%{ for local in var.items }${local.name}%{~ endfor }",
|
|
288
|
+
"%{~ for local in var.items ~}${local.name}%{~ endfor ~}",
|
|
289
|
+
"%{~for local in var.items~}${~local.name~}%{~endfor~}",
|
|
290
|
+
])
|
|
291
|
+
def test_strip_markers_do_not_hide_a_directive(loop):
|
|
292
|
+
text = f'output "names" {{\n value = "{loop}${{local.name}}"\n}}\n'
|
|
293
|
+
assert [r["name"] for r in read(text)["refs"]] == ["var.items", "local.name"]
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def test_a_directive_inside_a_template_for_collection_does_not_start_its_binding():
|
|
297
|
+
# The inner `if` directive's end began the outer loop's binding early.
|
|
298
|
+
text = (
|
|
299
|
+
'output "names" {\n'
|
|
300
|
+
' value = "%{ for local in ["%{ if var.on }${local.name}%{ endif }"] }'
|
|
301
|
+
'${local.name}%{ endfor }"\n'
|
|
302
|
+
'}\n'
|
|
303
|
+
)
|
|
304
|
+
assert [r["name"] for r in read(text)["refs"]] == ["var.on", "local.name"]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def test_a_template_for_directive_binds_its_names_until_endfor():
|
|
308
|
+
text = '''\
|
|
309
|
+
output "list" {
|
|
310
|
+
value = <<EOT
|
|
311
|
+
%{ for local in var.items }${local.name}%{ endfor }
|
|
312
|
+
${local.name}
|
|
313
|
+
EOT
|
|
314
|
+
}
|
|
315
|
+
'''
|
|
316
|
+
assert _names(read(text), "refs") == [("var.items", 3), ("local.name", 4)]
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@pytest.mark.parametrize("inside", [
|
|
320
|
+
'/* quote: " */ var.env',
|
|
321
|
+
'var.env # quote: "\n',
|
|
322
|
+
'var.env // quote: "\n',
|
|
323
|
+
'lookup({ a = "}" }, "a", var.env)',
|
|
324
|
+
'join("", [<<EOT\n"\nEOT\n, var.env])',
|
|
325
|
+
])
|
|
326
|
+
def test_what_an_interpolation_contains_does_not_end_its_string(inside):
|
|
327
|
+
text = (
|
|
328
|
+
'variable "env" {}\n'
|
|
329
|
+
f'locals {{ msg = "${{ {inside} }}-suffix" }}\n'
|
|
330
|
+
'output "env" { value = var.env }\n'
|
|
331
|
+
)
|
|
332
|
+
facts = read(text)
|
|
333
|
+
assert [d["name"] for d in facts["defs"]] == ["var.env", "local.msg", "output.env"]
|
|
334
|
+
assert [(r["name"], r["scope"]) for r in facts["refs"]] == [
|
|
335
|
+
("var.env", "local.msg"), ("var.env", "output.env"),
|
|
336
|
+
]
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@pytest.mark.parametrize("value", ['"${var.domain}."', '"${var.domain}{"', '"${var.domain}="'])
|
|
340
|
+
def test_string_text_that_reads_like_punctuation_is_text(value):
|
|
341
|
+
text = f'''\
|
|
342
|
+
variable "domain" {{}}
|
|
343
|
+
locals {{
|
|
344
|
+
fqdn = {value}
|
|
345
|
+
other = var.domain
|
|
346
|
+
}}
|
|
347
|
+
'''
|
|
348
|
+
facts = read(text)
|
|
349
|
+
assert [d["name"] for d in facts["defs"]] == ["var.domain", "local.fqdn", "local.other"]
|
|
350
|
+
assert [(r["name"], r["scope"]) for r in facts["refs"]] == [
|
|
351
|
+
("var.domain", "local.fqdn"), ("var.domain", "local.other"),
|
|
352
|
+
]
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def test_an_attribute_after_an_index_is_not_a_new_reference():
|
|
356
|
+
text = 'output "first" {\n value = aws_instance.web[0].private_ip\n}\n'
|
|
357
|
+
assert _names(read(text), "refs") == [("aws_instance.web", 2)]
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def test_templates_nested_too_deeply_are_refused():
|
|
361
|
+
deep = "x = " + '"${' * 100 + "1" + '}"' * 100 + "\n"
|
|
362
|
+
with pytest.raises(ValueError, match="nested too deeply"):
|
|
363
|
+
read(deep)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
PROVIDERS = '''\
|
|
367
|
+
terraform {
|
|
368
|
+
required_providers {
|
|
369
|
+
aws = {
|
|
370
|
+
source = "hashicorp/aws"
|
|
371
|
+
version = "~> 5.0"
|
|
372
|
+
}
|
|
373
|
+
random = "~> 3.0"
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
provider "aws" {
|
|
378
|
+
region = var.region
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
provider "aws" {
|
|
382
|
+
region = var.east
|
|
383
|
+
alias = "east"
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
module "network" {
|
|
387
|
+
source = "terraform-aws-modules/vpc/aws"
|
|
388
|
+
version = "5.1.0"
|
|
389
|
+
providers = {
|
|
390
|
+
aws = aws.east
|
|
391
|
+
}
|
|
392
|
+
name = var.name
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
module "iam" {
|
|
396
|
+
source = "../../modules/iam"
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
resource "aws_s3_bucket" "logs" {
|
|
400
|
+
provider = aws.east
|
|
401
|
+
version = "not a module's"
|
|
402
|
+
bucket = local.name
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
data "aws_ami" "base" {
|
|
406
|
+
provider = aws
|
|
407
|
+
}
|
|
408
|
+
'''
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def test_providers_are_definitions_and_module_calls_carry_their_source():
|
|
412
|
+
facts = read(PROVIDERS)
|
|
413
|
+
assert [(d["name"], d["kind"], d["line"], d["doc"]) for d in facts["defs"]] == [
|
|
414
|
+
("provider.aws", "required_provider", 3, "source hashicorp/aws, version ~> 5.0"),
|
|
415
|
+
("provider.random", "required_provider", 7, "version ~> 3.0"),
|
|
416
|
+
("provider.aws", "provider", 11, ""),
|
|
417
|
+
("provider.aws.east", "provider", 15, ""),
|
|
418
|
+
("module.network", "module", 20, "source terraform-aws-modules/vpc/aws, version 5.1.0"),
|
|
419
|
+
("module.iam", "module", 29, "source ../../modules/iam"),
|
|
420
|
+
("aws_s3_bucket.logs", "resource", 33, ""),
|
|
421
|
+
("data.aws_ami.base", "data", 39, ""),
|
|
422
|
+
]
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def test_a_provider_alias_names_its_block_and_what_selects_it():
|
|
426
|
+
# An alias set after the block's first attribute still renames the references read before it.
|
|
427
|
+
refs = [(r["name"], r["line"], r["scope"]) for r in read(PROVIDERS)["refs"]]
|
|
428
|
+
assert refs == [
|
|
429
|
+
("var.region", 12, "provider.aws"),
|
|
430
|
+
("var.east", 16, "provider.aws.east"),
|
|
431
|
+
("provider.aws.east", 24, "module.network"),
|
|
432
|
+
("var.name", 26, "module.network"),
|
|
433
|
+
("provider.aws.east", 34, "aws_s3_bucket.logs"),
|
|
434
|
+
("local.name", 36, "aws_s3_bucket.logs"),
|
|
435
|
+
("provider.aws", 40, "data.aws_ami.base"),
|
|
436
|
+
]
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def test_object_entries_separated_by_a_colon_are_read():
|
|
440
|
+
facts = read(
|
|
441
|
+
'terraform {\n required_providers {\n'
|
|
442
|
+
' aws = { source: "hashicorp/aws", version: "5.0" }\n }\n}\n'
|
|
443
|
+
'module "m" {\n source = "./m"\n providers = { aws: aws.east }\n}\n'
|
|
444
|
+
)
|
|
445
|
+
assert facts["defs"][0]["doc"] == "source hashicorp/aws, version 5.0"
|
|
446
|
+
assert [(r["name"], r["scope"]) for r in facts["refs"]] == [("provider.aws.east", "module.m")]
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def test_terrain_def_kind_filters_before_the_match_limit(tmp_path, capsys):
|
|
450
|
+
# More same-named definitions of other kinds than the match limit never hide the one asked.
|
|
451
|
+
root = tmp_path / "repo"
|
|
452
|
+
root.mkdir()
|
|
453
|
+
blocks = [f'variable "v{n}" {{}}\noutput "x{n}" {{}}\n' for n in range(120)]
|
|
454
|
+
text = "".join(f'resource "aws_s3_bucket" "logs{n}" {{}}\n' for n in range(120))
|
|
455
|
+
(root / "a.tf").write_text(
|
|
456
|
+
text.replace('"logs0"', '"logs"') + "".join(blocks), encoding="utf-8"
|
|
457
|
+
)
|
|
458
|
+
(root / "b.tf").write_text(
|
|
459
|
+
"".join(f'resource "aws_instance{n}" "logs" {{}}\n' for n in range(120)), encoding="utf-8"
|
|
460
|
+
)
|
|
461
|
+
(root / "c.tf").write_text('module "logs" {}\n', encoding="utf-8")
|
|
462
|
+
argv = ["--root", str(root), "--cache-dir", str(tmp_path / "cache")]
|
|
463
|
+
assert main(["def", "logs", "--kind", "module", "--json", *argv]) == 0
|
|
464
|
+
rows = json.loads(capsys.readouterr().out)["rows"]
|
|
465
|
+
assert [r["name"] for r in rows] == ["module.logs"]
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def test_a_module_description_is_kept_over_its_source():
|
|
469
|
+
facts = read('module "m" {\n description = "shared"\n source = "./m"\n}\n')
|
|
470
|
+
assert facts["defs"][0]["doc"] == "shared"
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def test_terrain_def_lists_every_definition_of_a_kind(tmp_path, capsys):
|
|
474
|
+
root = tmp_path / "repo"
|
|
475
|
+
(root / "envs" / "dev").mkdir(parents=True)
|
|
476
|
+
(root / "envs" / "dev" / "main.tf").write_text(PROVIDERS, encoding="utf-8")
|
|
477
|
+
(root / "app.py").write_text("class Thing:\n pass\n", encoding="utf-8")
|
|
478
|
+
argv = ["--root", str(root), "--cache-dir", str(tmp_path / "cache")]
|
|
479
|
+
assert main(["def", "--kind", "module", "--json", *argv]) == 0
|
|
480
|
+
rows = json.loads(capsys.readouterr().out)["rows"]
|
|
481
|
+
assert [(r["name"], r["file"], r["doc"]) for r in rows] == [
|
|
482
|
+
("module.network", "envs/dev/main.tf",
|
|
483
|
+
"source terraform-aws-modules/vpc/aws, version 5.1.0"),
|
|
484
|
+
("module.iam", "envs/dev/main.tf", "source ../../modules/iam"),
|
|
485
|
+
]
|
|
486
|
+
assert main(["def", "aws", "--kind", "provider", "--json", *argv]) == 0
|
|
487
|
+
rows = json.loads(capsys.readouterr().out)["rows"]
|
|
488
|
+
assert [(r["name"], r["line"]) for r in rows] == [("provider.aws", 11)]
|
|
489
|
+
assert main(["def", "--kind", "class", "--json", *argv]) == 0
|
|
490
|
+
assert [r["name"] for r in json.loads(capsys.readouterr().out)["rows"]] == ["app.Thing"]
|
|
491
|
+
assert main(["def", "--kind", "nope", *argv]) == 1
|
|
492
|
+
err = capsys.readouterr().err
|
|
493
|
+
assert "no definition of kind nope; kinds here: class, data, module, provider," in err
|
|
494
|
+
assert main(["def", *argv]) == 2
|
|
495
|
+
assert main(["refs", "provider.aws.east", *argv]) == 0
|
|
496
|
+
assert "2 references" in capsys.readouterr().out
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def test_the_pack_answers_def_and_refs_through_terrain(tmp_path, capsys):
|
|
500
|
+
found, problems = languages.discover()
|
|
501
|
+
assert "terraform" in [lang.name for lang in found], problems
|
|
502
|
+
root = tmp_path / "repo"
|
|
503
|
+
(root / "infra").mkdir(parents=True)
|
|
504
|
+
(root / "infra" / "main.tf").write_text(MAIN, encoding="utf-8")
|
|
505
|
+
argv = ["--root", str(root), "--cache-dir", str(tmp_path / "cache")]
|
|
506
|
+
assert main(["def", "logs", "--json", *argv]) == 0
|
|
507
|
+
rows = json.loads(capsys.readouterr().out)["rows"]
|
|
508
|
+
assert [(r["name"], r["file"], r["line"], r["language"]) for r in rows] == [
|
|
509
|
+
("aws_s3_bucket.logs", "infra/main.tf", 12, "terraform"),
|
|
510
|
+
]
|
|
511
|
+
assert main(["refs", "var.region", *argv]) == 0
|
|
512
|
+
out = capsys.readouterr().out
|
|
513
|
+
assert "variable var.region infra/main.tf:2" in out
|
|
514
|
+
assert "3 references" in out
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def test_the_language_object_matches_the_interface():
|
|
518
|
+
assert language.interface == languages.INTERFACE
|
|
519
|
+
assert language.suffixes == (".tf",)
|