Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)$'
13 changes: 9 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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 <root>` 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

Expand Down
5 changes: 4 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
21 changes: 17 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <root>` 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`.
Expand Down
18 changes: 16 additions & 2 deletions cisco_sccfm_cli/commands/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""
Expand Down Expand Up @@ -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))
Expand Down
52 changes: 51 additions & 1 deletion cisco_sccfm_cli/commands/tests/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ──────────────

Expand Down Expand Up @@ -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
1 change: 0 additions & 1 deletion cisco_sccfm_core/py.typed
Original file line number Diff line number Diff line change
@@ -1 +0,0 @@

19 changes: 16 additions & 3 deletions cisco_sccfm_core/tests/test_sccfm_ansible_skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.<module_name>" in skill
assert "ansible-doc -j -l -t inventory cisco.sccfm" in skill
assert "ansible-doc -j -t inventory <inventory_plugin_fqcn>" in skill
assert "ansible-doc -j -l -t lookup cisco.sccfm" in skill
assert "ansible-doc -j -t lookup <lookup_plugin_fqcn>" 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 <module-fqcn> <target-summary>" in skill
Expand Down
7 changes: 3 additions & 4 deletions cisco_sccfm_core/tests/test_sync_docs_readme.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -47,6 +46,6 @@ def test_sync_readme_writes_generated_include(tmp_path: Path) -> None:
assert output.read_text(encoding="utf-8") == (
"<!-- Generated by `sync-docs-readme`; do not edit by hand. -->\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"
)
12 changes: 11 additions & 1 deletion cisco_sccfm_scripts/build_ansible_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
# SPDX-License-Identifier: Apache-2.0

"""Build script for Ansible collection."""
import argparse
import os
import re
import shutil
import subprocess
import sys
import tomllib
from pathlib import Path
from typing import Sequence

import yaml

Expand Down Expand Up @@ -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"
Expand Down
Loading