fogies 0.0.0.dev2__tar.gz → 0.0.0.dev3__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.
- {fogies-0.0.0.dev2 → fogies-0.0.0.dev3}/PKG-INFO +1 -1
- {fogies-0.0.0.dev2 → fogies-0.0.0.dev3}/pyproject.toml +3 -4
- {fogies-0.0.0.dev2 → fogies-0.0.0.dev3}/src/fogies/tools/aws_environ.py +2 -1
- {fogies-0.0.0.dev2 → fogies-0.0.0.dev3}/src/fogies/tools/command.py +7 -7
- {fogies-0.0.0.dev2 → fogies-0.0.0.dev3}/src/fogies/tools/environ.py +2 -1
- {fogies-0.0.0.dev2 → fogies-0.0.0.dev3}/src/fogies/tools/ollama.py +52 -30
- {fogies-0.0.0.dev2 → fogies-0.0.0.dev3}/src/fogies/tools/terraform.py +445 -435
- {fogies-0.0.0.dev2 → fogies-0.0.0.dev3}/README.md +0 -0
- /fogies-0.0.0.dev2/paths.py → /fogies-0.0.0.dev3/fogies_paths.py +0 -0
- {fogies-0.0.0.dev2 → fogies-0.0.0.dev3}/src/fogies/__init__.py +0 -0
- {fogies-0.0.0.dev2 → fogies-0.0.0.dev3}/src/fogies/tasks/__init__.py +0 -0
- {fogies-0.0.0.dev2 → fogies-0.0.0.dev3}/src/fogies/tasks/format.py +0 -0
- {fogies-0.0.0.dev2 → fogies-0.0.0.dev3}/src/fogies/tasks/lint.py +0 -0
- {fogies-0.0.0.dev2 → fogies-0.0.0.dev3}/src/fogies/tasks/poetry.py +0 -0
- {fogies-0.0.0.dev2 → fogies-0.0.0.dev3}/src/fogies/tasks/test.py +0 -0
- {fogies-0.0.0.dev2 → fogies-0.0.0.dev3}/src/fogies/terraform/__init__.py +0 -0
- {fogies-0.0.0.dev2 → fogies-0.0.0.dev3}/src/fogies/terraform/backend.py +0 -0
- {fogies-0.0.0.dev2 → fogies-0.0.0.dev3}/src/fogies/tools/__init__.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fogies"
|
|
3
3
|
# Version follows semantic versioning <https://semver.org/>.
|
|
4
|
-
version = "0.0.0-dev.
|
|
4
|
+
version = "0.0.0-dev.3"
|
|
5
5
|
description = ""
|
|
6
6
|
authors = [
|
|
7
7
|
{name = "James Fogarty",email = "jayfo@users.noreply.github.com"}
|
|
@@ -12,7 +12,7 @@ requires-python = ">=3.14,<4.0"
|
|
|
12
12
|
[tool.poetry]
|
|
13
13
|
packages = [
|
|
14
14
|
{include = "fogies", from = "src"},
|
|
15
|
-
{include = "
|
|
15
|
+
{include = "fogies_paths.py", from = "."},
|
|
16
16
|
]
|
|
17
17
|
|
|
18
18
|
[tool.poetry.dependencies]
|
|
@@ -39,10 +39,9 @@ semver = "^3.0.4"
|
|
|
39
39
|
[tool.basedpyright]
|
|
40
40
|
typeCheckingMode = "recommended"
|
|
41
41
|
pythonVersion = "3.14"
|
|
42
|
-
include = ["
|
|
42
|
+
include = ["fogies_paths.py", "src", "tasks", "tests"]
|
|
43
43
|
allowedUntypedLibraries = ["invoke"]
|
|
44
44
|
|
|
45
45
|
[build-system]
|
|
46
46
|
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
47
47
|
build-backend = "poetry.core.masonry.api"
|
|
48
|
-
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import contextlib
|
|
5
6
|
import tomllib
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
@@ -57,7 +58,7 @@ def aws_environ(
|
|
|
57
58
|
*,
|
|
58
59
|
raise_if_exists: bool = True,
|
|
59
60
|
raise_if_changed: bool = True,
|
|
60
|
-
):
|
|
61
|
+
) -> contextlib.AbstractContextManager[None]:
|
|
61
62
|
"""Return a context manager that applies AWS variables from a TOML file.
|
|
62
63
|
|
|
63
64
|
The *profiles_path* parameter specifies the AWS TOML profiles file to read;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import contextlib
|
|
1
2
|
import dataclasses
|
|
2
3
|
import os
|
|
3
4
|
import pathlib
|
|
@@ -64,13 +65,12 @@ def command_run(
|
|
|
64
65
|
args_combined = [resolved_command] + (args or [])
|
|
65
66
|
command_str = " ".join(args_combined)
|
|
66
67
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
else:
|
|
68
|
+
cd_context = (
|
|
69
|
+
context.cd(str(command_params.cwd)) # pyright: ignore[reportUnknownMemberType]
|
|
70
|
+
if command_params.cwd is not None
|
|
71
|
+
else contextlib.nullcontext()
|
|
72
|
+
)
|
|
73
|
+
with cd_context:
|
|
74
74
|
result = context.run(command_str, in_stream=command_params.in_stream)
|
|
75
75
|
|
|
76
76
|
# invoke's Context.run() returns None when run with disown=True.
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import contextlib
|
|
5
6
|
import os
|
|
6
7
|
from collections.abc import Mapping
|
|
7
8
|
|
|
@@ -87,7 +88,7 @@ def environ(
|
|
|
87
88
|
*,
|
|
88
89
|
raise_if_exists: bool = True,
|
|
89
90
|
raise_if_changed: bool = True,
|
|
90
|
-
) ->
|
|
91
|
+
) -> contextlib.AbstractContextManager[None]:
|
|
91
92
|
"""Return a context manager that applies the given environment overrides.
|
|
92
93
|
|
|
93
94
|
The *variables* mapping provides environment variable names and string values
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import io
|
|
2
2
|
import pathlib
|
|
3
|
+
import shutil
|
|
3
4
|
import socket
|
|
4
5
|
import subprocess
|
|
5
6
|
import sys
|
|
@@ -38,7 +39,7 @@ class _PidWithCreateTime(BaseModel):
|
|
|
38
39
|
"""Persisted process identity state."""
|
|
39
40
|
|
|
40
41
|
pid: int
|
|
41
|
-
|
|
42
|
+
create_time_seconds: int
|
|
42
43
|
|
|
43
44
|
|
|
44
45
|
class _Ollama:
|
|
@@ -87,9 +88,7 @@ class _Ollama:
|
|
|
87
88
|
if not pid_path.exists():
|
|
88
89
|
return None
|
|
89
90
|
|
|
90
|
-
text = pid_path.read_text(encoding="utf-8")
|
|
91
|
-
assert text is not None
|
|
92
|
-
|
|
91
|
+
text = pid_path.read_text(encoding="utf-8")
|
|
93
92
|
state = _PidWithCreateTime.model_validate_json(text)
|
|
94
93
|
assert state.pid > 0
|
|
95
94
|
|
|
@@ -149,12 +148,18 @@ class _Ollama:
|
|
|
149
148
|
)
|
|
150
149
|
|
|
151
150
|
|
|
151
|
+
def _create_time_seconds(create_time: float) -> int:
|
|
152
|
+
"""Convert process create_time to truncated integer seconds."""
|
|
153
|
+
return int(create_time)
|
|
154
|
+
|
|
155
|
+
|
|
152
156
|
def _terminate_pid(*, pid: _PidWithCreateTime) -> None:
|
|
153
157
|
try:
|
|
154
158
|
process = psutil.Process(pid.pid)
|
|
155
159
|
|
|
156
160
|
# If the process has been restarted, the pid was reused, do not terminate it.
|
|
157
|
-
|
|
161
|
+
process_create_time_seconds = _create_time_seconds(process.create_time())
|
|
162
|
+
if process_create_time_seconds != pid.create_time_seconds:
|
|
158
163
|
return
|
|
159
164
|
|
|
160
165
|
children = process.children(recursive=True)
|
|
@@ -189,10 +194,22 @@ def _terminate_pid(*, pid: _PidWithCreateTime) -> None:
|
|
|
189
194
|
|
|
190
195
|
def _wait_until_listening(
|
|
191
196
|
*,
|
|
192
|
-
|
|
197
|
+
pid: _PidWithCreateTime,
|
|
198
|
+
timeout: float = _OLLAMA_LISTEN_PROBE_WAIT_TIMEOUT,
|
|
193
199
|
) -> None:
|
|
194
|
-
end = time.time() +
|
|
200
|
+
end = time.time() + timeout
|
|
195
201
|
while time.time() < end:
|
|
202
|
+
try:
|
|
203
|
+
proc = psutil.Process(pid.pid)
|
|
204
|
+
alive = (
|
|
205
|
+
proc.is_running()
|
|
206
|
+
and proc.status() != psutil.STATUS_ZOMBIE
|
|
207
|
+
and _create_time_seconds(proc.create_time()) == pid.create_time_seconds
|
|
208
|
+
)
|
|
209
|
+
except psutil.Error:
|
|
210
|
+
alive = False
|
|
211
|
+
if not alive:
|
|
212
|
+
raise RuntimeError("Ollama server process exited before becoming ready")
|
|
196
213
|
try:
|
|
197
214
|
with socket.create_connection(
|
|
198
215
|
_OLLAMA_LISTEN_ADDRESS,
|
|
@@ -202,7 +219,7 @@ def _wait_until_listening(
|
|
|
202
219
|
except OSError:
|
|
203
220
|
pass
|
|
204
221
|
time.sleep(_OLLAMA_LISTEN_POLL_INTERVAL)
|
|
205
|
-
raise TimeoutError("Ollama server did not become ready
|
|
222
|
+
raise TimeoutError("Ollama server did not become ready")
|
|
206
223
|
|
|
207
224
|
|
|
208
225
|
@contextmanager
|
|
@@ -236,19 +253,22 @@ def ollama(
|
|
|
236
253
|
|
|
237
254
|
dir_name = "ollama_{}".format(version.replace(".", "_"))
|
|
238
255
|
version_dir = binary_cache_path / dir_name
|
|
256
|
+
exe_path = version_dir / "ollama.exe"
|
|
239
257
|
|
|
240
|
-
if not
|
|
258
|
+
if not exe_path.exists():
|
|
241
259
|
version_dir.mkdir(parents=True, exist_ok=True)
|
|
260
|
+
try:
|
|
261
|
+
url = _OLLAMA_URL_TEMPLATE.format(version=version)
|
|
262
|
+
response = cast(HTTPResponse, urllib.request.urlopen(url))
|
|
263
|
+
with response:
|
|
264
|
+
zip_bytes: bytes = response.read()
|
|
242
265
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
|
249
|
-
zf.extractall(version_dir)
|
|
266
|
+
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
|
267
|
+
zf.extractall(version_dir)
|
|
268
|
+
except Exception:
|
|
269
|
+
shutil.rmtree(version_dir, ignore_errors=True)
|
|
270
|
+
raise
|
|
250
271
|
|
|
251
|
-
exe_path = version_dir / "ollama.exe"
|
|
252
272
|
if not exe_path.exists():
|
|
253
273
|
raise RuntimeError(
|
|
254
274
|
"Ollama executable 'ollama.exe' not found in '{}'".format(version_dir)
|
|
@@ -272,17 +292,16 @@ def ollama_client(
|
|
|
272
292
|
"""
|
|
273
293
|
with ollama(version=version, binary_cache_path=binary_cache_path) as ollama_binary:
|
|
274
294
|
with ollama_binary.lock():
|
|
275
|
-
running = False
|
|
276
|
-
|
|
277
295
|
# First check whether a process is already running.
|
|
278
|
-
|
|
279
|
-
if pid is not None
|
|
296
|
+
running = False
|
|
297
|
+
if ollama_binary.pid is not None:
|
|
280
298
|
try:
|
|
281
|
-
proc = psutil.Process(pid.pid)
|
|
299
|
+
proc = psutil.Process(ollama_binary.pid.pid)
|
|
282
300
|
running = (
|
|
283
301
|
proc.is_running()
|
|
284
302
|
and proc.status() != psutil.STATUS_ZOMBIE
|
|
285
|
-
and proc.create_time()
|
|
303
|
+
and _create_time_seconds(proc.create_time())
|
|
304
|
+
== ollama_binary.pid.create_time_seconds
|
|
286
305
|
)
|
|
287
306
|
except psutil.Error:
|
|
288
307
|
running = False
|
|
@@ -304,17 +323,22 @@ def ollama_client(
|
|
|
304
323
|
ollama_binary.set_pid(
|
|
305
324
|
_PidWithCreateTime(
|
|
306
325
|
pid=server_process.pid,
|
|
307
|
-
|
|
326
|
+
create_time_seconds=_create_time_seconds(
|
|
327
|
+
psutil.Process(server_process.pid).create_time()
|
|
328
|
+
),
|
|
308
329
|
)
|
|
309
330
|
)
|
|
310
331
|
|
|
332
|
+
# We now believe it is running.
|
|
333
|
+
assert ollama_binary.pid is not None
|
|
334
|
+
|
|
311
335
|
# Increment the refcount.
|
|
312
336
|
ollama_binary.set_refcount(ollama_binary.refcount + 1)
|
|
313
337
|
|
|
314
|
-
try:
|
|
315
338
|
# Wait until the server is listening.
|
|
316
|
-
_wait_until_listening()
|
|
339
|
+
_wait_until_listening(pid=ollama_binary.pid)
|
|
317
340
|
|
|
341
|
+
try:
|
|
318
342
|
# Create the client.
|
|
319
343
|
client = _ollama_client.Client()
|
|
320
344
|
yield client
|
|
@@ -322,8 +346,6 @@ def ollama_client(
|
|
|
322
346
|
with ollama_binary.lock():
|
|
323
347
|
ollama_binary.set_refcount(ollama_binary.refcount - 1)
|
|
324
348
|
|
|
325
|
-
if ollama_binary.refcount
|
|
326
|
-
pid
|
|
327
|
-
if pid is not None:
|
|
328
|
-
_terminate_pid(pid=pid)
|
|
349
|
+
if ollama_binary.refcount == 0:
|
|
350
|
+
_terminate_pid(pid=ollama_binary.pid)
|
|
329
351
|
ollama_binary.reset_state()
|
|
@@ -1,435 +1,445 @@
|
|
|
1
|
-
import dataclasses
|
|
2
|
-
import io
|
|
3
|
-
import json
|
|
4
|
-
import pathlib
|
|
5
|
-
import sys
|
|
6
|
-
import urllib.request
|
|
7
|
-
import zipfile
|
|
8
|
-
from collections.abc import Iterator
|
|
9
|
-
from contextlib import contextmanager
|
|
10
|
-
from http.client import HTTPResponse
|
|
11
|
-
from typing import TypeVar, cast
|
|
12
|
-
|
|
13
|
-
from invoke.runners import Result
|
|
14
|
-
from pydantic import BaseModel, RootModel
|
|
15
|
-
|
|
16
|
-
from fogies.terraform.backend import BackendOutput
|
|
17
|
-
from fogies.tools.command import CommandParams, command_run
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
@dataclasses.dataclass(frozen=True, slots=True)
|
|
21
|
-
class InitParams:
|
|
22
|
-
"""Params for terraform init."""
|
|
23
|
-
|
|
24
|
-
migrate_state: bool = False
|
|
25
|
-
reconfigure: bool = False
|
|
26
|
-
upgrade: bool = False
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
@dataclasses.dataclass(frozen=True, slots=True)
|
|
30
|
-
class ApplyParams:
|
|
31
|
-
"""Params for terraform apply."""
|
|
32
|
-
|
|
33
|
-
auto_approve: bool = False
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
@dataclasses.dataclass(frozen=True, slots=True)
|
|
37
|
-
class DestroyParams:
|
|
38
|
-
"""Params for terraform destroy."""
|
|
39
|
-
|
|
40
|
-
auto_approve: bool = False
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
_KNOWN_VERSIONS = [
|
|
44
|
-
"1.14.4",
|
|
45
|
-
]
|
|
46
|
-
|
|
47
|
-
_DEFAULT_VERSION = _KNOWN_VERSIONS[-1]
|
|
48
|
-
|
|
49
|
-
_TERRAFORM_URL_TEMPLATE = (
|
|
50
|
-
"https://releases.hashicorp.com/terraform"
|
|
51
|
-
"/{version}/terraform_{version}_windows_amd64.zip"
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
TerraformOutputModel = TypeVar("TerraformOutputModel", bound=BaseModel)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
class _TerraformCommandOutputEntryModel(BaseModel):
|
|
58
|
-
"""One output entry from `terraform output -json`, wrapping a value."""
|
|
59
|
-
|
|
60
|
-
value: object
|
|
61
|
-
type: object
|
|
62
|
-
sensitive: bool
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
class _TerraformCommandOutputModel(
|
|
66
|
-
RootModel[dict[str, _TerraformCommandOutputEntryModel]],
|
|
67
|
-
):
|
|
68
|
-
"""Root model for the full `terraform output -json` payload."""
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
@contextmanager
|
|
72
|
-
def terraform_tfbackend_s3(
|
|
73
|
-
*,
|
|
74
|
-
path: pathlib.Path,
|
|
75
|
-
backend: BackendOutput,
|
|
76
|
-
state: str,
|
|
77
|
-
delete_on_exit: bool = True,
|
|
78
|
-
) -> Iterator[pathlib.Path]:
|
|
79
|
-
"""Write S3 backend configuration to a file and yield the path.
|
|
80
|
-
|
|
81
|
-
The file is written as flat key/value entries, one per line, e.g.:
|
|
82
|
-
|
|
83
|
-
region = "us-west-2"
|
|
84
|
-
bucket = "pyfogies-test-backend-bucket"
|
|
85
|
-
key = "test-state-a/terraform.tfstate"
|
|
86
|
-
use_lockfile = true
|
|
87
|
-
"""
|
|
88
|
-
if path.suffixes[-2:] != [".s3", ".tfbackend"]:
|
|
89
|
-
raise ValueError("Path '{}' must end with '.s3.tfbackend'".format(path))
|
|
90
|
-
if state not in backend.state_keys:
|
|
91
|
-
raise ValueError(
|
|
92
|
-
"State '{}' is not declared as part of backend. Declared states: {}.".format(
|
|
93
|
-
state,
|
|
94
|
-
", ".join(sorted(backend.state_keys)),
|
|
95
|
-
)
|
|
96
|
-
)
|
|
97
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
-
with path.open("w") as f:
|
|
99
|
-
_ = f.write('region = "{}"\n'.format(backend.region))
|
|
100
|
-
_ = f.write('bucket = "{}"\n'.format(backend.bucket_name))
|
|
101
|
-
_ = f.write('key = "{}"\n'.format(backend.state_keys[state]))
|
|
102
|
-
_ = f.write("use_lockfile = true\n")
|
|
103
|
-
try:
|
|
104
|
-
yield path
|
|
105
|
-
finally:
|
|
106
|
-
if delete_on_exit and path.exists():
|
|
107
|
-
path.unlink()
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
@contextmanager
|
|
111
|
-
def terraform_tfvars(
|
|
112
|
-
*,
|
|
113
|
-
path: pathlib.Path,
|
|
114
|
-
variables: BaseModel,
|
|
115
|
-
delete_on_exit: bool = True,
|
|
116
|
-
) -> Iterator[pathlib.Path]:
|
|
117
|
-
"""Write in-memory variables to a file and yield the path for use with apply/destroy.
|
|
118
|
-
|
|
119
|
-
*path* is where the .tfvars.json file is written. *variables* must be a
|
|
120
|
-
Pydantic model; its fields are written as the Terraform variable set.
|
|
121
|
-
Yields *path* so the caller can pass it as the tfvars argument to apply()
|
|
122
|
-
or destroy(). If *delete_on_exit* is true, remove the file when exiting the
|
|
123
|
-
context.
|
|
124
|
-
"""
|
|
125
|
-
suffixes = path.suffixes
|
|
126
|
-
if suffixes[-2:] != [".tfvars", ".json"]:
|
|
127
|
-
raise ValueError("Path '{}' must end with '.tfvars.json'".format(path))
|
|
128
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
-
with path.open("w") as f:
|
|
130
|
-
json.dump(variables.model_dump(mode="json"), f, indent=2)
|
|
131
|
-
try:
|
|
132
|
-
yield path
|
|
133
|
-
finally:
|
|
134
|
-
if delete_on_exit and path.exists():
|
|
135
|
-
path.unlink()
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
class _Terraform:
|
|
139
|
-
"""Represents a Terraform binary."""
|
|
140
|
-
|
|
141
|
-
_version: str
|
|
142
|
-
_path: pathlib.Path
|
|
143
|
-
|
|
144
|
-
def __init__(self, *, version: str, path: pathlib.Path) -> None:
|
|
145
|
-
self._version = version
|
|
146
|
-
self._path = path
|
|
147
|
-
|
|
148
|
-
@property
|
|
149
|
-
def binary_version(self) -> str:
|
|
150
|
-
"""The Terraform binary version string."""
|
|
151
|
-
return self._version
|
|
152
|
-
|
|
153
|
-
@property
|
|
154
|
-
def binary_path(self) -> pathlib.Path:
|
|
155
|
-
"""The path to the Terraform executable."""
|
|
156
|
-
return self._path
|
|
157
|
-
|
|
158
|
-
def init(
|
|
159
|
-
self,
|
|
160
|
-
*,
|
|
161
|
-
command_params: CommandParams,
|
|
162
|
-
module_path: pathlib.Path,
|
|
163
|
-
tfbackend_path: pathlib.Path | None = None,
|
|
164
|
-
init_params: InitParams | None = None,
|
|
165
|
-
) -> Result:
|
|
166
|
-
"""Run terraform init.
|
|
167
|
-
|
|
168
|
-
*module_path* is the folder containing the Terraform files.
|
|
169
|
-
*init_params.migrate_state* when true passes -migrate-state to terraform init.
|
|
170
|
-
*init_params.reconfigure* when true passes -reconfigure to terraform init.
|
|
171
|
-
*init_params.upgrade* when true passes -upgrade to terraform init.
|
|
172
|
-
*tfbackend_path* when set passes -backend-config=<path> to terraform
|
|
173
|
-
init, where <path> is a backend configuration file.
|
|
174
|
-
"""
|
|
175
|
-
command_params = command_params.require_cwd(module_path)
|
|
176
|
-
if init_params is None:
|
|
177
|
-
init_params = InitParams()
|
|
178
|
-
init_args = ["init"]
|
|
179
|
-
if tfbackend_path is not None:
|
|
180
|
-
init_args.extend(["-backend-config", str(tfbackend_path)])
|
|
181
|
-
if init_params.migrate_state:
|
|
182
|
-
init_args.append("-migrate-state")
|
|
183
|
-
if init_params.reconfigure:
|
|
184
|
-
init_args.append("-reconfigure")
|
|
185
|
-
if init_params.upgrade:
|
|
186
|
-
init_args.append("-upgrade")
|
|
187
|
-
return command_run(
|
|
188
|
-
command=self.binary_path,
|
|
189
|
-
command_params=command_params,
|
|
190
|
-
args=init_args,
|
|
191
|
-
)
|
|
192
|
-
|
|
193
|
-
def apply(
|
|
194
|
-
self,
|
|
195
|
-
*,
|
|
196
|
-
command_params: CommandParams,
|
|
197
|
-
module_path: pathlib.Path,
|
|
198
|
-
tfvars_path: pathlib.Path | list[pathlib.Path] | None = None,
|
|
199
|
-
apply_params: ApplyParams | None = None,
|
|
200
|
-
) -> Result:
|
|
201
|
-
"""Run terraform apply.
|
|
202
|
-
|
|
203
|
-
*module_path* is the folder containing the Terraform files (used as
|
|
204
|
-
working directory). If *apply_params.auto_approve* is true, pass
|
|
205
|
-
-auto-approve. *tfvars_path* is optional; when set, pass -var-file for
|
|
206
|
-
each path.
|
|
207
|
-
"""
|
|
208
|
-
command_params = command_params.require_cwd(module_path)
|
|
209
|
-
if apply_params is None:
|
|
210
|
-
apply_params = ApplyParams()
|
|
211
|
-
|
|
212
|
-
apply_args = ["apply"]
|
|
213
|
-
if apply_params.auto_approve:
|
|
214
|
-
apply_args.append("-auto-approve")
|
|
215
|
-
if tfvars_path is not None:
|
|
216
|
-
paths = (
|
|
217
|
-
[tfvars_path] if isinstance(tfvars_path, pathlib.Path) else tfvars_path
|
|
218
|
-
)
|
|
219
|
-
for p in paths:
|
|
220
|
-
apply_args.extend(["-var-file", str(p)])
|
|
221
|
-
|
|
222
|
-
return command_run(
|
|
223
|
-
command=self.binary_path,
|
|
224
|
-
command_params=command_params,
|
|
225
|
-
args=apply_args,
|
|
226
|
-
)
|
|
227
|
-
|
|
228
|
-
def destroy(
|
|
229
|
-
self,
|
|
230
|
-
*,
|
|
231
|
-
command_params: CommandParams,
|
|
232
|
-
module_path: pathlib.Path,
|
|
233
|
-
tfvars_path: pathlib.Path | list[pathlib.Path] | None = None,
|
|
234
|
-
destroy_params: DestroyParams | None = None,
|
|
235
|
-
) -> Result:
|
|
236
|
-
"""Run terraform destroy.
|
|
237
|
-
|
|
238
|
-
*module_path* is the folder containing the Terraform files (used as
|
|
239
|
-
working directory). If *destroy_params.auto_approve* is true, pass
|
|
240
|
-
-auto-approve. *tfvars_path* is optional; when set, pass -var-file for
|
|
241
|
-
each path.
|
|
242
|
-
"""
|
|
243
|
-
command_params = command_params.require_cwd(module_path)
|
|
244
|
-
if destroy_params is None:
|
|
245
|
-
destroy_params = DestroyParams()
|
|
246
|
-
|
|
247
|
-
destroy_args = ["destroy"]
|
|
248
|
-
if destroy_params.auto_approve:
|
|
249
|
-
destroy_args.append("-auto-approve")
|
|
250
|
-
if tfvars_path is not None:
|
|
251
|
-
paths = (
|
|
252
|
-
[tfvars_path] if isinstance(tfvars_path, pathlib.Path) else tfvars_path
|
|
253
|
-
)
|
|
254
|
-
for p in paths:
|
|
255
|
-
destroy_args.extend(["-var-file", str(p)])
|
|
256
|
-
|
|
257
|
-
return command_run(
|
|
258
|
-
command=self.binary_path,
|
|
259
|
-
command_params=command_params,
|
|
260
|
-
args=destroy_args,
|
|
261
|
-
)
|
|
262
|
-
|
|
263
|
-
def output(
|
|
264
|
-
self,
|
|
265
|
-
*,
|
|
266
|
-
command_params: CommandParams,
|
|
267
|
-
module_path: pathlib.Path,
|
|
268
|
-
output_model: type[TerraformOutputModel],
|
|
269
|
-
) -> TerraformOutputModel:
|
|
270
|
-
"""Run terraform output -json and parse the result into a Pydantic model.
|
|
271
|
-
|
|
272
|
-
*module_path* is the folder containing the Terraform files (used as
|
|
273
|
-
working directory). *output_model* is the Pydantic BaseModel subclass
|
|
274
|
-
used to validate the outputs. The JSON produced by
|
|
275
|
-
`terraform output -json` is simplified to a mapping from output names
|
|
276
|
-
to their `value` fields before validation.
|
|
277
|
-
"""
|
|
278
|
-
command_params = command_params.require_cwd(module_path)
|
|
279
|
-
result = command_run(
|
|
280
|
-
command=self.binary_path,
|
|
281
|
-
command_params=command_params,
|
|
282
|
-
args=["output", "-json"],
|
|
283
|
-
)
|
|
284
|
-
|
|
285
|
-
parsed_terraform_output = _TerraformCommandOutputModel.model_validate_json(
|
|
286
|
-
result.stdout
|
|
287
|
-
)
|
|
288
|
-
recovered_values = {
|
|
289
|
-
name: entry.value for name, entry in parsed_terraform_output.root.items()
|
|
290
|
-
}
|
|
291
|
-
return output_model.model_validate(recovered_values)
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
@contextmanager
|
|
295
|
-
def terraform(
|
|
296
|
-
*,
|
|
297
|
-
version: str | None = None,
|
|
298
|
-
binary_cache_path: pathlib.Path,
|
|
299
|
-
command_params: CommandParams | None = None,
|
|
300
|
-
module_path: pathlib.Path | None = None,
|
|
301
|
-
tfbackend_path: pathlib.Path | None = None,
|
|
302
|
-
tfvars_path: pathlib.Path | list[pathlib.Path] | None = None,
|
|
303
|
-
init_on_entry: bool = False,
|
|
304
|
-
init_params: InitParams | None = None,
|
|
305
|
-
apply_on_entry: bool = False,
|
|
306
|
-
apply_params: ApplyParams | None = None,
|
|
307
|
-
delete_on_exit: bool = False,
|
|
308
|
-
destroy_params: DestroyParams | None = None,
|
|
309
|
-
) -> Iterator[_Terraform]:
|
|
310
|
-
"""Download a Terraform binary and yield a Terraform handle.
|
|
311
|
-
|
|
312
|
-
If *init_on_entry* is true, run init after preparing the binary; requires
|
|
313
|
-
*command_params* and *module_path*. If *apply_on_entry* is true, run
|
|
314
|
-
apply after init; requires *command_params* and *module_path*.
|
|
315
|
-
*tfbackend_path* is optional for init and, when set, is passed as
|
|
316
|
-
-backend-config=<path>. *tfvars_path* is optional for apply. If
|
|
317
|
-
*delete_on_exit* is true, run destroy when exiting the context; requires
|
|
318
|
-
*command_params* and *module_path*. *tfvars_path* is optional for destroy.
|
|
319
|
-
*version* when None uses the bundled default Terraform version.
|
|
320
|
-
"""
|
|
321
|
-
if sys.platform != "win32":
|
|
322
|
-
raise RuntimeError("Only implemented on Windows")
|
|
323
|
-
|
|
324
|
-
if version is None:
|
|
325
|
-
version = _DEFAULT_VERSION
|
|
326
|
-
|
|
327
|
-
if version not in _KNOWN_VERSIONS:
|
|
328
|
-
known = ", ".join(_KNOWN_VERSIONS)
|
|
329
|
-
raise ValueError(
|
|
330
|
-
"Unknown Terraform version '{}'; known versions: {}".format(
|
|
331
|
-
version,
|
|
332
|
-
known,
|
|
333
|
-
)
|
|
334
|
-
)
|
|
335
|
-
|
|
336
|
-
if init_on_entry and (command_params is None or module_path is None):
|
|
337
|
-
raise ValueError("init_on_entry requires command_params and module_path")
|
|
338
|
-
if apply_on_entry and (command_params is None or module_path is None):
|
|
339
|
-
raise ValueError("apply_on_entry requires command_params and module_path")
|
|
340
|
-
if delete_on_exit and (command_params is None or module_path is None):
|
|
341
|
-
raise ValueError("delete_on_exit requires command_params and module_path")
|
|
342
|
-
|
|
343
|
-
exe_name = "terraform_{}.exe".format(version.replace(".", "_"))
|
|
344
|
-
exe_path = binary_cache_path / exe_name
|
|
345
|
-
|
|
346
|
-
if not exe_path.exists():
|
|
347
|
-
binary_cache_path.mkdir(parents=True, exist_ok=True)
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
if
|
|
370
|
-
assert command_params is not None
|
|
371
|
-
assert module_path is not None
|
|
372
|
-
_ = tf.
|
|
373
|
-
command_params=command_params,
|
|
374
|
-
module_path=module_path,
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
)
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
1
|
+
import dataclasses
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import pathlib
|
|
5
|
+
import sys
|
|
6
|
+
import urllib.request
|
|
7
|
+
import zipfile
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from http.client import HTTPResponse
|
|
11
|
+
from typing import TypeVar, cast
|
|
12
|
+
|
|
13
|
+
from invoke.runners import Result
|
|
14
|
+
from pydantic import BaseModel, RootModel
|
|
15
|
+
|
|
16
|
+
from fogies.terraform.backend import BackendOutput
|
|
17
|
+
from fogies.tools.command import CommandParams, command_run
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
21
|
+
class InitParams:
|
|
22
|
+
"""Params for terraform init."""
|
|
23
|
+
|
|
24
|
+
migrate_state: bool = False
|
|
25
|
+
reconfigure: bool = False
|
|
26
|
+
upgrade: bool = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
30
|
+
class ApplyParams:
|
|
31
|
+
"""Params for terraform apply."""
|
|
32
|
+
|
|
33
|
+
auto_approve: bool = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
37
|
+
class DestroyParams:
|
|
38
|
+
"""Params for terraform destroy."""
|
|
39
|
+
|
|
40
|
+
auto_approve: bool = False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_KNOWN_VERSIONS = [
|
|
44
|
+
"1.14.4",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
_DEFAULT_VERSION = _KNOWN_VERSIONS[-1]
|
|
48
|
+
|
|
49
|
+
_TERRAFORM_URL_TEMPLATE = (
|
|
50
|
+
"https://releases.hashicorp.com/terraform"
|
|
51
|
+
"/{version}/terraform_{version}_windows_amd64.zip"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
TerraformOutputModel = TypeVar("TerraformOutputModel", bound=BaseModel)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _TerraformCommandOutputEntryModel(BaseModel):
|
|
58
|
+
"""One output entry from `terraform output -json`, wrapping a value."""
|
|
59
|
+
|
|
60
|
+
value: object
|
|
61
|
+
type: object
|
|
62
|
+
sensitive: bool
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class _TerraformCommandOutputModel(
|
|
66
|
+
RootModel[dict[str, _TerraformCommandOutputEntryModel]],
|
|
67
|
+
):
|
|
68
|
+
"""Root model for the full `terraform output -json` payload."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@contextmanager
|
|
72
|
+
def terraform_tfbackend_s3(
|
|
73
|
+
*,
|
|
74
|
+
path: pathlib.Path,
|
|
75
|
+
backend: BackendOutput,
|
|
76
|
+
state: str,
|
|
77
|
+
delete_on_exit: bool = True,
|
|
78
|
+
) -> Iterator[pathlib.Path]:
|
|
79
|
+
"""Write S3 backend configuration to a file and yield the path.
|
|
80
|
+
|
|
81
|
+
The file is written as flat key/value entries, one per line, e.g.:
|
|
82
|
+
|
|
83
|
+
region = "us-west-2"
|
|
84
|
+
bucket = "pyfogies-test-backend-bucket"
|
|
85
|
+
key = "test-state-a/terraform.tfstate"
|
|
86
|
+
use_lockfile = true
|
|
87
|
+
"""
|
|
88
|
+
if path.suffixes[-2:] != [".s3", ".tfbackend"]:
|
|
89
|
+
raise ValueError("Path '{}' must end with '.s3.tfbackend'".format(path))
|
|
90
|
+
if state not in backend.state_keys:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
"State '{}' is not declared as part of backend. Declared states: {}.".format(
|
|
93
|
+
state,
|
|
94
|
+
", ".join(sorted(backend.state_keys)),
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
with path.open("w", encoding="utf-8") as f:
|
|
99
|
+
_ = f.write('region = "{}"\n'.format(backend.region))
|
|
100
|
+
_ = f.write('bucket = "{}"\n'.format(backend.bucket_name))
|
|
101
|
+
_ = f.write('key = "{}"\n'.format(backend.state_keys[state]))
|
|
102
|
+
_ = f.write("use_lockfile = true\n")
|
|
103
|
+
try:
|
|
104
|
+
yield path
|
|
105
|
+
finally:
|
|
106
|
+
if delete_on_exit and path.exists():
|
|
107
|
+
path.unlink()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@contextmanager
|
|
111
|
+
def terraform_tfvars(
|
|
112
|
+
*,
|
|
113
|
+
path: pathlib.Path,
|
|
114
|
+
variables: BaseModel,
|
|
115
|
+
delete_on_exit: bool = True,
|
|
116
|
+
) -> Iterator[pathlib.Path]:
|
|
117
|
+
"""Write in-memory variables to a file and yield the path for use with apply/destroy.
|
|
118
|
+
|
|
119
|
+
*path* is where the .tfvars.json file is written. *variables* must be a
|
|
120
|
+
Pydantic model; its fields are written as the Terraform variable set.
|
|
121
|
+
Yields *path* so the caller can pass it as the tfvars argument to apply()
|
|
122
|
+
or destroy(). If *delete_on_exit* is true, remove the file when exiting the
|
|
123
|
+
context.
|
|
124
|
+
"""
|
|
125
|
+
suffixes = path.suffixes
|
|
126
|
+
if suffixes[-2:] != [".tfvars", ".json"]:
|
|
127
|
+
raise ValueError("Path '{}' must end with '.tfvars.json'".format(path))
|
|
128
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
with path.open("w", encoding="utf-8") as f:
|
|
130
|
+
json.dump(variables.model_dump(mode="json"), f, indent=2)
|
|
131
|
+
try:
|
|
132
|
+
yield path
|
|
133
|
+
finally:
|
|
134
|
+
if delete_on_exit and path.exists():
|
|
135
|
+
path.unlink()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class _Terraform:
|
|
139
|
+
"""Represents a Terraform binary."""
|
|
140
|
+
|
|
141
|
+
_version: str
|
|
142
|
+
_path: pathlib.Path
|
|
143
|
+
|
|
144
|
+
def __init__(self, *, version: str, path: pathlib.Path) -> None:
|
|
145
|
+
self._version = version
|
|
146
|
+
self._path = path
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def binary_version(self) -> str:
|
|
150
|
+
"""The Terraform binary version string."""
|
|
151
|
+
return self._version
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def binary_path(self) -> pathlib.Path:
|
|
155
|
+
"""The path to the Terraform executable."""
|
|
156
|
+
return self._path
|
|
157
|
+
|
|
158
|
+
def init(
|
|
159
|
+
self,
|
|
160
|
+
*,
|
|
161
|
+
command_params: CommandParams,
|
|
162
|
+
module_path: pathlib.Path,
|
|
163
|
+
tfbackend_path: pathlib.Path | None = None,
|
|
164
|
+
init_params: InitParams | None = None,
|
|
165
|
+
) -> Result:
|
|
166
|
+
"""Run terraform init.
|
|
167
|
+
|
|
168
|
+
*module_path* is the folder containing the Terraform files.
|
|
169
|
+
*init_params.migrate_state* when true passes -migrate-state to terraform init.
|
|
170
|
+
*init_params.reconfigure* when true passes -reconfigure to terraform init.
|
|
171
|
+
*init_params.upgrade* when true passes -upgrade to terraform init.
|
|
172
|
+
*tfbackend_path* when set passes -backend-config=<path> to terraform
|
|
173
|
+
init, where <path> is a backend configuration file.
|
|
174
|
+
"""
|
|
175
|
+
command_params = command_params.require_cwd(module_path)
|
|
176
|
+
if init_params is None:
|
|
177
|
+
init_params = InitParams()
|
|
178
|
+
init_args = ["init"]
|
|
179
|
+
if tfbackend_path is not None:
|
|
180
|
+
init_args.extend(["-backend-config", str(tfbackend_path)])
|
|
181
|
+
if init_params.migrate_state:
|
|
182
|
+
init_args.append("-migrate-state")
|
|
183
|
+
if init_params.reconfigure:
|
|
184
|
+
init_args.append("-reconfigure")
|
|
185
|
+
if init_params.upgrade:
|
|
186
|
+
init_args.append("-upgrade")
|
|
187
|
+
return command_run(
|
|
188
|
+
command=self.binary_path,
|
|
189
|
+
command_params=command_params,
|
|
190
|
+
args=init_args,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def apply(
|
|
194
|
+
self,
|
|
195
|
+
*,
|
|
196
|
+
command_params: CommandParams,
|
|
197
|
+
module_path: pathlib.Path,
|
|
198
|
+
tfvars_path: pathlib.Path | list[pathlib.Path] | None = None,
|
|
199
|
+
apply_params: ApplyParams | None = None,
|
|
200
|
+
) -> Result:
|
|
201
|
+
"""Run terraform apply.
|
|
202
|
+
|
|
203
|
+
*module_path* is the folder containing the Terraform files (used as
|
|
204
|
+
working directory). If *apply_params.auto_approve* is true, pass
|
|
205
|
+
-auto-approve. *tfvars_path* is optional; when set, pass -var-file for
|
|
206
|
+
each path.
|
|
207
|
+
"""
|
|
208
|
+
command_params = command_params.require_cwd(module_path)
|
|
209
|
+
if apply_params is None:
|
|
210
|
+
apply_params = ApplyParams()
|
|
211
|
+
|
|
212
|
+
apply_args = ["apply"]
|
|
213
|
+
if apply_params.auto_approve:
|
|
214
|
+
apply_args.append("-auto-approve")
|
|
215
|
+
if tfvars_path is not None:
|
|
216
|
+
paths = (
|
|
217
|
+
[tfvars_path] if isinstance(tfvars_path, pathlib.Path) else tfvars_path
|
|
218
|
+
)
|
|
219
|
+
for p in paths:
|
|
220
|
+
apply_args.extend(["-var-file", str(p)])
|
|
221
|
+
|
|
222
|
+
return command_run(
|
|
223
|
+
command=self.binary_path,
|
|
224
|
+
command_params=command_params,
|
|
225
|
+
args=apply_args,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def destroy(
|
|
229
|
+
self,
|
|
230
|
+
*,
|
|
231
|
+
command_params: CommandParams,
|
|
232
|
+
module_path: pathlib.Path,
|
|
233
|
+
tfvars_path: pathlib.Path | list[pathlib.Path] | None = None,
|
|
234
|
+
destroy_params: DestroyParams | None = None,
|
|
235
|
+
) -> Result:
|
|
236
|
+
"""Run terraform destroy.
|
|
237
|
+
|
|
238
|
+
*module_path* is the folder containing the Terraform files (used as
|
|
239
|
+
working directory). If *destroy_params.auto_approve* is true, pass
|
|
240
|
+
-auto-approve. *tfvars_path* is optional; when set, pass -var-file for
|
|
241
|
+
each path.
|
|
242
|
+
"""
|
|
243
|
+
command_params = command_params.require_cwd(module_path)
|
|
244
|
+
if destroy_params is None:
|
|
245
|
+
destroy_params = DestroyParams()
|
|
246
|
+
|
|
247
|
+
destroy_args = ["destroy"]
|
|
248
|
+
if destroy_params.auto_approve:
|
|
249
|
+
destroy_args.append("-auto-approve")
|
|
250
|
+
if tfvars_path is not None:
|
|
251
|
+
paths = (
|
|
252
|
+
[tfvars_path] if isinstance(tfvars_path, pathlib.Path) else tfvars_path
|
|
253
|
+
)
|
|
254
|
+
for p in paths:
|
|
255
|
+
destroy_args.extend(["-var-file", str(p)])
|
|
256
|
+
|
|
257
|
+
return command_run(
|
|
258
|
+
command=self.binary_path,
|
|
259
|
+
command_params=command_params,
|
|
260
|
+
args=destroy_args,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
def output(
|
|
264
|
+
self,
|
|
265
|
+
*,
|
|
266
|
+
command_params: CommandParams,
|
|
267
|
+
module_path: pathlib.Path,
|
|
268
|
+
output_model: type[TerraformOutputModel],
|
|
269
|
+
) -> TerraformOutputModel:
|
|
270
|
+
"""Run terraform output -json and parse the result into a Pydantic model.
|
|
271
|
+
|
|
272
|
+
*module_path* is the folder containing the Terraform files (used as
|
|
273
|
+
working directory). *output_model* is the Pydantic BaseModel subclass
|
|
274
|
+
used to validate the outputs. The JSON produced by
|
|
275
|
+
`terraform output -json` is simplified to a mapping from output names
|
|
276
|
+
to their `value` fields before validation.
|
|
277
|
+
"""
|
|
278
|
+
command_params = command_params.require_cwd(module_path)
|
|
279
|
+
result = command_run(
|
|
280
|
+
command=self.binary_path,
|
|
281
|
+
command_params=command_params,
|
|
282
|
+
args=["output", "-json"],
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
parsed_terraform_output = _TerraformCommandOutputModel.model_validate_json(
|
|
286
|
+
result.stdout
|
|
287
|
+
)
|
|
288
|
+
recovered_values = {
|
|
289
|
+
name: entry.value for name, entry in parsed_terraform_output.root.items()
|
|
290
|
+
}
|
|
291
|
+
return output_model.model_validate(recovered_values)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@contextmanager
|
|
295
|
+
def terraform(
|
|
296
|
+
*,
|
|
297
|
+
version: str | None = None,
|
|
298
|
+
binary_cache_path: pathlib.Path,
|
|
299
|
+
command_params: CommandParams | None = None,
|
|
300
|
+
module_path: pathlib.Path | None = None,
|
|
301
|
+
tfbackend_path: pathlib.Path | None = None,
|
|
302
|
+
tfvars_path: pathlib.Path | list[pathlib.Path] | None = None,
|
|
303
|
+
init_on_entry: bool = False,
|
|
304
|
+
init_params: InitParams | None = None,
|
|
305
|
+
apply_on_entry: bool = False,
|
|
306
|
+
apply_params: ApplyParams | None = None,
|
|
307
|
+
delete_on_exit: bool = False,
|
|
308
|
+
destroy_params: DestroyParams | None = None,
|
|
309
|
+
) -> Iterator[_Terraform]:
|
|
310
|
+
"""Download a Terraform binary and yield a Terraform handle.
|
|
311
|
+
|
|
312
|
+
If *init_on_entry* is true, run init after preparing the binary; requires
|
|
313
|
+
*command_params* and *module_path*. If *apply_on_entry* is true, run
|
|
314
|
+
apply after init; requires *command_params* and *module_path*.
|
|
315
|
+
*tfbackend_path* is optional for init and, when set, is passed as
|
|
316
|
+
-backend-config=<path>. *tfvars_path* is optional for apply. If
|
|
317
|
+
*delete_on_exit* is true, run destroy when exiting the context; requires
|
|
318
|
+
*command_params* and *module_path*. *tfvars_path* is optional for destroy.
|
|
319
|
+
*version* when None uses the bundled default Terraform version.
|
|
320
|
+
"""
|
|
321
|
+
if sys.platform != "win32":
|
|
322
|
+
raise RuntimeError("Only implemented on Windows")
|
|
323
|
+
|
|
324
|
+
if version is None:
|
|
325
|
+
version = _DEFAULT_VERSION
|
|
326
|
+
|
|
327
|
+
if version not in _KNOWN_VERSIONS:
|
|
328
|
+
known = ", ".join(_KNOWN_VERSIONS)
|
|
329
|
+
raise ValueError(
|
|
330
|
+
"Unknown Terraform version '{}'; known versions: {}".format(
|
|
331
|
+
version,
|
|
332
|
+
known,
|
|
333
|
+
)
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
if init_on_entry and (command_params is None or module_path is None):
|
|
337
|
+
raise ValueError("init_on_entry requires command_params and module_path")
|
|
338
|
+
if apply_on_entry and (command_params is None or module_path is None):
|
|
339
|
+
raise ValueError("apply_on_entry requires command_params and module_path")
|
|
340
|
+
if delete_on_exit and (command_params is None or module_path is None):
|
|
341
|
+
raise ValueError("delete_on_exit requires command_params and module_path")
|
|
342
|
+
|
|
343
|
+
exe_name = "terraform_{}.exe".format(version.replace(".", "_"))
|
|
344
|
+
exe_path = binary_cache_path / exe_name
|
|
345
|
+
|
|
346
|
+
if not exe_path.exists():
|
|
347
|
+
binary_cache_path.mkdir(parents=True, exist_ok=True)
|
|
348
|
+
try:
|
|
349
|
+
url = _TERRAFORM_URL_TEMPLATE.format(version=version)
|
|
350
|
+
response = cast(HTTPResponse, urllib.request.urlopen(url))
|
|
351
|
+
with response:
|
|
352
|
+
zip_bytes: bytes = response.read()
|
|
353
|
+
|
|
354
|
+
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
|
355
|
+
_ = exe_path.write_bytes(zf.read("terraform.exe"))
|
|
356
|
+
except Exception:
|
|
357
|
+
exe_path.unlink(missing_ok=True)
|
|
358
|
+
raise
|
|
359
|
+
|
|
360
|
+
if not exe_path.exists():
|
|
361
|
+
raise RuntimeError(
|
|
362
|
+
"Terraform executable 'terraform.exe' not found in '{}'".format(
|
|
363
|
+
binary_cache_path
|
|
364
|
+
)
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
tf = _Terraform(version=version, path=exe_path)
|
|
368
|
+
|
|
369
|
+
if init_on_entry:
|
|
370
|
+
assert command_params is not None
|
|
371
|
+
assert module_path is not None
|
|
372
|
+
_ = tf.init(
|
|
373
|
+
command_params=command_params,
|
|
374
|
+
module_path=module_path,
|
|
375
|
+
tfbackend_path=tfbackend_path,
|
|
376
|
+
init_params=init_params,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
if apply_on_entry:
|
|
380
|
+
assert command_params is not None
|
|
381
|
+
assert module_path is not None
|
|
382
|
+
_ = tf.apply(
|
|
383
|
+
command_params=command_params,
|
|
384
|
+
module_path=module_path,
|
|
385
|
+
tfvars_path=tfvars_path,
|
|
386
|
+
apply_params=apply_params,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
try:
|
|
390
|
+
yield tf
|
|
391
|
+
finally:
|
|
392
|
+
if delete_on_exit:
|
|
393
|
+
assert command_params is not None
|
|
394
|
+
assert module_path is not None
|
|
395
|
+
_ = tf.destroy(
|
|
396
|
+
command_params=command_params,
|
|
397
|
+
module_path=module_path,
|
|
398
|
+
tfvars_path=tfvars_path,
|
|
399
|
+
destroy_params=destroy_params,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@contextmanager
|
|
404
|
+
def terraform_output(
|
|
405
|
+
*,
|
|
406
|
+
version: str | None = None,
|
|
407
|
+
binary_cache_path: pathlib.Path,
|
|
408
|
+
command_params: CommandParams,
|
|
409
|
+
module_path: pathlib.Path,
|
|
410
|
+
tfbackend_path: pathlib.Path | None = None,
|
|
411
|
+
tfvars_path: pathlib.Path | list[pathlib.Path] | None = None,
|
|
412
|
+
init_on_entry: bool = False,
|
|
413
|
+
init_params: InitParams | None = None,
|
|
414
|
+
apply_on_entry: bool = False,
|
|
415
|
+
apply_params: ApplyParams | None = None,
|
|
416
|
+
delete_on_exit: bool = False,
|
|
417
|
+
destroy_params: DestroyParams | None = None,
|
|
418
|
+
output_model: type[TerraformOutputModel],
|
|
419
|
+
) -> Iterator[TerraformOutputModel]:
|
|
420
|
+
"""Run the terraform context manager, call output() internally, and yield the parsed result.
|
|
421
|
+
|
|
422
|
+
All entry/exit parameters are passed through to terraform(); the caller
|
|
423
|
+
sets *init_on_entry*, *apply_on_entry*, and *delete_on_exit* as needed.
|
|
424
|
+
Yields the output model; destroy runs on exit when *delete_on_exit* is true.
|
|
425
|
+
*version* when None uses the bundled default Terraform version.
|
|
426
|
+
"""
|
|
427
|
+
with terraform(
|
|
428
|
+
version=version,
|
|
429
|
+
binary_cache_path=binary_cache_path,
|
|
430
|
+
command_params=command_params,
|
|
431
|
+
module_path=module_path,
|
|
432
|
+
tfbackend_path=tfbackend_path,
|
|
433
|
+
tfvars_path=tfvars_path,
|
|
434
|
+
init_on_entry=init_on_entry,
|
|
435
|
+
init_params=init_params,
|
|
436
|
+
apply_on_entry=apply_on_entry,
|
|
437
|
+
apply_params=apply_params,
|
|
438
|
+
delete_on_exit=delete_on_exit,
|
|
439
|
+
destroy_params=destroy_params,
|
|
440
|
+
) as tf:
|
|
441
|
+
yield tf.output(
|
|
442
|
+
command_params=command_params,
|
|
443
|
+
module_path=module_path,
|
|
444
|
+
output_model=output_model,
|
|
445
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|