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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fogies
3
- Version: 0.0.0.dev2
3
+ Version: 0.0.0.dev3
4
4
  Summary:
5
5
  Author: James Fogarty
6
6
  Author-email: jayfo@users.noreply.github.com
@@ -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.2"
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 = "paths.py", from = "."},
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 = ["paths.py", "src", "tasks", "tests"]
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
- if command_params.cwd is not None:
68
- context_cd = context.cd( # pyright: ignore[reportUnknownMemberType]
69
- str(command_params.cwd)
70
- )
71
- with context_cd:
72
- result = context.run(command_str, in_stream=command_params.in_stream)
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
- ) -> _EnvironContext:
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
- create_time: float | None
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").strip()
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
- if pid.create_time is not None and process.create_time() != pid.create_time:
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
- timeout_s: float = _OLLAMA_LISTEN_PROBE_WAIT_TIMEOUT,
197
+ pid: _PidWithCreateTime,
198
+ timeout: float = _OLLAMA_LISTEN_PROBE_WAIT_TIMEOUT,
193
199
  ) -> None:
194
- end = time.time() + timeout_s
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 in time")
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 version_dir.exists():
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
- url = _OLLAMA_URL_TEMPLATE.format(version=version)
244
- response = cast(HTTPResponse, urllib.request.urlopen(url))
245
- with response:
246
- zip_bytes: bytes = response.read()
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
- pid = ollama_binary.pid
279
- if pid is not None and pid.create_time 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() == pid.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
- create_time=psutil.Process(server_process.pid).create_time(),
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 <= 0:
326
- pid = ollama_binary.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
- 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
-
357
- tf = _Terraform(version=version, path=exe_path)
358
-
359
- if init_on_entry:
360
- assert command_params is not None
361
- assert module_path is not None
362
- _ = tf.init(
363
- command_params=command_params,
364
- module_path=module_path,
365
- tfbackend_path=tfbackend_path,
366
- init_params=init_params,
367
- )
368
-
369
- if apply_on_entry:
370
- assert command_params is not None
371
- assert module_path is not None
372
- _ = tf.apply(
373
- command_params=command_params,
374
- module_path=module_path,
375
- tfvars_path=tfvars_path,
376
- apply_params=apply_params,
377
- )
378
-
379
- try:
380
- yield tf
381
- finally:
382
- if delete_on_exit:
383
- assert command_params is not None
384
- assert module_path is not None
385
- _ = tf.destroy(
386
- command_params=command_params,
387
- module_path=module_path,
388
- tfvars_path=tfvars_path,
389
- destroy_params=destroy_params,
390
- )
391
-
392
-
393
- @contextmanager
394
- def terraform_output(
395
- *,
396
- version: str | None = None,
397
- binary_cache_path: pathlib.Path,
398
- command_params: CommandParams,
399
- module_path: pathlib.Path,
400
- tfbackend_path: pathlib.Path | None = None,
401
- tfvars_path: pathlib.Path | list[pathlib.Path] | None = None,
402
- init_on_entry: bool = False,
403
- init_params: InitParams | None = None,
404
- apply_on_entry: bool = False,
405
- apply_params: ApplyParams | None = None,
406
- delete_on_exit: bool = False,
407
- destroy_params: DestroyParams | None = None,
408
- output_model: type[TerraformOutputModel],
409
- ) -> Iterator[TerraformOutputModel]:
410
- """Run the terraform context manager, call output() internally, and yield the parsed result.
411
-
412
- All entry/exit parameters are passed through to terraform(); the caller
413
- sets *init_on_entry*, *apply_on_entry*, and *delete_on_exit* as needed.
414
- Yields the output model; destroy runs on exit when *delete_on_exit* is true.
415
- *version* when None uses the bundled default Terraform version.
416
- """
417
- with terraform(
418
- version=version,
419
- binary_cache_path=binary_cache_path,
420
- command_params=command_params,
421
- module_path=module_path,
422
- tfbackend_path=tfbackend_path,
423
- tfvars_path=tfvars_path,
424
- init_on_entry=init_on_entry,
425
- init_params=init_params,
426
- apply_on_entry=apply_on_entry,
427
- apply_params=apply_params,
428
- delete_on_exit=delete_on_exit,
429
- destroy_params=destroy_params,
430
- ) as tf:
431
- yield tf.output(
432
- command_params=command_params,
433
- module_path=module_path,
434
- output_model=output_model,
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