nac-cli 0.2.0__tar.gz → 0.2.1__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.
- {nac_cli-0.2.0 → nac_cli-0.2.1}/CHANGELOG.md +5 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/PKG-INFO +1 -1
- {nac_cli-0.2.0 → nac_cli-0.2.1}/pyproject.toml +1 -1
- nac_cli-0.2.1/release-notes.md +3 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/cli/main.py +27 -2
- {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/cli/setup.py +9 -2
- {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/cli/validate.py +8 -1
- {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/cli/versions.py +8 -5
- {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/config.py +57 -5
- {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/manifest.py +114 -13
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/unit/test_cli_commands.py +84 -2
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/unit/test_cli_setup.py +46 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/unit/test_cli_versions.py +1 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/unit/test_config.py +67 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/unit/test_manifest.py +108 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/uv.lock +145 -141
- nac_cli-0.2.0/release-notes.md +0 -3
- {nac_cli-0.2.0 → nac_cli-0.2.1}/.github/dependabot.yml +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/.github/workflows/release.yml +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/.github/workflows/test.yml +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/.gitignore +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/.pre-commit-config.yaml +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/LICENSE +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/README.md +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/scripts/license_headers.py +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/__init__.py +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/__main__.py +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/bootstrap.py +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/cli/__init__.py +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/cli/apply.py +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/cli/destroy.py +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/cli/init.py +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/cli/options.py +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/cli/passthrough.py +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/cli/plan.py +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/cli/test.py +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/exceptions.py +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/output.py +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/runner.py +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/terraform.py +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/tools.py +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/fixtures/nac-yaml/aci-lean/defaults.yaml +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/fixtures/nac-yaml/aci-lean/nac.yaml +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/fixtures/nac-yaml/filters-absent/nac.yaml +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/fixtures/nac-yaml/filters-present/nac.yaml +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/fixtures/nac-yaml/filters-present/tests/filters/.gitkeep +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/fixtures/nac-yaml/invalid-syntax/nac.yaml +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/fixtures/nac-yaml/iosxe-lean/nac.yaml +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/fixtures/nac-yaml/minimal/nac.yaml +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/fixtures/nac-yaml/nxos-full/nac.yaml +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/fixtures/nac-yaml/nxos-lean/nac.yaml +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/fixtures/nac-yaml/templates-absent/nac.yaml +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/fixtures/nac-yaml/templates-present/nac.yaml +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/fixtures/nac-yaml/templates-present/tests/templates/.gitkeep +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/integration/fixtures/local_only/.terraform.lock.hcl +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/integration/fixtures/local_only/broken/main.tf +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/integration/fixtures/local_only/main.tf +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/integration/fixtures/validate_test/.schema.yaml +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/integration/fixtures/validate_test/data/main.yaml +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/integration/fixtures/validate_test/templates/test.robot +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/integration/test_streaming.py +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/integration/test_validate_test.py +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/unit/conftest.py +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/unit/test_bootstrap.py +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/unit/test_output.py +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/unit/test_placeholder.py +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/unit/test_runner.py +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/unit/test_terraform.py +0 -0
- {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/unit/test_tools.py +0 -0
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
## 0.2.1
|
|
2
|
+
|
|
3
|
+
- Read `nac.yaml` defaults from a `config` section in the module's `nac/manifest.yaml`: `render.resource` (relative to the module, e.g. `local_sensitive_file.model` becomes `module.nxos.local_sensitive_file.model`), `render.output` and `env.required`. Settings in the project's `nac.yaml` take precedence key by key, an explicit `null` turns a default off, and an invalid `config` section is ignored with a warning; `nac setup` lists the defaults taken from the module
|
|
4
|
+
- Fail `nac validate` with a clear message if the render step didn't write `render.output` (e.g. because it doesn't match the module's `write_model_file`), and show the output path in the Render Summary
|
|
5
|
+
|
|
1
6
|
## 0.2.0
|
|
2
7
|
|
|
3
8
|
- Only require `uv` when `nac-validate`/`nac-test` aren't installed locally in a satisfying version: `nac setup` no longer fails without `uv` if both tools resolve locally, and `nac validate`/`nac test` fail with a clear message (instead of a missing-executable error) when `uvx` would be needed but `uv` isn't on `PATH`
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: nac-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: An umbrella CLI wrapping terraform/tofu, nac-validate, and nac-test for Network-as-Code workflows.
|
|
5
5
|
Project-URL: homepage, https://github.com/netascode/nac
|
|
6
6
|
Project-URL: repository, https://github.com/netascode/nac
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
|
|
2
|
+
- Read `nac.yaml` defaults from a `config` section in the module's `nac/manifest.yaml`: `render.resource` (relative to the module, e.g. `local_sensitive_file.model` becomes `module.nxos.local_sensitive_file.model`), `render.output` and `env.required`. Settings in the project's `nac.yaml` take precedence key by key, an explicit `null` turns a default off, and an invalid `config` section is ignored with a warning; `nac setup` lists the defaults taken from the module
|
|
3
|
+
- Fail `nac validate` with a clear message if the render step didn't write `render.output` (e.g. because it doesn't match the module's `write_model_file`), and show the output path in the Render Summary
|
|
@@ -12,12 +12,23 @@ from typing import NoReturn, cast
|
|
|
12
12
|
import typer
|
|
13
13
|
|
|
14
14
|
from nac import terraform
|
|
15
|
-
from nac.config import
|
|
15
|
+
from nac.config import (
|
|
16
|
+
DEFAULT_CONFIG_PATH,
|
|
17
|
+
NacConfig,
|
|
18
|
+
build_config,
|
|
19
|
+
merge_defaults,
|
|
20
|
+
read_raw_config,
|
|
21
|
+
)
|
|
16
22
|
from nac.exceptions import ConfigError, EngineNotFoundError, NoEngineAvailableError
|
|
23
|
+
from nac.manifest import load_manifest
|
|
17
24
|
from nac.output import ensure_utf8_streams
|
|
18
25
|
|
|
19
26
|
from .options import ConfigPath, NoColor, Verbosity, VerbosityLevel, Version
|
|
20
27
|
|
|
28
|
+
# ctx.meta keys set by get_config
|
|
29
|
+
MANIFEST_META_KEY = "manifest"
|
|
30
|
+
MODULE_DEFAULTS_META_KEY = "module_defaults"
|
|
31
|
+
|
|
21
32
|
app = typer.Typer(
|
|
22
33
|
add_completion=False,
|
|
23
34
|
help="An umbrella CLI wrapping terraform/tofu, nac-validate, and nac-test.",
|
|
@@ -47,13 +58,27 @@ def get_config(ctx: typer.Context) -> NacConfig:
|
|
|
47
58
|
file present. `ctx.obj` starts out holding the raw --config path and
|
|
48
59
|
is replaced with the resolved NacConfig on first call, so repeated
|
|
49
60
|
calls within the same invocation don't re-read the file.
|
|
61
|
+
|
|
62
|
+
nac.yaml settings the project leaves unset are filled from the `config`
|
|
63
|
+
section of the initialized module's manifest. The manifest and the
|
|
64
|
+
dotted keys taken from it are stashed on `ctx.meta`.
|
|
50
65
|
"""
|
|
51
66
|
if isinstance(ctx.obj, NacConfig):
|
|
52
67
|
return ctx.obj
|
|
68
|
+
path = cast(Path, ctx.obj)
|
|
53
69
|
try:
|
|
54
|
-
|
|
70
|
+
raw = read_raw_config(path)
|
|
71
|
+
cfg = build_config(raw, path)
|
|
72
|
+
manifest = load_manifest(cfg.working_dir)
|
|
73
|
+
applied: list[str] = []
|
|
74
|
+
if manifest is not None and manifest.config:
|
|
75
|
+
merged, applied = merge_defaults(raw, manifest.config)
|
|
76
|
+
if applied:
|
|
77
|
+
cfg = build_config(merged, path)
|
|
55
78
|
except ConfigError as exc:
|
|
56
79
|
fail(str(exc))
|
|
80
|
+
ctx.meta[MANIFEST_META_KEY] = manifest
|
|
81
|
+
ctx.meta[MODULE_DEFAULTS_META_KEY] = applied
|
|
57
82
|
ctx.obj = cfg
|
|
58
83
|
return cfg
|
|
59
84
|
|
|
@@ -19,7 +19,7 @@ from nac.manifest import MANIFEST_FILE
|
|
|
19
19
|
from nac.output import apply_no_color_env, echo_summary, format_command
|
|
20
20
|
from nac.tools import ToolSource
|
|
21
21
|
|
|
22
|
-
from .main import app, fail, get_config, get_no_color
|
|
22
|
+
from .main import MODULE_DEFAULTS_META_KEY, app, fail, get_config, get_no_color
|
|
23
23
|
from .options import Prewarm, Yes
|
|
24
24
|
from .versions import emit_warnings, get_manifest, tool_warnings
|
|
25
25
|
|
|
@@ -114,7 +114,14 @@ def setup(ctx: typer.Context, yes: Yes = False, prewarm: Prewarm = False) -> Non
|
|
|
114
114
|
|
|
115
115
|
manifest = get_manifest(ctx, cfg)
|
|
116
116
|
if manifest is not None:
|
|
117
|
-
|
|
117
|
+
if manifest.tools:
|
|
118
|
+
lines.append(f"tested versions: {manifest.module} ({MANIFEST_FILE})")
|
|
119
|
+
module_defaults = ctx.meta.get(MODULE_DEFAULTS_META_KEY) or []
|
|
120
|
+
if module_defaults:
|
|
121
|
+
lines.append(
|
|
122
|
+
f"module defaults: {', '.join(module_defaults)} "
|
|
123
|
+
f"(from {manifest.module})"
|
|
124
|
+
)
|
|
118
125
|
|
|
119
126
|
missing_env = [name for name in cfg.env.required if name not in os.environ]
|
|
120
127
|
if missing_env:
|
|
@@ -78,12 +78,19 @@ def validate(ctx: typer.Context, artifacts: Artifacts = False) -> None:
|
|
|
78
78
|
log_written = True
|
|
79
79
|
if code != 0:
|
|
80
80
|
raise typer.Exit(code=code)
|
|
81
|
+
output = cfg.render.output
|
|
82
|
+
if output is not None and not (cfg.working_dir / output).exists():
|
|
83
|
+
fail(
|
|
84
|
+
f"Render step `{cfg.render.target}` did not write `{output}`. "
|
|
85
|
+
"Check that `render.output` matches the file written by the "
|
|
86
|
+
"render target (for Network-as-Code modules: `write_model_file`)."
|
|
87
|
+
)
|
|
81
88
|
if quiet:
|
|
82
89
|
echo_summary(
|
|
83
90
|
"Render Summary",
|
|
84
91
|
[
|
|
85
92
|
f"Render: {color(GREEN, 'OK', no_color=no_color)} "
|
|
86
|
-
f"({cfg.render.target})"
|
|
93
|
+
f"({cfg.render.target} -> {output})"
|
|
87
94
|
],
|
|
88
95
|
no_color=no_color,
|
|
89
96
|
)
|
|
@@ -19,14 +19,17 @@ from nac.config import NacConfig
|
|
|
19
19
|
from nac.manifest import Manifest, load_manifest
|
|
20
20
|
from nac.output import YELLOW, color
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
from .main import MANIFEST_META_KEY
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def get_manifest(ctx: typer.Context, cfg: NacConfig) -> Manifest | None:
|
|
26
|
-
"""Load (once per invocation) the manifest of the initialized module.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
"""Load (once per invocation) the manifest of the initialized module.
|
|
27
|
+
|
|
28
|
+
Normally already loaded by `get_config`.
|
|
29
|
+
"""
|
|
30
|
+
if MANIFEST_META_KEY not in ctx.meta:
|
|
31
|
+
ctx.meta[MANIFEST_META_KEY] = load_manifest(cfg.working_dir)
|
|
32
|
+
return cast("Manifest | None", ctx.meta[MANIFEST_META_KEY])
|
|
30
33
|
|
|
31
34
|
|
|
32
35
|
def tool_warnings(
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
# names as nac.yaml's own keys; pydantic only warns because they shadow
|
|
6
6
|
# deprecated pydantic-v1 classmethods that this codebase never calls.
|
|
7
7
|
import warnings
|
|
8
|
+
from collections.abc import Mapping
|
|
8
9
|
from pathlib import Path
|
|
9
|
-
from typing import Literal
|
|
10
|
+
from typing import Any, Literal
|
|
10
11
|
|
|
11
12
|
import pydantic
|
|
12
13
|
import ruamel.yaml
|
|
@@ -95,15 +96,28 @@ class NacConfig(BaseModel):
|
|
|
95
96
|
env: EnvConfig = EnvConfig()
|
|
96
97
|
|
|
97
98
|
|
|
98
|
-
def load_config(
|
|
99
|
+
def load_config(
|
|
100
|
+
path: Path | str = DEFAULT_CONFIG_PATH,
|
|
101
|
+
*,
|
|
102
|
+
defaults: Mapping[str, Any] | None = None,
|
|
103
|
+
) -> NacConfig:
|
|
99
104
|
"""Load, validate, and resolve conventions for a nac.yaml file.
|
|
100
105
|
|
|
101
106
|
A missing file is treated as an empty config -- every field defaults,
|
|
102
|
-
so `nac` works with zero config present.
|
|
103
|
-
|
|
104
|
-
|
|
107
|
+
so `nac` works with zero config present. `defaults` (a module's
|
|
108
|
+
manifest `config`) fills in whatever the file doesn't set; see
|
|
109
|
+
`merge_defaults`. Raises ConfigError (never a raw pydantic/ruamel
|
|
110
|
+
traceback) on malformed YAML or a schema validation failure.
|
|
105
111
|
"""
|
|
106
112
|
path = Path(path)
|
|
113
|
+
raw = read_raw_config(path)
|
|
114
|
+
if defaults is not None:
|
|
115
|
+
raw, _ = merge_defaults(raw, defaults)
|
|
116
|
+
return build_config(raw, path)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def read_raw_config(path: Path) -> dict[str, Any]:
|
|
120
|
+
"""The nac.yaml mapping as written, without validation or defaults."""
|
|
107
121
|
raw: object = {}
|
|
108
122
|
if path.is_file():
|
|
109
123
|
yaml = ruamel.yaml.YAML(typ="safe")
|
|
@@ -119,7 +133,11 @@ def load_config(path: Path | str = DEFAULT_CONFIG_PATH) -> NacConfig:
|
|
|
119
133
|
raise ConfigError(
|
|
120
134
|
f"Config file {path} must contain a YAML mapping at the top level"
|
|
121
135
|
)
|
|
136
|
+
return raw
|
|
122
137
|
|
|
138
|
+
|
|
139
|
+
def build_config(raw: Mapping[str, Any], path: Path) -> NacConfig:
|
|
140
|
+
"""Validate a raw nac.yaml mapping and apply filesystem conventions."""
|
|
123
141
|
try:
|
|
124
142
|
cfg = NacConfig.model_validate(raw)
|
|
125
143
|
except pydantic.ValidationError as exc:
|
|
@@ -128,6 +146,40 @@ def load_config(path: Path | str = DEFAULT_CONFIG_PATH) -> NacConfig:
|
|
|
128
146
|
return _apply_filesystem_defaults(cfg, path)
|
|
129
147
|
|
|
130
148
|
|
|
149
|
+
def merge_defaults(
|
|
150
|
+
raw: Mapping[str, Any], defaults: Mapping[str, Any]
|
|
151
|
+
) -> tuple[dict[str, Any], list[str]]:
|
|
152
|
+
"""Fill `raw` (nac.yaml as written) with `defaults` (from a manifest).
|
|
153
|
+
|
|
154
|
+
Mappings are merged key by key; any other value set in `raw` -- lists
|
|
155
|
+
and an explicit `null` included -- wins as a whole. So
|
|
156
|
+
`render: {output: x.yaml}` keeps the module's render target, while
|
|
157
|
+
`render: null` or `render: {target: null}` turns it off.
|
|
158
|
+
|
|
159
|
+
Returns the merged mapping and the dotted keys taken from `defaults`.
|
|
160
|
+
"""
|
|
161
|
+
merged = dict(raw)
|
|
162
|
+
applied: list[str] = []
|
|
163
|
+
for key, value in defaults.items():
|
|
164
|
+
if key not in raw:
|
|
165
|
+
merged[key] = value
|
|
166
|
+
applied.extend(_leaf_keys(key, value))
|
|
167
|
+
elif isinstance(raw[key], Mapping) and isinstance(value, Mapping):
|
|
168
|
+
merged[key], nested = merge_defaults(raw[key], value)
|
|
169
|
+
applied.extend(f"{key}.{leaf}" for leaf in nested)
|
|
170
|
+
return merged, applied
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _leaf_keys(prefix: str, value: Any) -> list[str]:
|
|
174
|
+
if isinstance(value, Mapping) and value:
|
|
175
|
+
return [
|
|
176
|
+
leaf
|
|
177
|
+
for key, nested in value.items()
|
|
178
|
+
for leaf in _leaf_keys(f"{prefix}.{key}", nested)
|
|
179
|
+
]
|
|
180
|
+
return [prefix]
|
|
181
|
+
|
|
182
|
+
|
|
131
183
|
def _format_validation_error(path: Path, exc: pydantic.ValidationError) -> str:
|
|
132
184
|
lines = [f"Invalid config file: {path}"]
|
|
133
185
|
for err in exc.errors():
|
|
@@ -1,29 +1,36 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MPL-2.0
|
|
2
2
|
# Copyright (c) 2026 Daniel Schmidt
|
|
3
3
|
|
|
4
|
-
"""
|
|
4
|
+
"""Manifest shipped with a Network-as-Code Terraform module.
|
|
5
5
|
|
|
6
6
|
A module may ship a `nac/manifest.yaml` (next to the rest of its nac-specific
|
|
7
|
-
content: schema, rules, test templates)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
outside the tested, semver-compatible range produce a
|
|
13
|
-
|
|
7
|
+
content: schema, rules, test templates) with:
|
|
8
|
+
|
|
9
|
+
- `tools`: the nac-validate/nac-test versions it was tested with.
|
|
10
|
+
terraform/tofu and provider versions are not part of it: the module's
|
|
11
|
+
`versions.tf` already constrains those, and Terraform enforces them
|
|
12
|
+
itself. Versions outside the tested, semver-compatible range produce a
|
|
13
|
+
warning, never an error.
|
|
14
|
+
- `config`: module-specific `nac.yaml` defaults (render target, required
|
|
15
|
+
environment variables), so that projects using the module don't have to
|
|
16
|
+
repeat them. The project's own `nac.yaml` always takes precedence.
|
|
17
|
+
|
|
18
|
+
After `terraform init`, `nac` finds it via `.terraform/modules/modules.json`.
|
|
19
|
+
A missing or unreadable manifest is silently ignored; an invalid `config`
|
|
20
|
+
section is ignored with a warning, keeping the rest of the manifest.
|
|
14
21
|
"""
|
|
15
22
|
|
|
16
23
|
import json
|
|
17
24
|
import logging
|
|
18
25
|
from dataclasses import dataclass, field
|
|
19
26
|
from pathlib import Path
|
|
20
|
-
from typing import Literal
|
|
27
|
+
from typing import Any, Literal
|
|
21
28
|
|
|
22
29
|
import pydantic
|
|
23
30
|
import ruamel.yaml
|
|
24
31
|
from packaging.specifiers import SpecifierSet
|
|
25
32
|
from packaging.version import InvalidVersion, Version
|
|
26
|
-
from pydantic import BaseModel, ConfigDict
|
|
33
|
+
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
|
27
34
|
|
|
28
35
|
logger = logging.getLogger(__name__)
|
|
29
36
|
|
|
@@ -38,6 +45,54 @@ class _ManifestFile(BaseModel):
|
|
|
38
45
|
|
|
39
46
|
schema_version: Literal[1] = pydantic.Field(alias="schema")
|
|
40
47
|
tools: dict[str, str] = {}
|
|
48
|
+
# Validated separately (_ManifestConfig) so that a broken `config`
|
|
49
|
+
# section doesn't also discard the tested versions.
|
|
50
|
+
config: object = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _RenderDefaults(BaseModel):
|
|
54
|
+
model_config = ConfigDict(extra="ignore")
|
|
55
|
+
|
|
56
|
+
# Resource address relative to the module, e.g.
|
|
57
|
+
# `local_sensitive_file.model`; nac prefixes the module's own address.
|
|
58
|
+
resource: str | None = None
|
|
59
|
+
output: str | None = None
|
|
60
|
+
|
|
61
|
+
@field_validator("resource")
|
|
62
|
+
@classmethod
|
|
63
|
+
def _relative_address(cls, value: str | None) -> str | None:
|
|
64
|
+
if value is not None and (not value.strip() or value.startswith("module.")):
|
|
65
|
+
raise ValueError(
|
|
66
|
+
"must be a resource address relative to the module, "
|
|
67
|
+
"e.g. `local_sensitive_file.model`"
|
|
68
|
+
)
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
@model_validator(mode="after")
|
|
72
|
+
def _resource_requires_output(self) -> "_RenderDefaults":
|
|
73
|
+
if self.resource is not None and self.output is None:
|
|
74
|
+
raise ValueError("render.output must be set when render.resource is set")
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _EnvDefaults(BaseModel):
|
|
79
|
+
model_config = ConfigDict(extra="ignore")
|
|
80
|
+
|
|
81
|
+
required: list[str] | None = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _ManifestConfig(BaseModel):
|
|
85
|
+
"""`nac.yaml` defaults a module may provide.
|
|
86
|
+
|
|
87
|
+
Deliberately limited to module-specific settings; anything about the
|
|
88
|
+
project's layout or toolchain (working_dir, data paths, terraform
|
|
89
|
+
engine, ...) stays with the project's own `nac.yaml`.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
model_config = ConfigDict(extra="ignore")
|
|
93
|
+
|
|
94
|
+
render: _RenderDefaults | None = None
|
|
95
|
+
env: _EnvDefaults | None = None
|
|
41
96
|
|
|
42
97
|
|
|
43
98
|
@dataclass(frozen=True)
|
|
@@ -50,6 +105,10 @@ class Manifest:
|
|
|
50
105
|
|
|
51
106
|
module: str
|
|
52
107
|
tools: dict[str, str] = field(default_factory=dict)
|
|
108
|
+
# `nac.yaml`-shaped defaults (see config.merge_defaults), already
|
|
109
|
+
# resolved against the module's address, e.g.
|
|
110
|
+
# {"render": {"target": "module.nxos.local_sensitive_file.model", ...}}
|
|
111
|
+
config: dict[str, Any] = field(default_factory=dict)
|
|
53
112
|
|
|
54
113
|
def check_tool(self, name: str, actual: str | None) -> str | None:
|
|
55
114
|
"""Warning message if `actual` is outside the tested range, else None."""
|
|
@@ -129,7 +188,9 @@ def load_manifest(working_dir: Path) -> Manifest | None:
|
|
|
129
188
|
for module in candidates:
|
|
130
189
|
path = working_dir / str(module["Dir"]) / MANIFEST_FILE
|
|
131
190
|
if path.is_file():
|
|
132
|
-
manifest = _parse_manifest(
|
|
191
|
+
manifest = _parse_manifest(
|
|
192
|
+
path, _module_label(module), _module_address(str(module["Key"]))
|
|
193
|
+
)
|
|
133
194
|
if manifest is not None:
|
|
134
195
|
return manifest
|
|
135
196
|
return None
|
|
@@ -144,7 +205,15 @@ def _module_label(module: dict[str, object]) -> str:
|
|
|
144
205
|
return f"module.{module['Key']}"
|
|
145
206
|
|
|
146
207
|
|
|
147
|
-
def
|
|
208
|
+
def _module_address(key: str) -> str:
|
|
209
|
+
"""Terraform address of a module from its modules.json key.
|
|
210
|
+
|
|
211
|
+
`nxos` -> `module.nxos`, `wrapper.nxos` -> `module.wrapper.module.nxos`.
|
|
212
|
+
"""
|
|
213
|
+
return ".".join(f"module.{part}" for part in key.split("."))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _parse_manifest(path: Path, label: str, address: str) -> Manifest | None:
|
|
148
217
|
yaml = ruamel.yaml.YAML(typ="safe")
|
|
149
218
|
try:
|
|
150
219
|
with path.open("r", encoding="utf-8") as fh:
|
|
@@ -152,4 +221,36 @@ def _parse_manifest(path: Path, label: str) -> Manifest | None:
|
|
|
152
221
|
except (OSError, ruamel.yaml.YAMLError, pydantic.ValidationError) as exc:
|
|
153
222
|
logger.debug("Ignoring unreadable manifest %s: %s", path, exc)
|
|
154
223
|
return None
|
|
155
|
-
return Manifest(
|
|
224
|
+
return Manifest(
|
|
225
|
+
module=label,
|
|
226
|
+
tools=dict(data.tools),
|
|
227
|
+
config=_parse_config(data.config, path, address),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _parse_config(raw: object, path: Path, address: str) -> dict[str, Any]:
|
|
232
|
+
"""Translate the manifest's `config` into `nac.yaml`-shaped defaults."""
|
|
233
|
+
if raw is None:
|
|
234
|
+
return {}
|
|
235
|
+
try:
|
|
236
|
+
config = _ManifestConfig.model_validate(raw)
|
|
237
|
+
except pydantic.ValidationError as exc:
|
|
238
|
+
details = "; ".join(
|
|
239
|
+
f"{'.'.join(str(p) for p in err['loc']) or '<root>'}: {err['msg']}"
|
|
240
|
+
for err in exc.errors()
|
|
241
|
+
)
|
|
242
|
+
logger.warning("Ignoring invalid `config` in %s: %s", path, details)
|
|
243
|
+
return {}
|
|
244
|
+
|
|
245
|
+
result: dict[str, Any] = {}
|
|
246
|
+
if config.render is not None:
|
|
247
|
+
render: dict[str, Any] = {}
|
|
248
|
+
if config.render.resource is not None:
|
|
249
|
+
render["target"] = f"{address}.{config.render.resource}"
|
|
250
|
+
if config.render.output is not None:
|
|
251
|
+
render["output"] = config.render.output
|
|
252
|
+
if render:
|
|
253
|
+
result["render"] = render
|
|
254
|
+
if config.env is not None and config.env.required is not None:
|
|
255
|
+
result["env"] = {"required": list(config.env.required)}
|
|
256
|
+
return result
|
|
@@ -166,6 +166,7 @@ def test_validate_without_uv_or_local_install_fails_before_render(tmp_path, mock
|
|
|
166
166
|
tmp_path,
|
|
167
167
|
"render:\n target: module.example\n output: rendered/\n",
|
|
168
168
|
)
|
|
169
|
+
(tmp_path / "rendered").mkdir()
|
|
169
170
|
mocker.patch("shutil.which", side_effect=_which({"tofu": "/usr/bin/tofu"}))
|
|
170
171
|
run_streaming_mock = mocker.patch("nac.cli.validate.runner.run_streaming")
|
|
171
172
|
|
|
@@ -185,6 +186,7 @@ def test_validate_with_render_target_runs_render_then_validate(
|
|
|
185
186
|
tmp_path,
|
|
186
187
|
"render:\n target: module.example\n output: rendered/\n",
|
|
187
188
|
)
|
|
189
|
+
(tmp_path / "rendered").mkdir()
|
|
188
190
|
run_streaming_mock = mocker.patch(
|
|
189
191
|
"nac.cli.validate.runner.run_streaming", return_value=0
|
|
190
192
|
)
|
|
@@ -204,7 +206,7 @@ def test_validate_with_render_target_runs_render_then_validate(
|
|
|
204
206
|
assert run_streaming_mock.call_args_list[0].kwargs.get("quiet") is True
|
|
205
207
|
assert "rendered/" in validate_argv
|
|
206
208
|
assert "Render Summary" in result.output
|
|
207
|
-
assert "✓ Render: OK (module.example)" in result.output
|
|
209
|
+
assert "✓ Render: OK (module.example -> rendered/)" in result.output
|
|
208
210
|
assert "─" * 80 in result.output
|
|
209
211
|
|
|
210
212
|
|
|
@@ -217,6 +219,7 @@ def test_validate_render_step_streams_live_at_info_verbosity_or_above(
|
|
|
217
219
|
tmp_path,
|
|
218
220
|
"render:\n target: module.example\n output: rendered/\n",
|
|
219
221
|
)
|
|
222
|
+
(tmp_path / "rendered").mkdir()
|
|
220
223
|
run_streaming_mock = mocker.patch(
|
|
221
224
|
"nac.cli.validate.runner.run_streaming", return_value=0
|
|
222
225
|
)
|
|
@@ -236,6 +239,7 @@ def test_validate_honors_global_no_color_flag(tmp_path, mocker, _tofu_on_path):
|
|
|
236
239
|
tmp_path,
|
|
237
240
|
"render:\n target: module.example\n output: rendered/\n",
|
|
238
241
|
)
|
|
242
|
+
(tmp_path / "rendered").mkdir()
|
|
239
243
|
run_streaming_mock = mocker.patch(
|
|
240
244
|
"nac.cli.validate.runner.run_streaming", return_value=0
|
|
241
245
|
)
|
|
@@ -253,7 +257,7 @@ def test_validate_honors_global_no_color_flag(tmp_path, mocker, _tofu_on_path):
|
|
|
253
257
|
]
|
|
254
258
|
validate_env = run_streaming_mock.call_args_list[1].kwargs["env"]
|
|
255
259
|
assert validate_env["NO_COLOR"] == "1"
|
|
256
|
-
assert "Render: OK (module.example)" in result.output
|
|
260
|
+
assert "Render: OK (module.example -> rendered/)" in result.output
|
|
257
261
|
|
|
258
262
|
|
|
259
263
|
@pytest.mark.unit
|
|
@@ -262,6 +266,7 @@ def test_validate_aborts_when_render_step_fails(tmp_path, mocker, _tofu_on_path)
|
|
|
262
266
|
tmp_path,
|
|
263
267
|
"render:\n target: module.example\n output: rendered/\n",
|
|
264
268
|
)
|
|
269
|
+
(tmp_path / "rendered").mkdir()
|
|
265
270
|
run_streaming_mock = mocker.patch(
|
|
266
271
|
"nac.cli.validate.runner.run_streaming", return_value=1
|
|
267
272
|
)
|
|
@@ -309,6 +314,7 @@ def test_validate_with_artifacts_and_render_appends_second_call(
|
|
|
309
314
|
tmp_path,
|
|
310
315
|
"render:\n target: module.example\n output: rendered/\n",
|
|
311
316
|
)
|
|
317
|
+
(tmp_path / "rendered").mkdir()
|
|
312
318
|
run_streaming_mock = mocker.patch(
|
|
313
319
|
"nac.cli.validate.runner.run_streaming", return_value=0
|
|
314
320
|
)
|
|
@@ -332,6 +338,7 @@ def test_validate_with_artifacts_render_failure_only_logs_render_call(
|
|
|
332
338
|
tmp_path,
|
|
333
339
|
"render:\n target: module.example\n output: rendered/\n",
|
|
334
340
|
)
|
|
341
|
+
(tmp_path / "rendered").mkdir()
|
|
335
342
|
run_streaming_mock = mocker.patch(
|
|
336
343
|
"nac.cli.validate.runner.run_streaming", return_value=1
|
|
337
344
|
)
|
|
@@ -1034,3 +1041,78 @@ def test_tool_artifacts_env_override_is_kept(
|
|
|
1034
1041
|
|
|
1035
1042
|
assert result.exit_code == 0
|
|
1036
1043
|
assert run_streaming_mock.call_args.kwargs["env"][env_var] == "false"
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
# ---------------------------------------------------------------------------
|
|
1047
|
+
# Module defaults from the manifest `config`
|
|
1048
|
+
# ---------------------------------------------------------------------------
|
|
1049
|
+
|
|
1050
|
+
_CONFIG_MANIFEST = """\
|
|
1051
|
+
schema: 1
|
|
1052
|
+
config:
|
|
1053
|
+
render:
|
|
1054
|
+
resource: local_sensitive_file.model
|
|
1055
|
+
output: model.yaml
|
|
1056
|
+
env:
|
|
1057
|
+
required: [NXOS_USERNAME]
|
|
1058
|
+
"""
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
@pytest.mark.unit
|
|
1062
|
+
def test_validate_uses_render_target_from_module_manifest(
|
|
1063
|
+
tmp_path, mocker, _tofu_on_path, write_module
|
|
1064
|
+
):
|
|
1065
|
+
write_module(tmp_path, manifest=_CONFIG_MANIFEST, key="fabric")
|
|
1066
|
+
config_path = _write_config(tmp_path, "")
|
|
1067
|
+
(tmp_path / "model.yaml").write_text("nxos: {}\n")
|
|
1068
|
+
run_streaming_mock = mocker.patch(
|
|
1069
|
+
"nac.cli.validate.runner.run_streaming", return_value=0
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
result = cli.invoke(app, ["--config", str(config_path), "validate"])
|
|
1073
|
+
|
|
1074
|
+
assert result.exit_code == 0, result.output
|
|
1075
|
+
render_argv = run_streaming_mock.call_args_list[0].args[0]
|
|
1076
|
+
validate_argv = run_streaming_mock.call_args_list[1].args[0]
|
|
1077
|
+
assert "-target=module.fabric.local_sensitive_file.model" in render_argv
|
|
1078
|
+
assert "model.yaml" in validate_argv
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
@pytest.mark.unit
|
|
1082
|
+
def test_validate_project_render_null_overrides_module_manifest(
|
|
1083
|
+
tmp_path, mocker, _tofu_on_path, write_module
|
|
1084
|
+
):
|
|
1085
|
+
write_module(tmp_path, manifest=_CONFIG_MANIFEST)
|
|
1086
|
+
config_path = _write_config(tmp_path, "render: null\n")
|
|
1087
|
+
run_streaming_mock = mocker.patch(
|
|
1088
|
+
"nac.cli.validate.runner.run_streaming", return_value=0
|
|
1089
|
+
)
|
|
1090
|
+
|
|
1091
|
+
result = cli.invoke(app, ["--config", str(config_path), "validate"])
|
|
1092
|
+
|
|
1093
|
+
assert result.exit_code == 0, result.output
|
|
1094
|
+
assert run_streaming_mock.call_count == 1
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
@pytest.mark.unit
|
|
1098
|
+
def test_validate_fails_when_render_step_does_not_write_output(
|
|
1099
|
+
tmp_path, mocker, _tofu_on_path
|
|
1100
|
+
):
|
|
1101
|
+
config_path = _write_config(
|
|
1102
|
+
tmp_path,
|
|
1103
|
+
"render:\n target: module.nxos.local_sensitive_file.model\n"
|
|
1104
|
+
" output: model.yaml\n",
|
|
1105
|
+
)
|
|
1106
|
+
run_streaming_mock = mocker.patch(
|
|
1107
|
+
"nac.cli.validate.runner.run_streaming", return_value=0
|
|
1108
|
+
)
|
|
1109
|
+
|
|
1110
|
+
result = cli.invoke(app, ["--config", str(config_path), "validate"])
|
|
1111
|
+
|
|
1112
|
+
assert result.exit_code == 1
|
|
1113
|
+
assert run_streaming_mock.call_count == 1 # nac-validate never runs
|
|
1114
|
+
assert (
|
|
1115
|
+
"Render step `module.nxos.local_sensitive_file.model` did not write "
|
|
1116
|
+
"`model.yaml`" in result.output
|
|
1117
|
+
)
|
|
1118
|
+
assert "write_model_file" in result.output
|
|
@@ -399,3 +399,49 @@ def test_setup_fails_without_uv_when_a_tool_needs_uvx(tmp_path, mocker):
|
|
|
399
399
|
)
|
|
400
400
|
assert "install nac-test>=0.9,<1.0 yourself" in result.output
|
|
401
401
|
assert "nac-validate" not in result.output
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
_CONFIG_MANIFEST = """\
|
|
405
|
+
schema: 1
|
|
406
|
+
config:
|
|
407
|
+
env:
|
|
408
|
+
required: [NXOS_USERNAME]
|
|
409
|
+
"""
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@pytest.mark.unit
|
|
413
|
+
def test_required_env_from_module_manifest_is_checked(
|
|
414
|
+
tmp_path, mocker, monkeypatch, write_module
|
|
415
|
+
):
|
|
416
|
+
monkeypatch.delenv("NXOS_USERNAME", raising=False)
|
|
417
|
+
write_module(tmp_path, manifest=_CONFIG_MANIFEST)
|
|
418
|
+
config_path = _write_config(tmp_path, "")
|
|
419
|
+
mocker.patch(
|
|
420
|
+
"shutil.which",
|
|
421
|
+
side_effect=_which({"uv": "/usr/bin/uv", "tofu": "/usr/bin/tofu"}),
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
result = cli.invoke(app, ["--config", str(config_path), "setup"])
|
|
425
|
+
|
|
426
|
+
assert result.exit_code != 0
|
|
427
|
+
assert "Missing required environment variable(s): NXOS_USERNAME" in result.output
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
@pytest.mark.unit
|
|
431
|
+
def test_setup_reports_module_defaults(tmp_path, mocker, monkeypatch, write_module):
|
|
432
|
+
monkeypatch.setenv("NXOS_USERNAME", "admin")
|
|
433
|
+
write_module(tmp_path, manifest=_CONFIG_MANIFEST)
|
|
434
|
+
config_path = _write_config(tmp_path, "")
|
|
435
|
+
mocker.patch(
|
|
436
|
+
"shutil.which",
|
|
437
|
+
side_effect=_which({"uv": "/usr/bin/uv", "tofu": "/usr/bin/tofu"}),
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
result = cli.invoke(app, ["--config", str(config_path), "setup"])
|
|
441
|
+
|
|
442
|
+
assert result.exit_code == 0, result.output
|
|
443
|
+
assert (
|
|
444
|
+
"module defaults: env.required (from netascode/nac-nxos/nxos 0.3.0)"
|
|
445
|
+
in result.output
|
|
446
|
+
)
|
|
447
|
+
assert "tested versions" not in result.output
|
|
@@ -183,6 +183,7 @@ def test_validate_with_render_artifacts_keep_warnings_first(
|
|
|
183
183
|
"render:\n target: module.nxos.model\n output: model.yaml\n"
|
|
184
184
|
"tools:\n nac_validate: 1.2.0\n",
|
|
185
185
|
)
|
|
186
|
+
(tmp_path / "model.yaml").write_text("nxos: {}\n")
|
|
186
187
|
run_streaming_mock = mocker.patch(
|
|
187
188
|
"nac.cli.validate.runner.run_streaming", return_value=0
|
|
188
189
|
)
|