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.
Files changed (69) hide show
  1. {nac_cli-0.2.0 → nac_cli-0.2.1}/CHANGELOG.md +5 -0
  2. {nac_cli-0.2.0 → nac_cli-0.2.1}/PKG-INFO +1 -1
  3. {nac_cli-0.2.0 → nac_cli-0.2.1}/pyproject.toml +1 -1
  4. nac_cli-0.2.1/release-notes.md +3 -0
  5. {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/cli/main.py +27 -2
  6. {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/cli/setup.py +9 -2
  7. {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/cli/validate.py +8 -1
  8. {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/cli/versions.py +8 -5
  9. {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/config.py +57 -5
  10. {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/manifest.py +114 -13
  11. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/unit/test_cli_commands.py +84 -2
  12. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/unit/test_cli_setup.py +46 -0
  13. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/unit/test_cli_versions.py +1 -0
  14. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/unit/test_config.py +67 -0
  15. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/unit/test_manifest.py +108 -0
  16. {nac_cli-0.2.0 → nac_cli-0.2.1}/uv.lock +145 -141
  17. nac_cli-0.2.0/release-notes.md +0 -3
  18. {nac_cli-0.2.0 → nac_cli-0.2.1}/.github/dependabot.yml +0 -0
  19. {nac_cli-0.2.0 → nac_cli-0.2.1}/.github/workflows/release.yml +0 -0
  20. {nac_cli-0.2.0 → nac_cli-0.2.1}/.github/workflows/test.yml +0 -0
  21. {nac_cli-0.2.0 → nac_cli-0.2.1}/.gitignore +0 -0
  22. {nac_cli-0.2.0 → nac_cli-0.2.1}/.pre-commit-config.yaml +0 -0
  23. {nac_cli-0.2.0 → nac_cli-0.2.1}/LICENSE +0 -0
  24. {nac_cli-0.2.0 → nac_cli-0.2.1}/README.md +0 -0
  25. {nac_cli-0.2.0 → nac_cli-0.2.1}/scripts/license_headers.py +0 -0
  26. {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/__init__.py +0 -0
  27. {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/__main__.py +0 -0
  28. {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/bootstrap.py +0 -0
  29. {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/cli/__init__.py +0 -0
  30. {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/cli/apply.py +0 -0
  31. {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/cli/destroy.py +0 -0
  32. {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/cli/init.py +0 -0
  33. {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/cli/options.py +0 -0
  34. {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/cli/passthrough.py +0 -0
  35. {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/cli/plan.py +0 -0
  36. {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/cli/test.py +0 -0
  37. {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/exceptions.py +0 -0
  38. {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/output.py +0 -0
  39. {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/runner.py +0 -0
  40. {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/terraform.py +0 -0
  41. {nac_cli-0.2.0 → nac_cli-0.2.1}/src/nac/tools.py +0 -0
  42. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/fixtures/nac-yaml/aci-lean/defaults.yaml +0 -0
  43. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/fixtures/nac-yaml/aci-lean/nac.yaml +0 -0
  44. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/fixtures/nac-yaml/filters-absent/nac.yaml +0 -0
  45. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/fixtures/nac-yaml/filters-present/nac.yaml +0 -0
  46. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/fixtures/nac-yaml/filters-present/tests/filters/.gitkeep +0 -0
  47. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/fixtures/nac-yaml/invalid-syntax/nac.yaml +0 -0
  48. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/fixtures/nac-yaml/iosxe-lean/nac.yaml +0 -0
  49. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/fixtures/nac-yaml/minimal/nac.yaml +0 -0
  50. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/fixtures/nac-yaml/nxos-full/nac.yaml +0 -0
  51. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/fixtures/nac-yaml/nxos-lean/nac.yaml +0 -0
  52. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/fixtures/nac-yaml/templates-absent/nac.yaml +0 -0
  53. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/fixtures/nac-yaml/templates-present/nac.yaml +0 -0
  54. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/fixtures/nac-yaml/templates-present/tests/templates/.gitkeep +0 -0
  55. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/integration/fixtures/local_only/.terraform.lock.hcl +0 -0
  56. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/integration/fixtures/local_only/broken/main.tf +0 -0
  57. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/integration/fixtures/local_only/main.tf +0 -0
  58. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/integration/fixtures/validate_test/.schema.yaml +0 -0
  59. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/integration/fixtures/validate_test/data/main.yaml +0 -0
  60. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/integration/fixtures/validate_test/templates/test.robot +0 -0
  61. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/integration/test_streaming.py +0 -0
  62. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/integration/test_validate_test.py +0 -0
  63. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/unit/conftest.py +0 -0
  64. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/unit/test_bootstrap.py +0 -0
  65. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/unit/test_output.py +0 -0
  66. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/unit/test_placeholder.py +0 -0
  67. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/unit/test_runner.py +0 -0
  68. {nac_cli-0.2.0 → nac_cli-0.2.1}/tests/unit/test_terraform.py +0 -0
  69. {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.0
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nac-cli"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "An umbrella CLI wrapping terraform/tofu, nac-validate, and nac-test for Network-as-Code workflows."
5
5
  license = { text = "MPL-2.0" }
6
6
  readme = "README.md"
@@ -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 DEFAULT_CONFIG_PATH, NacConfig, load_config
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
- cfg = load_config(cast(Path, ctx.obj))
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
- lines.append(f"tested versions: {manifest.module} ({MANIFEST_FILE})")
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
- _MANIFEST_KEY = "manifest"
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
- 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])
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(path: Path | str = DEFAULT_CONFIG_PATH) -> NacConfig:
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. Raises ConfigError (never a
103
- raw pydantic/ruamel traceback) on malformed YAML or a schema
104
- validation failure.
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
- """Tested-versions manifest shipped with a Network-as-Code Terraform module.
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) listing the nac-validate/nac-test
8
- versions it was tested with. terraform/tofu and provider versions are not
9
- part of it: the module's `versions.tf` already constrains those, and
10
- Terraform enforces them itself. After `terraform init`, `nac` finds it via
11
- `.terraform/modules/modules.json` and uses it purely as a hint: versions
12
- outside the tested, semver-compatible range produce a warning, never an
13
- error. A missing or unreadable manifest is silently ignored.
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(path, _module_label(module))
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 _parse_manifest(path: Path, label: str) -> Manifest | None:
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(module=label, tools=dict(data.tools))
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
  )