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 ADDED
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: MPL-2.0
2
+ # Copyright (c) 2026 Daniel Schmidt
3
+
4
+ from importlib.metadata import version
5
+
6
+ __version__ = version("nac-cli")
nac/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ # SPDX-License-Identifier: MPL-2.0
2
+ # Copyright (c) 2026 Daniel Schmidt
3
+
4
+ import nac.cli.main
5
+
6
+ if __name__ == "__main__":
7
+ nac.cli.main.run()
nac/bootstrap.py ADDED
@@ -0,0 +1,181 @@
1
+ # SPDX-License-Identifier: MPL-2.0
2
+ # Copyright (c) 2026 Daniel Schmidt
3
+
4
+ import hashlib
5
+ import io
6
+ import platform
7
+ import shutil
8
+ import stat
9
+ import sys
10
+ import zipfile
11
+ from pathlib import Path
12
+ from typing import Any, cast
13
+
14
+ import httpx
15
+ import platformdirs
16
+
17
+ from nac.exceptions import (
18
+ BootstrapDeclinedError,
19
+ BootstrapError,
20
+ ChecksumMismatchError,
21
+ UnsupportedPlatformError,
22
+ )
23
+
24
+ _OS_MAP = {
25
+ "darwin": "darwin",
26
+ "linux": "linux",
27
+ "win32": "windows",
28
+ "cygwin": "windows",
29
+ }
30
+ _ARCH_MAP = {
31
+ "x86_64": "amd64",
32
+ "amd64": "amd64",
33
+ "aarch64": "arm64",
34
+ "arm64": "arm64",
35
+ "i386": "386",
36
+ "i686": "386",
37
+ "x86": "386",
38
+ "armv7l": "arm",
39
+ "armv6l": "arm",
40
+ }
41
+
42
+ _GITHUB_API = "https://api.github.com/repos/opentofu/opentofu/releases"
43
+
44
+
45
+ def cache_dir() -> Path:
46
+ return Path(platformdirs.user_cache_dir("nac"))
47
+
48
+
49
+ def _binary_name() -> str:
50
+ return "tofu.exe" if sys.platform == "win32" else "tofu"
51
+
52
+
53
+ def _cached_binary_path(version: str) -> Path:
54
+ return cache_dir() / "bin" / "tofu" / version / _binary_name()
55
+
56
+
57
+ def find_cached_binary() -> Path | None:
58
+ """Return the first cached tofu binary found, or None. No network access."""
59
+ root = cache_dir() / "bin" / "tofu"
60
+ if not root.is_dir():
61
+ return None
62
+ name = _binary_name()
63
+ for version_dir in root.iterdir():
64
+ candidate = version_dir / name
65
+ if candidate.is_file():
66
+ return candidate
67
+ return None
68
+
69
+
70
+ def detect_platform() -> tuple[str, str]:
71
+ """Return (os_name, arch_name) in OpenTofu's release-asset vocabulary."""
72
+ os_name = _OS_MAP.get(sys.platform)
73
+ arch_name = _ARCH_MAP.get(platform.machine().lower())
74
+ if os_name is None or arch_name is None:
75
+ raise UnsupportedPlatformError(
76
+ f"Unsupported platform for OpenTofu bootstrap: {sys.platform}/{platform.machine()}"
77
+ )
78
+ return os_name, arch_name
79
+
80
+
81
+ def install(
82
+ version: str | None = None,
83
+ *,
84
+ confirmed: bool,
85
+ client: httpx.Client | None = None,
86
+ ) -> Path:
87
+ """Ensure a cached OpenTofu binary exists locally and return its path.
88
+
89
+ Never prompts — only gates on `confirmed`, which the caller (`nac
90
+ setup`) obtains via its own TTY-detection/prompting. If a binary is
91
+ already cached, it is returned immediately with no network call,
92
+ regardless of `confirmed`.
93
+ """
94
+ cached = find_cached_binary()
95
+ if cached is not None:
96
+ return cached
97
+
98
+ if not confirmed:
99
+ raise BootstrapDeclinedError(
100
+ "OpenTofu is not installed and installation was not confirmed."
101
+ )
102
+
103
+ owns_client = client is None
104
+ client = client or httpx.Client(timeout=30.0, follow_redirects=True)
105
+ try:
106
+ os_name, arch_name = detect_platform()
107
+ release = _get_release(client, version)
108
+ resolved_version = _tag_to_version(release["tag_name"])
109
+ dest = _cached_binary_path(resolved_version)
110
+
111
+ asset_name = f"tofu_{resolved_version}_{os_name}_{arch_name}.zip"
112
+ sums_name = f"tofu_{resolved_version}_SHA256SUMS"
113
+ archive_bytes = _download(client, _asset_url(release, asset_name))
114
+ sums_text = _download(client, _asset_url(release, sums_name)).decode()
115
+
116
+ expected = _parse_sha256sums(sums_text, asset_name)
117
+ actual = hashlib.sha256(archive_bytes).hexdigest()
118
+ if actual != expected:
119
+ raise ChecksumMismatchError(
120
+ f"Checksum mismatch for {asset_name}: expected {expected}, got {actual}"
121
+ )
122
+
123
+ _extract_binary(archive_bytes, dest, os_name)
124
+ return dest
125
+ finally:
126
+ if owns_client:
127
+ client.close()
128
+
129
+
130
+ def _get_release(client: httpx.Client, version: str | None) -> dict[str, Any]:
131
+ url = (
132
+ f"{_GITHUB_API}/latest" if version is None else f"{_GITHUB_API}/tags/v{version}"
133
+ )
134
+ resp = client.get(url)
135
+ if resp.status_code != 200:
136
+ raise BootstrapError(
137
+ f"Failed to query OpenTofu releases ({resp.status_code}): {url}"
138
+ )
139
+ result: dict[str, Any] = resp.json()
140
+ return result
141
+
142
+
143
+ def _tag_to_version(tag: str) -> str:
144
+ return tag[1:] if tag.startswith("v") else tag
145
+
146
+
147
+ def _asset_url(release: dict[str, Any], name: str) -> str:
148
+ for asset in release.get("assets", []):
149
+ if asset.get("name") == name:
150
+ return cast(str, asset["browser_download_url"])
151
+ raise BootstrapError(f"Release asset not found: {name}")
152
+
153
+
154
+ def _download(client: httpx.Client, url: str) -> bytes:
155
+ resp = client.get(url)
156
+ if resp.status_code != 200:
157
+ raise BootstrapError(f"Download failed ({resp.status_code}): {url}")
158
+ return resp.content
159
+
160
+
161
+ def _parse_sha256sums(text: str, filename: str) -> str:
162
+ for line in text.splitlines():
163
+ parts = line.split()
164
+ if len(parts) >= 2 and parts[-1].lstrip("*") == filename:
165
+ return parts[0]
166
+ raise BootstrapError(f"No checksum entry for {filename} in SHA256SUMS")
167
+
168
+
169
+ def _extract_binary(archive_bytes: bytes, dest: Path, os_name: str) -> None:
170
+ member = "tofu.exe" if os_name == "windows" else "tofu"
171
+ dest.parent.mkdir(parents=True, exist_ok=True)
172
+ tmp = dest.with_name(dest.name + ".part")
173
+ with (
174
+ zipfile.ZipFile(io.BytesIO(archive_bytes)) as zf,
175
+ zf.open(member) as src,
176
+ tmp.open("wb") as out,
177
+ ):
178
+ shutil.copyfileobj(src, out)
179
+ if os_name != "windows":
180
+ tmp.chmod(tmp.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
181
+ tmp.replace(dest)
nac/cli/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ # SPDX-License-Identifier: MPL-2.0
2
+ # Copyright (c) 2026 Daniel Schmidt
3
+
4
+ # SPDX-License-Identifier: MPL-2.0
5
+ # Copyright (c) 2026 Daniel Schmidt
6
+
7
+ from typer.models import CommandInfo
8
+
9
+ from . import apply, destroy, init, plan, setup, test, validate # noqa: F401
10
+ from .main import app
11
+
12
+ # Registration order above is alphabetical (required by import sorting), but
13
+ # the help output should mirror the setup -> init -> validate -> plan ->
14
+ # apply -> test workflow, so reorder registered_commands explicitly.
15
+ _WORKFLOW_ORDER = ("setup", "init", "validate", "plan", "apply", "test", "destroy")
16
+
17
+
18
+ def _workflow_index(command: CommandInfo) -> int:
19
+ assert command.callback is not None
20
+ module = command.callback.__module__.rsplit(".", 1)[-1]
21
+ return _WORKFLOW_ORDER.index(module)
22
+
23
+
24
+ app.registered_commands.sort(key=_workflow_index)
nac/cli/apply.py ADDED
@@ -0,0 +1,69 @@
1
+ # SPDX-License-Identifier: MPL-2.0
2
+ # Copyright (c) 2026 Daniel Schmidt
3
+
4
+ """`nac apply` -- applies a saved plan if present, else runs interactively."""
5
+
6
+ import typer
7
+
8
+ from nac import runner, terraform
9
+
10
+ from .main import app, get_config, get_no_color, resolve_engine_or_exit
11
+ from .options import Artifacts, AutoApprove
12
+ from .passthrough import (
13
+ PASSTHROUGH_CONTEXT_SETTINGS,
14
+ PASSTHROUGH_OPTIONS_METAVAR,
15
+ show_help_and_exit,
16
+ wants_help,
17
+ )
18
+
19
+
20
+ @app.command(
21
+ context_settings=PASSTHROUGH_CONTEXT_SETTINGS,
22
+ options_metavar=PASSTHROUGH_OPTIONS_METAVAR,
23
+ )
24
+ def apply(
25
+ ctx: typer.Context,
26
+ auto_approve: AutoApprove = False,
27
+ artifacts: Artifacts = False,
28
+ ) -> None:
29
+ """Apply a saved plan file if present, otherwise apply interactively.
30
+
31
+ Extra arguments are passed through to `terraform`/`tofu`.
32
+ """
33
+ cfg = get_config(ctx)
34
+ engine, binary = resolve_engine_or_exit(cfg)
35
+ env = terraform.build_engine_env(engine, cfg.tools.terraform.version)
36
+ no_color = get_no_color(ctx)
37
+
38
+ if wants_help(ctx):
39
+ show_help_and_exit(
40
+ ctx,
41
+ [binary, "apply", "--help"],
42
+ cwd=cfg.working_dir,
43
+ env=env,
44
+ no_color=get_no_color(ctx),
45
+ tool_label=f"{engine} apply",
46
+ usage_as="nac apply",
47
+ )
48
+
49
+ plan_file = cfg.working_dir / terraform.DEFAULT_PLAN_FILE
50
+ used_plan_file = plan_file.is_file()
51
+ if used_plan_file:
52
+ argv = terraform.build_apply_argv(
53
+ binary, plan_file=terraform.DEFAULT_PLAN_FILE, no_color=no_color
54
+ )
55
+ else:
56
+ argv = terraform.build_apply_argv(
57
+ binary, auto_approve=auto_approve, no_color=no_color
58
+ )
59
+
60
+ code = runner.run_streaming(
61
+ argv + ctx.args,
62
+ cwd=cfg.working_dir,
63
+ env=env,
64
+ log_path=cfg.working_dir / "apply.txt" if artifacts else None,
65
+ no_color=no_color,
66
+ )
67
+ if used_plan_file and code == 0:
68
+ plan_file.unlink()
69
+ raise typer.Exit(code=code)
nac/cli/destroy.py ADDED
@@ -0,0 +1,60 @@
1
+ # SPDX-License-Identifier: MPL-2.0
2
+ # Copyright (c) 2026 Daniel Schmidt
3
+
4
+ """`nac destroy` -- streams `terraform destroy`."""
5
+
6
+ import typer
7
+
8
+ from nac import runner, terraform
9
+
10
+ from .main import app, get_config, get_no_color, resolve_engine_or_exit
11
+ from .options import Artifacts, AutoApprove
12
+ from .passthrough import (
13
+ PASSTHROUGH_CONTEXT_SETTINGS,
14
+ PASSTHROUGH_OPTIONS_METAVAR,
15
+ show_help_and_exit,
16
+ wants_help,
17
+ )
18
+
19
+
20
+ @app.command(
21
+ context_settings=PASSTHROUGH_CONTEXT_SETTINGS,
22
+ options_metavar=PASSTHROUGH_OPTIONS_METAVAR,
23
+ )
24
+ def destroy(
25
+ ctx: typer.Context,
26
+ auto_approve: AutoApprove = False,
27
+ artifacts: Artifacts = False,
28
+ ) -> None:
29
+ """Destroy the managed infrastructure (interactive unless --auto-approve).
30
+
31
+ Extra arguments are passed through to `terraform`/`tofu`.
32
+ """
33
+ cfg = get_config(ctx)
34
+ engine, binary = resolve_engine_or_exit(cfg)
35
+ env = terraform.build_engine_env(engine, cfg.tools.terraform.version)
36
+ no_color = get_no_color(ctx)
37
+
38
+ if wants_help(ctx):
39
+ show_help_and_exit(
40
+ ctx,
41
+ [binary, "destroy", "--help"],
42
+ cwd=cfg.working_dir,
43
+ env=env,
44
+ no_color=get_no_color(ctx),
45
+ tool_label=f"{engine} destroy",
46
+ usage_as="nac destroy",
47
+ )
48
+
49
+ argv = terraform.build_destroy_argv(
50
+ binary, auto_approve=auto_approve, no_color=no_color
51
+ )
52
+
53
+ code = runner.run_streaming(
54
+ argv + ctx.args,
55
+ cwd=cfg.working_dir,
56
+ env=env,
57
+ log_path=cfg.working_dir / "destroy.txt" if artifacts else None,
58
+ no_color=no_color,
59
+ )
60
+ raise typer.Exit(code=code)
nac/cli/init.py ADDED
@@ -0,0 +1,52 @@
1
+ # SPDX-License-Identifier: MPL-2.0
2
+ # Copyright (c) 2026 Daniel Schmidt
3
+
4
+ """`nac init` -- streams terraform/tofu init in working_dir."""
5
+
6
+ import typer
7
+
8
+ from nac import runner, terraform
9
+
10
+ from .main import app, get_config, get_no_color, resolve_engine_or_exit
11
+ from .options import Artifacts
12
+ from .passthrough import (
13
+ PASSTHROUGH_CONTEXT_SETTINGS,
14
+ PASSTHROUGH_OPTIONS_METAVAR,
15
+ show_help_and_exit,
16
+ wants_help,
17
+ )
18
+
19
+
20
+ @app.command(
21
+ context_settings=PASSTHROUGH_CONTEXT_SETTINGS,
22
+ options_metavar=PASSTHROUGH_OPTIONS_METAVAR,
23
+ )
24
+ def init(ctx: typer.Context, artifacts: Artifacts = False) -> None:
25
+ """Initialize the Terraform/OpenTofu working directory.
26
+
27
+ Extra arguments are passed through to `terraform`/`tofu`.
28
+ """
29
+ cfg = get_config(ctx)
30
+ engine, binary = resolve_engine_or_exit(cfg)
31
+ env = terraform.build_engine_env(engine, cfg.tools.terraform.version)
32
+ no_color = get_no_color(ctx)
33
+
34
+ if wants_help(ctx):
35
+ show_help_and_exit(
36
+ ctx,
37
+ [binary, "init", "--help"],
38
+ cwd=cfg.working_dir,
39
+ env=env,
40
+ no_color=no_color,
41
+ tool_label=f"{engine} init",
42
+ usage_as="nac init",
43
+ )
44
+
45
+ code = runner.run_streaming(
46
+ terraform.build_init_argv(binary, no_color=no_color) + ctx.args,
47
+ cwd=cfg.working_dir,
48
+ env=env,
49
+ log_path=cfg.working_dir / "init.txt" if artifacts else None,
50
+ no_color=no_color,
51
+ )
52
+ raise typer.Exit(code=code)
nac/cli/main.py ADDED
@@ -0,0 +1,104 @@
1
+ # SPDX-License-Identifier: MPL-2.0
2
+ # Copyright (c) 2026 Daniel Schmidt
3
+
4
+ """CLI entry point for nac: global options, config loading, shared helpers."""
5
+
6
+ import logging
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import NoReturn, cast
11
+
12
+ import typer
13
+
14
+ from nac import terraform
15
+ from nac.config import DEFAULT_CONFIG_PATH, NacConfig, load_config
16
+ from nac.exceptions import ConfigError, EngineNotFoundError, NoEngineAvailableError
17
+ from nac.output import ensure_utf8_streams
18
+
19
+ from .options import ConfigPath, NoColor, Verbosity, VerbosityLevel, Version
20
+
21
+ app = typer.Typer(
22
+ add_completion=False,
23
+ help="An umbrella CLI wrapping terraform/tofu, nac-validate, and nac-test.",
24
+ )
25
+
26
+
27
+ def configure_logging(level: VerbosityLevel) -> None:
28
+ logging.basicConfig(
29
+ level=getattr(logging, level.value),
30
+ format="%(levelname)s - %(message)s",
31
+ stream=sys.stdout,
32
+ force=True,
33
+ )
34
+
35
+
36
+ def fail(message: str, code: int = 1) -> NoReturn:
37
+ """Print a clean error message to stderr and exit with a non-zero code."""
38
+ typer.echo(message, err=True)
39
+ raise typer.Exit(code=code)
40
+
41
+
42
+ def get_config(ctx: typer.Context) -> NacConfig:
43
+ """Load (once, lazily) and return the NacConfig for this invocation.
44
+
45
+ Deferred until a subcommand actually needs it -- rather than in the
46
+ app callback -- so `nac <subcommand> --help` works without a config
47
+ file present. `ctx.obj` starts out holding the raw --config path and
48
+ is replaced with the resolved NacConfig on first call, so repeated
49
+ calls within the same invocation don't re-read the file.
50
+ """
51
+ if isinstance(ctx.obj, NacConfig):
52
+ return ctx.obj
53
+ try:
54
+ cfg = load_config(cast(Path, ctx.obj))
55
+ except ConfigError as exc:
56
+ fail(str(exc))
57
+ ctx.obj = cfg
58
+ return cfg
59
+
60
+
61
+ def resolve_engine_or_exit(cfg: NacConfig) -> tuple[str, str]:
62
+ """Resolve the terraform/tofu binary, failing cleanly on any error.
63
+
64
+ Used by init/validate/plan/apply, none of which perform the
65
+ interactive FR-12 bootstrap prompt -- that is `nac setup`'s job.
66
+ """
67
+ try:
68
+ return terraform.resolve_engine_binary(cfg.tools.terraform)
69
+ except (EngineNotFoundError, NoEngineAvailableError) as exc:
70
+ fail(str(exc))
71
+
72
+
73
+ def get_no_color(ctx: typer.Context) -> bool:
74
+ """Whether colored output should be suppressed for this invocation.
75
+
76
+ Resolved once by the app callback from the `--no-color` flag / NO_COLOR
77
+ env var and stashed on `ctx.meta` (Click's per-invocation scratch dict),
78
+ so it doesn't collide with `ctx.obj`'s lazy NacConfig loading above.
79
+ """
80
+ return bool(ctx.meta.get("no_color", False))
81
+
82
+
83
+ @app.callback()
84
+ def main(
85
+ ctx: typer.Context,
86
+ config: ConfigPath = DEFAULT_CONFIG_PATH,
87
+ verbosity: Verbosity = VerbosityLevel.WARNING,
88
+ no_color: NoColor = False,
89
+ version: Version = False,
90
+ ) -> None:
91
+ """An umbrella CLI wrapping terraform/tofu, nac-validate, and nac-test."""
92
+ ensure_utf8_streams()
93
+ configure_logging(verbosity)
94
+ ctx.meta["no_color"] = no_color or "NO_COLOR" in os.environ
95
+ ctx.obj = config
96
+
97
+
98
+ def run() -> None:
99
+ """Console-script entry point for both `nac` and its `nac-cli` alias.
100
+
101
+ Pins the program name so help and usage always show `nac`, whichever
102
+ name it was invoked under.
103
+ """
104
+ app(prog_name="nac")
nac/cli/options.py ADDED
@@ -0,0 +1,114 @@
1
+ # SPDX-License-Identifier: MPL-2.0
2
+ # Copyright (c) 2026 Daniel Schmidt
3
+
4
+ """Annotated Typer option aliases shared across nac's subcommands."""
5
+
6
+ from enum import StrEnum
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ from nac import __version__
13
+
14
+
15
+ class VerbosityLevel(StrEnum):
16
+ DEBUG = "DEBUG"
17
+ INFO = "INFO"
18
+ WARNING = "WARNING"
19
+ ERROR = "ERROR"
20
+ CRITICAL = "CRITICAL"
21
+
22
+
23
+ ConfigPath = Annotated[
24
+ Path,
25
+ typer.Option(
26
+ "--config",
27
+ help="Path to the nac.yaml config file.",
28
+ envvar="NAC_CONFIG",
29
+ ),
30
+ ]
31
+
32
+ Verbosity = Annotated[
33
+ VerbosityLevel,
34
+ typer.Option(
35
+ "-v",
36
+ "--verbosity",
37
+ help="Verbosity level.",
38
+ envvar="NAC_VERBOSITY",
39
+ is_eager=True,
40
+ ),
41
+ ]
42
+
43
+ AutoApprove = Annotated[
44
+ bool,
45
+ typer.Option(
46
+ "--auto-approve",
47
+ help="Skip interactive approval (apply: only without a saved plan).",
48
+ ),
49
+ ]
50
+
51
+ Artifacts = Annotated[
52
+ bool,
53
+ typer.Option(
54
+ "--artifacts",
55
+ help=(
56
+ "Also write the command's output to <command>.txt in the "
57
+ "working dir (e.g. validate.txt, plan.txt/plan.json)."
58
+ ),
59
+ ),
60
+ ]
61
+
62
+ Yes = Annotated[
63
+ bool,
64
+ typer.Option(
65
+ "--yes",
66
+ "--install",
67
+ help="Assume yes for confirmation prompts (e.g. the OpenTofu bootstrap install).",
68
+ ),
69
+ ]
70
+
71
+ Prewarm = Annotated[
72
+ bool,
73
+ typer.Option(
74
+ "--prewarm",
75
+ help="Pre-warm the uvx cache for nac-validate/nac-test.",
76
+ ),
77
+ ]
78
+
79
+ NoColor = Annotated[
80
+ bool,
81
+ typer.Option(
82
+ "--no-color",
83
+ help="Disable colored output (also honored via the NO_COLOR env var).",
84
+ ),
85
+ ]
86
+
87
+
88
+ def _print_version(value: bool) -> None:
89
+ if value:
90
+ typer.echo(f"nac {__version__}")
91
+ raise typer.Exit()
92
+
93
+
94
+ Version = Annotated[
95
+ bool,
96
+ typer.Option(
97
+ "--version",
98
+ help="Show the version and exit.",
99
+ callback=_print_version,
100
+ is_eager=True,
101
+ ),
102
+ ]
103
+
104
+ __all__ = [
105
+ "AutoApprove",
106
+ "Artifacts",
107
+ "ConfigPath",
108
+ "NoColor",
109
+ "Prewarm",
110
+ "Verbosity",
111
+ "VerbosityLevel",
112
+ "Version",
113
+ "Yes",
114
+ ]