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/config.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright (c) 2026 Daniel Schmidt
|
|
3
|
+
|
|
4
|
+
# ValidateConfig.schema / NacConfig.validate intentionally use the same
|
|
5
|
+
# names as nac.yaml's own keys; pydantic only warns because they shadow
|
|
6
|
+
# deprecated pydantic-v1 classmethods that this codebase never calls.
|
|
7
|
+
import warnings
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
import pydantic
|
|
12
|
+
import ruamel.yaml
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, model_validator
|
|
14
|
+
|
|
15
|
+
from nac.exceptions import ConfigError
|
|
16
|
+
|
|
17
|
+
warnings.filterwarnings(
|
|
18
|
+
"ignore",
|
|
19
|
+
message=r'Field name ".*" in ".*" shadows an attribute in parent "BaseModel"',
|
|
20
|
+
category=UserWarning,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
DEFAULT_CONFIG_PATH = Path("nac.yaml")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DataConfig(BaseModel):
|
|
27
|
+
model_config = ConfigDict(extra="forbid")
|
|
28
|
+
|
|
29
|
+
paths: list[str] = ["data/"]
|
|
30
|
+
defaults: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RenderConfig(BaseModel):
|
|
34
|
+
model_config = ConfigDict(extra="forbid")
|
|
35
|
+
|
|
36
|
+
target: str | None = None
|
|
37
|
+
output: str | None = None
|
|
38
|
+
|
|
39
|
+
@model_validator(mode="after")
|
|
40
|
+
def _target_requires_output(self) -> "RenderConfig":
|
|
41
|
+
if self.target is not None and self.output is None:
|
|
42
|
+
raise ValueError("render.output must be set when render.target is set")
|
|
43
|
+
return self
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ValidateConfig(BaseModel):
|
|
47
|
+
model_config = ConfigDict(extra="forbid")
|
|
48
|
+
|
|
49
|
+
schema: str | None = None # ty: ignore[invalid-attribute-override]
|
|
50
|
+
rules: list[str] | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TestConfig(BaseModel):
|
|
54
|
+
# Tells pytest not to try collecting this as a test class -- it's a
|
|
55
|
+
# config model that happens to be named after nac.yaml's `test:` key.
|
|
56
|
+
__test__ = False
|
|
57
|
+
|
|
58
|
+
model_config = ConfigDict(extra="forbid")
|
|
59
|
+
|
|
60
|
+
templates: str | None = None
|
|
61
|
+
filters: str | None = None
|
|
62
|
+
output: str = "tests/results"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TerraformToolConfig(BaseModel):
|
|
66
|
+
model_config = ConfigDict(extra="forbid")
|
|
67
|
+
|
|
68
|
+
engine: Literal["tofu", "terraform"] | None = None
|
|
69
|
+
version: str | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ToolsConfig(BaseModel):
|
|
73
|
+
model_config = ConfigDict(extra="forbid")
|
|
74
|
+
|
|
75
|
+
terraform: TerraformToolConfig = TerraformToolConfig()
|
|
76
|
+
nac_validate: str | None = None
|
|
77
|
+
nac_test: str | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class EnvConfig(BaseModel):
|
|
81
|
+
model_config = ConfigDict(extra="forbid")
|
|
82
|
+
|
|
83
|
+
required: list[str] = []
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class NacConfig(BaseModel):
|
|
87
|
+
model_config = ConfigDict(extra="forbid")
|
|
88
|
+
|
|
89
|
+
working_dir: Path = Path(".")
|
|
90
|
+
data: DataConfig = DataConfig()
|
|
91
|
+
render: RenderConfig | None = None
|
|
92
|
+
validate: ValidateConfig = ValidateConfig() # ty: ignore[invalid-attribute-override]
|
|
93
|
+
test: TestConfig = TestConfig()
|
|
94
|
+
tools: ToolsConfig = ToolsConfig()
|
|
95
|
+
env: EnvConfig = EnvConfig()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def load_config(path: Path | str = DEFAULT_CONFIG_PATH) -> NacConfig:
|
|
99
|
+
"""Load, validate, and resolve conventions for a nac.yaml file.
|
|
100
|
+
|
|
101
|
+
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.
|
|
105
|
+
"""
|
|
106
|
+
path = Path(path)
|
|
107
|
+
raw: object = {}
|
|
108
|
+
if path.is_file():
|
|
109
|
+
yaml = ruamel.yaml.YAML(typ="safe")
|
|
110
|
+
try:
|
|
111
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
112
|
+
raw = yaml.load(fh)
|
|
113
|
+
except ruamel.yaml.YAMLError as exc:
|
|
114
|
+
raise ConfigError(f"Failed to parse {path}: {exc}") from exc
|
|
115
|
+
|
|
116
|
+
if raw is None:
|
|
117
|
+
raw = {}
|
|
118
|
+
if not isinstance(raw, dict):
|
|
119
|
+
raise ConfigError(
|
|
120
|
+
f"Config file {path} must contain a YAML mapping at the top level"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
cfg = NacConfig.model_validate(raw)
|
|
125
|
+
except pydantic.ValidationError as exc:
|
|
126
|
+
raise ConfigError(_format_validation_error(path, exc)) from exc
|
|
127
|
+
|
|
128
|
+
return _apply_filesystem_defaults(cfg, path)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _format_validation_error(path: Path, exc: pydantic.ValidationError) -> str:
|
|
132
|
+
lines = [f"Invalid config file: {path}"]
|
|
133
|
+
for err in exc.errors():
|
|
134
|
+
loc = ".".join(str(part) for part in err["loc"]) or "<root>"
|
|
135
|
+
lines.append(f" {loc}: {err['msg']}")
|
|
136
|
+
return "\n".join(lines)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _apply_filesystem_defaults(cfg: NacConfig, config_path: Path) -> NacConfig:
|
|
140
|
+
working_dir_abs = (config_path.parent / cfg.working_dir).resolve()
|
|
141
|
+
|
|
142
|
+
data = cfg.data
|
|
143
|
+
if data.defaults is None and (working_dir_abs / "defaults.yaml").is_file():
|
|
144
|
+
data = data.model_copy(update={"defaults": "defaults.yaml"})
|
|
145
|
+
|
|
146
|
+
test = cfg.test
|
|
147
|
+
if test.templates is None and (working_dir_abs / "tests" / "templates").is_dir():
|
|
148
|
+
test = test.model_copy(update={"templates": "tests/templates"})
|
|
149
|
+
if test.filters is None and (working_dir_abs / "tests" / "filters").is_dir():
|
|
150
|
+
test = test.model_copy(update={"filters": "tests/filters"})
|
|
151
|
+
|
|
152
|
+
return cfg.model_copy(
|
|
153
|
+
update={"working_dir": working_dir_abs, "data": data, "test": test}
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def resolve_validate_data(cfg: NacConfig) -> list[str]:
|
|
158
|
+
"""Data paths for `nac validate`.
|
|
159
|
+
|
|
160
|
+
`render.output` if `render.target` is set, else `data.paths`.
|
|
161
|
+
"""
|
|
162
|
+
if cfg.render is not None and cfg.render.target is not None:
|
|
163
|
+
if cfg.render.output is None:
|
|
164
|
+
raise ConfigError("render.output must be set when render.target is set")
|
|
165
|
+
return [cfg.render.output]
|
|
166
|
+
return list(cfg.data.paths)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def resolve_test_data(cfg: NacConfig) -> list[str]:
|
|
170
|
+
"""Data paths for `nac test`.
|
|
171
|
+
|
|
172
|
+
(`render.output` if `render` is configured, else `data.paths`) with
|
|
173
|
+
`data.defaults` appended whenever it's configured.
|
|
174
|
+
"""
|
|
175
|
+
if cfg.render is not None:
|
|
176
|
+
base: list[str] = [cfg.render.output] if cfg.render.output is not None else []
|
|
177
|
+
else:
|
|
178
|
+
base = list(cfg.data.paths)
|
|
179
|
+
if cfg.data.defaults is not None:
|
|
180
|
+
base = [*base, cfg.data.defaults]
|
|
181
|
+
return base
|
nac/exceptions.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright (c) 2026 Daniel Schmidt
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class NacError(Exception):
|
|
6
|
+
"""Base class for all nac errors."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ConfigError(NacError):
|
|
10
|
+
"""Missing, malformed, or invalid nac.yaml."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EngineNotFoundError(NacError):
|
|
14
|
+
"""An explicitly-configured tools.terraform.engine binary is not on PATH."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class NoEngineAvailableError(NacError):
|
|
18
|
+
"""Neither tofu nor terraform was found on PATH and no engine was configured."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BootstrapError(NacError):
|
|
22
|
+
"""Base class for OpenTofu zero-config bootstrap failures."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UnsupportedPlatformError(BootstrapError):
|
|
26
|
+
"""The current OS/architecture has no matching OpenTofu release asset."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BootstrapDeclinedError(BootstrapError):
|
|
30
|
+
"""Installation was not confirmed and no binary is already cached."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ChecksumMismatchError(BootstrapError):
|
|
34
|
+
"""The downloaded archive's SHA-256 didn't match the published checksum."""
|
nac/manifest.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright (c) 2026 Daniel Schmidt
|
|
3
|
+
|
|
4
|
+
"""Tested-versions manifest shipped with a Network-as-Code Terraform module.
|
|
5
|
+
|
|
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.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Literal
|
|
21
|
+
|
|
22
|
+
import pydantic
|
|
23
|
+
import ruamel.yaml
|
|
24
|
+
from packaging.specifiers import SpecifierSet
|
|
25
|
+
from packaging.version import InvalidVersion, Version
|
|
26
|
+
from pydantic import BaseModel, ConfigDict
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
MANIFEST_FILE = "nac/manifest.yaml"
|
|
31
|
+
MODULES_JSON = Path(".terraform") / "modules" / "modules.json"
|
|
32
|
+
_REGISTRY_HOSTS = ("registry.terraform.io/", "registry.opentofu.org/")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _ManifestFile(BaseModel):
|
|
36
|
+
# Unknown keys are ignored so newer manifests keep working with older nac.
|
|
37
|
+
model_config = ConfigDict(extra="ignore", coerce_numbers_to_str=True)
|
|
38
|
+
|
|
39
|
+
schema_version: Literal[1] = pydantic.Field(alias="schema")
|
|
40
|
+
tools: dict[str, str] = {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Manifest:
|
|
45
|
+
"""Tested versions for one module, keyed by tool name.
|
|
46
|
+
|
|
47
|
+
`module` is a human-readable label for messages, e.g.
|
|
48
|
+
`netascode/nac-nxos/nxos 0.3.0`.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
module: str
|
|
52
|
+
tools: dict[str, str] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
def check_tool(self, name: str, actual: str | None) -> str | None:
|
|
55
|
+
"""Warning message if `actual` is outside the tested range, else None."""
|
|
56
|
+
tested = self.tools.get(name)
|
|
57
|
+
if actual is None or tested is None:
|
|
58
|
+
return None
|
|
59
|
+
status = classify(actual, tested)
|
|
60
|
+
if status == "compatible":
|
|
61
|
+
logger.info(
|
|
62
|
+
"%s %s is newer than the version tested with %s (%s) but "
|
|
63
|
+
"semver-compatible",
|
|
64
|
+
name,
|
|
65
|
+
actual,
|
|
66
|
+
self.module,
|
|
67
|
+
tested,
|
|
68
|
+
)
|
|
69
|
+
if status != "untested":
|
|
70
|
+
return None
|
|
71
|
+
return (
|
|
72
|
+
f"{name} {actual} is outside the range tested with {self.module} "
|
|
73
|
+
f"(tested: {tested}, compatible: {compatible_range(tested)})"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def compatible_range(tested: str) -> str | None:
|
|
78
|
+
"""Semver-compatible range starting at `tested`.
|
|
79
|
+
|
|
80
|
+
Upward-only, up to the next major -- or the next minor for 0.x, where
|
|
81
|
+
a minor bump may be breaking. None if `tested` isn't a valid version.
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
version = Version(tested)
|
|
85
|
+
except InvalidVersion:
|
|
86
|
+
return None
|
|
87
|
+
upper = f"{version.major + 1}" if version.major > 0 else f"0.{version.minor + 1}"
|
|
88
|
+
return f">={version},<{upper}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def classify(
|
|
92
|
+
actual: str, tested: str
|
|
93
|
+
) -> Literal["tested", "compatible", "untested"] | None:
|
|
94
|
+
"""How `actual` relates to `tested`; None if either isn't a valid version."""
|
|
95
|
+
spec = compatible_range(tested)
|
|
96
|
+
try:
|
|
97
|
+
actual_version = Version(actual)
|
|
98
|
+
except InvalidVersion:
|
|
99
|
+
return None
|
|
100
|
+
if spec is None:
|
|
101
|
+
return None
|
|
102
|
+
if actual_version == Version(tested):
|
|
103
|
+
return "tested"
|
|
104
|
+
if SpecifierSet(spec).contains(actual_version, prereleases=True):
|
|
105
|
+
return "compatible"
|
|
106
|
+
return "untested"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_manifest(working_dir: Path) -> Manifest | None:
|
|
110
|
+
"""Find and parse the manifest of an initialized module in working_dir.
|
|
111
|
+
|
|
112
|
+
Considers every module in `.terraform/modules/modules.json` that ships
|
|
113
|
+
a manifest, preferring the shallowest (so a local wrapper module around
|
|
114
|
+
a netascode module still works). Returns None if there is none or it
|
|
115
|
+
can't be read -- the manifest is only ever a hint.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
raw = json.loads((working_dir / MODULES_JSON).read_text(encoding="utf-8"))
|
|
119
|
+
modules = raw["Modules"]
|
|
120
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
121
|
+
return None
|
|
122
|
+
if not isinstance(modules, list):
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
candidates = [
|
|
126
|
+
m for m in modules if isinstance(m, dict) and m.get("Key") and m.get("Dir")
|
|
127
|
+
]
|
|
128
|
+
candidates.sort(key=lambda m: str(m["Key"]).count("."))
|
|
129
|
+
for module in candidates:
|
|
130
|
+
path = working_dir / str(module["Dir"]) / MANIFEST_FILE
|
|
131
|
+
if path.is_file():
|
|
132
|
+
manifest = _parse_manifest(path, _module_label(module))
|
|
133
|
+
if manifest is not None:
|
|
134
|
+
return manifest
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _module_label(module: dict[str, object]) -> str:
|
|
139
|
+
source = str(module.get("Source") or "")
|
|
140
|
+
version = str(module.get("Version") or "")
|
|
141
|
+
for host in _REGISTRY_HOSTS:
|
|
142
|
+
if source.startswith(host):
|
|
143
|
+
return f"{source[len(host) :]} {version}".strip()
|
|
144
|
+
return f"module.{module['Key']}"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _parse_manifest(path: Path, label: str) -> Manifest | None:
|
|
148
|
+
yaml = ruamel.yaml.YAML(typ="safe")
|
|
149
|
+
try:
|
|
150
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
151
|
+
data = _ManifestFile.model_validate(yaml.load(fh))
|
|
152
|
+
except (OSError, ruamel.yaml.YAMLError, pydantic.ValidationError) as exc:
|
|
153
|
+
logger.debug("Ignoring unreadable manifest %s: %s", path, exc)
|
|
154
|
+
return None
|
|
155
|
+
return Manifest(module=label, tools=dict(data.tools))
|
nac/output.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright (c) 2026 Daniel Schmidt
|
|
3
|
+
|
|
4
|
+
"""Shared terminal output formatting, matching nac-validate's own summary
|
|
5
|
+
box style (80-char `-` rule, bold header, green checkmarks) so a `nac`
|
|
6
|
+
invocation reads as one coherent report.
|
|
7
|
+
|
|
8
|
+
Color is on by default (matching a plain `terraform apply`); callers pass
|
|
9
|
+
an explicit `no_color` bool -- resolved once per invocation from the
|
|
10
|
+
`--no-color` flag / `NO_COLOR` env var in `nac.cli.main.get_no_color` --
|
|
11
|
+
rather than this module re-reading the environment itself."""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shlex
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import Mapping, Sequence
|
|
19
|
+
|
|
20
|
+
import typer
|
|
21
|
+
|
|
22
|
+
SEP_WIDTH = 80
|
|
23
|
+
BOLD = "\033[1m"
|
|
24
|
+
GREEN = "\033[92m"
|
|
25
|
+
YELLOW = "\033[93m"
|
|
26
|
+
DIM = "\033[2m"
|
|
27
|
+
_RESET = "\033[0m"
|
|
28
|
+
|
|
29
|
+
_ANSI_ESCAPE_RE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def color(code: str, text: str, *, no_color: bool = False) -> str:
|
|
33
|
+
"""Wrap text in an ANSI color code, unless no_color is set.
|
|
34
|
+
|
|
35
|
+
Click strips these automatically when stdout isn't a TTY, so this
|
|
36
|
+
only needs to handle the explicit opt-out.
|
|
37
|
+
"""
|
|
38
|
+
if no_color:
|
|
39
|
+
return text
|
|
40
|
+
return f"{code}{text}{_RESET}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def echo_summary(title: str, lines: list[str], *, no_color: bool = False) -> None:
|
|
44
|
+
"""Print a status box: bold title between `-` rules, one
|
|
45
|
+
green-checkmarked line per entry in `lines`."""
|
|
46
|
+
sep = "─" * SEP_WIDTH
|
|
47
|
+
typer.echo(f"\n{color(BOLD, sep, no_color=no_color)}")
|
|
48
|
+
typer.echo(color(BOLD, title, no_color=no_color))
|
|
49
|
+
typer.echo(sep)
|
|
50
|
+
for line in lines:
|
|
51
|
+
typer.echo(f" {color(GREEN, '✓', no_color=no_color)} {line}")
|
|
52
|
+
typer.echo(f"{sep}\n")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def format_command(argv: Sequence[str]) -> str:
|
|
56
|
+
"""Render argv as a single copy-pasteable command line, quoted for the
|
|
57
|
+
current platform's shell (POSIX sh, or cmd.exe-style on Windows)."""
|
|
58
|
+
if sys.platform == "win32":
|
|
59
|
+
return subprocess.list2cmdline(argv)
|
|
60
|
+
return shlex.join(argv)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def echo_command(argv: Sequence[str], *, no_color: bool = False) -> None:
|
|
64
|
+
"""Print the exact command nac is about to run, as a dim `$ ...` line.
|
|
65
|
+
|
|
66
|
+
Goes to stderr so it never mixes into a wrapped tool's stdout when that
|
|
67
|
+
is piped or redirected. stdout is flushed first so the line still lands
|
|
68
|
+
in order relative to output nac has already written there.
|
|
69
|
+
"""
|
|
70
|
+
sys.stdout.flush()
|
|
71
|
+
typer.echo(color(DIM, f"$ {format_command(argv)}", no_color=no_color), err=True)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def utf8_env(base_env: Mapping[str, str]) -> dict[str, str]:
|
|
75
|
+
"""Copy of base_env that makes Python child processes (nac-validate,
|
|
76
|
+
nac-test) emit UTF-8 when piped; on Windows they would otherwise use the
|
|
77
|
+
ANSI code page and crash on characters like box-drawing glyphs."""
|
|
78
|
+
env = dict(base_env)
|
|
79
|
+
env.setdefault("PYTHONUTF8", "1")
|
|
80
|
+
env.setdefault("PYTHONIOENCODING", "utf-8")
|
|
81
|
+
return env
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def ensure_utf8_streams() -> None:
|
|
85
|
+
"""Make our own stdout/stderr able to print the glyphs nac emits (─, ✓)
|
|
86
|
+
even when redirected on Windows, where the default is the ANSI code page."""
|
|
87
|
+
for stream in (sys.stdout, sys.stderr):
|
|
88
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
89
|
+
if reconfigure is not None:
|
|
90
|
+
reconfigure(encoding="utf-8", errors="replace")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def strip_ansi(text: str) -> str:
|
|
94
|
+
"""Strip ANSI escape sequences, e.g. before writing subprocess output
|
|
95
|
+
to a log file artifact (plan.txt) so it stays clean, human-diffable
|
|
96
|
+
text even when the same output was streamed to the terminal in color."""
|
|
97
|
+
return _ANSI_ESCAPE_RE.sub("", text)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def apply_no_color_env(
|
|
101
|
+
no_color: bool, base_env: Mapping[str, str] | None = None
|
|
102
|
+
) -> dict[str, str]:
|
|
103
|
+
"""Build a subprocess env dict, adding NO_COLOR when no_color is set.
|
|
104
|
+
|
|
105
|
+
Terraform/OpenTofu only ever look at the literal `-no-color` CLI flag
|
|
106
|
+
(never the NO_COLOR env var), so this is for forwarding the setting to
|
|
107
|
+
subprocesses that *do* honor the NO_COLOR convention -- nac-validate
|
|
108
|
+
and nac-test.
|
|
109
|
+
"""
|
|
110
|
+
env = dict(base_env if base_env is not None else os.environ)
|
|
111
|
+
if no_color:
|
|
112
|
+
env["NO_COLOR"] = "1"
|
|
113
|
+
return env
|
nac/runner.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright (c) 2026 Daniel Schmidt
|
|
3
|
+
|
|
4
|
+
import codecs
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from contextlib import nullcontext
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import cast
|
|
12
|
+
|
|
13
|
+
from nac.output import echo_command, strip_ansi, utf8_env
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
_READ_SIZE = 4096
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _write_stdout(data: bytes) -> None:
|
|
21
|
+
"""Pass the child's raw bytes through untouched.
|
|
22
|
+
|
|
23
|
+
Going via the binary buffer avoids the text layer's newline translation
|
|
24
|
+
(\r\n -> \r\r\n on Windows) and its locale encoding (cp1252 on Windows
|
|
25
|
+
cannot encode much of what terraform/tofu print).
|
|
26
|
+
"""
|
|
27
|
+
sys.stdout.flush()
|
|
28
|
+
buffer = getattr(sys.stdout, "buffer", None)
|
|
29
|
+
if buffer is None: # replaced stdout without a binary layer
|
|
30
|
+
sys.stdout.write(data.decode("utf-8", errors="replace"))
|
|
31
|
+
sys.stdout.flush()
|
|
32
|
+
return
|
|
33
|
+
buffer.write(data)
|
|
34
|
+
buffer.flush()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _log_env_overrides(env: dict[str, str]) -> None:
|
|
38
|
+
overrides = {k: v for k, v in env.items() if os.environ.get(k) != v}
|
|
39
|
+
if overrides:
|
|
40
|
+
logger.debug(
|
|
41
|
+
"Environment overrides: %s",
|
|
42
|
+
" ".join(f"{k}={v}" for k, v in sorted(overrides.items())),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def run_streaming(
|
|
47
|
+
cmd: list[str],
|
|
48
|
+
cwd: Path,
|
|
49
|
+
env: dict[str, str],
|
|
50
|
+
log_path: Path | None = None,
|
|
51
|
+
quiet: bool = False,
|
|
52
|
+
append: bool = False,
|
|
53
|
+
no_color: bool = False,
|
|
54
|
+
) -> int:
|
|
55
|
+
"""Run cmd as a subprocess, streaming merged stdout+stderr in real time.
|
|
56
|
+
|
|
57
|
+
stdin is inherited from the parent so interactive prompts (e.g.
|
|
58
|
+
terraform's apply confirmation) still work. cmd[0] must already be a
|
|
59
|
+
resolved path — this function does no binary resolution itself.
|
|
60
|
+
|
|
61
|
+
Output is read in raw chunks via os.read (not line-by-line) -- a
|
|
62
|
+
line-buffered `for line in proc.stdout` blocks until a newline shows
|
|
63
|
+
up, so a prompt like terraform's trailing "Enter a value: " (no
|
|
64
|
+
newline, then it waits on stdin) would otherwise sit unflushed and
|
|
65
|
+
invisible until the next newline arrives.
|
|
66
|
+
|
|
67
|
+
When quiet is True, output is captured instead of being streamed live;
|
|
68
|
+
it is only written to stdout if the command exits non-zero, so a caller
|
|
69
|
+
can pair this with its own short status line on success while still
|
|
70
|
+
surfacing the real output on failure.
|
|
71
|
+
|
|
72
|
+
Output written to log_path always has ANSI escape codes stripped --
|
|
73
|
+
regardless of whether the terminal-facing copy was colored -- so log
|
|
74
|
+
file artifacts (e.g. plan.txt) stay clean, human-diffable text.
|
|
75
|
+
|
|
76
|
+
By default log_path is opened in truncate mode, so a stale artifact
|
|
77
|
+
from a previous, separate invocation never leaks into new output. Pass
|
|
78
|
+
append=True when this call is a continuation of an earlier
|
|
79
|
+
run_streaming call within the same invocation that already truncated
|
|
80
|
+
the same log_path (e.g. nac validate's render step followed by the
|
|
81
|
+
nac-validate call itself), so both calls' output lands in one file in
|
|
82
|
+
order. append has no effect when log_path is None.
|
|
83
|
+
|
|
84
|
+
The exact command line is always echoed first (to stderr, never to
|
|
85
|
+
log_path, so artifacts like plan.json stay pure tool output -- see
|
|
86
|
+
`echo_command`); the environment variables nac set or changed for the
|
|
87
|
+
child are logged at DEBUG, since they can affect behavior too (e.g. the
|
|
88
|
+
tenv version pin) but would be noise on every run.
|
|
89
|
+
"""
|
|
90
|
+
child_env = utf8_env(env)
|
|
91
|
+
echo_command(cmd, no_color=no_color)
|
|
92
|
+
_log_env_overrides(child_env)
|
|
93
|
+
|
|
94
|
+
if log_path is not None:
|
|
95
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
|
|
97
|
+
mode = "a" if append else "w"
|
|
98
|
+
# newline="" keeps the child's own line endings: on Windows it already
|
|
99
|
+
# emits \r\n, which default newline translation would turn into \r\r\n.
|
|
100
|
+
log_cm = (
|
|
101
|
+
log_path.open(mode, encoding="utf-8", newline="")
|
|
102
|
+
if log_path is not None
|
|
103
|
+
else nullcontext()
|
|
104
|
+
)
|
|
105
|
+
with log_cm as log_file:
|
|
106
|
+
proc = subprocess.Popen(
|
|
107
|
+
cmd,
|
|
108
|
+
cwd=cwd,
|
|
109
|
+
env=child_env,
|
|
110
|
+
stdout=subprocess.PIPE,
|
|
111
|
+
stderr=subprocess.STDOUT,
|
|
112
|
+
stdin=None,
|
|
113
|
+
bufsize=0,
|
|
114
|
+
)
|
|
115
|
+
if proc.stdout is None:
|
|
116
|
+
raise RuntimeError("subprocess stdout was not piped")
|
|
117
|
+
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
118
|
+
buffered: list[bytes] = []
|
|
119
|
+
fd = proc.stdout.fileno()
|
|
120
|
+
while True:
|
|
121
|
+
raw = os.read(fd, _READ_SIZE)
|
|
122
|
+
if not raw:
|
|
123
|
+
break
|
|
124
|
+
if quiet:
|
|
125
|
+
buffered.append(raw)
|
|
126
|
+
else:
|
|
127
|
+
_write_stdout(raw)
|
|
128
|
+
if log_file is not None:
|
|
129
|
+
log_file.write(strip_ansi(decoder.decode(raw)))
|
|
130
|
+
proc.wait()
|
|
131
|
+
if proc.returncode is None:
|
|
132
|
+
raise RuntimeError("subprocess did not report an exit code")
|
|
133
|
+
if quiet and proc.returncode != 0:
|
|
134
|
+
_write_stdout(b"".join(buffered))
|
|
135
|
+
return cast(int, proc.returncode)
|