diff --git a/.flake8 b/.flake8 index ad985391..0a8c3347 100644 --- a/.flake8 +++ b/.flake8 @@ -1,3 +1,7 @@ [flake8] max-line-length = 100 extend-ignore = E203 +per-file-ignores = + sccfm-ansible/plugins/inventory/*.py:E402 + sccfm-ansible/plugins/lookup/*.py:E402 + sccfm-ansible/plugins/modules/*.py:E402 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 6eb814bf..80c7d40d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -39,6 +39,7 @@ jobs: - name: Check docs run: | poetry run check-doc-links + poetry run check-doc-links --docs-root sccfm-ansible poetry run check-doc-artifacts - name: Build Pages site diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1da5530d..1ca7c150 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -50,4 +50,4 @@ repos: hooks: - id: doctoc args: ['--github', '--title', '## Table of Contents'] - files: '(README\.md|INSTALL\.md|sccfm-ansible/README\.md)$' + files: '^(README\.md|INSTALL\.md|sccfm-ansible/README\.md)$' diff --git a/AGENTS.md b/AGENTS.md index b0ef1138..0f644117 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,8 @@ coverage run -m pytest && coverage report ``` - Unit tests live alongside source: `cisco_sccfm_cli/tests/`, `cisco_sccfm_core/tests/`, `sccfm-ansible/` (excluding `e2e/`). -- Tests marked `ci` require a live SCCFM tenant and run only in CI. +- Tests marked `ci` require a live SCCFM tenant and are excluded from the ordinary offline suite; + run them explicitly in CI or locally with suitable sandbox credentials. - **Test the CLI against a real SCCFM tenant** using a DevNet sandbox: Visit [https://devnetsandbox.cisco.com/DevNet](https://devnetsandbox.cisco.com/DevNet) to book a related sandbox. @@ -100,8 +101,9 @@ No MCP servers are currently configured for this project. Skill files under `ski # Build and verify the collection artifact build-ansible-collection -# Install the built artifact locally -ansible-galaxy collection install dist/cisco-sccfm-*.tar.gz --force +# Install the exact artifact that was just built +ansible-galaxy collection install \ + "dist/cisco-sccfm-$(poetry version --short).tar.gz" --force # Configure or select profiles interactively sccfm-cli-interactive @@ -113,7 +115,10 @@ ansible-inventory -i sccfm-ansible/examples/inventory.sccfm.yml --graph ansible-playbook -i sccfm-ansible/examples/inventory.sccfm.yml sccfm-ansible/examples/show_devices.yml ``` -Add `sccfm-ansible` to `ANSIBLE_COLLECTIONS_PATH` so IDE/mypy resolves `ansible_collections.cisco.sccfm` imports. +Ansible discovers its default collection install directory automatically. For a custom install, +pass `--collections-path ` to `ansible-galaxy` and set `ANSIBLE_COLLECTIONS_PATH` to that +same root. Point IDEs and `MYPYPATH` at the installed root as needed; do not use the raw +`sccfm-ansible` source directory as a collection path. ## PR instructions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 855a13a7..91b2e362 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,7 +39,10 @@ reserve breaking changes until the next major version release. ## Development Setup -1. Run `cisco_sccfm_scripts/setup_environment.sh` to install dependencies and create the virtualenv. +1. Run `cisco_sccfm_scripts/setup_environment.sh` to install dependencies and create the + virtualenv. Poetry is kept in an isolated tooling environment so its dependencies do not + conflict with the project runtime. If an older setup reports that Poetry is installed in + `.venv/`, remove `.venv/` once and rerun the script. 2. Install [direnv](https://direnv.net/) for automatic environment activation: diff --git a/README.md b/README.md index 86ef5668..9c30cfeb 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,10 @@ sccfm-cli-interactive # customer-facing interactive CLI menu sccfm-devkit # repository development workflow menu ``` -`setup_environment.sh` keeps everything local to the repository: pyenv provides Python 3.12.4, `.venv/` hosts the runtime, and Poetry installs the project plus dev dependencies. +`setup_environment.sh` keeps the project runtime and Poetry dependencies isolated: pyenv provides +Python 3.12.4, `.venv/` hosts the project runtime, and `.venv/.poetry/` hosts Poetry. If `.venv/` +was created by an older version of the script that installed Poetry into the project runtime, +remove `.venv/` once and rerun the setup script. ## Commands @@ -98,10 +101,20 @@ The package root exports the supported public service classes and response model ## Ansible collection - macOS: `brew install ansible` (this includes `ansible-galaxy`; verify with `ansible-galaxy --version`). -- From an activated source checkout, build and install the collection with - `build-ansible-collection`. +- From an activated source checkout, build and verify the collection, then install the exact + artifact that was just built: + + ```bash + build-ansible-collection + ansible-galaxy collection install \ + "dist/cisco-sccfm-$(poetry version --short).tar.gz" --force + ``` + - Configure profiles interactively: run `sccfm-cli-interactive` and select **configure-profile**. -- For IDEs/mypy, add `sccfm-ansible` to `ANSIBLE_COLLECTIONS_PATH` (or mark it as a source root) so imports under `ansible_collections.cisco.sccfm` resolve without installing. +- Ansible discovers its default collection install directory automatically. For a custom install, + pass `--collections-path ` to `ansible-galaxy` and set `ANSIBLE_COLLECTIONS_PATH` to that + same root. Point IDEs and `MYPYPATH` at the installed root as needed; the raw `sccfm-ansible` + source directory does not provide the `ansible_collections/cisco/sccfm` package layout. - Ansible modules and inventory select the same named SCCFM profile; they do not duplicate its region or API token in environment variables, playbooks, or Ansible Vault. - Keep Ansible Vault for playbook-specific secrets such as managed-device passwords. - Point Ansible at an inventory file that uses the plugin, e.g. `ansible-inventory -i sccfm-ansible/examples/inventory.sccfm.yml --graph`. diff --git a/cisco_sccfm_cli/commands/base.py b/cisco_sccfm_cli/commands/base.py index ba1a2df0..911de219 100644 --- a/cisco_sccfm_cli/commands/base.py +++ b/cisco_sccfm_cli/commands/base.py @@ -5,6 +5,8 @@ from __future__ import annotations import json +import shlex +import subprocess import sys from abc import ABC, abstractmethod from pathlib import Path @@ -25,6 +27,15 @@ from cisco_sccfm_core.services.transaction_service import TransactionService from cisco_sccfm_core.types import ConfigLike +_WINDOWS_SHELL = sys.platform == "win32" + + +def _join_shell_command(arguments: Sequence[str]) -> str: + """Render arguments for the platform's default command shell.""" + if _WINDOWS_SHELL: + return subprocess.list2cmdline(arguments) + return shlex.join(arguments) + class BaseCommand(ABC): """Base class implementing the command pattern for CLI commands.""" @@ -60,9 +71,12 @@ def get_profile(self, ctx: click.Context, **kwargs: Any) -> ConfigLike: config_service = ConfigService(path=config_path) config = config_service.load(profile) if not config: + setup_arguments = ["sccfm-cli", "--profile", profile, "configure"] + if config_path is not None: + setup_arguments.extend(["--config-path", str(config_path)]) + setup_command = _join_shell_command(setup_arguments) raise click.ClickException( - f"Profile '{profile}' not found. " - f"Run 'sccfm-cli --profile {profile} configure' to set it up." + f"Profile '{profile}' not found. Run this command to set it up:\n{setup_command}" ) self._register_sensitive_value(ctx, config.api_token) return cast(ConfigLike, cast(object, config)) diff --git a/cisco_sccfm_cli/commands/tests/test_base.py b/cisco_sccfm_cli/commands/tests/test_base.py index 124afb30..0775a0b1 100644 --- a/cisco_sccfm_cli/commands/tests/test_base.py +++ b/cisco_sccfm_cli/commands/tests/test_base.py @@ -2,10 +2,11 @@ # # SPDX-License-Identifier: Apache-2.0 -"""Tests for cisco_sccfm_cli.commands.base — filter_online_devices.""" +"""Tests for cisco_sccfm_cli.commands.base.""" from __future__ import annotations +from pathlib import Path from typing import Any, Sequence import click @@ -18,7 +19,9 @@ EntityType, ) +from cisco_sccfm_cli.commands import base from cisco_sccfm_cli.commands.base import BaseCommand +from cisco_sccfm_cli.services import ConfigService # ── Concrete stub so we can instantiate BaseCommand ────────────── @@ -109,3 +112,50 @@ def test_empty_device_list_raises(self) -> None: cmd = self._make_command() with pytest.raises(click.ClickException, match="No online devices found"): cmd.filter_online_devices([]) + + +class TestGetProfile: + def _context(self, profile: str) -> click.Context: + return click.Context(click.Command("stub"), obj={"profile": profile}) + + def test_missing_profile_uses_default_config_guidance( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + command = _StubCommand(console=Console(stderr=True)) + monkeypatch.setattr(ConfigService, "load", lambda *_args: None) + + with pytest.raises(click.ClickException) as exc_info: + command.get_profile(self._context("lab"), config_path=None) + + assert "sccfm-cli --profile lab configure" in exc_info.value.message + assert "--config-path" not in exc_info.value.message + + def test_missing_profile_preserves_custom_config_path( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + command = _StubCommand(console=Console(stderr=True)) + config_path = tmp_path / "custom config.json" + monkeypatch.setattr(ConfigService, "load", lambda *_args: None) + + with pytest.raises(click.ClickException) as exc_info: + command.get_profile(self._context("offline audit"), config_path=config_path) + + assert ( + "sccfm-cli --profile 'offline audit' configure " f"--config-path '{config_path}'" + ) in exc_info.value.message + + def test_missing_profile_uses_windows_command_quoting( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + command = _StubCommand(console=Console(stderr=True)) + config_path = Path("C:/Users/Example User/sccfm config.json") + monkeypatch.setattr(ConfigService, "load", lambda *_args: None) + monkeypatch.setattr(base, "_WINDOWS_SHELL", True) + + with pytest.raises(click.ClickException) as exc_info: + command.get_profile(self._context("offline audit"), config_path=config_path) + + assert ( + 'sccfm-cli --profile "offline audit" configure ' + '--config-path "C:/Users/Example User/sccfm config.json"' + ) in exc_info.value.message diff --git a/cisco_sccfm_core/py.typed b/cisco_sccfm_core/py.typed index 8b137891..e69de29b 100644 --- a/cisco_sccfm_core/py.typed +++ b/cisco_sccfm_core/py.typed @@ -1 +0,0 @@ - diff --git a/cisco_sccfm_core/tests/test_sccfm_ansible_skill.py b/cisco_sccfm_core/tests/test_sccfm_ansible_skill.py index d42b20d9..29c38b04 100644 --- a/cisco_sccfm_core/tests/test_sccfm_ansible_skill.py +++ b/cisco_sccfm_core/tests/test_sccfm_ansible_skill.py @@ -45,29 +45,42 @@ def test_sccfm_skills_route_cli_and_ansible_requests_explicitly() -> None: def test_sccfm_ansible_skill_is_ansible_doc_driven() -> None: skill = _skill_text() + normalized_skill = " ".join(skill.split()) assert "Do NOT use for sccfm-cli commands" in skill assert "ansible-doc -j -l -t module cisco.sccfm" in skill assert "ansible-doc -j cisco.sccfm." in skill assert "ansible-doc -j -l -t inventory cisco.sccfm" in skill assert "ansible-doc -j -t inventory " in skill + assert "ansible-doc -j -l -t lookup cisco.sccfm" in skill + assert "ansible-doc -j -t lookup " in skill assert "cisco.sccfm.sccfm" not in skill assert "Do not hardcode module names" in skill - assert "All module knowledge comes from `ansible-doc`" in skill + assert "All module and plugin knowledge comes from" in normalized_skill assert "only hardcoded bootstrap commands" in skill - assert "ansible-galaxy collection install dist/cisco-sccfm-*.tar.gz --force" in skill - assert "only to detect a stale" in skill + assert '"dist/cisco-sccfm-$(poetry version --short).tar.gz" --force' in skill + assert "dist/cisco-sccfm-*.tar.gz" not in skill + assert "only to detect a stale" in normalized_skill assert "Do not use source filenames" in skill def test_sccfm_ansible_skill_documents_safety_and_secret_rules() -> None: skill = _skill_text() + normalized_skill = " ".join(skill.split()) assert "Class A: Readonly, no local writes" in skill assert "Class B: Readonly, local-write/export side effects" in skill assert "Class C: Mutating SCCFM or managed devices" in skill assert "Never ask the user to paste secrets into chat" in skill assert "name or description indicates a token, password, key, or secret" in skill + assert "when `field` is omitted" in normalized_skill + assert "it defaults to `api_token`" in normalized_skill + assert "field=api_token" in skill + assert "only inside a task with `no_log: true`" in normalized_skill + assert "never print, export, log, or return it in chat" in normalized_skill + assert "field=region" in skill + assert "explicitly non-secret field" in normalized_skill + assert "may be presented" in normalized_skill assert "module_defaults: group/cisco.sccfm.all" in skill assert "supports_check_mode=True" in skill assert "EXECUTE cisco.sccfm " in skill diff --git a/cisco_sccfm_core/tests/test_sync_docs_readme.py b/cisco_sccfm_core/tests/test_sync_docs_readme.py index e159e418..92058ed7 100644 --- a/cisco_sccfm_core/tests/test_sync_docs_readme.py +++ b/cisco_sccfm_core/tests/test_sync_docs_readme.py @@ -26,11 +26,10 @@ def test_render_include_shifts_headings_and_rewrites_relative_links() -> None: assert "\n### Setup\n" in rendered assert "![" not in rendered assert ( - "[docs](https://github.com/cisco-lockhart/sccfm-devkit/blob/main/docs/README.md)" - in rendered + "[docs](https://github.com/CiscoDevNet/sccfm-devkit/blob/main/docs/README.md)" in rendered ) assert ( - "[commands](https://github.com/cisco-lockhart/sccfm-devkit/tree/main/" + "[commands](https://github.com/CiscoDevNet/sccfm-devkit/tree/main/" "cisco_sccfm_cli/commands/)" in rendered ) assert "[setup](#setup)" in rendered @@ -47,6 +46,6 @@ def test_sync_readme_writes_generated_include(tmp_path: Path) -> None: assert output.read_text(encoding="utf-8") == ( "\n\n" "## Title\n\n" - "See [INSTALL](https://github.com/cisco-lockhart/sccfm-devkit/blob/main/" + "See [INSTALL](https://github.com/CiscoDevNet/sccfm-devkit/blob/main/" "INSTALL.md).\n" ) diff --git a/cisco_sccfm_scripts/build_ansible_collection.py b/cisco_sccfm_scripts/build_ansible_collection.py index 84120249..55c54250 100644 --- a/cisco_sccfm_scripts/build_ansible_collection.py +++ b/cisco_sccfm_scripts/build_ansible_collection.py @@ -5,6 +5,7 @@ # SPDX-License-Identifier: Apache-2.0 """Build script for Ansible collection.""" +import argparse import os import re import shutil @@ -12,6 +13,7 @@ import sys import tomllib from pathlib import Path +from typing import Sequence import yaml @@ -84,8 +86,16 @@ def _sync_runtime_requirement(dependencies_path: Path, version: str) -> None: dependencies_path.write_text(updated, encoding="utf-8") -def main() -> int: +def build_parser() -> argparse.ArgumentParser: + """Return the command-line parser without performing build work.""" + return argparse.ArgumentParser( + description="Build and verify the cisco.sccfm Ansible collection tarball." + ) + + +def main(argv: Sequence[str] | None = None) -> int: """Build the Ansible collection tarball.""" + build_parser().parse_args(argv) project_root = Path(__file__).parent.parent collection_dir = project_root / "sccfm-ansible" dist_dir = project_root / "dist" diff --git a/cisco_sccfm_scripts/interactive_cli.py b/cisco_sccfm_scripts/interactive_cli.py index 649efeea..614332ca 100644 --- a/cisco_sccfm_scripts/interactive_cli.py +++ b/cisco_sccfm_scripts/interactive_cli.py @@ -12,11 +12,14 @@ from __future__ import annotations +import os import shlex import subprocess import sys +import tempfile +from contextlib import contextmanager from pathlib import Path -from typing import Callable +from typing import Callable, Iterator, Mapping, Sequence import click import questionary @@ -50,6 +53,49 @@ def _ask( return answer if isinstance(answer, str) else None +def _require_success(action: str, return_code: int) -> None: + """Raise a user-facing error when an in-process development task fails.""" + if return_code: + raise click.ClickException(f"{action} failed with exit code {return_code}") + + +def _run_checked( + command: Sequence[str], + *, + cwd: Path, + env: Mapping[str, str] | None = None, +) -> None: + """Run a child command and propagate its nonzero status to the menu.""" + return_code = subprocess.call( + list(command), + cwd=cwd, + env=dict(env) if env is not None else None, + ) + if return_code: + raise click.ClickException( + f"Command failed with exit code {return_code}: {shlex.join(command)}" + ) + + +@contextmanager +def _source_collection_environment(project_root: Path) -> Iterator[dict[str, str]]: + """Expose the source collection through a valid Ansible collection root.""" + collection_dir = project_root / "sccfm-ansible" + with tempfile.TemporaryDirectory(prefix="sccfm-collection-") as temporary_directory: + collection_root = Path(temporary_directory) + namespace_dir = collection_root / "ansible_collections" / "cisco" + namespace_dir.mkdir(parents=True) + (namespace_dir / "sccfm").symlink_to(collection_dir, target_is_directory=True) + + env = os.environ.copy() + existing_paths = env.get("ANSIBLE_COLLECTIONS_PATH") + paths = [str(collection_root)] + if existing_paths: + paths.append(existing_paths) + env["ANSIBLE_COLLECTIONS_PATH"] = os.pathsep.join(paths) + yield env + + # ── Task implementations ───────────────────────────────────────── @@ -64,45 +110,35 @@ def _run_build_collection() -> None: """Build the cisco.sccfm Ansible collection tarball.""" from cisco_sccfm_scripts.build_ansible_collection import main as _build - rc = _build() - if rc: - console.print(f"[red]Build failed with exit code {rc}[/red]") + _require_success("Collection build", _build([])) def _run_generate_ansible_docs() -> None: """Generate Ansible documentation from ansible-doc metadata.""" from cisco_sccfm_scripts.generate_ansible_docs import main as _generate_ansible_docs - rc = _generate_ansible_docs([]) - if rc: - console.print(f"[red]Ansible docs generation failed with exit code {rc}[/red]") + _require_success("Ansible docs generation", _generate_ansible_docs([])) def _run_generate_cli_docs() -> None: """Generate CLI documentation from Click help output.""" from cisco_sccfm_scripts.generate_cli_docs import main as _generate_cli_docs - rc = _generate_cli_docs([]) - if rc: - console.print(f"[red]CLI docs generation failed with exit code {rc}[/red]") + _require_success("CLI docs generation", _generate_cli_docs([])) def _run_generate_cli_man_docs() -> None: """Generate CLI manual pages from Click metadata.""" from cisco_sccfm_scripts.generate_cli_man_docs import main as _generate_cli_man_docs - rc = _generate_cli_man_docs([]) - if rc: - console.print(f"[red]CLI man page generation failed with exit code {rc}[/red]") + _require_success("CLI man page generation", _generate_cli_man_docs([])) def _run_install_cli_man_docs() -> None: """Install generated CLI manual pages into the user's man path.""" from cisco_sccfm_scripts.install_cli_man_docs import main as _install_cli_man_docs - rc = _install_cli_man_docs([]) - if rc: - console.print(f"[red]CLI man page installation failed with exit code {rc}[/red]") + _require_success("CLI man page installation", _install_cli_man_docs([])) def _run_setup_env() -> None: @@ -113,33 +149,34 @@ def _run_setup_env() -> None: console.print(f"[red]Script not found: {script}[/red]") return console.print(f"[dim]Running {script}[/dim]") - subprocess.call(["bash", str(script)]) + _run_checked(["bash", str(script)], cwd=root) activate = root / "cisco_sccfm_scripts" / "activate.sh" if activate.exists(): - console.print(f"[dim]Sourcing {activate}[/dim]") - subprocess.call(["bash", "-c", f"source {activate}"]) - console.print("[green]Environment activated.[/green]") + console.print(f"[green]Setup complete. Run 'source {activate}' in your shell.[/green]") def _run_lint() -> None: - """Run lint with fix (black, isort, mypy).""" + """Run read-only formatting and type checks.""" root = _project_root() console.print("[bold]Running black …[/bold]") - subprocess.call([sys.executable, "-m", "black", "."], cwd=root) + _run_checked([sys.executable, "-m", "black", "--check", "."], cwd=root) console.print("[bold]Running isort …[/bold]") - subprocess.call([sys.executable, "-m", "isort", "."], cwd=root) + _run_checked([sys.executable, "-m", "isort", "--check-only", "."], cwd=root) console.print("[bold]Running mypy…[/bold]") - subprocess.call([sys.executable, "-m", "mypy", "cisco_sccfm_cli", "cisco_sccfm_core"], cwd=root) + _run_checked( + [sys.executable, "-m", "mypy", "cisco_sccfm_cli", "cisco_sccfm_core"], + cwd=root, + ) def _run_format() -> None: """Auto-format code with black and isort.""" root = _project_root() console.print("[bold]Running isort…[/bold]") - subprocess.call([sys.executable, "-m", "isort", "."], cwd=root) + _run_checked([sys.executable, "-m", "isort", "."], cwd=root) console.print("[bold]Running black…[/bold]") - subprocess.call([sys.executable, "-m", "black", "."], cwd=root) + _run_checked([sys.executable, "-m", "black", "."], cwd=root) def _run_test() -> None: @@ -157,7 +194,7 @@ def _run_test() -> None: if normalized_expression: cmd.extend(["-k", normalized_expression]) - subprocess.call(cmd, cwd=_project_root()) + _run_checked(cmd, cwd=_project_root()) def _run_e2e() -> None: @@ -168,7 +205,7 @@ def _run_e2e() -> None: console.print(f"[red]Script not found: {script}[/red]") return console.print("[bold]Running Ansible e2e integration tests…[/bold]") - subprocess.call(["bash", str(script)], cwd=root) + _run_checked(["bash", str(script)], cwd=root) # ── Run Ansible examples ────────────────────────────────────────── @@ -185,6 +222,27 @@ def _playbook_requires_vault(playbook: Path) -> bool: ) +def _vault_password_arguments(examples_dir: Path, playbook: Path) -> list[str]: + """Return Vault arguments required by the selected example workspace.""" + vault_file = examples_dir / "group_vars" / "all" / "vault.yml" + vault_password_file = examples_dir / ".vault_pass" + playbook_uses_vault = _playbook_requires_vault(playbook) + + if not vault_file.exists() and not playbook_uses_vault: + return [] + if not vault_file.is_file(): + raise click.ClickException( + f"{playbook.name} uses Vault variables but {vault_file} was not found. " + "Create and encrypt it as described in sccfm-ansible/README.md." + ) + if not vault_password_file.is_file(): + raise click.ClickException( + f"{vault_file} is present, but {vault_password_file} was not found. " + "Create the password file before running Ansible examples." + ) + return ["--vault-password-file", vault_password_file.name] + + def _run_ansible_examples() -> None: """Interactively select and run an Ansible example playbook.""" examples_dir = _project_root() / "sccfm-ansible" / "examples" @@ -213,17 +271,10 @@ def _run_ansible_examples() -> None: "-i", "inventory.sccfm.yml", ] - vault_password_file = examples_dir / ".vault_pass" - if _playbook_requires_vault(examples_dir / answer): - if not vault_password_file.exists(): - console.print( - f"[yellow]{answer} uses vault variables but " - f"{vault_password_file} was not found — running without Vault.[/yellow]" - ) - else: - cmd.extend(["--vault-password-file", vault_password_file.name]) + cmd.extend(_vault_password_arguments(examples_dir, examples_dir / answer)) console.print(f"[bold cyan]$ {shlex.join(cmd)}[/bold cyan]") - subprocess.call(cmd, cwd=str(examples_dir)) + with _source_collection_environment(_project_root()) as env: + _run_checked(cmd, cwd=examples_dir, env=env) # ── Menu definition ─────────────────────────────────────────────── diff --git a/cisco_sccfm_scripts/setup_ci_environment.sh b/cisco_sccfm_scripts/setup_ci_environment.sh index f28aee14..9b8dbac4 100755 --- a/cisco_sccfm_scripts/setup_ci_environment.sh +++ b/cisco_sccfm_scripts/setup_ci_environment.sh @@ -44,7 +44,7 @@ setup_pyenv() { } build_python() { - if pyenv versions --bare 2>/dev/null | grep -qx "${PYTHON_VERSION}"; then + if pyenv versions --bare 2>/dev/null | grep -Fx "${PYTHON_VERSION}" >/dev/null; then echo "Python ${PYTHON_VERSION} already installed." return fi diff --git a/cisco_sccfm_scripts/setup_environment.sh b/cisco_sccfm_scripts/setup_environment.sh index 093d2064..49511bb0 100755 --- a/cisco_sccfm_scripts/setup_environment.sh +++ b/cisco_sccfm_scripts/setup_environment.sh @@ -23,7 +23,7 @@ function ensure_pyenv() { } function ensure_python() { - if pyenv versions --bare | grep -qx "${PYTHON_VERSION}"; then + if pyenv versions --bare | grep -Fx "${PYTHON_VERSION}" >/dev/null; then return fi echo "Installing Python ${PYTHON_VERSION}" @@ -31,7 +31,7 @@ function ensure_python() { } function create_venv() { - local python_bin + local poetry_venv python_bin python_bin="$(pyenv root)/versions/${PYTHON_VERSION}/bin/python3" if [[ ! -x "${python_bin}" ]]; then echo "Python ${PYTHON_VERSION} is not available in pyenv" >&2 @@ -44,11 +44,31 @@ function create_venv() { fi # shellcheck source=/dev/null source "${VENV_DIR}/bin/activate" + + if python -c 'import importlib.metadata; importlib.metadata.version("poetry")' \ + >/dev/null 2>&1; then + echo "The existing ${VENV_DIR} contains Poetry in the project runtime." >&2 + echo "Remove ${VENV_DIR} and rerun this script to migrate to the isolated setup." >&2 + exit 1 + fi + python -m pip install --upgrade pip - if ! command -v poetry >/dev/null 2>&1; then - pip install poetry + + poetry_venv="${VENV_DIR}/.poetry" + if [[ ! -x "${poetry_venv}/bin/poetry" ]]; then + echo "Installing Poetry in an isolated tooling environment at ${poetry_venv}" + "${python_bin}" -m venv "${poetry_venv}" + "${poetry_venv}/bin/python" -m pip install --upgrade pip + "${poetry_venv}/bin/pip" install poetry + fi + + POETRY_VIRTUALENVS_IN_PROJECT=1 "${poetry_venv}/bin/poetry" install --with dev + ln -sfn "../.poetry/bin/poetry" "${VENV_DIR}/bin/poetry" + + if ! python -m pip check; then + echo "The project environment has incompatible dependencies." >&2 + exit 1 fi - POETRY_VIRTUALENVS_IN_PROJECT=1 poetry install --with dev if [[ ! -x "${VENV_DIR}/bin/cz" ]]; then echo "Commitizen did not install correctly." >&2 exit 1 diff --git a/cisco_sccfm_scripts/sync_docs_readme.py b/cisco_sccfm_scripts/sync_docs_readme.py index 0be2b757..3e93eaa3 100644 --- a/cisco_sccfm_scripts/sync_docs_readme.py +++ b/cisco_sccfm_scripts/sync_docs_readme.py @@ -15,7 +15,7 @@ from typing import Sequence from urllib.parse import quote, urlparse -REPOSITORY_URL = "https://github.com/cisco-lockhart/sccfm-devkit" +REPOSITORY_URL = "https://github.com/CiscoDevNet/sccfm-devkit" DEFAULT_SOURCE = Path("README.md") DEFAULT_OUTPUT = Path("docs/_includes/repository-readme.md") GENERATED_HEADER = "" diff --git a/cisco_sccfm_scripts/test_interactive_cli.py b/cisco_sccfm_scripts/test_interactive_cli.py index bb67d804..9d0bacbc 100644 --- a/cisco_sccfm_scripts/test_interactive_cli.py +++ b/cisco_sccfm_scripts/test_interactive_cli.py @@ -5,8 +5,10 @@ from __future__ import annotations from pathlib import Path -from unittest.mock import MagicMock +from unittest.mock import MagicMock, call +import click +import pytest from pytest import MonkeyPatch from cisco_sccfm_cli.interactive import customer_tasks @@ -70,6 +72,9 @@ def test_run_ansible_examples_adds_vault_argument_when_required( '- hosts: all\n vars:\n password: "{{ vault_asa_password }}"\n', encoding="utf-8", ) + vault = examples / "group_vars" / "all" / "vault.yml" + vault.parent.mkdir(parents=True) + vault.write_text("encrypted fixture\n", encoding="utf-8") (examples / ".vault_pass").write_text("secret\n", encoding="utf-8") monkeypatch.setattr(interactive_cli, "_project_root", lambda: tmp_path) monkeypatch.setattr(interactive_cli, "_ask", lambda *a, **k: "onboard_asas.yml") @@ -86,3 +91,82 @@ def test_run_ansible_examples_adds_vault_argument_when_required( "--vault-password-file", ".vault_pass", ] + + +def test_run_ansible_examples_adds_vault_argument_for_auto_loaded_group_vars( + tmp_path: Path, + monkeypatch: MonkeyPatch, +) -> None: + examples = tmp_path / "sccfm-ansible" / "examples" + vault = examples / "group_vars" / "all" / "vault.yml" + vault.parent.mkdir(parents=True) + vault.write_text("encrypted fixture\n", encoding="utf-8") + (examples / ".vault_pass").write_text("secret\n", encoding="utf-8") + (examples / "show_devices.yml").write_text("- hosts: all\n", encoding="utf-8") + monkeypatch.setattr(interactive_cli, "_project_root", lambda: tmp_path) + monkeypatch.setattr(interactive_cli, "_ask", lambda *a, **k: "show_devices.yml") + call = MagicMock(return_value=0) + monkeypatch.setattr(interactive_cli.subprocess, "call", call) + + interactive_cli._run_ansible_examples() + + assert call.call_args.args[0][-2:] == ["--vault-password-file", ".vault_pass"] + + +def test_run_ansible_examples_rejects_auto_loaded_vault_without_password( + tmp_path: Path, + monkeypatch: MonkeyPatch, +) -> None: + examples = tmp_path / "sccfm-ansible" / "examples" + vault = examples / "group_vars" / "all" / "vault.yml" + vault.parent.mkdir(parents=True) + vault.write_text("encrypted fixture\n", encoding="utf-8") + (examples / "show_devices.yml").write_text("- hosts: all\n", encoding="utf-8") + monkeypatch.setattr(interactive_cli, "_project_root", lambda: tmp_path) + monkeypatch.setattr(interactive_cli, "_ask", lambda *a, **k: "show_devices.yml") + call = MagicMock(return_value=0) + monkeypatch.setattr(interactive_cli.subprocess, "call", call) + + with pytest.raises(click.ClickException, match=".vault_pass.*was not found"): + interactive_cli._run_ansible_examples() + + call.assert_not_called() + + +def test_lint_uses_read_only_checks(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(interactive_cli, "_project_root", lambda: tmp_path) + subprocess_call = MagicMock(return_value=0) + monkeypatch.setattr(interactive_cli.subprocess, "call", subprocess_call) + + interactive_cli._run_lint() + + assert subprocess_call.call_args_list == [ + call( + [interactive_cli.sys.executable, "-m", "black", "--check", "."], + cwd=tmp_path, + env=None, + ), + call( + [interactive_cli.sys.executable, "-m", "isort", "--check-only", "."], + cwd=tmp_path, + env=None, + ), + call( + [ + interactive_cli.sys.executable, + "-m", + "mypy", + "cisco_sccfm_cli", + "cisco_sccfm_core", + ], + cwd=tmp_path, + env=None, + ), + ] + + +def test_checked_subprocess_failure_is_propagated(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(interactive_cli.subprocess, "call", MagicMock(return_value=7)) + + with pytest.raises(click.ClickException, match="exit code 7"): + interactive_cli._run_checked(["example-command"], cwd=tmp_path) diff --git a/docs/README.md b/docs/README.md index 7089a7cd..7e846b21 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,7 @@ The CLI and Ansible references are generated from source metadata: - CLI man pages come from Click command metadata via `click-man`. - Ansible docs come from `ansible-doc` output. -Generate local previews with: +Generate and validate the documentation sources locally with: ```bash source cisco_sccfm_scripts/activate.sh @@ -20,6 +20,7 @@ generate-cli-docs generate-cli-man-docs generate-ansible-docs check-doc-links +check-doc-links --docs-root sccfm-ansible check-doc-artifacts ``` @@ -30,6 +31,10 @@ The generated files are written to: - `docs/ansible/` - `docs/_includes/repository-readme.md` (ignored locally; generated from the root `README.md`) +The repository does not pin a local Ruby/Jekyll toolchain or provide a supported local Pages +server. Open the generated Markdown directly for a source preview; the Docs workflow performs the +authoritative rendered-site build with GitHub's Pages builder. + Pull requests run the Docs workflow, which regenerates all docs, validates internal generated links, scans generated text artifacts for terminal escape sequences, and builds the static docs site without publishing it. After CI succeeds on `main`, the Generated diff --git a/docs/index.md b/docs/index.md index 6efba69d..e40bd74a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,4 +14,4 @@ These references are generated from the source code on every merge to `main`. CLI man pages are also generated under `docs/man/man1/` for Unix package managers. -[Local preview instructions](local-preview.html){:.doc-button} +[Local validation instructions](local-preview.html){:.doc-button} diff --git a/docs/local-preview.md b/docs/local-preview.md index e5267fcd..24d686de 100644 --- a/docs/local-preview.md +++ b/docs/local-preview.md @@ -1,11 +1,11 @@ --- layout: page -title: Local Preview +title: Local Documentation Validation --- [Back to Documentation Home](index.html){:.doc-button} -Generate and validate the local docs from the repository root: +Generate and validate the documentation sources from the repository root: ```bash source cisco_sccfm_scripts/activate.sh @@ -14,6 +14,7 @@ generate-cli-docs generate-cli-man-docs generate-ansible-docs check-doc-links +check-doc-links --docs-root sccfm-ansible check-doc-artifacts ``` @@ -26,6 +27,10 @@ The generated files are written to: To install the generated CLI man pages into a local man directory for testing `man sccfm-cli`, run `install-cli-man-docs`. +This repository does not pin a local Ruby/Jekyll toolchain or provide a supported local Pages +server. Open the generated Markdown directly for a source preview. The Docs workflow performs the +authoritative rendered-site build with GitHub's Pages builder. + Pull requests run the Docs workflow, which regenerates all docs, validates internal generated links, scans generated text artifacts for terminal escape sequences, and builds the static docs site without publishing it. diff --git a/sccfm-ansible/CHANGELOG.rst b/sccfm-ansible/CHANGELOG.rst index 13a38b33..1d844f1d 100644 --- a/sccfm-ansible/CHANGELOG.rst +++ b/sccfm-ansible/CHANGELOG.rst @@ -4,6 +4,14 @@ Cisco SCCFM Collection Release Notes .. contents:: Topics +v0.39.3 +======== + +Bugfixes +-------- + +- Corrected development setup and Ansible example guidance so collection installation, Vault handling, profile lookup safety, and command failures follow the supported workflows. + v0.39.2 ======== diff --git a/sccfm-ansible/README.md b/sccfm-ansible/README.md index 300a67fb..38d7108f 100644 --- a/sccfm-ansible/README.md +++ b/sccfm-ansible/README.md @@ -11,8 +11,8 @@ Ansible collection for managing Cisco Security Cloud Control Firewall Manager (S - [Local Development](#local-development) - [Trying out examples](#trying-out-examples) - [1. Configure an SCCFM Profile](#1-configure-an-sccfm-profile) - - [2. Edit playbook](#2-edit-playbook) - - [4. Run Examples](#4-run-examples) + - [2. Edit the onboarding playbook](#2-edit-the-onboarding-playbook) + - [3. Run Examples](#3-run-examples) - [Test Inventory](#test-inventory) - [Host Variables](#host-variables) - [Modules](#modules) @@ -51,13 +51,14 @@ Ansible collection for managing Cisco Security Cloud Control Firewall Manager (S ## Installation -See instructions in the [INSTALL.md](INSTALL.md) file. +See [Installing the Ansible collection](https://github.com/CiscoDevNet/sccfm-devkit/blob/main/INSTALL.md#installing-the-ansible-collection) +for released and downloaded-artifact installation instructions. ### Local Development Run these commands from an activated repository checkout. -**Build and install (recommended):** +**Build and verify:** ```bash sccfm-devkit # then select "build-collection" from the menu @@ -68,10 +69,13 @@ Or directly: build-ansible-collection ``` -This will: -1. Initialize the poetry virtual environment (if needed) -2. Install Python dependencies (`cisco_sccfm_core`, `cisco_sccfm_cli`, etc.) -3. Install the Ansible collection +This creates and verifies the collection tarball; it does not install it. Install the exact +artifact that was just built: + +```bash +ansible-galaxy collection install \ + "dist/cisco-sccfm-$(poetry version --short).tar.gz" --force +``` ## Trying out examples @@ -106,23 +110,30 @@ sccfm-devkit This migration helper is development-only and is not included in the public Python package. +The example commands below assume the collection root as the current working directory. From a +source checkout, enter it first: + +```bash +cd sccfm-ansible +``` +
Set up Ansible-specific device secrets -Create a vault password file (do NOT commit this!): +Only examples that reference managed-device passwords require Ansible Vault. Create a vault +password file for those examples (do NOT commit this!): ```bash -cd examples -cp .vault_pass.example .vault_pass -echo "YourSecureVaultPassword" > .vault_pass -chmod 600 .vault_pass +cp examples/.vault_pass.example examples/.vault_pass +vim examples/.vault_pass +chmod 600 examples/.vault_pass ``` Copy and edit the example vault file: ```bash -cp group_vars/all/vault.yml.example group_vars/all/vault.yml.temp -vim group_vars/all/vault.yml.temp +cp examples/group_vars/all/vault.yml.example examples/group_vars/all/vault.yml.temp +vim examples/group_vars/all/vault.yml.temp ``` Add only playbook-specific secrets: @@ -133,20 +144,26 @@ vault_asa_branch_office_01_password: "ActualPassword1" Encrypt the vault file: ```bash -ansible-vault encrypt group_vars/all/vault.yml.temp \ - --vault-password-file .vault_pass \ - --output group_vars/all/vault.yml +ansible-vault encrypt examples/group_vars/all/vault.yml.temp \ + --vault-password-file examples/.vault_pass \ + --output examples/group_vars/all/vault.yml -rm group_vars/all/vault.yml.temp +rm examples/group_vars/all/vault.yml.temp ```
-### 2. Edit playbook +The commands below work in a fresh checkout, where `examples/group_vars/all/vault.yml` does not +exist. After creating that encrypted file, pass +`--vault-password-file examples/.vault_pass` to every `ansible-playbook` and `ansible-inventory` +command that loads the `examples` directory. Ansible decrypts `group_vars` before running tasks, +even for read-only playbooks and inventory output. + +### 2. Edit the onboarding playbook -Edit the `onboard_asas.yml` playbook, and change the `asas_to_onboard` list to match your devices. +Edit `examples/onboard_asas.yml` and change the `asas_to_onboard` list to match your devices. -### 4. Run Examples +### 3. Run Examples **Graph inventory:** ```bash @@ -155,26 +172,47 @@ ansible-inventory -i examples/inventory.sccfm.yml \ --playbook-dir examples ``` +With encrypted example `group_vars`: + +```bash +ansible-inventory -i examples/inventory.sccfm.yml \ + --graph \ + --playbook-dir examples \ + --vault-password-file examples/.vault_pass +``` + **Show all devices:** ```bash ansible-playbook \ --i examples/inventory.sccfm.yml \ -examples/show_devices.yml \ ---vault-password-file examples/.vault_pass + -i examples/inventory.sccfm.yml \ + examples/show_devices.yml +``` + +With encrypted example `group_vars`: + +```bash +ansible-playbook \ + -i examples/inventory.sccfm.yml \ + examples/show_devices.yml \ + --vault-password-file examples/.vault_pass ``` **Onboard ASA devices:** ```bash -ansible-playbook onboard_asas.yml --vault-password-file .vault_pass +ansible-playbook examples/onboard_asas.yml \ + --vault-password-file examples/.vault_pass ``` ### Test Inventory ```bash -ansible-inventory -i inventory.sccfm.yml --list --vault-password-file .vault_pass -ansible-inventory -i inventory.sccfm.yml --graph --vault-password-file .vault_pass +ansible-inventory -i examples/inventory.sccfm.yml --list +ansible-inventory -i examples/inventory.sccfm.yml --graph ``` +If encrypted example `group_vars` exists, append +`--vault-password-file examples/.vault_pass` to either command. + ### Host Variables Each device gets the following variables: @@ -283,41 +321,47 @@ Ansible Vault encrypts playbook-specific secrets such as managed-device password **Create new encrypted file:** ```bash -ansible-vault create group_vars/all/vault.yml --vault-password-file .vault_pass +ansible-vault create examples/group_vars/all/vault.yml \ + --vault-password-file examples/.vault_pass ``` **Edit encrypted vault file (recommended):** ```bash -ansible-vault edit group_vars/all/vault.yml --vault-password-file .vault_pass +ansible-vault edit examples/group_vars/all/vault.yml \ + --vault-password-file examples/.vault_pass ``` **View encrypted vault file:** ```bash -ansible-vault view group_vars/all/vault.yml --vault-password-file .vault_pass +ansible-vault view examples/group_vars/all/vault.yml \ + --vault-password-file examples/.vault_pass ``` **Encrypt existing file:** ```bash -ansible-vault encrypt group_vars/all/vault.yml --vault-password-file .vault_pass +ansible-vault encrypt examples/group_vars/all/vault.yml \ + --vault-password-file examples/.vault_pass ``` **Decrypt vault file (temporarily):** ```bash -ansible-vault decrypt group_vars/all/vault.yml --vault-password-file .vault_pass +ansible-vault decrypt examples/group_vars/all/vault.yml \ + --vault-password-file examples/.vault_pass # Edit the file -ansible-vault encrypt group_vars/all/vault.yml --vault-password-file .vault_pass +ansible-vault encrypt examples/group_vars/all/vault.yml \ + --vault-password-file examples/.vault_pass ``` **Change vault password:** ```bash -ansible-vault rekey group_vars/all/vault.yml \ - --vault-password-file .vault_pass \ - --new-vault-password-file .vault_pass_new +ansible-vault rekey examples/group_vars/all/vault.yml \ + --vault-password-file examples/.vault_pass \ + --new-vault-password-file examples/.vault_pass_new ``` **Verify file is encrypted:** ```bash -head -1 group_vars/all/vault.yml +head -1 examples/group_vars/all/vault.yml # Should output: $ANSIBLE_VAULT;1.1;AES256 ``` diff --git a/sccfm-ansible/changelogs/changelog.yaml b/sccfm-ansible/changelogs/changelog.yaml index 6d8a3824..358e6957 100644 --- a/sccfm-ansible/changelogs/changelog.yaml +++ b/sccfm-ansible/changelogs/changelog.yaml @@ -2,6 +2,14 @@ ancestor: null # sccfm-release-retarget-seed: 0.39.0 releases: + 0.39.3: + changes: + bugfixes: + - Corrected development setup and Ansible example guidance so collection + installation, Vault handling, profile lookup safety, and command failures + follow the supported workflows. + fragments: [] + release_date: '2026-08-19' 0.39.2: changes: bugfixes: diff --git a/sccfm-ansible/examples/access_rules.yml b/sccfm-ansible/examples/access_rules.yml index e7661762..eb721064 100644 --- a/sccfm-ansible/examples/access_rules.yml +++ b/sccfm-ansible/examples/access_rules.yml @@ -19,7 +19,7 @@ hosts: localhost gather_facts: false - # Use module_defaults to avoid repeating region and api_token + # Use module_defaults to select one named profile for all collection tasks module_defaults: group/cisco.sccfm.all: profile: default diff --git a/sccfm-ansible/examples/configure_manager.yml b/sccfm-ansible/examples/configure_manager.yml index 872ec605..258f11d6 100644 --- a/sccfm-ansible/examples/configure_manager.yml +++ b/sccfm-ansible/examples/configure_manager.yml @@ -5,8 +5,8 @@ # "configure manager add ..." command (the cli_key) produced by onboarding, # which tells the FTD to phone home to the cdFMC and register. # -# Unlike the other modules, configure_manager talks directly to the device over -# SSH, so it does NOT use region/api_token. It needs the FTD's SSH credentials +# Unlike the SCCFM API modules, configure_manager talks directly to the device +# over SSH, so it does not use an SCCFM profile. It needs the FTD's SSH credentials # (and, optionally, a jump host) instead. Keep device passwords in vault. # # Only works if the FTD is reachable on its SSH port (ftd_port, default 22) from diff --git a/sccfm-ansible/examples/create_network_groups.yml b/sccfm-ansible/examples/create_network_groups.yml index 6413b216..db1af1d8 100644 --- a/sccfm-ansible/examples/create_network_groups.yml +++ b/sccfm-ansible/examples/create_network_groups.yml @@ -13,7 +13,7 @@ hosts: localhost gather_facts: false - # Use module_defaults to avoid repeating region and api_token + # Use module_defaults to select one named profile for all collection tasks module_defaults: group/cisco.sccfm.all: profile: default diff --git a/sccfm-ansible/examples/create_network_objects.yml b/sccfm-ansible/examples/create_network_objects.yml index d7cad233..b25c81ac 100644 --- a/sccfm-ansible/examples/create_network_objects.yml +++ b/sccfm-ansible/examples/create_network_objects.yml @@ -15,7 +15,7 @@ hosts: localhost gather_facts: false - # Use module_defaults to avoid repeating region and api_token + # Use module_defaults to select one named profile for all collection tasks module_defaults: group/cisco.sccfm.all: profile: default diff --git a/sccfm-ansible/examples/delete_network_groups.yml b/sccfm-ansible/examples/delete_network_groups.yml index 463e5ace..8d090fc2 100644 --- a/sccfm-ansible/examples/delete_network_groups.yml +++ b/sccfm-ansible/examples/delete_network_groups.yml @@ -15,7 +15,7 @@ hosts: localhost gather_facts: false - # Use module_defaults to avoid repeating region and api_token + # Use module_defaults to select one named profile for all collection tasks module_defaults: group/cisco.sccfm.all: profile: default diff --git a/sccfm-ansible/examples/delete_network_objects.yml b/sccfm-ansible/examples/delete_network_objects.yml index a32b1ce1..3200bf1e 100644 --- a/sccfm-ansible/examples/delete_network_objects.yml +++ b/sccfm-ansible/examples/delete_network_objects.yml @@ -15,7 +15,7 @@ hosts: localhost gather_facts: false - # Use module_defaults to avoid repeating region and api_token + # Use module_defaults to select one named profile for all collection tasks module_defaults: group/cisco.sccfm.all: profile: default diff --git a/sccfm-ansible/examples/execute_asa_cli.yml b/sccfm-ansible/examples/execute_asa_cli.yml index 49906d9a..663b479a 100644 --- a/sccfm-ansible/examples/execute_asa_cli.yml +++ b/sccfm-ansible/examples/execute_asa_cli.yml @@ -18,7 +18,7 @@ hosts: localhost gather_facts: false - # Use module_defaults to avoid repeating region and api_token + # Use module_defaults to select one named profile for all collection tasks module_defaults: group/cisco.sccfm.all: profile: default diff --git a/sccfm-ansible/examples/list_asa_not_on_version.yml b/sccfm-ansible/examples/list_asa_not_on_version.yml index 75c5a3da..efe85bad 100644 --- a/sccfm-ansible/examples/list_asa_not_on_version.yml +++ b/sccfm-ansible/examples/list_asa_not_on_version.yml @@ -7,18 +7,18 @@ # 2. Lists all ASA devices that are NOT currently running that version. # # RUN THIS PLAYBOOK (auto-detect majority version): -# ansible-playbook examples/list_asa_not_on_version.yml \ -# --vault-password-file examples/.vault_pass +# ansible-playbook examples/list_asa_not_on_version.yml # # Narrow the check to a subset of devices (optional): # ansible-playbook examples/list_asa_not_on_version.yml \ -# --vault-password-file examples/.vault_pass \ # -e check_query="name:branch-*" # # Specify the target version directly (recommended): # ansible-playbook examples/list_asa_not_on_version.yml \ -# --vault-password-file examples/.vault_pass \ # -e target_version="9.20(3)13" +# +# If examples/group_vars/all/vault.yml exists, also pass: +# --vault-password-file examples/.vault_pass - name: List ASA devices not on the target version hosts: localhost diff --git a/sccfm-ansible/examples/list_ftd_not_on_version.yml b/sccfm-ansible/examples/list_ftd_not_on_version.yml index 10fad2b6..06325d26 100644 --- a/sccfm-ansible/examples/list_ftd_not_on_version.yml +++ b/sccfm-ansible/examples/list_ftd_not_on_version.yml @@ -6,18 +6,18 @@ # 2. Recommended version: check each device against its Cisco-suggested version. # # RUN THIS PLAYBOOK (check against recommended version): -# ansible-playbook examples/list_ftd_not_on_version.yml \ -# --vault-password-file examples/.vault_pass +# ansible-playbook examples/list_ftd_not_on_version.yml # # Specify the target version directly: # ansible-playbook examples/list_ftd_not_on_version.yml \ -# --vault-password-file examples/.vault_pass \ # -e target_version="7.4.1" # # Narrow the check to a subset of devices (optional): # ansible-playbook examples/list_ftd_not_on_version.yml \ -# --vault-password-file examples/.vault_pass \ # -e check_query="name:branch-*" +# +# If examples/group_vars/all/vault.yml exists, also pass: +# --vault-password-file examples/.vault_pass - name: List FTD devices not on the target or recommended version hosts: localhost diff --git a/sccfm-ansible/examples/list_network_groups.yml b/sccfm-ansible/examples/list_network_groups.yml index 77f6c14e..2ebf508d 100644 --- a/sccfm-ansible/examples/list_network_groups.yml +++ b/sccfm-ansible/examples/list_network_groups.yml @@ -11,7 +11,7 @@ hosts: localhost gather_facts: false - # Use module_defaults to avoid repeating region and api_token + # Use module_defaults to select one named profile for all collection tasks module_defaults: group/cisco.sccfm.all: profile: default diff --git a/sccfm-ansible/examples/list_network_objects.yml b/sccfm-ansible/examples/list_network_objects.yml index 9234d961..5f80dc12 100644 --- a/sccfm-ansible/examples/list_network_objects.yml +++ b/sccfm-ansible/examples/list_network_objects.yml @@ -11,7 +11,7 @@ hosts: localhost gather_facts: false - # Use module_defaults to avoid repeating region and api_token + # Use module_defaults to select one named profile for all collection tasks module_defaults: group/cisco.sccfm.all: profile: default diff --git a/sccfm-ansible/examples/onboard_asas.yml b/sccfm-ansible/examples/onboard_asas.yml index 2f6c4c3b..18fb5781 100644 --- a/sccfm-ansible/examples/onboard_asas.yml +++ b/sccfm-ansible/examples/onboard_asas.yml @@ -3,7 +3,7 @@ hosts: localhost gather_facts: false - # Use module_defaults to avoid repeating region and api_token + # Use module_defaults to select one named profile for all collection tasks module_defaults: group/cisco.sccfm.all: profile: default diff --git a/sccfm-ansible/examples/update_network_groups.yml b/sccfm-ansible/examples/update_network_groups.yml index 178f396a..3dc80843 100644 --- a/sccfm-ansible/examples/update_network_groups.yml +++ b/sccfm-ansible/examples/update_network_groups.yml @@ -17,7 +17,7 @@ hosts: localhost gather_facts: false - # Use module_defaults to avoid repeating region and api_token + # Use module_defaults to select one named profile for all collection tasks module_defaults: group/cisco.sccfm.all: profile: default diff --git a/sccfm-ansible/examples/update_network_objects.yml b/sccfm-ansible/examples/update_network_objects.yml index b9cc770a..9707e12f 100644 --- a/sccfm-ansible/examples/update_network_objects.yml +++ b/sccfm-ansible/examples/update_network_objects.yml @@ -19,7 +19,7 @@ hosts: localhost gather_facts: false - # Use module_defaults to avoid repeating region and api_token + # Use module_defaults to select one named profile for all collection tasks module_defaults: group/cisco.sccfm.all: profile: default diff --git a/skills/sccfm-ansible/SKILL.md b/skills/sccfm-ansible/SKILL.md index 098de8fc..39c59794 100644 --- a/skills/sccfm-ansible/SKILL.md +++ b/skills/sccfm-ansible/SKILL.md @@ -1,7 +1,7 @@ --- name: sccfm-ansible -description: Use the cisco.sccfm Ansible collection for SCC Firewall Manager by discovering modules and inventory plugins with ansible-doc at runtime, validating parameters, auth, check mode, and safety before generating or running playbooks. Use for cisco.sccfm Ansible modules, inventory, vault, and playbook workflows. Do NOT use for sccfm-cli commands; use the sccfm-cli skill instead. Do not use for Jira/Confluence work, architecture design, or non-Ansible tasks. -allowed-tools: "Bash(command -v *) Bash(source cisco_sccfm_scripts/activate.sh) Bash(ansible-doc *) Bash(ansible-playbook *) Bash(ansible-inventory *) Bash(ansible-vault *) Bash(ansible-galaxy *) Bash(build-ansible-collection) Bash(sccfm-cli *) Bash(sccfm-cli-interactive *) Bash(jq *) Read Grep Glob Write Edit" +description: Use the cisco.sccfm Ansible collection for SCC Firewall Manager by discovering modules, inventory plugins, and lookup plugins with ansible-doc at runtime, validating parameters, auth, check mode, and safety before generating or running playbooks. Use for cisco.sccfm Ansible modules, inventory, lookups, vault, and playbook workflows. Do NOT use for sccfm-cli commands; use the sccfm-cli skill instead. Do not use for Jira/Confluence work, architecture design, or non-Ansible tasks. +allowed-tools: "Bash(command -v *) Bash(source cisco_sccfm_scripts/activate.sh) Bash(poetry version --short) Bash(ansible-doc *) Bash(ansible-playbook *) Bash(ansible-inventory *) Bash(ansible-vault *) Bash(ansible-galaxy *) Bash(build-ansible-collection) Bash(sccfm-cli *) Bash(sccfm-cli-interactive *) Bash(jq *) Read Grep Glob Write Edit" --- # SCC Firewall Manager Ansible Collection @@ -17,15 +17,15 @@ managed devices. Optimize for customer safety first and convenience second. ## Scope: Ansible vs. CLI -This skill covers only the `cisco.sccfm` Ansible collection (modules and the -inventory plugin). For `sccfm-cli` command-line invocations, use the `sccfm-cli` -skill. For requests spanning both surfaces, apply each skill only to its -respective operations. +This skill covers only the `cisco.sccfm` Ansible collection (modules, inventory +plugins, and lookup plugins). For `sccfm-cli` command-line invocations, use the +`sccfm-cli` skill. For requests spanning both surfaces, apply each skill only to +its respective operations. ## Core Rules 1. Run `ansible-doc` before writing, running, or answering detailed questions - about any `cisco.sccfm` module or inventory plugin. + about any `cisco.sccfm` module, inventory plugin, or lookup plugin. 2. Prefer stopping over guessing. If module match, target identity, region, credentials, inventory, or safety class is ambiguous, ask the user or switch to Generate-Only. @@ -133,10 +133,12 @@ Follow these checks in order: 2. If you are inside this repository, `ansible-doc` is missing, and `cisco_sccfm_scripts/activate.sh` exists, run `source cisco_sccfm_scripts/activate.sh` once for the shell session, then resolve again. Do not use `poetry run`. -3. Run discovery: +3. Run collection discovery: ```bash ansible-doc -j -l -t module cisco.sccfm + ansible-doc -j -l -t inventory cisco.sccfm + ansible-doc -j -l -t lookup cisco.sccfm ``` 4. If discovery fails and you are inside this repository, run both commands, @@ -144,13 +146,16 @@ Follow these checks in order: ```bash build-ansible-collection - ansible-galaxy collection install dist/cisco-sccfm-*.tar.gz --force + ansible-galaxy collection install \ + "dist/cisco-sccfm-$(poetry version --short).tar.gz" --force ``` 5. If discovery succeeds and you are inside this repository, compare discovered - module FQCNs with `sccfm-ansible/plugins/modules/*.py` only to detect a stale - installed collection. If source modules are missing from `ansible-doc`, build - and install the generated tarball as above, then rerun discovery. Do not use source filenames as the module schema. + FQCNs with the corresponding files under `sccfm-ansible/plugins/modules/`, + `sccfm-ansible/plugins/inventory/`, or `sccfm-ansible/plugins/lookup/` only to + detect a stale installed collection. If source plugins are missing from + `ansible-doc`, build and install the generated tarball as above, then rerun + discovery. Do not use source filenames as the runtime schema. 6. If you are outside this repository, install or modify local Ansible state only when the user explicitly asks for setup. Otherwise, stop and explain that the `cisco.sccfm` collection is not installed. @@ -177,6 +182,13 @@ ansible-doc -j -l -t inventory cisco.sccfm ansible-doc -j -t inventory ``` +For lookup work, list lookup plugins, then fetch the matched plugin docs: + +```bash +ansible-doc -j -l -t lookup cisco.sccfm +ansible-doc -j -t lookup +``` + Parse the JSON output. Use these fields as the schema: - module or plugin FQCN @@ -185,7 +197,7 @@ Parse the JSON output. Use these fields as the schema: `elements`, `env`, and `no_log` - examples - return values -- plugin type and inventory options +- plugin type and inventory or lookup options Cache the discovered JSON in memory for the session. Do not use stale docs after building or reinstalling the collection. @@ -194,9 +206,9 @@ If discovery fails, stop and report the error. Do not guess what the collection supports. The discovery commands above are the only hardcoded bootstrap commands. They are -the Ansible equivalent of schema export: all module, inventory plugin, -parameter, example, and return-value knowledge must come from the discovered -`ansible-doc` JSON. +the Ansible equivalent of schema export: all module, inventory plugin, lookup +plugin, parameter, example, and return-value knowledge must come from the +discovered `ansible-doc` JSON. ### Step C: Verify Credentials Without Exposing Secrets @@ -216,6 +228,11 @@ Rules: command to run without asking them to paste the token into chat. 8. Use Write/Edit only for non-secret playbook, inventory, vars template, or documentation artifacts. +9. Treat a lookup result as a secret when `field` is omitted (it defaults to + `api_token`) or explicitly set to `field=api_token`. Use the result only + inside a task with `no_log: true`; never print, export, log, or return it in + chat. Only an explicitly non-secret field such as `field=region` may be + presented. Use `sccfm-cli configure` or the `configure-profile` option in `sccfm-cli-interactive` for local SCCFM credential setup only when the user @@ -240,7 +257,8 @@ Then match modules using this algorithm: 2. Fetch full docs for every plausible candidate. 3. Reject candidates whose documented behavior conflicts with the user's intent. 4. Prefer modules whose documented action and object type both match exactly. -5. Use the inventory plugin only for inventory/discovery requests. +5. Use inventory plugins only for inventory/discovery requests and lookup plugins + only for lookup requests. 6. If exactly one candidate remains, use it. 7. If multiple plausible candidates remain, show the candidates and ask the user to choose. @@ -493,7 +511,8 @@ When modifying or adding Ansible modules in this repository: ## Important Rules -1. Never hardcode modules. All module knowledge comes from `ansible-doc`. +1. Never hardcode modules or plugins. All module and plugin knowledge comes from + `ansible-doc`. 2. Never fabricate options. Only use parameters listed in the matched docs. 3. Always use FQCNs. 4. Always protect playbook-specific secrets with Vault or placeholders; keep diff --git a/tests/test_build_ansible_collection.py b/tests/test_build_ansible_collection.py index a4a36c17..cf410442 100644 --- a/tests/test_build_ansible_collection.py +++ b/tests/test_build_ansible_collection.py @@ -12,9 +12,18 @@ CollectionBuildError, _sync_paired_python_requirement, _sync_runtime_requirement, + main, ) +def test_help_exits_without_building(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exc_info: + main(["--help"]) + + assert exc_info.value.code == 0 + assert "Build and verify the cisco.sccfm" in capsys.readouterr().out + + def test_sync_paired_python_requirement_writes_canonical_pair_pin(tmp_path: Path) -> None: requirements = tmp_path / "requirements.txt" requirements.write_text( diff --git a/tests/test_development_commands.py b/tests/test_development_commands.py index a586e4ab..ff20d4e0 100644 --- a/tests/test_development_commands.py +++ b/tests/test_development_commands.py @@ -14,6 +14,8 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] DEVTOOLS_PYPROJECT = PROJECT_ROOT / "devtools" / "pyproject.toml" +DOCS_WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "docs.yml" +COLLECTION_README = PROJECT_ROOT / "sccfm-ansible" / "README.md" COMMAND_MODULES = { "build-ansible-collection": "cisco_sccfm_scripts.build_ansible_collection:main", "check-doc-artifacts": "cisco_sccfm_scripts.check_doc_artifacts:main", @@ -93,3 +95,25 @@ def test_requested_poetry_run_commands_work_without_activation() -> None: ) assert result.returncode == 0, result.stderr + + +def test_docs_workflow_checks_collection_readme_links() -> None: + workflow = DOCS_WORKFLOW.read_text(encoding="utf-8") + + assert "poetry run check-doc-links --docs-root sccfm-ansible" in workflow + + +def test_collection_docs_explain_conditional_vault_password_file() -> None: + readme = COLLECTION_README.read_text(encoding="utf-8") + + assert "commands below work in a fresh checkout" in readme + assert "examples/group_vars/all/vault.yml` does not" in readme + assert "Ansible decrypts `group_vars` before running tasks" in readme + assert "--vault-password-file examples/.vault_pass" in readme + + for example_name in ("list_asa_not_on_version.yml", "list_ftd_not_on_version.yml"): + example = (PROJECT_ROOT / "sccfm-ansible" / "examples" / example_name).read_text( + encoding="utf-8" + ) + assert "If examples/group_vars/all/vault.yml exists" in example + assert "--vault-password-file examples/.vault_pass" in example diff --git a/tests/test_development_config.py b/tests/test_development_config.py new file mode 100644 index 00000000..1fe937ec --- /dev/null +++ b/tests/test_development_config.py @@ -0,0 +1,59 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for contributor-facing lint and pre-commit configuration.""" + +from __future__ import annotations + +import configparser +import re +from pathlib import Path +from typing import Any, cast + +import yaml + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def _pre_commit_hooks() -> dict[str, dict[str, Any]]: + config = cast( + dict[str, Any], + yaml.safe_load((PROJECT_ROOT / ".pre-commit-config.yaml").read_text(encoding="utf-8")), + ) + hooks: dict[str, dict[str, Any]] = {} + for repository in cast(list[dict[str, Any]], config["repos"]): + for hook in cast(list[dict[str, Any]], repository["hooks"]): + hooks[cast(str, hook["id"])] = hook + return hooks + + +def test_type_markers_use_the_empty_file_convention() -> None: + assert "exclude" not in _pre_commit_hooks()["end-of-file-fixer"] + for package in ("cisco_sccfm_cli", "cisco_sccfm_core"): + assert (PROJECT_ROOT / package / "py.typed").read_bytes() == b"" + + +def test_doctoc_only_matches_intended_documents() -> None: + files = cast(str, _pre_commit_hooks()["doctoc"]["files"]) + matcher = re.compile(files) + + assert matcher.fullmatch("README.md") + assert matcher.fullmatch("INSTALL.md") + assert matcher.fullmatch("sccfm-ansible/README.md") + assert not matcher.fullmatch("docs/README.md") + assert not matcher.fullmatch("skills/sccfm-ansible/README.md") + + +def test_flake8_e402_exception_is_limited_to_ansible_plugin_layouts() -> None: + config = configparser.ConfigParser() + config.read(PROJECT_ROOT / ".flake8", encoding="utf-8") + flake8 = config["flake8"] + + assert "E402" not in flake8["extend-ignore"].split(",") + exceptions = {line.strip() for line in flake8["per-file-ignores"].splitlines() if line.strip()} + assert exceptions == { + "sccfm-ansible/plugins/inventory/*.py:E402", + "sccfm-ansible/plugins/lookup/*.py:E402", + "sccfm-ansible/plugins/modules/*.py:E402", + } diff --git a/tests/test_environment_setup.py b/tests/test_environment_setup.py index f876151b..ab99a412 100644 --- a/tests/test_environment_setup.py +++ b/tests/test_environment_setup.py @@ -15,6 +15,7 @@ PROJECT_ROOT / "cisco_sccfm_scripts" / "setup_ci_environment.sh", PROJECT_ROOT / "cisco_sccfm_scripts" / "setup_environment.sh", ) +LOCAL_SETUP_SCRIPT = PROJECT_ROOT / "cisco_sccfm_scripts" / "setup_environment.sh" POETRY_GROUP_ARGUMENT = re.compile( r"\binstall --with (?P[A-Za-z0-9_-]+(?:,[A-Za-z0-9_-]+)*)" ) @@ -47,3 +48,31 @@ def test_setup_scripts_request_defined_poetry_groups() -> None: f"{script.name} requests undefined Poetry groups: " f"{sorted(requested_groups - defined_groups)}" ) + + +def test_setup_scripts_read_all_pyenv_versions_with_pipefail_enabled() -> None: + for script in SETUP_SCRIPTS: + source = script.read_text(encoding="utf-8") + + assert 'grep -Fx "${PYTHON_VERSION}" >/dev/null' in source + assert "grep -q" not in source + + +def test_setup_scripts_isolate_poetry_from_project_runtime() -> None: + for script in SETUP_SCRIPTS: + source = script.read_text(encoding="utf-8") + poetry_install_lines = [ + line.strip() for line in source.splitlines() if 'pip" install poetry' in line + ] + + assert 'poetry_venv="${VENV_DIR}/.poetry"' in source + assert poetry_install_lines == ['"${poetry_venv}/bin/pip" install poetry'] + assert '"${poetry_venv}/bin/poetry" install --with dev' in source + assert 'ln -sfn "../.poetry/bin/poetry" "${VENV_DIR}/bin/poetry"' in source + + +def test_local_setup_validates_runtime_dependencies() -> None: + source = LOCAL_SETUP_SCRIPT.read_text(encoding="utf-8") + + assert "python -m pip check" in source + assert 'importlib.metadata.version("poetry")' in source