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/terraform.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright (c) 2026 Daniel Schmidt
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
|
|
8
|
+
from nac import bootstrap
|
|
9
|
+
from nac.config import TerraformToolConfig
|
|
10
|
+
from nac.exceptions import EngineNotFoundError, NoEngineAvailableError
|
|
11
|
+
|
|
12
|
+
TOFU_VERSION_ENV = "TOFUENV_TOFU_VERSION"
|
|
13
|
+
TOFU_AUTO_INSTALL_ENV = "TOFUENV_AUTO_INSTALL"
|
|
14
|
+
TERRAFORM_VERSION_ENV = "TFENV_TERRAFORM_VERSION"
|
|
15
|
+
TERRAFORM_AUTO_INSTALL_ENV = "TFENV_AUTO_INSTALL"
|
|
16
|
+
|
|
17
|
+
DEFAULT_PLAN_FILE = "plan.tfplan"
|
|
18
|
+
DEFAULT_PLAN_TEXT_FILE = "plan.txt"
|
|
19
|
+
DEFAULT_PLAN_JSON_FILE = "plan.json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def resolve_engine_binary(cfg: TerraformToolConfig) -> tuple[str, str]:
|
|
23
|
+
"""Resolve which terraform/tofu binary to use.
|
|
24
|
+
|
|
25
|
+
Returns (engine_name, resolved_absolute_path). An explicit
|
|
26
|
+
`cfg.engine` is a hard requirement — it never falls back to the
|
|
27
|
+
other binary. When unset, `tofu` is tried before `terraform`, then
|
|
28
|
+
a previously bootstrap-installed `tofu` in the cache dir (FR-12),
|
|
29
|
+
so a machine set up via `nac setup` keeps working on subsequent
|
|
30
|
+
commands without re-prompting.
|
|
31
|
+
"""
|
|
32
|
+
if cfg.engine is not None:
|
|
33
|
+
path = shutil.which(cfg.engine)
|
|
34
|
+
if path is None:
|
|
35
|
+
raise EngineNotFoundError(
|
|
36
|
+
f"`{cfg.engine}` not found on PATH — install it, or set "
|
|
37
|
+
f"`tools.terraform.version` to have `nac` manage it via `tenv`."
|
|
38
|
+
)
|
|
39
|
+
return cfg.engine, path
|
|
40
|
+
|
|
41
|
+
for engine in ("tofu", "terraform"):
|
|
42
|
+
path = shutil.which(engine)
|
|
43
|
+
if path is not None:
|
|
44
|
+
return engine, path
|
|
45
|
+
|
|
46
|
+
cached = bootstrap.find_cached_binary()
|
|
47
|
+
if cached is not None:
|
|
48
|
+
return "tofu", str(cached)
|
|
49
|
+
|
|
50
|
+
raise NoEngineAvailableError("Neither `tofu` nor `terraform` was found on PATH.")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_engine_env(
|
|
54
|
+
engine: str,
|
|
55
|
+
version: str | None,
|
|
56
|
+
base_env: Mapping[str, str] | None = None,
|
|
57
|
+
) -> dict[str, str]:
|
|
58
|
+
"""Build the subprocess environment for the resolved engine.
|
|
59
|
+
|
|
60
|
+
Returns a new dict (never mutates base_env). Only when `version` is
|
|
61
|
+
set does it add the matching tenv version-pin + auto-install vars.
|
|
62
|
+
"""
|
|
63
|
+
env = dict(base_env if base_env is not None else os.environ)
|
|
64
|
+
if version is not None:
|
|
65
|
+
if engine == "tofu":
|
|
66
|
+
env[TOFU_VERSION_ENV] = version
|
|
67
|
+
env[TOFU_AUTO_INSTALL_ENV] = "true"
|
|
68
|
+
elif engine == "terraform":
|
|
69
|
+
env[TERRAFORM_VERSION_ENV] = version
|
|
70
|
+
env[TERRAFORM_AUTO_INSTALL_ENV] = "true"
|
|
71
|
+
return env
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_init_argv(binary: str, *, no_color: bool = False) -> list[str]:
|
|
75
|
+
argv = [binary, "init"]
|
|
76
|
+
if no_color:
|
|
77
|
+
argv.append("-no-color")
|
|
78
|
+
return argv
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_render_apply_argv(
|
|
82
|
+
binary: str, target: str, *, no_color: bool = False
|
|
83
|
+
) -> list[str]:
|
|
84
|
+
argv = [binary, "apply", f"-target={target}", "-auto-approve"]
|
|
85
|
+
if no_color:
|
|
86
|
+
argv.append("-no-color")
|
|
87
|
+
return argv
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def build_plan_argv(
|
|
91
|
+
binary: str, out_file: str = DEFAULT_PLAN_FILE, *, no_color: bool = False
|
|
92
|
+
) -> list[str]:
|
|
93
|
+
argv = [binary, "plan", f"-out={out_file}"]
|
|
94
|
+
if no_color:
|
|
95
|
+
argv.append("-no-color")
|
|
96
|
+
return argv
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def build_show_text_argv(
|
|
100
|
+
binary: str, plan_file: str = DEFAULT_PLAN_FILE, *, no_color: bool = False
|
|
101
|
+
) -> list[str]:
|
|
102
|
+
argv = [binary, "show"]
|
|
103
|
+
if no_color:
|
|
104
|
+
argv.append("-no-color")
|
|
105
|
+
argv.append(plan_file)
|
|
106
|
+
return argv
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def build_show_json_argv(binary: str, plan_file: str = DEFAULT_PLAN_FILE) -> list[str]:
|
|
110
|
+
return [binary, "show", "-json", plan_file]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def build_apply_argv(
|
|
114
|
+
binary: str,
|
|
115
|
+
*,
|
|
116
|
+
plan_file: str | None = None,
|
|
117
|
+
auto_approve: bool = False,
|
|
118
|
+
no_color: bool = False,
|
|
119
|
+
) -> list[str]:
|
|
120
|
+
argv = [binary, "apply"]
|
|
121
|
+
if no_color:
|
|
122
|
+
argv.append("-no-color")
|
|
123
|
+
if plan_file is not None:
|
|
124
|
+
argv.append(plan_file)
|
|
125
|
+
return argv
|
|
126
|
+
if auto_approve:
|
|
127
|
+
argv.append("-auto-approve")
|
|
128
|
+
return argv
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def build_destroy_argv(
|
|
132
|
+
binary: str,
|
|
133
|
+
*,
|
|
134
|
+
auto_approve: bool = False,
|
|
135
|
+
no_color: bool = False,
|
|
136
|
+
) -> list[str]:
|
|
137
|
+
argv = [binary, "destroy"]
|
|
138
|
+
if no_color:
|
|
139
|
+
argv.append("-no-color")
|
|
140
|
+
if auto_approve:
|
|
141
|
+
argv.append("-auto-approve")
|
|
142
|
+
return argv
|
nac/tools.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright (c) 2026 Daniel Schmidt
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
|
12
|
+
from packaging.version import InvalidVersion, Version
|
|
13
|
+
|
|
14
|
+
from nac.config import NacConfig, resolve_test_data, resolve_validate_data
|
|
15
|
+
from nac.manifest import Manifest, compatible_range
|
|
16
|
+
from nac.output import format_command
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_SPECIFIER_PREFIXES = ("==", "!=", ">=", "<=", "~=", ">", "<")
|
|
21
|
+
_VERSION_RE = re.compile(r"\d+(?:\.\d+)+(?:[a-zA-Z0-9.\-+]*)?")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _pin_suffix(version: str) -> str:
|
|
25
|
+
v = version.strip()
|
|
26
|
+
return v if v.startswith(_SPECIFIER_PREFIXES) else f"=={v}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ToolSource:
|
|
31
|
+
"""Where a wrapped tool (`nac-validate`/`nac-test`) will be run from.
|
|
32
|
+
|
|
33
|
+
`version` is populated whenever a local binary was found, even if it
|
|
34
|
+
didn't satisfy `constraint` (mode is then "uvx") -- so callers can
|
|
35
|
+
explain *why* uvx was used instead of the local install.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
mode: Literal["local", "uvx"]
|
|
39
|
+
path: str | None
|
|
40
|
+
version: str | None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _local_tool_version(binary: str) -> str | None:
|
|
44
|
+
argv = [binary, "--version"]
|
|
45
|
+
logger.debug("Running %s", format_command(argv))
|
|
46
|
+
try:
|
|
47
|
+
result = subprocess.run(
|
|
48
|
+
argv,
|
|
49
|
+
capture_output=True,
|
|
50
|
+
text=True,
|
|
51
|
+
encoding="utf-8",
|
|
52
|
+
errors="replace",
|
|
53
|
+
check=False,
|
|
54
|
+
)
|
|
55
|
+
except OSError:
|
|
56
|
+
return None
|
|
57
|
+
text = (result.stdout or "") + (result.stderr or "")
|
|
58
|
+
match = _VERSION_RE.search(text)
|
|
59
|
+
return match.group(0) if match else None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _satisfies(version: str, constraint: str) -> bool:
|
|
63
|
+
try:
|
|
64
|
+
spec = SpecifierSet(_pin_suffix(constraint))
|
|
65
|
+
return spec.contains(Version(version), prereleases=True)
|
|
66
|
+
except (InvalidSpecifier, InvalidVersion):
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def tool_constraint(
|
|
71
|
+
tool: Literal["nac-validate", "nac-test"],
|
|
72
|
+
explicit: str | None,
|
|
73
|
+
manifest: Manifest | None,
|
|
74
|
+
) -> str | None:
|
|
75
|
+
"""Version constraint to resolve `tool` with.
|
|
76
|
+
|
|
77
|
+
An explicit `tools.nac_validate`/`tools.nac_test` always wins. Otherwise,
|
|
78
|
+
if the module's manifest lists a tested version, default to the latest
|
|
79
|
+
semver-compatible release of it rather than the latest overall.
|
|
80
|
+
"""
|
|
81
|
+
if explicit is not None:
|
|
82
|
+
return explicit
|
|
83
|
+
tested = manifest.tools.get(tool) if manifest is not None else None
|
|
84
|
+
return compatible_range(tested) if tested is not None else None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def exact_pin(constraint: str) -> str | None:
|
|
88
|
+
"""The single version `constraint` allows (`1.2.0`, `==1.2.0`), else None."""
|
|
89
|
+
try:
|
|
90
|
+
specs = list(SpecifierSet(_pin_suffix(constraint)))
|
|
91
|
+
except InvalidSpecifier:
|
|
92
|
+
return None
|
|
93
|
+
if len(specs) != 1:
|
|
94
|
+
return None
|
|
95
|
+
spec = specs[0]
|
|
96
|
+
if spec.operator not in ("==", "===") or "*" in spec.version:
|
|
97
|
+
return None
|
|
98
|
+
return spec.version
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def resolve_tool_source(
|
|
102
|
+
tool: Literal["nac-validate", "nac-test"], constraint: str | None
|
|
103
|
+
) -> ToolSource:
|
|
104
|
+
"""Prefer a local install that satisfies `constraint`; else fall back to uvx.
|
|
105
|
+
|
|
106
|
+
Mirrors the PATH-first resolution `nac.terraform.resolve_engine_binary`
|
|
107
|
+
does for terraform/tofu, adapted for the fact that there's no external
|
|
108
|
+
version manager here -- `nac` itself checks the constraint.
|
|
109
|
+
"""
|
|
110
|
+
path = shutil.which(tool)
|
|
111
|
+
if path is None:
|
|
112
|
+
return ToolSource(mode="uvx", path=None, version=None)
|
|
113
|
+
|
|
114
|
+
version = _local_tool_version(path)
|
|
115
|
+
if constraint is None or (version is not None and _satisfies(version, constraint)):
|
|
116
|
+
return ToolSource(mode="local", path=path, version=version)
|
|
117
|
+
return ToolSource(mode="uvx", path=None, version=version)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _tool_prefix(
|
|
121
|
+
tool: Literal["nac-validate", "nac-test"], constraint: str | None
|
|
122
|
+
) -> list[str]:
|
|
123
|
+
source = resolve_tool_source(tool, constraint)
|
|
124
|
+
if source.mode == "local":
|
|
125
|
+
assert source.path is not None
|
|
126
|
+
return [source.path]
|
|
127
|
+
argv = ["uvx"]
|
|
128
|
+
if constraint is not None:
|
|
129
|
+
argv += ["--from", f"{tool}{_pin_suffix(constraint)}"]
|
|
130
|
+
return argv + [tool]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def build_validate_argv(cfg: NacConfig, manifest: Manifest | None = None) -> list[str]:
|
|
134
|
+
data = resolve_validate_data(cfg)
|
|
135
|
+
constraint = tool_constraint("nac-validate", cfg.tools.nac_validate, manifest)
|
|
136
|
+
argv = _tool_prefix("nac-validate", constraint)
|
|
137
|
+
argv += data
|
|
138
|
+
if cfg.validate.schema is not None:
|
|
139
|
+
argv += ["-s", cfg.validate.schema]
|
|
140
|
+
for rule in cfg.validate.rules or []:
|
|
141
|
+
argv += ["-r", rule]
|
|
142
|
+
return argv
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def build_test_argv(cfg: NacConfig, manifest: Manifest | None = None) -> list[str]:
|
|
146
|
+
data = resolve_test_data(cfg)
|
|
147
|
+
constraint = tool_constraint("nac-test", cfg.tools.nac_test, manifest)
|
|
148
|
+
argv = _tool_prefix("nac-test", constraint)
|
|
149
|
+
for d in data:
|
|
150
|
+
argv += ["-d", d]
|
|
151
|
+
if cfg.test.templates is not None:
|
|
152
|
+
argv += ["-t", cfg.test.templates]
|
|
153
|
+
if cfg.test.filters is not None:
|
|
154
|
+
argv += ["-f", cfg.test.filters]
|
|
155
|
+
argv += ["-o", cfg.test.output]
|
|
156
|
+
return argv
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def build_prewarm_argv(
|
|
160
|
+
tool: Literal["nac-validate", "nac-test"], version: str | None
|
|
161
|
+
) -> list[str]:
|
|
162
|
+
name = f"{tool}{_pin_suffix(version)}" if version is not None else tool
|
|
163
|
+
return ["uv", "tool", "install", "--from", name, tool]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def build_help_argv(
|
|
167
|
+
tool: Literal["nac-validate", "nac-test"], version: str | None
|
|
168
|
+
) -> list[str]:
|
|
169
|
+
return _tool_prefix(tool, version) + ["--help"]
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: nac-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An umbrella CLI wrapping terraform/tofu, nac-validate, and nac-test for Network-as-Code workflows.
|
|
5
|
+
Project-URL: homepage, https://github.com/netascode/nac
|
|
6
|
+
Project-URL: repository, https://github.com/netascode/nac
|
|
7
|
+
Project-URL: documentation, https://github.com/netascode/nac
|
|
8
|
+
Author-email: Daniel Schmidt <danischm@cisco.com>
|
|
9
|
+
Maintainer-email: Daniel Schmidt <danischm@cisco.com>
|
|
10
|
+
License: MPL-2.0
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: automation,cli,nac,network-as-code,opentofu,terraform
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Requires-Dist: httpx>=0.28
|
|
26
|
+
Requires-Dist: packaging>=24
|
|
27
|
+
Requires-Dist: platformdirs>=4.3
|
|
28
|
+
Requires-Dist: pydantic>=2.9
|
|
29
|
+
Requires-Dist: ruamel-yaml>=0.19.1
|
|
30
|
+
Requires-Dist: typer>=0.25.1
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: bandit[toml]>=1.9.1; extra == 'dev'
|
|
33
|
+
Requires-Dist: pre-commit>=4.4.0; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest-cov>=7.0.0; extra == 'dev'
|
|
35
|
+
Requires-Dist: pytest-mock>=3.15.1; extra == 'dev'
|
|
36
|
+
Requires-Dist: pytest>=9.0.1; extra == 'dev'
|
|
37
|
+
Requires-Dist: ruff>=0.14.5; extra == 'dev'
|
|
38
|
+
Requires-Dist: ty>=0.0.78; extra == 'dev'
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
[](https://github.com/netascode/nac/actions/workflows/test.yml)
|
|
42
|
+

|
|
43
|
+
|
|
44
|
+
# nac
|
|
45
|
+
|
|
46
|
+
An umbrella CLI for Network-as-Code workflows, driven by a single
|
|
47
|
+
`nac.yaml` config file.
|
|
48
|
+
|
|
49
|
+
## Overview
|
|
50
|
+
|
|
51
|
+
Network-as-Code repos run the same workflow regardless of architecture:
|
|
52
|
+
validate the data, plan, apply, test. `nac` is one CLI for all of it. Instead
|
|
53
|
+
of juggling `terraform`/`tofu`, `nac-validate` and `nac-test` with their own
|
|
54
|
+
flags, you describe the repo once in `nac.yaml` and run `nac validate`,
|
|
55
|
+
`nac plan`, `nac apply`, `nac test`. The same commands work on a laptop and
|
|
56
|
+
in CI.
|
|
57
|
+
|
|
58
|
+
## Installation
|
|
59
|
+
|
|
60
|
+
Install `nac` itself and there's nothing else to set up: it resolves and
|
|
61
|
+
runs the rest of the toolchain for you (via `uvx`, falling back to a local
|
|
62
|
+
install if one's already on `PATH`), so there's no separate
|
|
63
|
+
`nac-validate`/`nac-test` install or version-pinning to manage yourself.
|
|
64
|
+
|
|
65
|
+
The package is published on PyPI as **`nac-cli`**; the command it installs is
|
|
66
|
+
`nac`. Install [`uv`](https://docs.astral.sh/uv/getting-started/installation/)
|
|
67
|
+
first if you don't have it, then:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
uv tool install nac-cli
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Upgrade or uninstall with:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
uv tool upgrade nac-cli
|
|
77
|
+
uv tool uninstall nac-cli
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Pin a specific version with `uv tool install nac-cli==0.1.0`.
|
|
81
|
+
|
|
82
|
+
> [!WARNING]
|
|
83
|
+
> Don't `pip install nac` or run `uvx nac` -- that is a different, unrelated
|
|
84
|
+
> package.
|
|
85
|
+
|
|
86
|
+
## Prerequisites
|
|
87
|
+
|
|
88
|
+
- [`uv`](https://docs.astral.sh/uv/getting-started/installation/) — always
|
|
89
|
+
required; `nac` uses it to run `nac-validate`/`nac-test` via `uvx`, so
|
|
90
|
+
you never install those two yourself.
|
|
91
|
+
- `terraform` or `tofu` on `PATH` — if neither is installed, `nac setup` can
|
|
92
|
+
bootstrap-install OpenTofu for you (see
|
|
93
|
+
[Tool/version management](#toolversion-management)).
|
|
94
|
+
- [`tenv`](https://github.com/tofuutils/tenv) — only needed if you pin
|
|
95
|
+
`tools.terraform.version` in `nac.yaml`.
|
|
96
|
+
|
|
97
|
+
Run `nac setup` to verify all of the above (plus any `env.required`
|
|
98
|
+
variables from your config) before running anything else.
|
|
99
|
+
|
|
100
|
+
## Quickstart
|
|
101
|
+
|
|
102
|
+
A `nac.yaml` at the repo root is optional — every field defaults, so an
|
|
103
|
+
empty or missing file is perfectly valid for a repo that sticks to the
|
|
104
|
+
`data/`/`tests/` conventions and needs no env vars checked. The most
|
|
105
|
+
common lean config just declares the environment variables the wrapped tools
|
|
106
|
+
expect, so `nac setup` has something to check:
|
|
107
|
+
|
|
108
|
+
```yaml
|
|
109
|
+
env:
|
|
110
|
+
required: [ACI_URL, ACI_USERNAME, ACI_PASSWORD]
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Then, from the repo root:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
nac setup # verify prerequisites, report resolved tool versions
|
|
117
|
+
nac init # terraform/tofu init
|
|
118
|
+
nac validate # nac-validate against the configured data paths
|
|
119
|
+
nac plan # terraform/tofu plan -> plan.tfplan
|
|
120
|
+
nac apply # apply the saved plan (or interactively, without one)
|
|
121
|
+
nac test # nac-test against the configured data/templates/output
|
|
122
|
+
nac destroy # destroy the managed infrastructure (interactive unless --auto-approve)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Every field not shown above falls back to a sensible default — see
|
|
126
|
+
[`nac.yaml` reference](#nacyaml-reference) below for the full set of
|
|
127
|
+
defaults and a worked example for repos that render a `model.yaml`
|
|
128
|
+
before validating (e.g. NX-OS).
|
|
129
|
+
|
|
130
|
+
## CLI reference
|
|
131
|
+
|
|
132
|
+
Global options, available on every subcommand:
|
|
133
|
+
|
|
134
|
+
| Option | Env var | Default | Description |
|
|
135
|
+
|---|---|---|---|
|
|
136
|
+
| `--config` | `NAC_CONFIG` | `./nac.yaml` | Path to the config file. |
|
|
137
|
+
| `-v`, `--verbosity` | `NAC_VERBOSITY` | `WARNING` | `DEBUG`\|`INFO`\|`WARNING`\|`ERROR`\|`CRITICAL`. |
|
|
138
|
+
| `--no-color` | `NO_COLOR` | off | Disable colored output. |
|
|
139
|
+
| `--version` | | | Show the version and exit. |
|
|
140
|
+
|
|
141
|
+
Every subcommand except `setup` forwards extra arguments to the wrapped tool
|
|
142
|
+
(e.g. `nac plan -var-file=prod.tfvars`), and `--help` shows `nac`'s help
|
|
143
|
+
followed by the wrapped tool's own options. The wrapped tool's exit code is
|
|
144
|
+
returned. `--artifacts`, available on the same subcommands, also writes the
|
|
145
|
+
command's output to `<command>.txt` in `working_dir` (e.g. `validate.txt`).
|
|
146
|
+
|
|
147
|
+
### `nac setup`
|
|
148
|
+
|
|
149
|
+
Verifies prerequisites and reports resolved tool versions — a pre-flight for
|
|
150
|
+
CI and local use. Checks `uv` is on `PATH`; checks `tenv` only if
|
|
151
|
+
`tools.terraform.version` is set; resolves the `terraform`/`tofu` binary
|
|
152
|
+
(explicit `tools.terraform.engine`, or auto-detected: `tofu` then
|
|
153
|
+
`terraform`); checks every `env.required` variable is set (never prints
|
|
154
|
+
values); reports resolved `terraform`/`tofu`, `nac-validate`, and `nac-test`
|
|
155
|
+
versions, and warns if `nac-validate`/`nac-test` are outside the module's
|
|
156
|
+
[tested versions](#tested-versions). Fails fast with a non-zero exit on any
|
|
157
|
+
missing prerequisite (tested-versions warnings never fail it).
|
|
158
|
+
|
|
159
|
+
| Flag | Description |
|
|
160
|
+
|---|---|
|
|
161
|
+
| `--yes`, `--install` | Skip the confirmation prompt for the OpenTofu bootstrap install. |
|
|
162
|
+
| `--prewarm` | Pre-warm the `uvx` cache for `nac-validate`/`nac-test` — skipped for a tool that already resolves to a local install. |
|
|
163
|
+
|
|
164
|
+
### `nac init`
|
|
165
|
+
|
|
166
|
+
Streams `terraform init` (or `tofu init`) in `working_dir`.
|
|
167
|
+
|
|
168
|
+
| Flag | Description |
|
|
169
|
+
|---|---|
|
|
170
|
+
| `--artifacts` | Also write the output to `init.txt`. |
|
|
171
|
+
|
|
172
|
+
### `nac validate`
|
|
173
|
+
|
|
174
|
+
If `render.target` is set, first streams a targeted
|
|
175
|
+
`terraform apply -target=<target> -auto-approve` to produce `render.output`,
|
|
176
|
+
then streams `nac-validate` against the resolved data paths (`render.output`
|
|
177
|
+
if a render just happened, else `data.paths`). The render step is fully
|
|
178
|
+
implied by config, never a separate opt-in.
|
|
179
|
+
|
|
180
|
+
| Flag | Description |
|
|
181
|
+
|---|---|
|
|
182
|
+
| `--artifacts` | Also write the output (render step included) to `validate.txt`. |
|
|
183
|
+
|
|
184
|
+
### `nac plan`
|
|
185
|
+
|
|
186
|
+
Streams `terraform plan -out=plan.tfplan`.
|
|
187
|
+
|
|
188
|
+
| Flag | Description |
|
|
189
|
+
|---|---|
|
|
190
|
+
| `--artifacts` | Also stream `terraform show` twice to emit `plan.txt` and `plan.json` alongside the saved plan. |
|
|
191
|
+
|
|
192
|
+
### `nac apply`
|
|
193
|
+
|
|
194
|
+
If a saved `plan.tfplan` exists in `working_dir`, streams
|
|
195
|
+
`terraform apply plan.tfplan` (and deletes the plan file after a successful
|
|
196
|
+
apply); otherwise runs `terraform apply` interactively (stdin is inherited,
|
|
197
|
+
so the normal confirmation prompt works).
|
|
198
|
+
|
|
199
|
+
| Flag | Description |
|
|
200
|
+
|---|---|
|
|
201
|
+
| `--auto-approve` | Pass `-auto-approve` when applying without a saved plan file. |
|
|
202
|
+
| `--artifacts` | Also write the output to `apply.txt`. |
|
|
203
|
+
|
|
204
|
+
### `nac destroy`
|
|
205
|
+
|
|
206
|
+
Streams `terraform destroy` (interactive unless `--auto-approve`, matching
|
|
207
|
+
`terraform destroy`'s own default confirmation prompt).
|
|
208
|
+
|
|
209
|
+
| Flag | Description |
|
|
210
|
+
|---|---|
|
|
211
|
+
| `--auto-approve` | Pass `-auto-approve` when destroying. |
|
|
212
|
+
| `--artifacts` | Also write the output to `destroy.txt`. |
|
|
213
|
+
|
|
214
|
+
### `nac test`
|
|
215
|
+
|
|
216
|
+
Streams `nac-test` against the resolved data paths (`render.output` if
|
|
217
|
+
`render` is configured, else `data.paths`, plus `data.defaults` whenever
|
|
218
|
+
it's configured) and the `templates`/`filters`/`output` from the `test:`
|
|
219
|
+
config block.
|
|
220
|
+
|
|
221
|
+
| Flag | Description |
|
|
222
|
+
|---|---|
|
|
223
|
+
| `--artifacts` | Also write the output to `test.txt`. |
|
|
224
|
+
|
|
225
|
+
## `nac.yaml` reference
|
|
226
|
+
|
|
227
|
+
A single `nac.yaml` at the repo root (path overridable with `--config`)
|
|
228
|
+
drives every subcommand. Every field is optional, and so is the file itself
|
|
229
|
+
— a missing `nac.yaml` is treated the same as an empty one, with every
|
|
230
|
+
field falling back to its default. That's a legitimate config for a repo
|
|
231
|
+
that needs no `env.required` checks and follows the `data/`/`tests/`
|
|
232
|
+
conventions, not just a fallback for ad hoc smoke-testing.
|
|
233
|
+
|
|
234
|
+
**Data resolution rules** (no boolean toggles anywhere — both follow
|
|
235
|
+
automatically from whether `render`/`render.target`/`data.defaults` are
|
|
236
|
+
configured):
|
|
237
|
+
|
|
238
|
+
- **validate data** = `render.output` if `render.target` is set, else
|
|
239
|
+
`data.paths`.
|
|
240
|
+
- **test data** = (`render.output` if `render` is configured at all, else
|
|
241
|
+
`data.paths`) + `data.defaults` (appended whenever it's configured).
|
|
242
|
+
|
|
243
|
+
**Field reference:**
|
|
244
|
+
|
|
245
|
+
| Field | Default | Notes |
|
|
246
|
+
|---|---|---|
|
|
247
|
+
| `working_dir` | `.` | Directory `terraform`/`tofu` runs in. |
|
|
248
|
+
| `data.paths` | `[data/]` | Raw data source(s) for `validate`/`test` when no render applies. |
|
|
249
|
+
| `data.defaults` | `defaults.yaml` if it exists in `working_dir`, else unset | Appended to `test` data whenever configured. |
|
|
250
|
+
| `render.target` | unset | Terraform target for a pre-validation render, e.g. `module.nxos.local_sensitive_file.model`. |
|
|
251
|
+
| `render.output` | unset | Rendered file path, e.g. `model.yaml`; required if `render.target` is set. |
|
|
252
|
+
| `validate.schema` | unset → flag omitted | Passed to `nac-validate -s`; omitted lets `nac-validate` use its own default. |
|
|
253
|
+
| `validate.rules` | unset → flag omitted | Passed to `nac-validate -r`; omitted lets `nac-validate` use its own default. |
|
|
254
|
+
| `test.templates` | `tests/templates` if that directory exists in `working_dir`, else unset → flag omitted | Passed to `nac-test -t`; if omitted, `nac-test` uses templates provided by a module or bundle. |
|
|
255
|
+
| `test.filters` | `tests/filters` if that directory exists in `working_dir`, else unset | Passed to `nac-test -f`. |
|
|
256
|
+
| `test.output` | `tests/results` | Passed to `nac-test -o`. |
|
|
257
|
+
| `tools.terraform.engine` | auto-detected: `tofu` if on `PATH`, else `terraform` | Explicit value always wins over detection. |
|
|
258
|
+
| `tools.terraform.version` | unset | Only set if you need `tenv` to pin/install a specific version. |
|
|
259
|
+
| `tools.nac_validate` | unset (latest compatible with the module's [tested version](#tested-versions); without a manifest: any local install, else latest via `uvx`) | Version/spec a local install must satisfy, else passed to `uvx --from nac-validate<spec>`; e.g. `1.2.0` or `>=0.9,<1.0`. |
|
|
260
|
+
| `tools.nac_test` | unset (latest compatible with the module's [tested version](#tested-versions); without a manifest: any local install, else latest via `uvx`) | Version/spec a local install must satisfy, else passed to `uvx --from nac-test<spec>`; e.g. `2.0.0` or `>=2.0,<3.0`. |
|
|
261
|
+
| `env.required` | `[]` | Variable names `nac setup` checks are present (values are never printed). |
|
|
262
|
+
|
|
263
|
+
**Worked examples.** Lean NX-OS (render + validate + test all use the
|
|
264
|
+
rendered `model.yaml`):
|
|
265
|
+
|
|
266
|
+
```yaml
|
|
267
|
+
tools:
|
|
268
|
+
terraform:
|
|
269
|
+
engine: tofu
|
|
270
|
+
|
|
271
|
+
render:
|
|
272
|
+
target: module.nxos.local_sensitive_file.model
|
|
273
|
+
output: model.yaml
|
|
274
|
+
|
|
275
|
+
env:
|
|
276
|
+
required: [NXOS_USERNAME, NXOS_PASSWORD]
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Lean ACI (no `render` at all; `test` appends `data.defaults` since
|
|
280
|
+
`defaults.yaml` exists at the repo root):
|
|
281
|
+
|
|
282
|
+
```yaml
|
|
283
|
+
env:
|
|
284
|
+
required: [ACI_URL, ACI_USERNAME, ACI_PASSWORD]
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Fully-expanded NX-OS config, with every field made explicit — useful when a
|
|
288
|
+
repo genuinely deviates from convention:
|
|
289
|
+
|
|
290
|
+
```yaml
|
|
291
|
+
working_dir: .
|
|
292
|
+
|
|
293
|
+
data:
|
|
294
|
+
paths: [data/]
|
|
295
|
+
defaults: null
|
|
296
|
+
|
|
297
|
+
render:
|
|
298
|
+
target: module.nxos.local_sensitive_file.model
|
|
299
|
+
output: model.yaml
|
|
300
|
+
|
|
301
|
+
validate:
|
|
302
|
+
schema: .schema.yaml
|
|
303
|
+
rules: [.rules]
|
|
304
|
+
|
|
305
|
+
test:
|
|
306
|
+
templates: tests/templates
|
|
307
|
+
filters: tests/filters
|
|
308
|
+
output: tests/results
|
|
309
|
+
|
|
310
|
+
tools:
|
|
311
|
+
terraform:
|
|
312
|
+
engine: tofu
|
|
313
|
+
version: "1.9.5"
|
|
314
|
+
nac_validate: ">=0.9,<1.0"
|
|
315
|
+
nac_test: ">=2.0,<3.0"
|
|
316
|
+
|
|
317
|
+
env:
|
|
318
|
+
required: [NXOS_USERNAME, NXOS_PASSWORD]
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
## Tool/version management
|
|
322
|
+
|
|
323
|
+
- **`terraform`/`tofu`**: with no `tools.terraform.version` set, `nac`
|
|
324
|
+
resolves the binary directly via `PATH` — `tofu` first, `terraform` as
|
|
325
|
+
fallback (or whichever `tools.terraform.engine` names explicitly). If
|
|
326
|
+
neither is found, `nac setup` (interactively, or with `--yes`/`--install`)
|
|
327
|
+
offers to download and cache a verified OpenTofu binary itself — no
|
|
328
|
+
package manager or `tenv` required. Setting `tools.terraform.version`
|
|
329
|
+
switches to [`tenv`](https://github.com/tofuutils/tenv) for version
|
|
330
|
+
pinning/auto-install (`TOFUENV_TOFU_VERSION`/`TFENV_TERRAFORM_VERSION` +
|
|
331
|
+
the matching auto-install env var).
|
|
332
|
+
- **`nac-validate`/`nac-test`**: prefers a local install already on `PATH`
|
|
333
|
+
if it satisfies `tools.nac_validate`/`tools.nac_test` (unset means any
|
|
334
|
+
local version is acceptable) — same PATH-first philosophy as
|
|
335
|
+
`terraform`/`tofu` above. Otherwise falls back to `uvx`
|
|
336
|
+
(`uv tool run`) -- `uvx --from nac-validate==<version> nac-validate ...`.
|
|
337
|
+
Omit `tools.nac_validate`/`tools.nac_test` to let `uvx` resolve the latest
|
|
338
|
+
published release when no usable local install exists -- or, if the
|
|
339
|
+
module ships a [manifest](#tested-versions), the latest release compatible
|
|
340
|
+
with the tested one. `nac setup` reports which source (`local` or `uvx`)
|
|
341
|
+
each tool actually resolved to.
|
|
342
|
+
|
|
343
|
+
## Tested versions
|
|
344
|
+
|
|
345
|
+
A Network-as-Code Terraform module can ship a `nac/manifest.yaml` --
|
|
346
|
+
alongside its other nac-specific content (schema, rules, test templates) --
|
|
347
|
+
listing the `nac-validate`/`nac-test` versions it was tested with:
|
|
348
|
+
|
|
349
|
+
```yaml
|
|
350
|
+
schema: 1
|
|
351
|
+
tools:
|
|
352
|
+
nac-validate: "2.0.0"
|
|
353
|
+
nac-test: "2.0.0"
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
`terraform`/`tofu` and provider versions don't belong here: the module's
|
|
357
|
+
`versions.tf` (`required_version`, `required_providers`) already constrains
|
|
358
|
+
them, and Terraform enforces that itself.
|
|
359
|
+
|
|
360
|
+
After `nac init` has downloaded the module, `nac` finds the manifest via
|
|
361
|
+
`.terraform/modules/modules.json` (the shallowest module that ships one,
|
|
362
|
+
so a local wrapper around the module works too). It is purely a hint --
|
|
363
|
+
nothing is ever enforced, and without a manifest (e.g. before the first
|
|
364
|
+
`init`) nothing changes:
|
|
365
|
+
|
|
366
|
+
- **Defaults:** with `tools.nac_validate`/`tools.nac_test` unset,
|
|
367
|
+
`nac-validate`/`nac-test` resolve to the latest release compatible with
|
|
368
|
+
the tested version instead of the latest overall -- a local install is
|
|
369
|
+
used if it's in that range, otherwise `uvx` fetches it.
|
|
370
|
+
- **Warnings:** an explicit `tools.nac_validate`/`tools.nac_test` value
|
|
371
|
+
always wins, but if it resolves to a version outside the tested,
|
|
372
|
+
semver-compatible range, `nac validate`/`nac test` (and `nac setup`)
|
|
373
|
+
print a warning (to stderr, and into the `--artifacts` file), plus a
|
|
374
|
+
short note after the output if the command then fails. Exit codes are
|
|
375
|
+
never changed. A range resolved through `uvx` (e.g. `>=1.0`) isn't
|
|
376
|
+
checked, since the version `uvx` picks isn't known up front; an exact pin
|
|
377
|
+
or a local install is.
|
|
378
|
+
|
|
379
|
+
"Compatible" means the tested version or newer, up to the next major --
|
|
380
|
+
or the next minor for `0.x` versions, where a minor bump may be breaking.
|
|
381
|
+
A tested `2.0.0` allows `>=2.0.0,<3`, a tested `0.13.1` allows
|
|
382
|
+
`>=0.13.1,<0.14`. Newer-but-compatible versions are only logged at `INFO`.
|
|
383
|
+
|
|
384
|
+
## License
|
|
385
|
+
|
|
386
|
+
[MPL-2.0](LICENSE)
|