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/cli/passthrough.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright (c) 2026 Daniel Schmidt
|
|
3
|
+
|
|
4
|
+
"""Shared plumbing for forwarding extra CLI args to a wrapped tool and for
|
|
5
|
+
delegating `--help`/`-h` to that tool's own help output.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import NoReturn
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from nac.output import BOLD, color, format_command, utf8_env
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# `options_metavar` replaces the bare `[OPTIONS]` in nac's own usage line so it
|
|
23
|
+
# is clear that unrecognised arguments are forwarded to the wrapped tool.
|
|
24
|
+
PASSTHROUGH_OPTIONS_METAVAR = "[OPTIONS] [PASSTHROUGH-ARGS]..."
|
|
25
|
+
|
|
26
|
+
_ANSI = r"(?:\x1b\[[0-9;]*m|\s)*"
|
|
27
|
+
# First Rich panel border (`╭─ Arguments ─╮` / `╭─ Options ─╮`), possibly
|
|
28
|
+
# preceded by ANSI styling.
|
|
29
|
+
_FIRST_PANEL_RE = re.compile(r"^(?:\x1b\[[0-9;]*m)*╭", re.MULTILINE)
|
|
30
|
+
# First options section heading in terraform/tofu help (`Options:`,
|
|
31
|
+
# `Plan Customization Options:`, ...).
|
|
32
|
+
_FIRST_OPTIONS_HEADING_RE = re.compile(r"^[A-Za-z ]*Options:$", re.MULTILINE)
|
|
33
|
+
_USAGE_PROG_RE = re.compile(
|
|
34
|
+
rf"(Usage:{_ANSI})(?:nac-validate|nac-test|terraform|tofu)(?: \[global options\](?: [a-z]+)?)?"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
PASSTHROUGH_CONTEXT_SETTINGS = {
|
|
38
|
+
"allow_extra_args": True,
|
|
39
|
+
"ignore_unknown_options": True,
|
|
40
|
+
"help_option_names": [],
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def wants_help(ctx: typer.Context) -> bool:
|
|
45
|
+
"""Whether the leftover passthrough args include a help flag.
|
|
46
|
+
|
|
47
|
+
Safe to check anywhere in `ctx.args` (not just the first token) since
|
|
48
|
+
`ignore_unknown_options` guarantees nothing else has been pre-interpreted,
|
|
49
|
+
and none of the wrapped tools (nac-validate, nac-test, terraform/tofu)
|
|
50
|
+
have an unrelated `-h` short flag that could collide.
|
|
51
|
+
"""
|
|
52
|
+
return "--help" in ctx.args or "-h" in ctx.args
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _tool_help_body(text: str, usage_as: str) -> str:
|
|
56
|
+
"""Reduce the wrapped tool's help to what nac's own help doesn't cover.
|
|
57
|
+
|
|
58
|
+
Rich-based tools (nac-validate, nac-test) print a usage line and a
|
|
59
|
+
description before their Arguments/Options panels, and terraform/tofu
|
|
60
|
+
print theirs before the first `... Options:` heading; both are dropped,
|
|
61
|
+
as nac already printed its own usage and description. If neither marker
|
|
62
|
+
is found, the full text is kept with its usage line rewritten to the
|
|
63
|
+
`nac <command>` form the user actually types.
|
|
64
|
+
"""
|
|
65
|
+
start = _FIRST_PANEL_RE.search(text) or _FIRST_OPTIONS_HEADING_RE.search(text)
|
|
66
|
+
if start:
|
|
67
|
+
return text[start.start() :]
|
|
68
|
+
return _USAGE_PROG_RE.sub(lambda m: m.group(1) + usage_as, text, count=1)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def show_help_and_exit(
|
|
72
|
+
ctx: typer.Context,
|
|
73
|
+
tool_argv: list[str],
|
|
74
|
+
*,
|
|
75
|
+
cwd: Path,
|
|
76
|
+
env: dict[str, str],
|
|
77
|
+
no_color: bool,
|
|
78
|
+
tool_label: str,
|
|
79
|
+
usage_as: str,
|
|
80
|
+
) -> NoReturn:
|
|
81
|
+
"""Print nac's own help for this subcommand, then the wrapped tool's
|
|
82
|
+
own `--help` output below a labeled separator, and exit with that
|
|
83
|
+
tool's code.
|
|
84
|
+
|
|
85
|
+
The tool's output is captured (its stdout is not a TTY) so that:
|
|
86
|
+
- color and terminal width are forced on for it when nac's own stdout is
|
|
87
|
+
a TTY (and color is not disabled), matching nac's own help rendering;
|
|
88
|
+
- its own usage/description header is dropped (see `_tool_help_body`),
|
|
89
|
+
since nac already printed its own, leaving just the options.
|
|
90
|
+
"""
|
|
91
|
+
typer.echo(ctx.get_help())
|
|
92
|
+
|
|
93
|
+
child_env = utf8_env(env)
|
|
94
|
+
if sys.stdout.isatty():
|
|
95
|
+
child_env["COLUMNS"] = str(shutil.get_terminal_size().columns)
|
|
96
|
+
if not no_color:
|
|
97
|
+
child_env["FORCE_COLOR"] = "1"
|
|
98
|
+
|
|
99
|
+
logger.debug("Running %s", format_command(tool_argv))
|
|
100
|
+
proc = subprocess.run(
|
|
101
|
+
tool_argv,
|
|
102
|
+
cwd=cwd,
|
|
103
|
+
env=child_env,
|
|
104
|
+
stdout=subprocess.PIPE,
|
|
105
|
+
stderr=subprocess.STDOUT,
|
|
106
|
+
text=True,
|
|
107
|
+
encoding="utf-8",
|
|
108
|
+
errors="replace",
|
|
109
|
+
)
|
|
110
|
+
text = _tool_help_body(proc.stdout, usage_as)
|
|
111
|
+
|
|
112
|
+
rule = f"── Passed through to {tool_label} "
|
|
113
|
+
rule += "─" * max(0, shutil.get_terminal_size().columns - len(rule))
|
|
114
|
+
typer.echo(color(BOLD, rule, no_color=no_color))
|
|
115
|
+
typer.echo()
|
|
116
|
+
typer.echo(text, nl=False)
|
|
117
|
+
raise typer.Exit(code=proc.returncode)
|
nac/cli/plan.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright (c) 2026 Daniel Schmidt
|
|
3
|
+
|
|
4
|
+
"""`nac plan` -- saves a plan file and, optionally, plan.txt/plan.json."""
|
|
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 plan(ctx: typer.Context, artifacts: Artifacts = False) -> None:
|
|
25
|
+
"""Produce a saved plan file, optionally with plan.txt/plan.json artifacts.
|
|
26
|
+
|
|
27
|
+
Extra arguments are passed through to `terraform`/`tofu` (only to the
|
|
28
|
+
`plan` invocation itself, not to the `show` calls used for artifacts).
|
|
29
|
+
"""
|
|
30
|
+
cfg = get_config(ctx)
|
|
31
|
+
engine, binary = resolve_engine_or_exit(cfg)
|
|
32
|
+
env = terraform.build_engine_env(engine, cfg.tools.terraform.version)
|
|
33
|
+
no_color = get_no_color(ctx)
|
|
34
|
+
|
|
35
|
+
if wants_help(ctx):
|
|
36
|
+
show_help_and_exit(
|
|
37
|
+
ctx,
|
|
38
|
+
[binary, "plan", "--help"],
|
|
39
|
+
cwd=cfg.working_dir,
|
|
40
|
+
env=env,
|
|
41
|
+
no_color=get_no_color(ctx),
|
|
42
|
+
tool_label=f"{engine} plan",
|
|
43
|
+
usage_as="nac plan",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
code = runner.run_streaming(
|
|
47
|
+
terraform.build_plan_argv(binary, no_color=no_color) + ctx.args,
|
|
48
|
+
cwd=cfg.working_dir,
|
|
49
|
+
env=env,
|
|
50
|
+
no_color=no_color,
|
|
51
|
+
)
|
|
52
|
+
if code != 0:
|
|
53
|
+
raise typer.Exit(code=code)
|
|
54
|
+
|
|
55
|
+
if artifacts:
|
|
56
|
+
code = runner.run_streaming(
|
|
57
|
+
terraform.build_show_text_argv(binary, no_color=no_color),
|
|
58
|
+
cwd=cfg.working_dir,
|
|
59
|
+
env=env,
|
|
60
|
+
log_path=cfg.working_dir / "plan.txt",
|
|
61
|
+
no_color=no_color,
|
|
62
|
+
)
|
|
63
|
+
if code != 0:
|
|
64
|
+
raise typer.Exit(code=code)
|
|
65
|
+
|
|
66
|
+
code = runner.run_streaming(
|
|
67
|
+
terraform.build_show_json_argv(binary),
|
|
68
|
+
cwd=cfg.working_dir,
|
|
69
|
+
env=env,
|
|
70
|
+
log_path=cfg.working_dir / "plan.json",
|
|
71
|
+
no_color=no_color,
|
|
72
|
+
)
|
|
73
|
+
if code != 0:
|
|
74
|
+
raise typer.Exit(code=code)
|
|
75
|
+
|
|
76
|
+
raise typer.Exit(code=0)
|
nac/cli/setup.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright (c) 2026 Daniel Schmidt
|
|
3
|
+
|
|
4
|
+
"""`nac setup` -- verify prerequisites and report resolved tool versions."""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from nac import bootstrap, runner, terraform, tools
|
|
17
|
+
from nac.exceptions import BootstrapError, EngineNotFoundError, NoEngineAvailableError
|
|
18
|
+
from nac.manifest import MANIFEST_FILE
|
|
19
|
+
from nac.output import apply_no_color_env, echo_summary, format_command
|
|
20
|
+
from nac.tools import ToolSource
|
|
21
|
+
|
|
22
|
+
from .main import app, fail, get_config, get_no_color
|
|
23
|
+
from .options import Prewarm, Yes
|
|
24
|
+
from .versions import emit_warnings, get_manifest, tool_warnings
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
UV_NOT_FOUND = (
|
|
29
|
+
"`uv` not found on PATH -- install it: "
|
|
30
|
+
"https://docs.astral.sh/uv/getting-started/installation/"
|
|
31
|
+
)
|
|
32
|
+
TENV_NOT_FOUND = (
|
|
33
|
+
"`tenv` not found on PATH -- required because `tools.terraform.version` "
|
|
34
|
+
"is set. Install tenv: https://github.com/tofuutils/tenv"
|
|
35
|
+
)
|
|
36
|
+
MANUAL_INSTALL_MESSAGE = (
|
|
37
|
+
"Neither Terraform nor OpenTofu is installed. Install Terraform or "
|
|
38
|
+
"OpenTofu: https://developer.hashicorp.com/terraform/install or "
|
|
39
|
+
"https://opentofu.org/docs/intro/install/ -- or set "
|
|
40
|
+
"`tools.terraform.version` to have `nac` manage it via `tenv`."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_interactive() -> bool:
|
|
45
|
+
return bool(sys.stdin.isatty())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _confirm_bootstrap(yes: bool) -> bool:
|
|
49
|
+
if yes:
|
|
50
|
+
return True
|
|
51
|
+
if not _is_interactive():
|
|
52
|
+
return False
|
|
53
|
+
return typer.confirm(
|
|
54
|
+
"Neither Terraform nor OpenTofu is installed. Install OpenTofu now?",
|
|
55
|
+
default=True,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _binary_version(binary: str, env: dict[str, str]) -> str:
|
|
60
|
+
argv = [binary, "version"]
|
|
61
|
+
logger.debug("Running %s", format_command(argv))
|
|
62
|
+
result = subprocess.run(
|
|
63
|
+
argv,
|
|
64
|
+
capture_output=True,
|
|
65
|
+
text=True,
|
|
66
|
+
encoding="utf-8",
|
|
67
|
+
errors="replace",
|
|
68
|
+
env=env,
|
|
69
|
+
check=False,
|
|
70
|
+
)
|
|
71
|
+
for line in (result.stdout or result.stderr or "").splitlines():
|
|
72
|
+
if line.strip():
|
|
73
|
+
return line.strip()
|
|
74
|
+
return "unknown"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _is_cached_binary(binary: str) -> bool:
|
|
78
|
+
"""True if `binary` lives under nac's bootstrap cache dir rather than PATH."""
|
|
79
|
+
try:
|
|
80
|
+
Path(binary).resolve().relative_to(bootstrap.cache_dir().resolve())
|
|
81
|
+
except ValueError:
|
|
82
|
+
return False
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command()
|
|
87
|
+
def setup(ctx: typer.Context, yes: Yes = False, prewarm: Prewarm = False) -> None:
|
|
88
|
+
"""Verify prerequisites and report resolved tool versions."""
|
|
89
|
+
cfg = get_config(ctx)
|
|
90
|
+
no_color = get_no_color(ctx)
|
|
91
|
+
|
|
92
|
+
if shutil.which("uv") is None:
|
|
93
|
+
fail(UV_NOT_FOUND)
|
|
94
|
+
lines: list[str] = ["uv: found"]
|
|
95
|
+
|
|
96
|
+
if cfg.tools.terraform.version is not None and shutil.which("tenv") is None:
|
|
97
|
+
fail(TENV_NOT_FOUND)
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
engine, binary = terraform.resolve_engine_binary(cfg.tools.terraform)
|
|
101
|
+
if cfg.tools.terraform.engine is not None:
|
|
102
|
+
detection = "explicit"
|
|
103
|
+
elif _is_cached_binary(binary):
|
|
104
|
+
detection = "bootstrap-installed"
|
|
105
|
+
else:
|
|
106
|
+
detection = "auto-detected"
|
|
107
|
+
except EngineNotFoundError as exc:
|
|
108
|
+
fail(str(exc))
|
|
109
|
+
except NoEngineAvailableError:
|
|
110
|
+
if not _confirm_bootstrap(yes):
|
|
111
|
+
fail(MANUAL_INSTALL_MESSAGE)
|
|
112
|
+
try:
|
|
113
|
+
binary_path = bootstrap.install(confirmed=True)
|
|
114
|
+
except BootstrapError as exc:
|
|
115
|
+
fail(str(exc))
|
|
116
|
+
engine, binary, detection = "tofu", str(binary_path), "bootstrap-installed"
|
|
117
|
+
|
|
118
|
+
env = terraform.build_engine_env(engine, cfg.tools.terraform.version)
|
|
119
|
+
lines.append(f"{engine}: {_binary_version(binary, env)} ({detection})")
|
|
120
|
+
|
|
121
|
+
manifest = get_manifest(ctx, cfg)
|
|
122
|
+
if manifest is not None:
|
|
123
|
+
lines.append(f"tested versions: {manifest.module} ({MANIFEST_FILE})")
|
|
124
|
+
|
|
125
|
+
missing_env = [name for name in cfg.env.required if name not in os.environ]
|
|
126
|
+
if missing_env:
|
|
127
|
+
fail("Missing required environment variable(s): " + ", ".join(missing_env))
|
|
128
|
+
if cfg.env.required:
|
|
129
|
+
lines.append("env.required: all present (" + ", ".join(cfg.env.required) + ")")
|
|
130
|
+
|
|
131
|
+
def _tool_summary_line(
|
|
132
|
+
name: Literal["nac-validate", "nac-test"], explicit: str | None
|
|
133
|
+
) -> tuple[str, ToolSource, str | None]:
|
|
134
|
+
constraint = tools.tool_constraint(name, explicit, manifest)
|
|
135
|
+
source = tools.resolve_tool_source(name, constraint)
|
|
136
|
+
if source.mode == "local":
|
|
137
|
+
return f"{name}: v{source.version or 'unknown'} (local)", source, constraint
|
|
138
|
+
pin = constraint or "latest/unpinned"
|
|
139
|
+
if explicit is None and constraint is not None and manifest is not None:
|
|
140
|
+
pin += f", latest compatible with {manifest.module}"
|
|
141
|
+
suffix = (
|
|
142
|
+
f" -- local v{source.version} does not satisfy"
|
|
143
|
+
if source.version is not None
|
|
144
|
+
else ""
|
|
145
|
+
)
|
|
146
|
+
return f"{name}: {pin} (uvx{suffix})", source, constraint
|
|
147
|
+
|
|
148
|
+
validate_line, validate_source, validate_constraint = _tool_summary_line(
|
|
149
|
+
"nac-validate", cfg.tools.nac_validate
|
|
150
|
+
)
|
|
151
|
+
test_line, test_source, test_constraint = _tool_summary_line(
|
|
152
|
+
"nac-test", cfg.tools.nac_test
|
|
153
|
+
)
|
|
154
|
+
lines.append(validate_line)
|
|
155
|
+
lines.append(test_line)
|
|
156
|
+
warnings = [
|
|
157
|
+
*tool_warnings(manifest, "nac-validate", cfg.tools.nac_validate),
|
|
158
|
+
*tool_warnings(manifest, "nac-test", cfg.tools.nac_test),
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
if prewarm:
|
|
162
|
+
base_env = apply_no_color_env(no_color)
|
|
163
|
+
if validate_source.mode != "local":
|
|
164
|
+
argv = tools.build_prewarm_argv("nac-validate", validate_constraint)
|
|
165
|
+
code = runner.run_streaming(
|
|
166
|
+
argv, cwd=cfg.working_dir, env=base_env, no_color=no_color
|
|
167
|
+
)
|
|
168
|
+
if code != 0:
|
|
169
|
+
raise typer.Exit(code=code)
|
|
170
|
+
if test_source.mode != "local":
|
|
171
|
+
argv = tools.build_prewarm_argv("nac-test", test_constraint)
|
|
172
|
+
code = runner.run_streaming(
|
|
173
|
+
argv, cwd=cfg.working_dir, env=base_env, no_color=no_color
|
|
174
|
+
)
|
|
175
|
+
if code != 0:
|
|
176
|
+
raise typer.Exit(code=code)
|
|
177
|
+
|
|
178
|
+
echo_summary("Setup Summary", lines, no_color=no_color)
|
|
179
|
+
emit_warnings(warnings, no_color=no_color)
|
nac/cli/test.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright (c) 2026 Daniel Schmidt
|
|
3
|
+
|
|
4
|
+
"""`nac test` -- streams nac-test built from config."""
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from nac import runner, tools
|
|
9
|
+
from nac.output import apply_no_color_env
|
|
10
|
+
|
|
11
|
+
from .main import app, get_config, get_no_color
|
|
12
|
+
from .options import Artifacts
|
|
13
|
+
from .passthrough import (
|
|
14
|
+
PASSTHROUGH_CONTEXT_SETTINGS,
|
|
15
|
+
PASSTHROUGH_OPTIONS_METAVAR,
|
|
16
|
+
show_help_and_exit,
|
|
17
|
+
wants_help,
|
|
18
|
+
)
|
|
19
|
+
from .versions import emit_warnings, get_manifest, note_on_failure, tool_warnings
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.command(
|
|
23
|
+
name="test",
|
|
24
|
+
context_settings=PASSTHROUGH_CONTEXT_SETTINGS,
|
|
25
|
+
options_metavar=PASSTHROUGH_OPTIONS_METAVAR,
|
|
26
|
+
)
|
|
27
|
+
def test_(ctx: typer.Context, artifacts: Artifacts = False) -> None:
|
|
28
|
+
"""Run nac-test against the configured data/templates/filters/output.
|
|
29
|
+
|
|
30
|
+
Extra arguments are passed through to `nac-test`.
|
|
31
|
+
"""
|
|
32
|
+
cfg = get_config(ctx)
|
|
33
|
+
no_color = get_no_color(ctx)
|
|
34
|
+
log_path = cfg.working_dir / "test.txt" if artifacts else None
|
|
35
|
+
manifest = get_manifest(ctx, cfg)
|
|
36
|
+
|
|
37
|
+
if wants_help(ctx):
|
|
38
|
+
show_help_and_exit(
|
|
39
|
+
ctx,
|
|
40
|
+
tools.build_help_argv(
|
|
41
|
+
"nac-test",
|
|
42
|
+
tools.tool_constraint("nac-test", cfg.tools.nac_test, manifest),
|
|
43
|
+
),
|
|
44
|
+
cwd=cfg.working_dir,
|
|
45
|
+
env=apply_no_color_env(no_color),
|
|
46
|
+
no_color=no_color,
|
|
47
|
+
tool_label="nac-test",
|
|
48
|
+
usage_as="nac test",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
warnings = tool_warnings(manifest, "nac-test", cfg.tools.nac_test)
|
|
52
|
+
log_written = emit_warnings(warnings, no_color=no_color, log_path=log_path)
|
|
53
|
+
|
|
54
|
+
code = runner.run_streaming(
|
|
55
|
+
tools.build_test_argv(cfg, manifest) + ctx.args,
|
|
56
|
+
cwd=cfg.working_dir,
|
|
57
|
+
env=apply_no_color_env(no_color),
|
|
58
|
+
log_path=log_path,
|
|
59
|
+
append=log_written,
|
|
60
|
+
no_color=no_color,
|
|
61
|
+
)
|
|
62
|
+
note_on_failure("test", code, warnings, no_color=no_color, log_path=log_path)
|
|
63
|
+
raise typer.Exit(code=code)
|
nac/cli/validate.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright (c) 2026 Daniel Schmidt
|
|
3
|
+
|
|
4
|
+
"""`nac validate` -- optional targeted render, then nac-validate."""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from nac import runner, terraform, tools
|
|
11
|
+
from nac.output import GREEN, apply_no_color_env, color, echo_summary
|
|
12
|
+
|
|
13
|
+
from .main import app, get_config, get_no_color, resolve_engine_or_exit
|
|
14
|
+
from .options import Artifacts
|
|
15
|
+
from .passthrough import (
|
|
16
|
+
PASSTHROUGH_CONTEXT_SETTINGS,
|
|
17
|
+
PASSTHROUGH_OPTIONS_METAVAR,
|
|
18
|
+
show_help_and_exit,
|
|
19
|
+
wants_help,
|
|
20
|
+
)
|
|
21
|
+
from .versions import emit_warnings, get_manifest, note_on_failure, tool_warnings
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command(
|
|
25
|
+
context_settings=PASSTHROUGH_CONTEXT_SETTINGS,
|
|
26
|
+
options_metavar=PASSTHROUGH_OPTIONS_METAVAR,
|
|
27
|
+
)
|
|
28
|
+
def validate(ctx: typer.Context, artifacts: Artifacts = False) -> None:
|
|
29
|
+
"""Render (if configured) then validate the resolved data paths.
|
|
30
|
+
|
|
31
|
+
Extra arguments are passed through to `nac-validate`.
|
|
32
|
+
"""
|
|
33
|
+
cfg = get_config(ctx)
|
|
34
|
+
no_color = get_no_color(ctx)
|
|
35
|
+
log_path = cfg.working_dir / "validate.txt" if artifacts else None
|
|
36
|
+
manifest = get_manifest(ctx, cfg)
|
|
37
|
+
constraint = tools.tool_constraint("nac-validate", cfg.tools.nac_validate, manifest)
|
|
38
|
+
|
|
39
|
+
if wants_help(ctx):
|
|
40
|
+
show_help_and_exit(
|
|
41
|
+
ctx,
|
|
42
|
+
tools.build_help_argv("nac-validate", constraint),
|
|
43
|
+
cwd=cfg.working_dir,
|
|
44
|
+
env=apply_no_color_env(no_color),
|
|
45
|
+
no_color=no_color,
|
|
46
|
+
tool_label="nac-validate",
|
|
47
|
+
usage_as="nac validate",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
warnings = tool_warnings(manifest, "nac-validate", cfg.tools.nac_validate)
|
|
51
|
+
log_written = emit_warnings(warnings, no_color=no_color, log_path=log_path)
|
|
52
|
+
|
|
53
|
+
if cfg.render is not None and cfg.render.target is not None:
|
|
54
|
+
engine, binary = resolve_engine_or_exit(cfg)
|
|
55
|
+
env = terraform.build_engine_env(engine, cfg.tools.terraform.version)
|
|
56
|
+
quiet = not logging.getLogger().isEnabledFor(logging.INFO)
|
|
57
|
+
code = runner.run_streaming(
|
|
58
|
+
terraform.build_render_apply_argv(
|
|
59
|
+
binary, cfg.render.target, no_color=no_color
|
|
60
|
+
),
|
|
61
|
+
cwd=cfg.working_dir,
|
|
62
|
+
env=env,
|
|
63
|
+
quiet=quiet,
|
|
64
|
+
log_path=log_path,
|
|
65
|
+
append=log_written,
|
|
66
|
+
no_color=no_color,
|
|
67
|
+
)
|
|
68
|
+
log_written = True
|
|
69
|
+
if code != 0:
|
|
70
|
+
raise typer.Exit(code=code)
|
|
71
|
+
if quiet:
|
|
72
|
+
echo_summary(
|
|
73
|
+
"Render Summary",
|
|
74
|
+
[
|
|
75
|
+
f"Render: {color(GREEN, 'OK', no_color=no_color)} "
|
|
76
|
+
f"({cfg.render.target})"
|
|
77
|
+
],
|
|
78
|
+
no_color=no_color,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
code = runner.run_streaming(
|
|
82
|
+
tools.build_validate_argv(cfg, manifest) + ctx.args,
|
|
83
|
+
cwd=cfg.working_dir,
|
|
84
|
+
env=apply_no_color_env(no_color),
|
|
85
|
+
log_path=log_path,
|
|
86
|
+
append=log_written,
|
|
87
|
+
no_color=no_color,
|
|
88
|
+
)
|
|
89
|
+
note_on_failure("validate", code, warnings, no_color=no_color, log_path=log_path)
|
|
90
|
+
raise typer.Exit(code=code)
|
nac/cli/versions.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright (c) 2026 Daniel Schmidt
|
|
3
|
+
|
|
4
|
+
"""Advisory checks of nac-validate/nac-test against the module's tested versions.
|
|
5
|
+
|
|
6
|
+
`validate`/`test` check the tool they run (and `setup` both) and print a
|
|
7
|
+
warning for versions outside the tested, semver-compatible range -- before
|
|
8
|
+
the tool runs, plus a short note afterwards if it failed. Never changes
|
|
9
|
+
exit codes.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Literal, cast
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from nac import tools
|
|
18
|
+
from nac.config import NacConfig
|
|
19
|
+
from nac.manifest import Manifest, load_manifest
|
|
20
|
+
from nac.output import YELLOW, color
|
|
21
|
+
|
|
22
|
+
_MANIFEST_KEY = "manifest"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_manifest(ctx: typer.Context, cfg: NacConfig) -> Manifest | None:
|
|
26
|
+
"""Load (once per invocation) the manifest of the initialized module."""
|
|
27
|
+
if _MANIFEST_KEY not in ctx.meta:
|
|
28
|
+
ctx.meta[_MANIFEST_KEY] = load_manifest(cfg.working_dir)
|
|
29
|
+
return cast("Manifest | None", ctx.meta[_MANIFEST_KEY])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def tool_warnings(
|
|
33
|
+
manifest: Manifest | None,
|
|
34
|
+
tool: Literal["nac-validate", "nac-test"],
|
|
35
|
+
explicit: str | None,
|
|
36
|
+
) -> list[str]:
|
|
37
|
+
"""Warnings for nac-validate/nac-test.
|
|
38
|
+
|
|
39
|
+
Without an explicit `tools.*` constraint the tool is resolved to the
|
|
40
|
+
latest compatible release (see `tools.tool_constraint`), so there is
|
|
41
|
+
nothing to warn about. With one, the version is known only for a local
|
|
42
|
+
install or an exact pin -- a range resolved by uvx isn't checked.
|
|
43
|
+
"""
|
|
44
|
+
if manifest is None or explicit is None or tool not in manifest.tools:
|
|
45
|
+
return []
|
|
46
|
+
source = tools.resolve_tool_source(tool, explicit)
|
|
47
|
+
version = source.version if source.mode == "local" else tools.exact_pin(explicit)
|
|
48
|
+
warning = manifest.check_tool(tool, version)
|
|
49
|
+
return [warning] if warning is not None else []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def emit_warnings(
|
|
53
|
+
warnings: list[str],
|
|
54
|
+
*,
|
|
55
|
+
no_color: bool,
|
|
56
|
+
log_path: Path | None = None,
|
|
57
|
+
append: bool = False,
|
|
58
|
+
) -> bool:
|
|
59
|
+
"""Print warnings to stderr and, if given, write them to log_path.
|
|
60
|
+
|
|
61
|
+
Returns whether log_path was written, i.e. whether the next
|
|
62
|
+
`run_streaming` call for the same artifact must append to it.
|
|
63
|
+
"""
|
|
64
|
+
for warning in warnings:
|
|
65
|
+
typer.echo(color(YELLOW, f"Warning: {warning}", no_color=no_color), err=True)
|
|
66
|
+
if not warnings or log_path is None:
|
|
67
|
+
return False
|
|
68
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
with log_path.open("a" if append else "w", encoding="utf-8") as fh:
|
|
70
|
+
fh.writelines(f"Warning: {warning}\n" for warning in warnings)
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def note_on_failure(
|
|
75
|
+
command: str,
|
|
76
|
+
code: int,
|
|
77
|
+
warnings: list[str],
|
|
78
|
+
*,
|
|
79
|
+
no_color: bool,
|
|
80
|
+
log_path: Path | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""After a failed run, point back at the version warnings printed earlier."""
|
|
83
|
+
if code == 0 or not warnings:
|
|
84
|
+
return
|
|
85
|
+
note = (
|
|
86
|
+
f"Note: `nac {command}` failed while using versions outside the tested "
|
|
87
|
+
"range (see warnings above); this may be related."
|
|
88
|
+
)
|
|
89
|
+
typer.echo(color(YELLOW, note, no_color=no_color), err=True)
|
|
90
|
+
if log_path is not None and log_path.is_file():
|
|
91
|
+
with log_path.open("a", encoding="utf-8") as fh:
|
|
92
|
+
fh.write(f"{note}\n")
|