fogies 0.0.0.dev1__tar.gz → 0.0.0.dev2__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.dev1 → fogies-0.0.0.dev2}/PKG-INFO +5 -1
- fogies-0.0.0.dev2/paths.py +21 -0
- {fogies-0.0.0.dev1 → fogies-0.0.0.dev2}/pyproject.toml +12 -3
- {fogies-0.0.0.dev1 → fogies-0.0.0.dev2}/src/fogies/tasks/test.py +5 -2
- fogies-0.0.0.dev2/src/fogies/terraform/__init__.py +0 -0
- fogies-0.0.0.dev2/src/fogies/terraform/backend.py +17 -0
- fogies-0.0.0.dev2/src/fogies/tools/__init__.py +0 -0
- fogies-0.0.0.dev2/src/fogies/tools/aws_environ.py +79 -0
- fogies-0.0.0.dev2/src/fogies/tools/command.py +78 -0
- fogies-0.0.0.dev2/src/fogies/tools/environ.py +107 -0
- fogies-0.0.0.dev2/src/fogies/tools/ollama.py +329 -0
- fogies-0.0.0.dev2/src/fogies/tools/terraform.py +435 -0
- {fogies-0.0.0.dev1 → fogies-0.0.0.dev2}/README.md +0 -0
- {fogies-0.0.0.dev1 → fogies-0.0.0.dev2}/src/fogies/__init__.py +0 -0
- {fogies-0.0.0.dev1 → fogies-0.0.0.dev2}/src/fogies/tasks/__init__.py +0 -0
- {fogies-0.0.0.dev1 → fogies-0.0.0.dev2}/src/fogies/tasks/format.py +0 -0
- {fogies-0.0.0.dev1 → fogies-0.0.0.dev2}/src/fogies/tasks/lint.py +0 -0
- {fogies-0.0.0.dev1 → fogies-0.0.0.dev2}/src/fogies/tasks/poetry.py +0 -0
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fogies
|
|
3
|
-
Version: 0.0.0.
|
|
3
|
+
Version: 0.0.0.dev2
|
|
4
4
|
Summary:
|
|
5
5
|
Author: James Fogarty
|
|
6
6
|
Author-email: jayfo@users.noreply.github.com
|
|
7
7
|
Requires-Python: >=3.14,<4.0
|
|
8
8
|
Classifier: Programming Language :: Python :: 3
|
|
9
9
|
Classifier: Programming Language :: Python :: 3.14
|
|
10
|
+
Requires-Dist: filelock (>=3.25.2,<4.0.0)
|
|
11
|
+
Requires-Dist: ollama (>=0.6.1,<0.7.0)
|
|
12
|
+
Requires-Dist: psutil (>=7.2.2,<8.0.0)
|
|
13
|
+
Requires-Dist: pydantic (>=2.12.5,<3.0.0)
|
|
10
14
|
Description-Content-Type: text/markdown
|
|
11
15
|
|
|
12
16
|
# pyfogies
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Paths used by tasks and tests in this development environment."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
# Secrets directory.
|
|
6
|
+
PATH_SECRETS = Path("secrets")
|
|
7
|
+
|
|
8
|
+
# Path to the AWS profile secrets configuration file.
|
|
9
|
+
PATH_SECRETS_AWS = PATH_SECRETS / "aws.toml"
|
|
10
|
+
|
|
11
|
+
# AWS profile name for pyfogies test environment.
|
|
12
|
+
AWS_PROFILE_PYFOGIES_TEST = "pyfogies-test"
|
|
13
|
+
|
|
14
|
+
# Path to the Poetry secrets configuration file.
|
|
15
|
+
PATH_SECRETS_POETRY = PATH_SECRETS / "poetry.toml"
|
|
16
|
+
|
|
17
|
+
# Staging directory.
|
|
18
|
+
PATH_STAGING = Path(".staging")
|
|
19
|
+
|
|
20
|
+
# Binary cache directory (inside staging).
|
|
21
|
+
PATH_STAGING_BINARY_CACHE = PATH_STAGING / "bin"
|
|
@@ -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.2"
|
|
5
5
|
description = ""
|
|
6
6
|
authors = [
|
|
7
7
|
{name = "James Fogarty",email = "jayfo@users.noreply.github.com"}
|
|
@@ -10,9 +10,16 @@ readme = "README.md"
|
|
|
10
10
|
requires-python = ">=3.14,<4.0"
|
|
11
11
|
|
|
12
12
|
[tool.poetry]
|
|
13
|
-
packages = [
|
|
13
|
+
packages = [
|
|
14
|
+
{include = "fogies", from = "src"},
|
|
15
|
+
{include = "paths.py", from = "."},
|
|
16
|
+
]
|
|
14
17
|
|
|
15
18
|
[tool.poetry.dependencies]
|
|
19
|
+
pydantic = "^2.12.5"
|
|
20
|
+
ollama = "^0.6.1"
|
|
21
|
+
filelock = "^3.25.2"
|
|
22
|
+
psutil = "^7.2.2"
|
|
16
23
|
|
|
17
24
|
[tool.poetry.group.dev.dependencies]
|
|
18
25
|
# ^ allows all minor and patch releases.
|
|
@@ -21,6 +28,8 @@ packages = [{include = "fogies", from = "src"}]
|
|
|
21
28
|
# Example: ~1.2.3 will match versions >=1.2.3 but <1.3.0.
|
|
22
29
|
basedpyright = "^1.36.1"
|
|
23
30
|
black = "~25.12.0" # Black does not semver.
|
|
31
|
+
boto3 = "^1.42.68"
|
|
32
|
+
boto3-stubs-full = "^1.42.68"
|
|
24
33
|
invoke = "^2.2.1"
|
|
25
34
|
isort = "^7.0.0"
|
|
26
35
|
poetry = "^2.2.1"
|
|
@@ -30,7 +39,7 @@ semver = "^3.0.4"
|
|
|
30
39
|
[tool.basedpyright]
|
|
31
40
|
typeCheckingMode = "recommended"
|
|
32
41
|
pythonVersion = "3.14"
|
|
33
|
-
include = ["src", "tasks", "tests"]
|
|
42
|
+
include = ["paths.py", "src", "tasks", "tests"]
|
|
34
43
|
allowedUntypedLibraries = ["invoke"]
|
|
35
44
|
|
|
36
45
|
[build-system]
|
|
@@ -10,7 +10,7 @@ from invoke.context import Context
|
|
|
10
10
|
from invoke.tasks import Task, task
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
def get_task_test() -> Task[Callable[[Context], None]]:
|
|
13
|
+
def get_task_test(path_tests: str | None = None) -> Task[Callable[[Context], None]]:
|
|
14
14
|
@task(name="test") # pyright: ignore[reportUntypedFunctionDecorator]
|
|
15
15
|
def task_test(context: Context) -> None:
|
|
16
16
|
"""
|
|
@@ -21,6 +21,9 @@ def get_task_test() -> Task[Callable[[Context], None]]:
|
|
|
21
21
|
env = os.environ.copy()
|
|
22
22
|
env["COLUMNS"] = str(shutil.get_terminal_size().columns)
|
|
23
23
|
|
|
24
|
+
# Default to current directory.
|
|
25
|
+
param_path_tests = path_tests if path_tests is not None else "."
|
|
26
|
+
|
|
24
27
|
_ = context.run(
|
|
25
28
|
command=" ".join(
|
|
26
29
|
[
|
|
@@ -28,7 +31,7 @@ def get_task_test() -> Task[Callable[[Context], None]]:
|
|
|
28
31
|
# Explicitly enable color output.
|
|
29
32
|
# Without this, output through invoke will not be in color.
|
|
30
33
|
"--color=yes",
|
|
31
|
-
|
|
34
|
+
param_path_tests,
|
|
32
35
|
]
|
|
33
36
|
),
|
|
34
37
|
echo=True,
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Pydantic models for Terraform backend module variables and output."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BackendVars(BaseModel):
|
|
7
|
+
name: str
|
|
8
|
+
region: str
|
|
9
|
+
states: list[str] = []
|
|
10
|
+
tags: dict[str, str] = {}
|
|
11
|
+
force_destroy: bool = False
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BackendOutput(BaseModel):
|
|
15
|
+
bucket_name: str
|
|
16
|
+
region: str
|
|
17
|
+
state_keys: dict[str, str]
|
|
File without changes
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Helpers for configuring AWS-related environment variables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from fogies.tools.environ import environ
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _AwsProfile(BaseModel):
|
|
14
|
+
aws_access_key_id: str
|
|
15
|
+
aws_secret_access_key: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load_aws_profile_from_toml(profiles_path: Path, profile: str) -> _AwsProfile:
|
|
19
|
+
"""Return AWS profile loaded from a TOML profiles file.
|
|
20
|
+
|
|
21
|
+
The file is expected to contain a table for each profile, for example:
|
|
22
|
+
|
|
23
|
+
[test]
|
|
24
|
+
aws_access_key_id = "value-id"
|
|
25
|
+
aws_secret_access_key = "value-secret"
|
|
26
|
+
"""
|
|
27
|
+
if profiles_path.suffix != ".toml":
|
|
28
|
+
raise ValueError(
|
|
29
|
+
"AWS profiles file must have .toml extension, got '{}'".format(
|
|
30
|
+
profiles_path
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
if not profiles_path.exists():
|
|
34
|
+
raise FileNotFoundError(
|
|
35
|
+
"AWS profiles file '{}' does not exist".format(profiles_path)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
with profiles_path.open("rb") as profiles_file:
|
|
39
|
+
data: dict[str, object] = tomllib.load(profiles_file)
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
profile_raw = data[profile]
|
|
43
|
+
except KeyError as exc:
|
|
44
|
+
raise KeyError(
|
|
45
|
+
"AWS profile '{}' not found in '{}'".format(
|
|
46
|
+
profile,
|
|
47
|
+
profiles_path,
|
|
48
|
+
)
|
|
49
|
+
) from exc
|
|
50
|
+
|
|
51
|
+
return _AwsProfile.model_validate(profile_raw)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def aws_environ(
|
|
55
|
+
profiles_path: Path,
|
|
56
|
+
profile: str,
|
|
57
|
+
*,
|
|
58
|
+
raise_if_exists: bool = True,
|
|
59
|
+
raise_if_changed: bool = True,
|
|
60
|
+
):
|
|
61
|
+
"""Return a context manager that applies AWS variables from a TOML file.
|
|
62
|
+
|
|
63
|
+
The *profiles_path* parameter specifies the AWS TOML profiles file to read;
|
|
64
|
+
it must have a ``.toml`` extension. The *profile* parameter specifies the
|
|
65
|
+
AWS profile name, which is mapped to a ``[<name>]`` table in the profiles
|
|
66
|
+
file.
|
|
67
|
+
"""
|
|
68
|
+
aws_profile = _load_aws_profile_from_toml(
|
|
69
|
+
profiles_path=profiles_path, profile=profile
|
|
70
|
+
)
|
|
71
|
+
variables: dict[str, str] = {
|
|
72
|
+
"AWS_ACCESS_KEY_ID": aws_profile.aws_access_key_id,
|
|
73
|
+
"AWS_SECRET_ACCESS_KEY": aws_profile.aws_secret_access_key,
|
|
74
|
+
}
|
|
75
|
+
return environ(
|
|
76
|
+
variables=variables,
|
|
77
|
+
raise_if_exists=raise_if_exists,
|
|
78
|
+
raise_if_changed=raise_if_changed,
|
|
79
|
+
)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import os
|
|
3
|
+
import pathlib
|
|
4
|
+
from typing import cast
|
|
5
|
+
|
|
6
|
+
from invoke.context import Context
|
|
7
|
+
from invoke.runners import Result
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
11
|
+
class CommandParams:
|
|
12
|
+
"""Params for execution via invoke."""
|
|
13
|
+
|
|
14
|
+
context: Context | None = None
|
|
15
|
+
cwd: pathlib.Path | None = None
|
|
16
|
+
in_stream: bool = True
|
|
17
|
+
|
|
18
|
+
def require_cwd(self, path: pathlib.Path) -> "CommandParams":
|
|
19
|
+
"""Ensure cwd is set to *path*; return updated params or raise if conflicting."""
|
|
20
|
+
if self.cwd is None:
|
|
21
|
+
return dataclasses.replace(self, cwd=path)
|
|
22
|
+
if self.cwd == path:
|
|
23
|
+
return self
|
|
24
|
+
raise ValueError(
|
|
25
|
+
"CommandParams requires cwd '{}' but already has '{}'".format(
|
|
26
|
+
path,
|
|
27
|
+
self.cwd,
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _resolve_command(
|
|
33
|
+
command: pathlib.Path,
|
|
34
|
+
cwd: pathlib.Path | None,
|
|
35
|
+
) -> str:
|
|
36
|
+
"""Return the command string to pass to the shell.
|
|
37
|
+
|
|
38
|
+
When *cwd* is set and *command* resolves to an existing file (a specific
|
|
39
|
+
binary), returns a path relative to *cwd*. Otherwise returns the command as
|
|
40
|
+
given so a name like \"bash\" is left for PATH (e.g. shutil.which).
|
|
41
|
+
"""
|
|
42
|
+
if cwd is not None:
|
|
43
|
+
resolved = command.resolve()
|
|
44
|
+
if resolved.exists():
|
|
45
|
+
return os.path.relpath(resolved, cwd.resolve())
|
|
46
|
+
return str(command)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def command_run(
|
|
50
|
+
*,
|
|
51
|
+
command: pathlib.Path,
|
|
52
|
+
command_params: CommandParams | None = None,
|
|
53
|
+
args: list[str] | None = None,
|
|
54
|
+
) -> Result:
|
|
55
|
+
"""Run a command via invoke run().
|
|
56
|
+
|
|
57
|
+
If *context* is provided, use context.run(). Otherwise create a new
|
|
58
|
+
Context and run the command there.
|
|
59
|
+
"""
|
|
60
|
+
command_params = command_params or CommandParams()
|
|
61
|
+
context = command_params.context or Context()
|
|
62
|
+
|
|
63
|
+
resolved_command = _resolve_command(command, command_params.cwd)
|
|
64
|
+
args_combined = [resolved_command] + (args or [])
|
|
65
|
+
command_str = " ".join(args_combined)
|
|
66
|
+
|
|
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:
|
|
74
|
+
result = context.run(command_str, in_stream=command_params.in_stream)
|
|
75
|
+
|
|
76
|
+
# invoke's Context.run() returns None when run with disown=True.
|
|
77
|
+
# Ensure future revisions to this code never introduce the parameter.
|
|
78
|
+
return cast(Result, result)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Context managers for temporary environment variable overrides."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _EnvironContext:
|
|
10
|
+
"""Context manager for environment variable overrides."""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
variables: Mapping[str, str],
|
|
15
|
+
*,
|
|
16
|
+
raise_if_exists: bool,
|
|
17
|
+
raise_if_changed: bool,
|
|
18
|
+
) -> None:
|
|
19
|
+
self._variables: dict[str, str] = {
|
|
20
|
+
name: str(value) for name, value in variables.items()
|
|
21
|
+
}
|
|
22
|
+
self._raise_if_exists: bool = raise_if_exists
|
|
23
|
+
self._raise_if_changed: bool = raise_if_changed
|
|
24
|
+
self._original_variables: dict[str, str | None] = {}
|
|
25
|
+
self._applied_variables: list[str] = []
|
|
26
|
+
|
|
27
|
+
def _restore_originals(self) -> None:
|
|
28
|
+
"""Restore each applied variable to its original value or remove it."""
|
|
29
|
+
for name in reversed(self._applied_variables):
|
|
30
|
+
original = self._original_variables.get(name)
|
|
31
|
+
if original is None:
|
|
32
|
+
if name in os.environ:
|
|
33
|
+
del os.environ[name]
|
|
34
|
+
else:
|
|
35
|
+
os.environ[name] = original
|
|
36
|
+
|
|
37
|
+
def __enter__(self) -> None:
|
|
38
|
+
try:
|
|
39
|
+
for name, value in self._variables.items():
|
|
40
|
+
existing = os.environ.get(name)
|
|
41
|
+
if existing is not None and self._raise_if_exists:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
"Environment variable '{}' already exists".format(name)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
self._original_variables[name] = existing
|
|
47
|
+
os.environ[name] = value
|
|
48
|
+
self._applied_variables.append(name)
|
|
49
|
+
except Exception:
|
|
50
|
+
# Roll back any changes made before the failure.
|
|
51
|
+
self._restore_originals()
|
|
52
|
+
raise
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
def __exit__(
|
|
56
|
+
self,
|
|
57
|
+
exc_type: type[BaseException] | None,
|
|
58
|
+
exc: BaseException | None,
|
|
59
|
+
exc_tb: object | None,
|
|
60
|
+
) -> bool:
|
|
61
|
+
error: RuntimeError | None = None
|
|
62
|
+
|
|
63
|
+
# Enforce raise_if_changed only when the body exited normally.
|
|
64
|
+
if exc_type is None and self._raise_if_changed:
|
|
65
|
+
for name in self._applied_variables:
|
|
66
|
+
expected = self._variables[name]
|
|
67
|
+
current = os.environ.get(name)
|
|
68
|
+
if current != expected:
|
|
69
|
+
error = RuntimeError(
|
|
70
|
+
"Environment variable '{}' was modified while context manager was active".format(
|
|
71
|
+
name
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
break
|
|
75
|
+
|
|
76
|
+
# Always restore original values.
|
|
77
|
+
self._restore_originals()
|
|
78
|
+
|
|
79
|
+
if error is not None:
|
|
80
|
+
raise error
|
|
81
|
+
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def environ(
|
|
86
|
+
variables: Mapping[str, str],
|
|
87
|
+
*,
|
|
88
|
+
raise_if_exists: bool = True,
|
|
89
|
+
raise_if_changed: bool = True,
|
|
90
|
+
) -> _EnvironContext:
|
|
91
|
+
"""Return a context manager that applies the given environment overrides.
|
|
92
|
+
|
|
93
|
+
The *variables* mapping provides environment variable names and string values
|
|
94
|
+
to assign for the duration of the context.
|
|
95
|
+
|
|
96
|
+
For each variable:
|
|
97
|
+
- If raise_if_exists is True (default) and the variable already exists,
|
|
98
|
+
raises ValueError and leaves the environment unchanged.
|
|
99
|
+
- On normal exit, if raise_if_changed is True (default) and the value in
|
|
100
|
+
the environment differs from the value set by this context manager,
|
|
101
|
+
raises RuntimeError.
|
|
102
|
+
"""
|
|
103
|
+
return _EnvironContext(
|
|
104
|
+
variables=variables,
|
|
105
|
+
raise_if_exists=raise_if_exists,
|
|
106
|
+
raise_if_changed=raise_if_changed,
|
|
107
|
+
)
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import pathlib
|
|
3
|
+
import socket
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
import urllib.request
|
|
8
|
+
import zipfile
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from http.client import HTTPResponse
|
|
12
|
+
from typing import cast
|
|
13
|
+
|
|
14
|
+
import ollama as _ollama_client
|
|
15
|
+
import psutil
|
|
16
|
+
from filelock import BaseFileLock, FileLock
|
|
17
|
+
from pydantic import BaseModel
|
|
18
|
+
|
|
19
|
+
_KNOWN_VERSIONS = [
|
|
20
|
+
"0.17.7",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
_DEFAULT_VERSION = _KNOWN_VERSIONS[-1]
|
|
24
|
+
|
|
25
|
+
_OLLAMA_URL_TEMPLATE = (
|
|
26
|
+
"https://github.com/ollama/ollama/releases/download"
|
|
27
|
+
"/v{version}/ollama-windows-amd64.zip"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_OLLAMA_LISTEN_ADDRESS: tuple[str, int] = ("127.0.0.1", 11434)
|
|
31
|
+
_OLLAMA_LISTEN_PROBE_TIMEOUT = 0.25
|
|
32
|
+
_OLLAMA_LISTEN_POLL_INTERVAL = 0.1
|
|
33
|
+
_OLLAMA_LISTEN_PROBE_WAIT_TIMEOUT = 10.0
|
|
34
|
+
_OLLAMA_TERMINATE_WAIT_TIMEOUT = 10.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _PidWithCreateTime(BaseModel):
|
|
38
|
+
"""Persisted process identity state."""
|
|
39
|
+
|
|
40
|
+
pid: int
|
|
41
|
+
create_time: float | None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _Ollama:
|
|
45
|
+
"""Represents an Ollama CLI binary."""
|
|
46
|
+
|
|
47
|
+
_version: str
|
|
48
|
+
_path: pathlib.Path
|
|
49
|
+
_file_lock: BaseFileLock
|
|
50
|
+
|
|
51
|
+
def __init__(self, *, version: str, path: pathlib.Path) -> None:
|
|
52
|
+
self._version = version
|
|
53
|
+
self._path = path
|
|
54
|
+
self._file_lock = FileLock(str(self._lock_path), is_singleton=True)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def binary_version(self) -> str:
|
|
58
|
+
"""The Ollama binary version string."""
|
|
59
|
+
return self._version
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def binary_path(self) -> pathlib.Path:
|
|
63
|
+
"""The path to the Ollama executable."""
|
|
64
|
+
return self._path
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def _pid_path(self) -> pathlib.Path:
|
|
68
|
+
"""Return the PID file path for this binary."""
|
|
69
|
+
return self._path.with_suffix(".pid")
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def _refcount_path(self) -> pathlib.Path:
|
|
73
|
+
"""Return the refcount file path for this binary."""
|
|
74
|
+
return self._path.with_suffix(".refcount")
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def _lock_path(self) -> pathlib.Path:
|
|
78
|
+
"""Return the lock file path for this binary."""
|
|
79
|
+
return self._path.with_suffix(".lock")
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def pid(self) -> _PidWithCreateTime | None:
|
|
83
|
+
"""Return process identity state, or None if not managed."""
|
|
84
|
+
self._assert_lock()
|
|
85
|
+
|
|
86
|
+
pid_path = self._pid_path
|
|
87
|
+
if not pid_path.exists():
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
text = pid_path.read_text(encoding="utf-8").strip()
|
|
91
|
+
assert text is not None
|
|
92
|
+
|
|
93
|
+
state = _PidWithCreateTime.model_validate_json(text)
|
|
94
|
+
assert state.pid > 0
|
|
95
|
+
|
|
96
|
+
return state
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def refcount(self) -> int:
|
|
100
|
+
"""Return the server refcount, or 0 if not managed."""
|
|
101
|
+
self._assert_lock()
|
|
102
|
+
|
|
103
|
+
refcount_path = self._refcount_path
|
|
104
|
+
if not refcount_path.exists():
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
text = refcount_path.read_text(encoding="utf-8").strip()
|
|
108
|
+
if not text:
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
return int(text)
|
|
112
|
+
|
|
113
|
+
@contextmanager
|
|
114
|
+
def lock(self) -> Iterator[None]:
|
|
115
|
+
"""Acquire and hold the file lock."""
|
|
116
|
+
with self._file_lock:
|
|
117
|
+
yield
|
|
118
|
+
|
|
119
|
+
def _assert_lock(self) -> None:
|
|
120
|
+
assert (
|
|
121
|
+
self._file_lock.is_locked
|
|
122
|
+
), "Must be accessed within an Ollama lock context."
|
|
123
|
+
|
|
124
|
+
def reset_state(self) -> None:
|
|
125
|
+
"""Clear the server state files."""
|
|
126
|
+
self._assert_lock()
|
|
127
|
+
|
|
128
|
+
self._pid_path.unlink(missing_ok=True)
|
|
129
|
+
self._refcount_path.unlink(missing_ok=True)
|
|
130
|
+
|
|
131
|
+
def set_pid(self, pid: _PidWithCreateTime) -> None:
|
|
132
|
+
"""Set the managed server PID state."""
|
|
133
|
+
self._assert_lock()
|
|
134
|
+
|
|
135
|
+
pid_path = self._pid_path
|
|
136
|
+
|
|
137
|
+
text = "{}\n".format(pid.model_dump_json())
|
|
138
|
+
_ = pid_path.write_text(text, encoding="utf-8")
|
|
139
|
+
|
|
140
|
+
def set_refcount(self, count: int) -> None:
|
|
141
|
+
"""Set the managed server refcount state."""
|
|
142
|
+
self._assert_lock()
|
|
143
|
+
|
|
144
|
+
refcount_path = self._refcount_path
|
|
145
|
+
|
|
146
|
+
_ = refcount_path.write_text(
|
|
147
|
+
"{}\n".format(count),
|
|
148
|
+
encoding="utf-8",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _terminate_pid(*, pid: _PidWithCreateTime) -> None:
|
|
153
|
+
try:
|
|
154
|
+
process = psutil.Process(pid.pid)
|
|
155
|
+
|
|
156
|
+
# 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:
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
children = process.children(recursive=True)
|
|
161
|
+
processes = [*children, process]
|
|
162
|
+
|
|
163
|
+
for process_to_end in processes:
|
|
164
|
+
try:
|
|
165
|
+
process_to_end.terminate()
|
|
166
|
+
except psutil.Error:
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
_, alive = psutil.wait_procs(
|
|
170
|
+
processes,
|
|
171
|
+
timeout=_OLLAMA_TERMINATE_WAIT_TIMEOUT,
|
|
172
|
+
)
|
|
173
|
+
if not alive:
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
for process_to_kill in alive:
|
|
177
|
+
try:
|
|
178
|
+
process_to_kill.kill()
|
|
179
|
+
except psutil.Error:
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
_ = psutil.wait_procs(
|
|
183
|
+
alive,
|
|
184
|
+
timeout=_OLLAMA_TERMINATE_WAIT_TIMEOUT,
|
|
185
|
+
)
|
|
186
|
+
except psutil.Error:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _wait_until_listening(
|
|
191
|
+
*,
|
|
192
|
+
timeout_s: float = _OLLAMA_LISTEN_PROBE_WAIT_TIMEOUT,
|
|
193
|
+
) -> None:
|
|
194
|
+
end = time.time() + timeout_s
|
|
195
|
+
while time.time() < end:
|
|
196
|
+
try:
|
|
197
|
+
with socket.create_connection(
|
|
198
|
+
_OLLAMA_LISTEN_ADDRESS,
|
|
199
|
+
timeout=_OLLAMA_LISTEN_PROBE_TIMEOUT,
|
|
200
|
+
):
|
|
201
|
+
return
|
|
202
|
+
except OSError:
|
|
203
|
+
pass
|
|
204
|
+
time.sleep(_OLLAMA_LISTEN_POLL_INTERVAL)
|
|
205
|
+
raise TimeoutError("Ollama server did not become ready in time")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@contextmanager
|
|
209
|
+
def ollama(
|
|
210
|
+
*,
|
|
211
|
+
version: str | None = None,
|
|
212
|
+
binary_cache_path: pathlib.Path,
|
|
213
|
+
) -> Iterator[_Ollama]:
|
|
214
|
+
"""Download an Ollama Windows CLI release and yield an Ollama handle.
|
|
215
|
+
|
|
216
|
+
*version* is the Ollama release tag version (e.g., "0.17.7"). The archive is
|
|
217
|
+
downloaded from the GitHub releases page if it does not already exist in
|
|
218
|
+
*binary_cache_path*. The CLI zip archive `ollama-windows-amd64.zip` is
|
|
219
|
+
fetched and the full folder structure is extracted into a versioned
|
|
220
|
+
directory inside *binary_cache_path* and used from there.
|
|
221
|
+
"""
|
|
222
|
+
if sys.platform != "win32":
|
|
223
|
+
raise RuntimeError("Only implemented on Windows")
|
|
224
|
+
|
|
225
|
+
if version is None:
|
|
226
|
+
version = _DEFAULT_VERSION
|
|
227
|
+
|
|
228
|
+
if version not in _KNOWN_VERSIONS:
|
|
229
|
+
known = ", ".join(_KNOWN_VERSIONS)
|
|
230
|
+
raise ValueError(
|
|
231
|
+
"Unknown Ollama version '{}'; known versions: {}".format(
|
|
232
|
+
version,
|
|
233
|
+
known,
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
dir_name = "ollama_{}".format(version.replace(".", "_"))
|
|
238
|
+
version_dir = binary_cache_path / dir_name
|
|
239
|
+
|
|
240
|
+
if not version_dir.exists():
|
|
241
|
+
version_dir.mkdir(parents=True, exist_ok=True)
|
|
242
|
+
|
|
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)
|
|
250
|
+
|
|
251
|
+
exe_path = version_dir / "ollama.exe"
|
|
252
|
+
if not exe_path.exists():
|
|
253
|
+
raise RuntimeError(
|
|
254
|
+
"Ollama executable 'ollama.exe' not found in '{}'".format(version_dir)
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
yield _Ollama(version=version, path=exe_path)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@contextmanager
|
|
261
|
+
def ollama_client(
|
|
262
|
+
*,
|
|
263
|
+
version: str | None = None,
|
|
264
|
+
binary_cache_path: pathlib.Path,
|
|
265
|
+
show_window: bool = False,
|
|
266
|
+
) -> Iterator[_ollama_client.Client]:
|
|
267
|
+
"""Provide an Ollama Python client with a running local server.
|
|
268
|
+
|
|
269
|
+
Starts Ollama server in background and yields a configured
|
|
270
|
+
:class:`ollama.Client` instance connected to default local host.
|
|
271
|
+
Set *show_window* to True to allow a visible console window.
|
|
272
|
+
"""
|
|
273
|
+
with ollama(version=version, binary_cache_path=binary_cache_path) as ollama_binary:
|
|
274
|
+
with ollama_binary.lock():
|
|
275
|
+
running = False
|
|
276
|
+
|
|
277
|
+
# 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:
|
|
280
|
+
try:
|
|
281
|
+
proc = psutil.Process(pid.pid)
|
|
282
|
+
running = (
|
|
283
|
+
proc.is_running()
|
|
284
|
+
and proc.status() != psutil.STATUS_ZOMBIE
|
|
285
|
+
and proc.create_time() == pid.create_time
|
|
286
|
+
)
|
|
287
|
+
except psutil.Error:
|
|
288
|
+
running = False
|
|
289
|
+
|
|
290
|
+
# If needed, start the server.
|
|
291
|
+
if not running:
|
|
292
|
+
ollama_binary.reset_state()
|
|
293
|
+
|
|
294
|
+
stdout = None if show_window else subprocess.DEVNULL
|
|
295
|
+
stderr = None if show_window else subprocess.DEVNULL
|
|
296
|
+
creation_flags = subprocess.CREATE_NEW_CONSOLE if show_window else 0
|
|
297
|
+
|
|
298
|
+
server_process = subprocess.Popen(
|
|
299
|
+
[str(ollama_binary.binary_path), "serve"],
|
|
300
|
+
stdout=stdout,
|
|
301
|
+
stderr=stderr,
|
|
302
|
+
creationflags=creation_flags,
|
|
303
|
+
)
|
|
304
|
+
ollama_binary.set_pid(
|
|
305
|
+
_PidWithCreateTime(
|
|
306
|
+
pid=server_process.pid,
|
|
307
|
+
create_time=psutil.Process(server_process.pid).create_time(),
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
# Increment the refcount.
|
|
312
|
+
ollama_binary.set_refcount(ollama_binary.refcount + 1)
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
# Wait until the server is listening.
|
|
316
|
+
_wait_until_listening()
|
|
317
|
+
|
|
318
|
+
# Create the client.
|
|
319
|
+
client = _ollama_client.Client()
|
|
320
|
+
yield client
|
|
321
|
+
finally:
|
|
322
|
+
with ollama_binary.lock():
|
|
323
|
+
ollama_binary.set_refcount(ollama_binary.refcount - 1)
|
|
324
|
+
|
|
325
|
+
if ollama_binary.refcount <= 0:
|
|
326
|
+
pid = ollama_binary.pid
|
|
327
|
+
if pid is not None:
|
|
328
|
+
_terminate_pid(pid=pid)
|
|
329
|
+
ollama_binary.reset_state()
|
|
@@ -0,0 +1,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") 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
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|