nac-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nac/__init__.py +6 -0
- nac/__main__.py +7 -0
- nac/bootstrap.py +181 -0
- nac/cli/__init__.py +24 -0
- nac/cli/apply.py +69 -0
- nac/cli/destroy.py +60 -0
- nac/cli/init.py +52 -0
- nac/cli/main.py +104 -0
- nac/cli/options.py +114 -0
- nac/cli/passthrough.py +117 -0
- nac/cli/plan.py +76 -0
- nac/cli/setup.py +179 -0
- nac/cli/test.py +63 -0
- nac/cli/validate.py +90 -0
- nac/cli/versions.py +92 -0
- nac/config.py +181 -0
- nac/exceptions.py +34 -0
- nac/manifest.py +155 -0
- nac/output.py +113 -0
- nac/runner.py +135 -0
- nac/terraform.py +142 -0
- nac/tools.py +169 -0
- nac_cli-0.1.0.dist-info/METADATA +386 -0
- nac_cli-0.1.0.dist-info/RECORD +27 -0
- nac_cli-0.1.0.dist-info/WHEEL +4 -0
- nac_cli-0.1.0.dist-info/entry_points.txt +3 -0
- nac_cli-0.1.0.dist-info/licenses/LICENSE +385 -0
nac/__init__.py
ADDED
nac/__main__.py
ADDED
nac/bootstrap.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright (c) 2026 Daniel Schmidt
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import io
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
import stat
|
|
9
|
+
import sys
|
|
10
|
+
import zipfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, cast
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
import platformdirs
|
|
16
|
+
|
|
17
|
+
from nac.exceptions import (
|
|
18
|
+
BootstrapDeclinedError,
|
|
19
|
+
BootstrapError,
|
|
20
|
+
ChecksumMismatchError,
|
|
21
|
+
UnsupportedPlatformError,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_OS_MAP = {
|
|
25
|
+
"darwin": "darwin",
|
|
26
|
+
"linux": "linux",
|
|
27
|
+
"win32": "windows",
|
|
28
|
+
"cygwin": "windows",
|
|
29
|
+
}
|
|
30
|
+
_ARCH_MAP = {
|
|
31
|
+
"x86_64": "amd64",
|
|
32
|
+
"amd64": "amd64",
|
|
33
|
+
"aarch64": "arm64",
|
|
34
|
+
"arm64": "arm64",
|
|
35
|
+
"i386": "386",
|
|
36
|
+
"i686": "386",
|
|
37
|
+
"x86": "386",
|
|
38
|
+
"armv7l": "arm",
|
|
39
|
+
"armv6l": "arm",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
_GITHUB_API = "https://api.github.com/repos/opentofu/opentofu/releases"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def cache_dir() -> Path:
|
|
46
|
+
return Path(platformdirs.user_cache_dir("nac"))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _binary_name() -> str:
|
|
50
|
+
return "tofu.exe" if sys.platform == "win32" else "tofu"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _cached_binary_path(version: str) -> Path:
|
|
54
|
+
return cache_dir() / "bin" / "tofu" / version / _binary_name()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def find_cached_binary() -> Path | None:
|
|
58
|
+
"""Return the first cached tofu binary found, or None. No network access."""
|
|
59
|
+
root = cache_dir() / "bin" / "tofu"
|
|
60
|
+
if not root.is_dir():
|
|
61
|
+
return None
|
|
62
|
+
name = _binary_name()
|
|
63
|
+
for version_dir in root.iterdir():
|
|
64
|
+
candidate = version_dir / name
|
|
65
|
+
if candidate.is_file():
|
|
66
|
+
return candidate
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def detect_platform() -> tuple[str, str]:
|
|
71
|
+
"""Return (os_name, arch_name) in OpenTofu's release-asset vocabulary."""
|
|
72
|
+
os_name = _OS_MAP.get(sys.platform)
|
|
73
|
+
arch_name = _ARCH_MAP.get(platform.machine().lower())
|
|
74
|
+
if os_name is None or arch_name is None:
|
|
75
|
+
raise UnsupportedPlatformError(
|
|
76
|
+
f"Unsupported platform for OpenTofu bootstrap: {sys.platform}/{platform.machine()}"
|
|
77
|
+
)
|
|
78
|
+
return os_name, arch_name
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def install(
|
|
82
|
+
version: str | None = None,
|
|
83
|
+
*,
|
|
84
|
+
confirmed: bool,
|
|
85
|
+
client: httpx.Client | None = None,
|
|
86
|
+
) -> Path:
|
|
87
|
+
"""Ensure a cached OpenTofu binary exists locally and return its path.
|
|
88
|
+
|
|
89
|
+
Never prompts — only gates on `confirmed`, which the caller (`nac
|
|
90
|
+
setup`) obtains via its own TTY-detection/prompting. If a binary is
|
|
91
|
+
already cached, it is returned immediately with no network call,
|
|
92
|
+
regardless of `confirmed`.
|
|
93
|
+
"""
|
|
94
|
+
cached = find_cached_binary()
|
|
95
|
+
if cached is not None:
|
|
96
|
+
return cached
|
|
97
|
+
|
|
98
|
+
if not confirmed:
|
|
99
|
+
raise BootstrapDeclinedError(
|
|
100
|
+
"OpenTofu is not installed and installation was not confirmed."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
owns_client = client is None
|
|
104
|
+
client = client or httpx.Client(timeout=30.0, follow_redirects=True)
|
|
105
|
+
try:
|
|
106
|
+
os_name, arch_name = detect_platform()
|
|
107
|
+
release = _get_release(client, version)
|
|
108
|
+
resolved_version = _tag_to_version(release["tag_name"])
|
|
109
|
+
dest = _cached_binary_path(resolved_version)
|
|
110
|
+
|
|
111
|
+
asset_name = f"tofu_{resolved_version}_{os_name}_{arch_name}.zip"
|
|
112
|
+
sums_name = f"tofu_{resolved_version}_SHA256SUMS"
|
|
113
|
+
archive_bytes = _download(client, _asset_url(release, asset_name))
|
|
114
|
+
sums_text = _download(client, _asset_url(release, sums_name)).decode()
|
|
115
|
+
|
|
116
|
+
expected = _parse_sha256sums(sums_text, asset_name)
|
|
117
|
+
actual = hashlib.sha256(archive_bytes).hexdigest()
|
|
118
|
+
if actual != expected:
|
|
119
|
+
raise ChecksumMismatchError(
|
|
120
|
+
f"Checksum mismatch for {asset_name}: expected {expected}, got {actual}"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
_extract_binary(archive_bytes, dest, os_name)
|
|
124
|
+
return dest
|
|
125
|
+
finally:
|
|
126
|
+
if owns_client:
|
|
127
|
+
client.close()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _get_release(client: httpx.Client, version: str | None) -> dict[str, Any]:
|
|
131
|
+
url = (
|
|
132
|
+
f"{_GITHUB_API}/latest" if version is None else f"{_GITHUB_API}/tags/v{version}"
|
|
133
|
+
)
|
|
134
|
+
resp = client.get(url)
|
|
135
|
+
if resp.status_code != 200:
|
|
136
|
+
raise BootstrapError(
|
|
137
|
+
f"Failed to query OpenTofu releases ({resp.status_code}): {url}"
|
|
138
|
+
)
|
|
139
|
+
result: dict[str, Any] = resp.json()
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _tag_to_version(tag: str) -> str:
|
|
144
|
+
return tag[1:] if tag.startswith("v") else tag
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _asset_url(release: dict[str, Any], name: str) -> str:
|
|
148
|
+
for asset in release.get("assets", []):
|
|
149
|
+
if asset.get("name") == name:
|
|
150
|
+
return cast(str, asset["browser_download_url"])
|
|
151
|
+
raise BootstrapError(f"Release asset not found: {name}")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _download(client: httpx.Client, url: str) -> bytes:
|
|
155
|
+
resp = client.get(url)
|
|
156
|
+
if resp.status_code != 200:
|
|
157
|
+
raise BootstrapError(f"Download failed ({resp.status_code}): {url}")
|
|
158
|
+
return resp.content
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _parse_sha256sums(text: str, filename: str) -> str:
|
|
162
|
+
for line in text.splitlines():
|
|
163
|
+
parts = line.split()
|
|
164
|
+
if len(parts) >= 2 and parts[-1].lstrip("*") == filename:
|
|
165
|
+
return parts[0]
|
|
166
|
+
raise BootstrapError(f"No checksum entry for {filename} in SHA256SUMS")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _extract_binary(archive_bytes: bytes, dest: Path, os_name: str) -> None:
|
|
170
|
+
member = "tofu.exe" if os_name == "windows" else "tofu"
|
|
171
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
tmp = dest.with_name(dest.name + ".part")
|
|
173
|
+
with (
|
|
174
|
+
zipfile.ZipFile(io.BytesIO(archive_bytes)) as zf,
|
|
175
|
+
zf.open(member) as src,
|
|
176
|
+
tmp.open("wb") as out,
|
|
177
|
+
):
|
|
178
|
+
shutil.copyfileobj(src, out)
|
|
179
|
+
if os_name != "windows":
|
|
180
|
+
tmp.chmod(tmp.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
|
181
|
+
tmp.replace(dest)
|
nac/cli/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright (c) 2026 Daniel Schmidt
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
# Copyright (c) 2026 Daniel Schmidt
|
|
6
|
+
|
|
7
|
+
from typer.models import CommandInfo
|
|
8
|
+
|
|
9
|
+
from . import apply, destroy, init, plan, setup, test, validate # noqa: F401
|
|
10
|
+
from .main import app
|
|
11
|
+
|
|
12
|
+
# Registration order above is alphabetical (required by import sorting), but
|
|
13
|
+
# the help output should mirror the setup -> init -> validate -> plan ->
|
|
14
|
+
# apply -> test workflow, so reorder registered_commands explicitly.
|
|
15
|
+
_WORKFLOW_ORDER = ("setup", "init", "validate", "plan", "apply", "test", "destroy")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _workflow_index(command: CommandInfo) -> int:
|
|
19
|
+
assert command.callback is not None
|
|
20
|
+
module = command.callback.__module__.rsplit(".", 1)[-1]
|
|
21
|
+
return _WORKFLOW_ORDER.index(module)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
app.registered_commands.sort(key=_workflow_index)
|
nac/cli/apply.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright (c) 2026 Daniel Schmidt
|
|
3
|
+
|
|
4
|
+
"""`nac apply` -- applies a saved plan if present, else runs interactively."""
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from nac import runner, terraform
|
|
9
|
+
|
|
10
|
+
from .main import app, get_config, get_no_color, resolve_engine_or_exit
|
|
11
|
+
from .options import Artifacts, AutoApprove
|
|
12
|
+
from .passthrough import (
|
|
13
|
+
PASSTHROUGH_CONTEXT_SETTINGS,
|
|
14
|
+
PASSTHROUGH_OPTIONS_METAVAR,
|
|
15
|
+
show_help_and_exit,
|
|
16
|
+
wants_help,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command(
|
|
21
|
+
context_settings=PASSTHROUGH_CONTEXT_SETTINGS,
|
|
22
|
+
options_metavar=PASSTHROUGH_OPTIONS_METAVAR,
|
|
23
|
+
)
|
|
24
|
+
def apply(
|
|
25
|
+
ctx: typer.Context,
|
|
26
|
+
auto_approve: AutoApprove = False,
|
|
27
|
+
artifacts: Artifacts = False,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Apply a saved plan file if present, otherwise apply interactively.
|
|
30
|
+
|
|
31
|
+
Extra arguments are passed through to `terraform`/`tofu`.
|
|
32
|
+
"""
|
|
33
|
+
cfg = get_config(ctx)
|
|
34
|
+
engine, binary = resolve_engine_or_exit(cfg)
|
|
35
|
+
env = terraform.build_engine_env(engine, cfg.tools.terraform.version)
|
|
36
|
+
no_color = get_no_color(ctx)
|
|
37
|
+
|
|
38
|
+
if wants_help(ctx):
|
|
39
|
+
show_help_and_exit(
|
|
40
|
+
ctx,
|
|
41
|
+
[binary, "apply", "--help"],
|
|
42
|
+
cwd=cfg.working_dir,
|
|
43
|
+
env=env,
|
|
44
|
+
no_color=get_no_color(ctx),
|
|
45
|
+
tool_label=f"{engine} apply",
|
|
46
|
+
usage_as="nac apply",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
plan_file = cfg.working_dir / terraform.DEFAULT_PLAN_FILE
|
|
50
|
+
used_plan_file = plan_file.is_file()
|
|
51
|
+
if used_plan_file:
|
|
52
|
+
argv = terraform.build_apply_argv(
|
|
53
|
+
binary, plan_file=terraform.DEFAULT_PLAN_FILE, no_color=no_color
|
|
54
|
+
)
|
|
55
|
+
else:
|
|
56
|
+
argv = terraform.build_apply_argv(
|
|
57
|
+
binary, auto_approve=auto_approve, no_color=no_color
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
code = runner.run_streaming(
|
|
61
|
+
argv + ctx.args,
|
|
62
|
+
cwd=cfg.working_dir,
|
|
63
|
+
env=env,
|
|
64
|
+
log_path=cfg.working_dir / "apply.txt" if artifacts else None,
|
|
65
|
+
no_color=no_color,
|
|
66
|
+
)
|
|
67
|
+
if used_plan_file and code == 0:
|
|
68
|
+
plan_file.unlink()
|
|
69
|
+
raise typer.Exit(code=code)
|
nac/cli/destroy.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright (c) 2026 Daniel Schmidt
|
|
3
|
+
|
|
4
|
+
"""`nac destroy` -- streams `terraform destroy`."""
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from nac import runner, terraform
|
|
9
|
+
|
|
10
|
+
from .main import app, get_config, get_no_color, resolve_engine_or_exit
|
|
11
|
+
from .options import Artifacts, AutoApprove
|
|
12
|
+
from .passthrough import (
|
|
13
|
+
PASSTHROUGH_CONTEXT_SETTINGS,
|
|
14
|
+
PASSTHROUGH_OPTIONS_METAVAR,
|
|
15
|
+
show_help_and_exit,
|
|
16
|
+
wants_help,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command(
|
|
21
|
+
context_settings=PASSTHROUGH_CONTEXT_SETTINGS,
|
|
22
|
+
options_metavar=PASSTHROUGH_OPTIONS_METAVAR,
|
|
23
|
+
)
|
|
24
|
+
def destroy(
|
|
25
|
+
ctx: typer.Context,
|
|
26
|
+
auto_approve: AutoApprove = False,
|
|
27
|
+
artifacts: Artifacts = False,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Destroy the managed infrastructure (interactive unless --auto-approve).
|
|
30
|
+
|
|
31
|
+
Extra arguments are passed through to `terraform`/`tofu`.
|
|
32
|
+
"""
|
|
33
|
+
cfg = get_config(ctx)
|
|
34
|
+
engine, binary = resolve_engine_or_exit(cfg)
|
|
35
|
+
env = terraform.build_engine_env(engine, cfg.tools.terraform.version)
|
|
36
|
+
no_color = get_no_color(ctx)
|
|
37
|
+
|
|
38
|
+
if wants_help(ctx):
|
|
39
|
+
show_help_and_exit(
|
|
40
|
+
ctx,
|
|
41
|
+
[binary, "destroy", "--help"],
|
|
42
|
+
cwd=cfg.working_dir,
|
|
43
|
+
env=env,
|
|
44
|
+
no_color=get_no_color(ctx),
|
|
45
|
+
tool_label=f"{engine} destroy",
|
|
46
|
+
usage_as="nac destroy",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
argv = terraform.build_destroy_argv(
|
|
50
|
+
binary, auto_approve=auto_approve, no_color=no_color
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
code = runner.run_streaming(
|
|
54
|
+
argv + ctx.args,
|
|
55
|
+
cwd=cfg.working_dir,
|
|
56
|
+
env=env,
|
|
57
|
+
log_path=cfg.working_dir / "destroy.txt" if artifacts else None,
|
|
58
|
+
no_color=no_color,
|
|
59
|
+
)
|
|
60
|
+
raise typer.Exit(code=code)
|
nac/cli/init.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright (c) 2026 Daniel Schmidt
|
|
3
|
+
|
|
4
|
+
"""`nac init` -- streams terraform/tofu init in working_dir."""
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from nac import runner, terraform
|
|
9
|
+
|
|
10
|
+
from .main import app, get_config, get_no_color, resolve_engine_or_exit
|
|
11
|
+
from .options import Artifacts
|
|
12
|
+
from .passthrough import (
|
|
13
|
+
PASSTHROUGH_CONTEXT_SETTINGS,
|
|
14
|
+
PASSTHROUGH_OPTIONS_METAVAR,
|
|
15
|
+
show_help_and_exit,
|
|
16
|
+
wants_help,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command(
|
|
21
|
+
context_settings=PASSTHROUGH_CONTEXT_SETTINGS,
|
|
22
|
+
options_metavar=PASSTHROUGH_OPTIONS_METAVAR,
|
|
23
|
+
)
|
|
24
|
+
def init(ctx: typer.Context, artifacts: Artifacts = False) -> None:
|
|
25
|
+
"""Initialize the Terraform/OpenTofu working directory.
|
|
26
|
+
|
|
27
|
+
Extra arguments are passed through to `terraform`/`tofu`.
|
|
28
|
+
"""
|
|
29
|
+
cfg = get_config(ctx)
|
|
30
|
+
engine, binary = resolve_engine_or_exit(cfg)
|
|
31
|
+
env = terraform.build_engine_env(engine, cfg.tools.terraform.version)
|
|
32
|
+
no_color = get_no_color(ctx)
|
|
33
|
+
|
|
34
|
+
if wants_help(ctx):
|
|
35
|
+
show_help_and_exit(
|
|
36
|
+
ctx,
|
|
37
|
+
[binary, "init", "--help"],
|
|
38
|
+
cwd=cfg.working_dir,
|
|
39
|
+
env=env,
|
|
40
|
+
no_color=no_color,
|
|
41
|
+
tool_label=f"{engine} init",
|
|
42
|
+
usage_as="nac init",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
code = runner.run_streaming(
|
|
46
|
+
terraform.build_init_argv(binary, no_color=no_color) + ctx.args,
|
|
47
|
+
cwd=cfg.working_dir,
|
|
48
|
+
env=env,
|
|
49
|
+
log_path=cfg.working_dir / "init.txt" if artifacts else None,
|
|
50
|
+
no_color=no_color,
|
|
51
|
+
)
|
|
52
|
+
raise typer.Exit(code=code)
|
nac/cli/main.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright (c) 2026 Daniel Schmidt
|
|
3
|
+
|
|
4
|
+
"""CLI entry point for nac: global options, config loading, shared helpers."""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import NoReturn, cast
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from nac import terraform
|
|
15
|
+
from nac.config import DEFAULT_CONFIG_PATH, NacConfig, load_config
|
|
16
|
+
from nac.exceptions import ConfigError, EngineNotFoundError, NoEngineAvailableError
|
|
17
|
+
from nac.output import ensure_utf8_streams
|
|
18
|
+
|
|
19
|
+
from .options import ConfigPath, NoColor, Verbosity, VerbosityLevel, Version
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
add_completion=False,
|
|
23
|
+
help="An umbrella CLI wrapping terraform/tofu, nac-validate, and nac-test.",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def configure_logging(level: VerbosityLevel) -> None:
|
|
28
|
+
logging.basicConfig(
|
|
29
|
+
level=getattr(logging, level.value),
|
|
30
|
+
format="%(levelname)s - %(message)s",
|
|
31
|
+
stream=sys.stdout,
|
|
32
|
+
force=True,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def fail(message: str, code: int = 1) -> NoReturn:
|
|
37
|
+
"""Print a clean error message to stderr and exit with a non-zero code."""
|
|
38
|
+
typer.echo(message, err=True)
|
|
39
|
+
raise typer.Exit(code=code)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_config(ctx: typer.Context) -> NacConfig:
|
|
43
|
+
"""Load (once, lazily) and return the NacConfig for this invocation.
|
|
44
|
+
|
|
45
|
+
Deferred until a subcommand actually needs it -- rather than in the
|
|
46
|
+
app callback -- so `nac <subcommand> --help` works without a config
|
|
47
|
+
file present. `ctx.obj` starts out holding the raw --config path and
|
|
48
|
+
is replaced with the resolved NacConfig on first call, so repeated
|
|
49
|
+
calls within the same invocation don't re-read the file.
|
|
50
|
+
"""
|
|
51
|
+
if isinstance(ctx.obj, NacConfig):
|
|
52
|
+
return ctx.obj
|
|
53
|
+
try:
|
|
54
|
+
cfg = load_config(cast(Path, ctx.obj))
|
|
55
|
+
except ConfigError as exc:
|
|
56
|
+
fail(str(exc))
|
|
57
|
+
ctx.obj = cfg
|
|
58
|
+
return cfg
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resolve_engine_or_exit(cfg: NacConfig) -> tuple[str, str]:
|
|
62
|
+
"""Resolve the terraform/tofu binary, failing cleanly on any error.
|
|
63
|
+
|
|
64
|
+
Used by init/validate/plan/apply, none of which perform the
|
|
65
|
+
interactive FR-12 bootstrap prompt -- that is `nac setup`'s job.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
return terraform.resolve_engine_binary(cfg.tools.terraform)
|
|
69
|
+
except (EngineNotFoundError, NoEngineAvailableError) as exc:
|
|
70
|
+
fail(str(exc))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_no_color(ctx: typer.Context) -> bool:
|
|
74
|
+
"""Whether colored output should be suppressed for this invocation.
|
|
75
|
+
|
|
76
|
+
Resolved once by the app callback from the `--no-color` flag / NO_COLOR
|
|
77
|
+
env var and stashed on `ctx.meta` (Click's per-invocation scratch dict),
|
|
78
|
+
so it doesn't collide with `ctx.obj`'s lazy NacConfig loading above.
|
|
79
|
+
"""
|
|
80
|
+
return bool(ctx.meta.get("no_color", False))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@app.callback()
|
|
84
|
+
def main(
|
|
85
|
+
ctx: typer.Context,
|
|
86
|
+
config: ConfigPath = DEFAULT_CONFIG_PATH,
|
|
87
|
+
verbosity: Verbosity = VerbosityLevel.WARNING,
|
|
88
|
+
no_color: NoColor = False,
|
|
89
|
+
version: Version = False,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""An umbrella CLI wrapping terraform/tofu, nac-validate, and nac-test."""
|
|
92
|
+
ensure_utf8_streams()
|
|
93
|
+
configure_logging(verbosity)
|
|
94
|
+
ctx.meta["no_color"] = no_color or "NO_COLOR" in os.environ
|
|
95
|
+
ctx.obj = config
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def run() -> None:
|
|
99
|
+
"""Console-script entry point for both `nac` and its `nac-cli` alias.
|
|
100
|
+
|
|
101
|
+
Pins the program name so help and usage always show `nac`, whichever
|
|
102
|
+
name it was invoked under.
|
|
103
|
+
"""
|
|
104
|
+
app(prog_name="nac")
|
nac/cli/options.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright (c) 2026 Daniel Schmidt
|
|
3
|
+
|
|
4
|
+
"""Annotated Typer option aliases shared across nac's subcommands."""
|
|
5
|
+
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from nac import __version__
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class VerbosityLevel(StrEnum):
|
|
16
|
+
DEBUG = "DEBUG"
|
|
17
|
+
INFO = "INFO"
|
|
18
|
+
WARNING = "WARNING"
|
|
19
|
+
ERROR = "ERROR"
|
|
20
|
+
CRITICAL = "CRITICAL"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
ConfigPath = Annotated[
|
|
24
|
+
Path,
|
|
25
|
+
typer.Option(
|
|
26
|
+
"--config",
|
|
27
|
+
help="Path to the nac.yaml config file.",
|
|
28
|
+
envvar="NAC_CONFIG",
|
|
29
|
+
),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
Verbosity = Annotated[
|
|
33
|
+
VerbosityLevel,
|
|
34
|
+
typer.Option(
|
|
35
|
+
"-v",
|
|
36
|
+
"--verbosity",
|
|
37
|
+
help="Verbosity level.",
|
|
38
|
+
envvar="NAC_VERBOSITY",
|
|
39
|
+
is_eager=True,
|
|
40
|
+
),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
AutoApprove = Annotated[
|
|
44
|
+
bool,
|
|
45
|
+
typer.Option(
|
|
46
|
+
"--auto-approve",
|
|
47
|
+
help="Skip interactive approval (apply: only without a saved plan).",
|
|
48
|
+
),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
Artifacts = Annotated[
|
|
52
|
+
bool,
|
|
53
|
+
typer.Option(
|
|
54
|
+
"--artifacts",
|
|
55
|
+
help=(
|
|
56
|
+
"Also write the command's output to <command>.txt in the "
|
|
57
|
+
"working dir (e.g. validate.txt, plan.txt/plan.json)."
|
|
58
|
+
),
|
|
59
|
+
),
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
Yes = Annotated[
|
|
63
|
+
bool,
|
|
64
|
+
typer.Option(
|
|
65
|
+
"--yes",
|
|
66
|
+
"--install",
|
|
67
|
+
help="Assume yes for confirmation prompts (e.g. the OpenTofu bootstrap install).",
|
|
68
|
+
),
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
Prewarm = Annotated[
|
|
72
|
+
bool,
|
|
73
|
+
typer.Option(
|
|
74
|
+
"--prewarm",
|
|
75
|
+
help="Pre-warm the uvx cache for nac-validate/nac-test.",
|
|
76
|
+
),
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
NoColor = Annotated[
|
|
80
|
+
bool,
|
|
81
|
+
typer.Option(
|
|
82
|
+
"--no-color",
|
|
83
|
+
help="Disable colored output (also honored via the NO_COLOR env var).",
|
|
84
|
+
),
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _print_version(value: bool) -> None:
|
|
89
|
+
if value:
|
|
90
|
+
typer.echo(f"nac {__version__}")
|
|
91
|
+
raise typer.Exit()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
Version = Annotated[
|
|
95
|
+
bool,
|
|
96
|
+
typer.Option(
|
|
97
|
+
"--version",
|
|
98
|
+
help="Show the version and exit.",
|
|
99
|
+
callback=_print_version,
|
|
100
|
+
is_eager=True,
|
|
101
|
+
),
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
__all__ = [
|
|
105
|
+
"AutoApprove",
|
|
106
|
+
"Artifacts",
|
|
107
|
+
"ConfigPath",
|
|
108
|
+
"NoColor",
|
|
109
|
+
"Prewarm",
|
|
110
|
+
"Verbosity",
|
|
111
|
+
"VerbosityLevel",
|
|
112
|
+
"Version",
|
|
113
|
+
"Yes",
|
|
114
|
+
]
|